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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions