Ruby MCP Client

by simonx1

Not rated
GitHub

About

A Ruby client for the Model Context Protocol (MCP), enabling integration with external tools and services via a standardized protocol.

Details

Author
simonx1
Categories
Developer Tools, API

Setup

Install Ruby MCP Client in your MCP client (Claude Desktop, Cursor, Windsurf, and others).

Repository: https://github.com/simonx1/ruby-mcp-client

Follow the installation instructions in the repository README, then restart your MCP client.

A Ruby client for the Model Context Protocol (MCP), enabling integration with external tools and services via a standardized protocol.

# Gemfile gem 'ruby-mcp-client'
bundle install # or gem install ruby-mcp-client

MCP enables AI assistants to discover and invoke external tools via different transport mechanisms:

- stdio- Local processes implementing the MCP protocol
- SSE- Server-Sent Events with streaming support
- HTTP- Simple request/response (non-streaming)
- Streamable HTTP- HTTP POST with SSE-formatted responses

Built-in API conversions:to_openai_tools(),to_anthropic_tools(),to_google_tools()

Implements theMCP 2025-11-25specification. The client negotiates the protocol version duringinitializeand disconnects if the server answers with a revision it cannot speak (supported:2025-11-25,2025-06-18,2025-03-26,2024-11-05):

- Tools: list, call, streaming, annotations (hint-style), structured outputs, title
- Prompts: list, get with parameters
- Resources: list, read, templates, subscriptions, pagination, ResourceLink content
- Elicitation: Server-initiated user interactions (stdio, SSE, Streamable HTTP)
- Roots: Filesystem scope boundaries with change notifications
- Sampling: Server-requested LLM completions with modelPreferences
- Completion: Autocomplete for prompts/resources with context
- Logging: Server log messages with level filtering
- Tasks: Task-augmentedtools/call— create with attl, polltasks/get, retrieve viatasks/result, plustasks/listandtasks/cancel
- Audio: Audio content type support
- Progress & Cancellation:progressTokenplumbing with per-call callbacks; automaticnotifications/cancelledfor abandoned requests
- Metadata:icons,titleand_metaparsed on tools, prompts and resources
- OAuth 2.1: PKCE (S256 required), RFC 8414/9728 discovery, dynamic registration, Client ID Metadata Documents, scope step-up challenges

Transports treat the server as untrusted input — seeTreating the Server as Untrustedfor the limits applied to peer-controlled data.

The simplest way to connect to an MCP server:

require 'mcp_client' # Auto-detect transport from URL client = MCPClient.connect('http://localhost:8000/sse') # SSE client = MCPClient.connect('http://localhost:8931/mcp') # Streamable HTTP client = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem /home') # stdio # With options client = MCPClient.connect('http://api.example.com/mcp', headers: { 'Authorization' => 'Bearer TOKEN' }, read_timeout: 60, retries: 3, logger: Logger.new($stdout) ) # Multiple servers client = MCPClient.connect(['http://server1/mcp', 'http://server2/sse']) # Force specific transport client = MCPClient.connect('http://custom.com/api', transport: :streamable_http) # Use the client tools = client.list_tools result = client.call_tool('example_tool', { param: 'value' }) client.cleanup

Working with Tools, Prompts & Resources

