Class: ClaudeAgentSDK::SdkMcpServer
- Inherits:
-
Object
- Object
- ClaudeAgentSDK::SdkMcpServer
- Defined in:
- lib/claude_agent_sdk/sdk_mcp_server.rb
Overview
SDK MCP Server - wraps official MCP::Server with block-based API
Unlike external MCP servers that run as separate processes, SDK MCP servers run directly in your application's process, providing better performance and simpler deployment.
This class wraps the official MCP Ruby SDK and provides a simpler block-based API for defining tools, resources, and prompts.
Instance Attribute Summary collapse
-
#callback_scheduling ⇒ Object
Default for where user handlers run when this server is invoked DIRECTLY (call_tool / read_resource / get_prompt outside a session): :thread hops to a plain thread, :inline runs in place.
-
#callback_wrapper ⇒ Object
Default callback wrapper for DIRECT invocations of this server (call_tool / read_resource / get_prompt outside a session).
-
#mcp_server ⇒ Object
readonly
Returns the value of attribute mcp_server.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#prompts ⇒ Object
readonly
Returns the value of attribute prompts.
-
#resources ⇒ Object
readonly
Returns the value of attribute resources.
-
#tools ⇒ Object
readonly
Returns the value of attribute tools.
-
#version ⇒ Object
readonly
Returns the value of attribute version.
Instance Method Summary collapse
-
#call_tool(name, arguments) ⇒ Hash
Execute a tool by name (backward-compat public API; Query's tools/call dispatch routes through handle_message/the official MCP::Server, which also validates arguments against the tool's inputSchema — this direct path bypasses that validation).
-
#effective_callback_dispatch ⇒ Array(Symbol, #call)
private
Internal — public only so the dynamic tool classes can reach it.
-
#effective_callback_scheduling ⇒ Object
private
Internal.
-
#effective_callback_wrapper ⇒ Object
private
Internal.
-
#get_prompt(name, arguments = {}) ⇒ Hash
Get a prompt by name (for backward compatibility).
-
#handle_json(json_string) ⇒ String
Handle a JSON-RPC request.
-
#handle_message(message) ⇒ Hash
Route one JSON-RPC request hash (symbol keys, as produced by the transport) through the official MCP::Server.
-
#initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: []) ⇒ SdkMcpServer
constructor
A new instance of SdkMcpServer.
-
#list_prompts ⇒ Array<Hash>
List all available prompts (for backward compatibility).
-
#list_resources ⇒ Array<Hash>
List all available resources (for backward compatibility).
-
#list_tools ⇒ Array<Hash>
List all available tools (for backward compatibility).
-
#read_resource(uri) ⇒ Hash
Read a resource by URI (for backward compatibility).
Constructor Details
#initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: []) ⇒ SdkMcpServer
Returns a new instance of SdkMcpServer.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 163 def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: []) @name = name @version = version @tools = tools @resources = resources @prompts = prompts @callback_scheduling = :thread @callback_wrapper = nil # Create dynamic Tool classes from tool definitions tool_classes = create_tool_classes(tools) # Resources are served as MCP::Resource instances; reads go through # the gem's registerable handler (see register_resources_read_handler). resource_instances = create_resource_instances(resources) # Create dynamic Prompt classes from prompt definitions prompt_classes = create_prompt_classes(prompts) # Create the official MCP::Server instance @mcp_server = MCP::Server.new( name: name, version: version, tools: tool_classes, resources: resource_instances, prompts: prompt_classes ) register_resources_read_handler end |
Instance Attribute Details
#callback_scheduling ⇒ Object
Default for where user handlers run when this server is invoked DIRECTLY (call_tool / read_resource / get_prompt outside a session): :thread hops to a plain thread, :inline runs in place. When a session dispatches to this server, the session's own mode arrives via fiber storage instead (see #effective_callback_scheduling) — a server shared by concurrent sessions with different modes is never mutated, so modes cannot cross-contaminate or persist past a session.
91 92 93 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 91 def callback_scheduling @callback_scheduling end |
#callback_wrapper ⇒ Object
Default callback wrapper for DIRECT invocations of this server (call_tool / read_resource / get_prompt outside a session). When a session dispatches to this server, the session's own wrapper arrives via fiber storage instead (see #effective_callback_wrapper) — same never-mutate-the-shared-server rule as callback_scheduling.
98 99 100 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 98 def callback_wrapper @callback_wrapper end |
#mcp_server ⇒ Object (readonly)
Returns the value of attribute mcp_server.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def mcp_server @mcp_server end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def name @name end |
#prompts ⇒ Object (readonly)
Returns the value of attribute prompts.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def prompts @prompts end |
#resources ⇒ Object (readonly)
Returns the value of attribute resources.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def resources @resources end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def tools @tools end |
#version ⇒ Object (readonly)
Returns the value of attribute version.
82 83 84 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 82 def version @version end |
Instance Method Details
#call_tool(name, arguments) ⇒ Hash
Execute a tool by name (backward-compat public API; Query's tools/call dispatch routes through handle_message/the official MCP::Server, which also validates arguments against the tool's inputSchema — this direct path bypasses that validation). Tool-execution failures are reported in-band (isError: true) per the MCP spec and Python parity (the mcp lowlevel server converts handler exceptions to CallToolResult(isError=True)); they must NOT become JSON-RPC protocol errors — the model needs the error text to self-correct.
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 251 def call_tool(name, arguments) tool = @tools.find { |t| t.name == name } return error_tool_result("Tool '#{name}' not found") unless tool # Call the tool's handler on a plain thread (default) so the async # gem's Fiber scheduler is not visible to user code (which may hit # AR/PG); in :inline mode it runs in place on the reactor fiber. scheduling, wrapper = effective_callback_dispatch result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do tool.handler.call(arguments) end # Guard before flexible_fetch: it raises on non-Hash inputs. content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content result rescue StandardError => e # Bare e.message like Python's str(e) — no prefix. error_tool_result(e.) end |
#effective_callback_dispatch ⇒ Array(Symbol, #call)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Internal — public only so the dynamic tool classes can reach it. The
(scheduling mode, wrapper) pair for the current invocation, resolved
from ONE liveness decision: the dispatching session's pair when a
live CallbackDispatchScope is in fiber storage (set by Query around the
dispatch), else this server's own defaults. The pair MUST be resolved
together — deciding active? once per accessor lets the scope close
between the two reads (the dispatch's ensure runs concurrently with a
descendant reader) and yields a torn mix of session mode with server
wrapper. A scope inherited from an already-finished dispatch is
closed and deliberately ignored — a child task spawned inside a
handler must not carry the session's pair into later direct calls.
140 141 142 143 144 145 146 147 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 140 def effective_callback_dispatch scope = Fiber[FiberBoundary::DISPATCH_KEY] if scope&.active? [scope.mode, scope.wrapper] else [@callback_scheduling, @callback_wrapper] end end |
#effective_callback_scheduling ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Internal. Prefer #effective_callback_dispatch when both values are needed — separate calls re-decide scope liveness and can tear.
152 153 154 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 152 def effective_callback_scheduling effective_callback_dispatch[0] end |
#effective_callback_wrapper ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Internal. Prefer #effective_callback_dispatch when both values are needed — separate calls re-decide scope liveness and can tear.
159 160 161 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 159 def effective_callback_wrapper effective_callback_dispatch[1] end |
#get_prompt(name, arguments = {}) ⇒ Hash
Get a prompt by name (for backward compatibility)
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 325 def get_prompt(name, arguments = {}) prompt = @prompts.find { |p| p.name == name } raise "Prompt '#{name}' not found" unless prompt # Hop off the Fiber scheduler before invoking user code — same reason # as `call_tool` above. scheduling, wrapper = effective_callback_dispatch result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do prompt.generator.call(arguments) end # Ensure result has the expected format (symbol or string keys) = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil raise "Prompt '#{name}' must return a hash with :messages key" if .nil? result end |
#handle_json(json_string) ⇒ String
Handle a JSON-RPC request
196 197 198 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 196 def handle_json(json_string) @mcp_server.handle_json(json_string) end |
#handle_message(message) ⇒ Hash
Route one JSON-RPC request hash (symbol keys, as produced by the transport) through the official MCP::Server. Two sanitations, both empirically required:
- The gem's JsonRpcHandler rejects string ids not matching /\A[a-zA-Z0-9_-]+\z/ with nil, error: -32600 (Python echoes any id verbatim) — swap in a safe id and re-stamp the original on the response (error envelopes too).
- The gem rejects messages lacking jsonrpc: '2.0' with -32600; Python never inspects this field and the CLI's embedded mcp_message shape is not guaranteed — force it. NOTE on concurrency: Query runs each control_request in its own async task, so two tools/call can interleave inside the gem's Server#handle. Responses are built from per-call locals (safe), but the gem's instrumentation_callback attribution (@instrumentation_data ivar) can cross-contaminate under concurrency — harmless with the default no-op.
217 218 219 220 221 222 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 217 def () original_id = [:id] response = @mcp_server.handle(.merge(jsonrpc: '2.0', id: 0)) response[:id] = original_id if response.is_a?(Hash) && response.key?(:id) normalize_tools_call_errors(, response) end |
#list_prompts ⇒ Array<Hash>
List all available prompts (for backward compatibility)
311 312 313 314 315 316 317 318 319 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 311 def list_prompts @prompts.map do |prompt| { name: prompt.name, description: prompt.description, arguments: prompt.arguments }.compact end end |
#list_resources ⇒ Array<Hash>
List all available resources (for backward compatibility)
275 276 277 278 279 280 281 282 283 284 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 275 def list_resources @resources.map do |resource| { uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mime_type }.compact end end |
#list_tools ⇒ Array<Hash>
List all available tools (for backward compatibility)
226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 226 def list_tools @tools.map do |tool| entry = { name: tool.name, description: tool.description, inputSchema: convert_input_schema(tool.input_schema) } entry[:annotations] = tool.annotations if tool.annotations entry[:_meta] = tool. if tool. entry end end |
#read_resource(uri) ⇒ Hash
Read a resource by URI (for backward compatibility)
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 289 def read_resource(uri) resource = @resources.find { |r| r.uri == uri } raise "Resource '#{uri}' not found" unless resource # Hop off the Fiber scheduler before invoking user code — same reason # as `call_tool` above: reader blocks may touch Thread.current-keyed # libraries (ActiveRecord, pg, ...) and must run on a plain thread. scheduling, wrapper = effective_callback_dispatch content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do resource.reader.call end # Ensure content has the expected format (symbol or string keys; guard # before flexible_fetch — it raises on non-Hash inputs) contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, "contents", "contents") : nil raise "Resource '#{uri}' must return a hash with :contents key" if contents.nil? content end |