Skip to content

Commit d309fc9

Browse files
committed
Support Sorbet typed tools
1 parent 784b8b8 commit d309fc9

File tree

6 files changed

+100
-1
lines changed

6 files changed

+100
-1
lines changed

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ GEM
9191
ruby-progressbar (1.13.0)
9292
ruby2_keywords (0.0.5)
9393
securerandom (0.4.1)
94+
sorbet (0.5.11495)
95+
sorbet-static (= 0.5.11495)
96+
sorbet-runtime (0.5.11495)
97+
sorbet-static (0.5.11495-universal-darwin)
98+
sorbet-static-and-runtime (0.5.11495)
99+
sorbet (= 0.5.11495)
100+
sorbet-runtime (= 0.5.11495)
94101
stringio (3.1.7)
95102
tzinfo (2.0.6)
96103
concurrent-ruby (~> 1.0)
@@ -112,6 +119,7 @@ DEPENDENCIES
112119
model_context_protocol!
113120
rake (~> 13.0)
114121
rubocop-shopify
122+
sorbet-static-and-runtime
115123

116124
BUNDLED WITH
117125
2.5.9

lib/model_context_protocol/server.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ def call_tool(request)
193193
end
194194

195195
begin
196-
call_params = tool.method(:call).parameters.flatten
196+
call_params = tool_call_parameters(tool)
197+
197198
if call_params.include?(:server_context)
198199
tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h
199200
else
@@ -254,5 +255,24 @@ def index_resources_by_uri(resources)
254255
hash[resource.uri] = resource
255256
end
256257
end
258+
259+
def tool_call_parameters(tool)
260+
method_def = tool_call_method_def(tool)
261+
method_def.parameters.flatten
262+
end
263+
264+
def tool_call_method_def(tool)
265+
method = tool.method(:call)
266+
267+
if defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method)
268+
sorbet_typed_method_definition = T::Utils.signature_for_method(method)&.method
269+
270+
# Return the Sorbet typed method definition if it exists, otherwise fallback to original method
271+
# definition if Sorbet is defined but not used by this tool.
272+
sorbet_typed_method_definition || method
273+
else
274+
method
275+
end
276+
end
257277
end
258278
end

model_context_protocol.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ Gem::Specification.new do |spec|
2929

3030
spec.add_dependency("json_rpc_handler", "~> 0.1")
3131
spec.add_development_dependency("activesupport")
32+
spec.add_development_dependency("sorbet-static-and-runtime")
3233
end

test/model_context_protocol/server_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# typed: true
12
# frozen_string_literal: true
23

34
require "test_helper"
@@ -255,6 +256,43 @@ class ServerTest < ActiveSupport::TestCase
255256
assert_instrumentation_data({ method: "tools/call", tool_name: })
256257
end
257258

259+
test "#handle_json tools/call executes tool and returns result, when the tool is typed with Sorbet" do
260+
class TypedTestTool < Tool
261+
tool_name "test_tool"
262+
description "a test tool for testing"
263+
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
264+
265+
class << self
266+
extend T::Sig
267+
268+
sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) }
269+
def call(message:, server_context: nil)
270+
Tool::Response.new([{ type: "text", content: "OK" }])
271+
end
272+
end
273+
end
274+
275+
request = JSON.generate({
276+
jsonrpc: "2.0",
277+
method: "tools/call",
278+
params: { name: "test_tool", arguments: { message: "Hello, world!" } },
279+
id: 1,
280+
})
281+
282+
server = Server.new(
283+
name: @server_name,
284+
tools: [TypedTestTool],
285+
prompts: [@prompt],
286+
resources: [@resource],
287+
resource_templates: [@resource_template],
288+
)
289+
290+
raw_response = server.handle_json(request)
291+
response = JSON.parse(raw_response, symbolize_names: true) if raw_response
292+
293+
assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result])
294+
end
295+
258296
test "#handle tools/call returns internal error and reports exception if the tool raises an error" do
259297
@server.configuration.exception_reporter.expects(:call).with do |exception, server_context|
260298
assert_not_nil exception

test/model_context_protocol/tool_test.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# typed: true
12
# frozen_string_literal: true
23

34
require "test_helper"
@@ -203,5 +204,34 @@ class UpdatableAnnotationsTool < Tool
203204
tool.annotations(title: "Updated")
204205
assert_equal tool.annotations_value.title, "Updated"
205206
end
207+
208+
test "#call with Sorbet typed tools invokes the tool block and returns the response" do
209+
class TypedTestTool < Tool
210+
tool_name "test_tool"
211+
description "a test tool for testing"
212+
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
213+
annotations(
214+
title: "Test Tool",
215+
read_only_hint: true,
216+
destructive_hint: false,
217+
idempotent_hint: true,
218+
open_world_hint: false,
219+
)
220+
221+
class << self
222+
extend T::Sig
223+
224+
sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) }
225+
def call(message, server_context: nil)
226+
Tool::Response.new([{ type: "text", content: "OK" }])
227+
end
228+
end
229+
end
230+
231+
tool = TypedTestTool
232+
response = tool.call("test")
233+
assert_equal response.content, [{ type: "text", content: "OK" }]
234+
assert_equal response.is_error, false
235+
end
206236
end
207237
end

test/test_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
require "active_support"
1414
require "active_support/test_case"
1515

16+
require "sorbet-runtime"
17+
1618
require_relative "instrumentation_test_helper"
1719

1820
Minitest::Reporters.use!(Minitest::Reporters::ProgressReporter.new)

0 commit comments

Comments
 (0)