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