Class: ClaudeAgentSDK::SdkMcpServer

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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_schedulingObject

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_wrapperObject

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_serverObject (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

#nameObject (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

#promptsObject (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

#resourcesObject (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

#toolsObject (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

#versionObject (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.

Parameters:

  • name (String)

    Tool name

  • arguments (Hash)

    Tool arguments

Returns:

  • (Hash)

    Tool result (with isError: true on failure)



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.message)
end

#effective_callback_dispatchArray(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.

Returns:

  • (Array(Symbol, #call))

    [scheduling, wrapper]



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_schedulingObject

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_wrapperObject

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)

Parameters:

  • name (String)

    Prompt name

  • arguments (Hash) (defaults to: {})

    Arguments to fill in the prompt template

Returns:

  • (Hash)

    Prompt with filled-in arguments



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)
  messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
  raise "Prompt '#{name}' must return a hash with :messages key" if messages.nil?

  result
end

#handle_json(json_string) ⇒ String

Handle a JSON-RPC request

Parameters:

  • json_string (String)

    JSON-RPC request

Returns:

  • (String)

    JSON-RPC response



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:

  1. 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).
  2. 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.

Parameters:

  • message (Hash)

    JSON-RPC request hash

Returns:

  • (Hash)

    JSON-RPC response hash



217
218
219
220
221
222
# File 'lib/claude_agent_sdk/sdk_mcp_server.rb', line 217

def handle_message(message)
  original_id = message[:id]
  response = @mcp_server.handle(message.merge(jsonrpc: '2.0', id: 0))
  response[:id] = original_id if response.is_a?(Hash) && response.key?(:id)
  normalize_tools_call_errors(message, response)
end

#list_promptsArray<Hash>

List all available prompts (for backward compatibility)

Returns:

  • (Array<Hash>)

    Array of prompt definitions



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_resourcesArray<Hash>

List all available resources (for backward compatibility)

Returns:

  • (Array<Hash>)

    Array of resource definitions



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_toolsArray<Hash>

List all available tools (for backward compatibility)

Returns:

  • (Array<Hash>)

    Array of tool definitions



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.meta if tool.meta
    entry
  end
end

#read_resource(uri) ⇒ Hash

Read a resource by URI (for backward compatibility)

Parameters:

  • uri (String)

    Resource URI

Returns:

  • (Hash)

    Resource content



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