# Tools tools = client.list_tools result = client.call_tool('tool_name', { param: 'value' }) result = client.call_tool('tool_name', { param: 'value' }, server: 'server_name') # Batch tool calls results = client.call_tools([ { name: 'tool1', parameters: { key: 'value' } }, { name: 'tool2', parameters: { key: 'value' }, server: 'specific_server' } ]) # Streaming (SSE/Streamable HTTP) client.call_tool_streaming('tool', { param: 'value' }).each do |chunk| puts chunk end # Prompts prompts = client.list_prompts result = client.get_prompt('greeting', { name: 'Alice' }) # Pagination: list_tools and list_prompts automatically follow the server's # nextCursor and return the COMPLETE set across all pages (with a per-call # safety bound and an identical-cursor loop guard). No manual cursor handling # is required. # Resources result = client.list_resources contents = client.read_resource('file:///example.txt') contents.each do |content| puts content.text if content.text? data = Base64.decode64(content.blob) if content.binary? end
tool = client.find_tool('delete_user') # Hint-style annotations (MCP 2025-11-25) # Defaults follow the MCP ToolAnnotations schema: when a hint is absent the # client assumes the less-safe value, so an un-annotated tool is treated as # writable, potentially destructive, and open-world. tool.read_only_hint? # Defaults to false; tool may modify its environment tool.destructive_hint? # Defaults to true; tool may perform destructive updates tool.idempotent_hint? # Defaults to false; repeated calls may have additional effects tool.open_world_hint? # Defaults to true; tool may interact with external entities # Legacy annotations tool.read_only? # Safe to execute? tool.destructive? # Warning: destructive operation tool.requires_confirmation? # Needs user confirmation
tool = client.find_tool('get_weather') tool.structured_output? # Has output schema? tool.output_schema # JSON Schema for output result = client.call_tool('get_weather', { location: 'SF' }) data = result['structuredContent'] # Type-safe structured data # Per MCP 2025-11-25, clients SHOULD validate structured results against the # tool's output schema, and a tool that declares an outputSchema must return # structuredContent in successful results. call_tool checks both automatically # for the common JSON Schema keywords (type, properties, required, items, enum, # numeric/string bounds). The full 2020-12 vocabulary ($ref/$dynamicRef/$defs, # allOf/anyOf/oneOf/not, if/then/else, additionalProperties, patternProperties, # propertyNames, prefixItems, contains/minContains/maxContains, uniqueItems, # multipleOf, format, dependentRequired/dependentSchemas, minProperties/ # maxProperties, unevaluated) is NOT evaluated: when a schema uses any of # those keywords, call_tool logs a "validation is partial" warning naming them # (in both modes), since data may pass this check that a full validator would # reject. By default a violation (mismatch, or missing structuredContent on a # successful result) logs a warning; opt in to strict mode to raise instead: client = MCPClient::Client.new( mcp_server_configs: [...], validate_structured_content: :strict # raises MCPClient::Errors::ValidationError on violation ) # Task-delivered results (get_task_result) are not validated yet.
# Set filesystem scope boundaries client.roots = [ { uri: 'file:///home/user/project', name: 'Project' }, { uri: 'file:///var/log', name: 'Logs' } ] # Access current roots client.roots

Sampling (Server-requested LLM completions)

# Configure handler when creating client client = MCPClient.connect('http://server/mcp', sampling_handler: ->(messages, model_prefs, system_prompt, max_tokens) { # Process server's LLM request { 'model' => 'gpt-4', 'stopReason' => 'endTurn', 'role' => 'assistant', 'content' => { 'type' => 'text', 'text' => 'Response here' } } } )

Sampling tool calling (SEP-1577) is opt-in: passsampling_supports_tools: trueto declare thesampling.toolscapability. The handler then receives the full request params (includingtools/toolChoice) as an optional fifth argument; without the opt-in, tool-enabled sampling requests are rejected with-32602as the spec requires:

