Skip to content

Sorbet runtime call validation breaks MCP tool invocation #9

@chrisbutcher

Description

@chrisbutcher

Describe the bug

With Sorbet strict typing on a tool definition (example tool below), internal MCP server introspection of tool call method parameters breaks, since it is re-written by sorbet-runtime's call validation.

i.e. Despite a tool defining call with the param server_context:, the logic falls incorrectly into the else case of this logic.

Image

Which leads to a runtime error of:

> tool.call(**arguments.transform_keys(&:to_sym)).to_h
=> eval error: missing keyword: :server_context

To Reproduce

  1. Setup an MCP server with a Sorbet-typed tool as below
  2. Try invoking the tool with Claude IDE (for example)
  3. 💥
# typed: strict
# frozen_string_literal: true

class CounterTool < ModelContextProtocol::Tool
  extend T::Sig

  description "A simple counter tool that can increment and read a counter value"

  input_schema(
    properties: {
      action: {
        type: "string",
        enum: ["increment", "read"],
        description: "The action to perform: increment the counter or read its current value",
      },
    },
    required: ["action"],
  )

  # Track the counter state
  @@counter = T.let(0, Integer)

  sig { params(action: String, server_context: T::Hash[T.any(String, Symbol), T.untyped]).returns(ModelContextProtocol::Tool::Response) }
  def self.call(action:, server_context:)
    case action
    when "increment"
      @@counter += 1
      ModelContextProtocol::Tool::Response.new(
        [{ type: "text", text: "Counter incremented to #{@@counter}" }],
      )
    when "read"
      ModelContextProtocol::Tool::Response.new(
        [{ type: "text", text: "Current counter value is #{@@counter}" }],
      )
    else
      ModelContextProtocol::Tool::Response.new(
        [{ type: "text", text: "Invalid action. Use 'increment' or 'read'." }],
        is_error: true,
      )
    end
  end
end

Expected behavior

Sorbet typed MCP tools should "just work".

Suggested solution

See #10

Sorbet provides T::Utils.signature_for_method, which we can use if it's defined to introspect the original method's parameters.

# Calling code

def call_tool(request)
  # ...

  call_params = method_parameters(tool.method(:call))
  
  if call_params.include?(:server_context)
    tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h
  else
    tool.call(**arguments.transform_keys(&:to_sym)).to_h
  end

# ...

private

def method_parameters(method)
  default_value = method.parameters.flatten

  if defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method)
    method_sig = T::Utils.signature_for_method(method)

    if method_sig
      method_sig.parameters.flatten
    else
      default_value
    end
  else
    default_value
  end
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions