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.(
authorization_url: discovery.,
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..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.}"
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.}"
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