Project

manceps

0.01
The project is in a healthy, maintained state
A production-grade MCP client with first-class auth support. Connect to MCP servers over Streamable HTTP or stdio, discover and invoke tools, read resources, and get prompts.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.2
~> 1.0
 Project Readme

Manceps

A Ruby client for the Model Context Protocol (MCP).

From Latin manceps -- one who takes in hand (contractor, acquirer). From manus (hand) + capere (to take).

Installation

# Gemfile
gem "manceps"

Or install directly:

gem install manceps

Requires Ruby >= 3.4.0.

Quick Start

require "manceps"

# HTTP server with bearer auth
Manceps::Client.open("https://mcp.example.com/mcp", auth: Manceps::Auth::Bearer.new(ENV["MCP_TOKEN"])) do |client|
  client.tools.each { |t| puts "#{t.name}: #{t.description}" }

  result = client.call_tool("search_documents", query: "quarterly report")
  puts result.text
end

# stdio server (local process)
Manceps::Client.open("npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]) do |client|
  contents = client.read_resource("file:///tmp/hello.txt")
  puts contents.text
end

The block form connects, yields the client, and disconnects on exit -- even if an exception is raised.

Transports

Streamable HTTP

The primary MCP transport. Uses httpx for persistent connections -- MCP servers bind sessions to TCP connections, so connection reuse is required.

client = Manceps::Client.new("https://mcp.example.com/mcp", auth: auth)

stdio

Spawns a local subprocess and communicates via newline-delimited JSON over stdin/stdout.

client = Manceps::Client.new("npx", args: ["-y", "@modelcontextprotocol/server-memory"])

# With environment variables
client = Manceps::Client.new("mm-mcp", env: { "MM_TOKEN" => "...", "MM_URL" => "..." })

The transport auto-detects: HTTP(S) URLs use Streamable HTTP, everything else uses stdio.

Authentication

Bearer Token

auth = Manceps::Auth::Bearer.new("your-token")

API Key Header

auth = Manceps::Auth::ApiKeyHeader.new("x-api-key", "your-key")

OAuth 2.1 (Experimental)

RFC 8414 discovery, RFC 7591 dynamic registration, PKCE, and automatic token refresh. Works but not yet tested against a wide range of authorization servers.

# If you already have tokens
auth = Manceps::Auth::OAuth.new(
  access_token: "...",
  refresh_token: "...",
  token_url: "https://auth.example.com/token",
  client_id: "...",
  expires_at: Time.now + 3600,
  on_token_refresh: ->(tokens) { save_tokens(tokens) }
)

# Full discovery + authorization flow
discovery = Manceps::Auth::OAuth.discover("https://mcp.example.com", redirect_uri: "http://localhost:3000/callback")
pkce = Manceps::Auth::OAuth.generate_pkce

url = Manceps::Auth::OAuth.authorize_url(
  authorization_url: discovery.authorization_url,
  client_id: discovery.client_id,
  redirect_uri: "http://localhost:3000/callback",
  state: SecureRandom.hex(16),
  scopes: discovery.scopes,
  code_challenge: pkce[:challenge]
)
# Redirect user to `url`, then exchange the code:

tokens = Manceps::Auth::OAuth.exchange_code(
  token_url: discovery.token_url,
  client_id: discovery.client_id,
  client_secret: discovery.client_secret,
  code: params[:code],
  redirect_uri: "http://localhost:3000/callback",
  code_verifier: pkce[:verifier]
)

Token refresh happens automatically when a token is within 5 minutes of expiry. The on_token_refresh callback fires after each refresh so you can persist the new tokens.

No Auth

The default. Useful for local servers:

client = Manceps::Client.new("http://localhost:3000/mcp")

Tools

# List available tools
tools = client.tools
tools.each do |tool|
  puts "#{tool.title || tool.name}: #{tool.description}"
  puts "  Input:  #{tool.input_schema}"
  puts "  Output: #{tool.output_schema}" if tool.output_schema  # structured output (2025-06-18+)
end

# Call a tool
result = client.call_tool("get_weather", location: "New York")
result.text                # joined text content
result.content             # Array<Content>
result.error?              # true if server flagged an error
result.structured_content  # parsed structured output (when tool declares outputSchema)
result.structured?         # true if structured content present

# Stream a long-running tool call
client.call_tool_streaming("analyze_data", dataset: "large.csv") do |event|
  puts "Progress: #{event}"
end

Resources

# List resources
resources = client.resources
resources.each { |r| puts "#{r.uri}: #{r.title || r.name}" }

# List resource templates
templates = client.resource_templates
templates.each { |t| puts "#{t.uri_template}: #{t.title || t.name}" }

# Read a resource
contents = client.read_resource("file:///project/src/main.rs")
puts contents.text

Prompts

# List prompts
prompts = client.prompts
prompts.each do |p|
  puts "#{p.name}: #{p.description}"
  p.arguments.each { |a| puts "  #{a.name} (required: #{a.required?})" }
end

# Get a prompt
result = client.get_prompt("code_review", code: "def hello; end")
result.messages.each { |m| puts "#{m.role}: #{m.text}" }

Configuration

Manceps.configure do |c|
  c.client_name      = "MyApp"           # default: "Manceps"
  c.client_version   = "1.0.0"           # default: Manceps::VERSION
  c.protocol_version = "2025-11-25"      # default: "2025-11-25"
  c.request_timeout  = 60                # default: 30 (seconds)
  c.connect_timeout  = 15                # default: 10 (seconds)
  c.client_description = "My app"        # optional, sent in clientInfo
  c.supported_versions = ["2025-11-25", "2025-06-18", "2025-03-26"]  # for negotiation
end

Error Handling

All errors inherit from Manceps::Error:

Manceps::Error
  Manceps::ConnectionError         # transport-level failures
  Manceps::TimeoutError            # request or connect timeout
  Manceps::ProtocolError           # JSON-RPC error (has #code, #data)
  Manceps::AuthenticationError     # 401, failed OAuth flows
  Manceps::SessionExpiredError     # server invalidated the session (404)
  Manceps::ToolError               # tool invocation failed (has #result)
begin
  result = client.call_tool("risky_operation", id: 42)
rescue Manceps::SessionExpiredError
  client.connect  # re-establish session
  retry
rescue Manceps::ProtocolError => e
  puts "RPC error #{e.code}: #{e.message}"
end

Why Manceps?

Persistent connections. MCP servers bind sessions to TCP connections. Manceps uses httpx to keep connections alive across requests, which most HTTP libraries don't do by default.

Auth-first. Bearer, API key, and OAuth 2.1 (experimental) are built in, not bolted on.

No LLM coupling. Pure protocol client. No to_openai_tools() or framework integrations -- use it with anything.

Extracted from production. Built and tested under real MCP load, not just spec examples.

Full 2025-11-25 spec. Protocol version negotiation, elicitation, tasks, structured tool output, MCP-Protocol-Version header -- not just the basics.

Notifications

Register handlers for server-initiated messages:

client.on("notifications/tools/list_changed") { puts "Tools changed!" }
client.on("notifications/resources/updated") { |params| puts "Updated: #{params['uri']}" }

# Subscribe to resource updates
client.subscribe_resource("file:///project/config.yml")

# Cancel a long-running request
client.cancel_request(request_id, reason: "User cancelled")

# Listen for notifications (blocking)
client.listen  # dispatches to registered handlers

Elicitation

Handle server requests for additional user input during tool calls:

client.on_elicitation do |elicitation|
  puts "Server asks: #{elicitation.message}"
  puts "Schema: #{elicitation.requested_schema}"

  # Respond with user input
  Manceps::Elicitation.accept({ "name" => "Alice", "confirm" => true })
  # Or decline/cancel:
  # Manceps::Elicitation.decline
  # Manceps::Elicitation.cancel
end

Elicitation capability is automatically declared during initialization when a handler is registered.

Tasks (Experimental)

Track long-running operations:

# List tasks
client.tasks.each { |t| puts "#{t.id}: #{t.status}" }

# Get a specific task
task = client.get_task("task-123")
task.completed?  # => false
task.running?    # => true

# Poll until done
task = client.await_task("task-123", interval: 2, timeout: 60)
puts task.result

# Cancel a task
client.cancel_task("task-123")

Resilience

Automatic retry with exponential backoff on connection failures:

# Connect retries up to max_retries (default: 3)
client = Manceps::Client.new("https://mcp.example.com/mcp", auth: auth, max_retries: 5)

# Requests auto-retry once on session expiry (re-initializes session)
client.call_tool("search", query: "test")  # retries transparently on 404

# Check connection health
client.ping  # => true/false

# Manual reconnect
client.reconnect!

Protocol Version

Manceps targets MCP protocol 2025-11-25 by default. It negotiates with the server during initialization and supports fallback to 2025-06-18 and 2025-03-26.

After initialization, the MCP-Protocol-Version header is included on all HTTP requests per the spec.

License

MIT. See LICENSE for details.


Author: Obie Fernandez