client = MCPClient::Client.new( mcp_server_configs: [...], sampling_supports_tools: true, sampling_handler: ->(messages, prefs, system_prompt, max_tokens, params = nil) { tools = params && params['tools'] # ToolUseContent may be returned in content # ... } )

Attach a per-call progress callback — the client generates a uniqueprogressToken, places it in the request_meta, and routes matchingnotifications/progressto your block while the request is active (stale tokens after completion are dropped):

client.call_tool('long_running', args, progress: ->(progress, total, message) { puts "#{message}: #{progress}/#{total}" })

A request-level_meta(e.g. a hand-pickedprogressToken) can also be passed inside the arguments under the'_meta'key on every transport — it is hoisted to the JSON-RPC params level on the wire, never sent as a tool argument.

Timeouts are configurable per request in addition to the per-serverread_timeout. A timed-out request raisesMCPClient::Errors::RequestTimeoutError(aTransportErrorsubclass), isneversilently re-sent by the retry layer, and a best-effortnotifications/cancelledis sent for the abandoned request (never forinitialize, and task-augmented calls usetasks/cancelinstead):

client.send_rpc('tools/call', params: { name: 'slow', arguments: {} }, timeout: 300) server.rpc_request('tools/list', {}, timeout: 5)

Hosts can present their ownImplementationinfo (sent asclientInfoduring initialize;nameandversionrequired —title,description,websiteUrl,iconsoptional), and read the server'sinstructionshint after connecting:

client = MCPClient::Client.new( mcp_server_configs: [...], client_info: { 'name' => 'my-ide', 'version' => '2.0.0', 'description' => 'An MCP-powered IDE' } ) client.servers.first.connect puts client.servers.first.instructions # e.g. "Use the search tool before answering."
result = client.complete( ref: { type: 'ref/prompt', name: 'greeting' }, argument: { name: 'name', value: 'A' } ) # => { 'values' => ['Alice', 'Alex'], 'total' => 100, 'hasMore' => true }
# Set log level client.log_level = 'debug' # debug/info/notice/warning/error/critical # Handle log notifications client.on_notification do |server, method, params| if method == 'notifications/message' puts "[#{params['level']}] #{params['logger']}: #{params['data']}" end end

Tasks (Long-running, task-augmented tools)

A task-capable server (one advertisingtasks.requests.tools.call) can run a tool whoseexecution.taskSupportisoptionalorrequiredas a background task: the call returns immediately with a task handle, and the result is fetched later. Try it locally:python3 examples/echo_server_streamable.py &then./examples/tasks_example.rbruns the full lifecycle against a task-capable demo server.

tool = client.find_tool('long_job') tool.supports_task? # execution.taskSupport is optional/required? # Create the task (returns immediately); ttl is the requested lifetime in ms task = client.call_tool_as_task('long_job', { input: 'data' }, ttl: 60_000) # Poll until the task reaches a terminal (or input-required) status, # honoring the server's suggested poll interval until task.terminal? || task.input_required? sleep((task.poll_interval || 1000) / 1000.0) task = client.get_task(task) # tasks/get, routed to the task's own server end # Retrieve the underlying result (e.g. a CallToolResult) via tasks/result result = client.get_task_result(task) # List and cancel tasks page = client.list_tasks # { tasks: [...], next_cursor: ... } client.cancel_task(task) # tasks/cancel

Task IDs are only unique within the server that issued them, so pass theTaskreturned bycall_tool_as_task— it carries its own server. A bare task ID also works when the client has a single server; with several servers configured it raisesArgumentErrorrather than guessing, so name the server explicitly:

client.get_task('task-123', server: 'my-server') # React to server-pushed status updates client.on_notification do |server, method, params| puts "Task #{params['taskId']} -> #{params['status']}" if method == 'notifications/tasks/status' end

Elicitation (Server-initiated user interactions)

client = MCPClient::Client.new( mcp_server_configs: [MCPClient.stdio_config(command: 'python server.py')], elicitation_handler: ->(message, schema) { puts "Server asks: #{message}" # Return: { 'action' => 'accept', 'content' => { 'field' => 'value' } } # Or: { 'action' => 'decline' } or { 'action' => 'cancel' } } )

For more control, usecreate_clientwith explicit configs:

client = MCPClient.create_client( mcp_server_configs: [ MCPClient.stdio_config(command: 'npx server', name: 'local'), MCPClient.sse_config( base_url: 'https://api.example.com/sse', headers: { 'Authorization' => 'Bearer TOKEN' }, read_timeout: 30, ping: 10, retries: 3 ), MCPClient.http_config( base_url: 'https://api.example.com', endpoint: '/rpc', headers: { 'Authorization' => 'Bearer TOKEN' } ), MCPClient.streamable_http_config( base_url: 'https://api.example.com/mcp', read_timeout: 60, retries: 3 ) ], logger: Logger.new($stdout) ) # Or load from JSON file client = MCPClient.create_client(server_definition_file: 'servers.json')

Theretries:option controls automatic retry with exponential backoff. Only failures where the request most likely didnotcomplete at the server are retried: transport/network errors and HTTP5xxresponses. Application-level failures — a JSON-RPC error response or an HTTP4xx— areneverretried, because the server already processed or rejected the request. Retryable server failures raiseMCPClient::Errors::TransientServerError, a subclass ofMCPClient::Errors::ServerError, so existingrescue ServerErrorhandlers are unaffected.

tools/callis never retried automatically.Even a "transient" failure can arriveafterthe server executed the request, and JSON-RPC has no idempotency key that would make a replay safe — so a retry could run a side effect twice. Retry a tool call explicitly if your application knows it is safe to repeat, and treat the raised error asoutcome unknownrather thannot executed:

begin client.call_tool('send_invoice', { customer: 'acme' }) rescue MCPClient::Errors::TransportError => e # The server may or may not have sent the invoice. Check before retrying. end

The same reasoning excludesRequestTimeoutErrorandResponseTooLargeErrorfrom retries, and applies to session recovery: if atools/callcomes back with an expired-session 404, the client starts a fresh session but doesnotre-send the call — it raises so you can decide. Idempotent requests are re-sent against the new session as before.

A gzip-encoded response is decompressed incrementally and abandoned once it expands pastmax_decompressed_body_bytes(default64 MiB), so a small highly-compressed body cannot exhaust memory. Exceeding it raisesMCPClient::Errors::ResponseTooLargeError.

Raise the limit if you legitimately exchange very large payloads — base64 resource blobs or audio — so that whether a response is accepted does not depend on the server's choice to compress it:

MCPClient.streamable_http_config( base_url: 'https://api.example.com/mcp', max_decompressed_body_bytes: 256  1024  1024 )
MCPClient.http_config(base_url: 'https://internal.company.com') do |faraday| faraday.ssl.cert_store = custom_cert_store faraday.ssl.verify = true end
{ "mcpServers": { "filesystem": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home"] }, "api": { "type": "streamable_http", "url": "https://api.example.com/mcp", "headers": { "Authorization": "Bearer TOKEN" } } } }
require 'mcp_client' require 'openai' mcp = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem .') tools = mcp.to_openai_tools client = OpenAI::Client.new(api_key: ENV['OPENAI_API_KEY']) response = client.chat.completions.create( model: 'gpt-4', messages: [{ role: 'user', content: 'List files' }], tools: tools )
require 'mcp_client' require 'anthropic' mcp = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem .') tools = mcp.to_anthropic_tools client = Anthropic::Client.new(access_token: ENV['ANTHROPIC_API_KEY']) # Use tools with Claude API
require 'mcp_client' require 'ruby_llm' RubyLLM.configure { |c| c.openai_api_key = ENV['OPENAI_API_KEY'] } mcp = MCPClient.connect('http://localhost:8931/mcp') # Playwright MCP # Wrap each MCP tool as a RubyLLM tool tools = mcp.list_tools.map do |t| tool_name = t.name Class.new(RubyLLM::Tool) do description t.description params t.schema define_method(:name) { tool_name } define_method(:execute) { |args| mcp.call_tool(tool_name, args) } end.new end chat = RubyLLM.chat(model: 'gpt-4o-mini') tools.each { |tool| chat.with_tool(tool) } response = chat.ask('Navigate to google.com and tell me the page title')

Seeexamples/for complete implementations:

- ruby_openai_mcp.rb,openai_ruby_mcp.rb- OpenAI integration
- ruby_anthropic_mcp.rb- Anthropic integration
- gemini_ai_mcp.rb- Google Vertex AI integration
- ruby_llm_mcp.rb- RubyLLM integration (OpenAI provider)

Theexamples/run_all_examples.shharness runs every example that can run on the current machine — self-contained stdio servers, the Python/Flask/FastMCP echo and elicitation servers,npx-based MCP servers, and (optionally) the paid LLM integrations. It starts and tears down each server automatically and prints aPASS/FAIL/SKIPsummary.tasks_example.rbis always skipped (it needs a task-capable remote server);oauth_browser_auth.rbis interactive and only runs when you opt in withRUN_OAUTH=1.

Runbundle installfirst. The script preflight-checks the following and prints a warning (it doesnotabort) for anything missing; affected examples are then skipped or fail:

- ruby,bundle,curl,lsof- onPATH
- python3(or$PYTHON) plus a separatepythonbinary - onPATH
- Python packagesflask,fastmcp,mcp- importable by$PYTHON
- npx(Node) - needed by thenpx-based example (json_input) and by every LLM example, which spawnnpxfilesystem/Playwright servers

examples/run_all_examples.sh # run everything runnable on this machine RUN_AI=0 examples/run_all_examples.sh # skip the paid-LLM examples RUN_NPX=0 examples/run_all_examples.sh # skip the npx-based example (json_input) LOG_DIR=/path examples/run_all_examples.sh # write logs to a chosen dir PYTHON=python3.12 TIMEOUT=180 examples/run_all_examples.sh # override interpreter and per-example timeout

Real secrets live inexamples/secrets.env, which isgitignoredand sourced automatically (everyKEY=valueline is exported) when present. Copy the tracked template to get started:

cp examples/secrets.env.example examples/secrets.env # then set ZAPIER_MCP_TOKEN=... to enable the Zapier streamable-HTTP example

SetZAPIER_MCP_TOKEN(from the Zapier MCP setup page, "Option 1: Authorization header") to runstreamable_http_example.rbandoauth_example.rbagainst Zapier; overrideZAPIER_MCP_URLif your connect URL differs. To run the interactiveoauth_browser_auth.rb, setMCP_SERVER_URL(e.g. an ngrok tunnel to your OAuth-protected MCP server) insecrets.envand passRUN_OAUTH=1. The LLM examples each need their own credentials in the environment and are skipped without them:

- ruby_anthropic_mcp.rb-ANTHROPIC_API_KEY(+npx)
- openai_ruby_mcp.rb-OPENAI_API_KEY(+npx)
- ruby_openai_mcp.rb,ruby_llm_mcp.rb-OPENAI_API_KEY(+npx, plus a Playwright MCP server on:8931)
- gemini_ai_mcp.rb- a Vertex service-account JSON atVERTEX_CREDENTIALS_FILE(defaultexamples/google-credentials.json, +npx)

Most examples print their own success/failure marks but exit0regardless, so the harness combines the exit code with a scan of the output rather than trusting the exit status alone. An exampleFAILs when it exits nonzero, times out (exit124), prints a hard-error signature (a Ruby/Python traceback,Connection refused,uninitialized constant, and similar), prints amark, or is missing its expected success marker; otherwise itPASSes. (Thecheck is suppressed withIGNORE_XMARK=1for the interactive elicitation demos, wherecan be legitimate "declined" output.) The script exits0only if zero examples failed —SKIPs do not affect the exit status.

For deeper, per-topic walkthroughs seeexamples/README.md,examples/README_ECHO_SERVER.md,examples/STREAMABLE_HTTP_TESTING.md, andexamples/elicitation/README.md.

require 'mcp_client' require 'mcp_client/auth/browser_oauth' oauth = MCPClient::Auth::OAuthProvider.new( server_url: 'https://api.example.com/mcp', redirect_uri: 'http://localhost:8080/callback', scope: 'mcp:read mcp:write' ) browser_oauth = MCPClient::Auth::BrowserOAuth.new(oauth) token = browser_oauth.authenticate # Opens browser, handles callback client = MCPClient::Client.new( mcp_server_configs: [{ type: 'streamable_http', base_url: 'https://api.example.com/mcp', oauth_provider: oauth }] )

Features: PKCE, server discovery (.well-known), dynamic registration, token refresh.

- Client ID Metadata Documents (SEP-991)— passclient_id_metadata_url: 'https://myapp.example/oauth-client.json'(an HTTPS URL with a path, which doubles as theclient_id); when the authorization server advertisesclient_id_metadata_document_supported, dynamic client registration is skipped entirely.
-
Scope challenges (SEP-835)— an HTTP 403insufficient_scopechallenge raisesMCPClient::Errors::InsufficientScopeError(aConnectionErrorsubclass) exposing#scopeand#error_description; the challenged scopes are treated as authoritative for the next authorization flow.
-
PKCE— authorization refuses to proceed when the authorization server does not advertisecode_challenge_methods_supportedincludingS256.

client.on_notification do |server, method, params| case method when 'notifications/tools/list_changed' client.clear_cache # Auto-handled when 'notifications/message' puts "Log: #{params['data']}" when 'notifications/roots/list_changed' puts "Roots changed" end end

Both HTTP and Streamable HTTP transports automatically handle session-based servers:

- Session capture: ExtractsMcp-Session-Idfrom initialize response
-
Session persistence: Includes session header in subsequent requests
-
Session termination: Sends DELETE request during cleanup
-
Resumability(Streamable HTTP, SEP-1699): tracks SSE event IDs and, when a response stream is interrupted, resumes via GET withLast-Event-IDso the server can replay missed messages — honoring the server'sretry:directive

No configuration required - works automatically.

- @modelcontextprotocol/server-filesystem
-
@playwright/mcp
-
FastMCP
- Custom servers implementing MCP protocol

# Start server python examples/echo_server_streamable.py
# Connect and use client = MCPClient.connect('http://localhost:8931/mcp') tools = client.list_tools result = client.call_tool('echo', { message: 'Hello!' })

A connected MCP server controls everything it sends you, and the transports are written on that assumption. You do not need to configure any of this — it is the default behaviour — but it is worth knowing what the client will refuse:

Known limit:the OAuth check is textual. A peer can still advertise a public hostname whose DNS record points inside your network; catching that needs resolution-time filtering in the HTTP layer, which this gem does not do. If you run in an environment where that matters, restrict egress at the network layer.

Two related defaults worth calling out because they affectyourdata rather than the peer's:

- Payloads are never written to logs.At DEBUG the client logs a method/id summary and a byte count, not request params, response bodies or raw SSE chunks. Server configurations are logged with credential-bearing keys redacted.
-
Host exceptions are not reflected to the server.
*A raising elicitation, sampling or roots handler yields a constant JSON-RPC error message; the detail stays in your local log.

- Ruby >= 3.2.0
- Runtime dependencies:faraday(~> 2.0) withfaraday-follow_redirectsandfaraday-retry, plusbase64— all pulled in automatically by the gem

Development uses Ruby 4.0.6 (see.ruby-version). CI runs the suite on 4.0.6 plus the supported floor, 3.2 and 3.3.

Available as open source under theMIT License.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.