diff --git a/Gemfile.lock b/Gemfile.lock index e412c55..3a99330 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.5.1) + model_context_protocol (0.5.2) json_rpc_handler (~> 0.1) GEM diff --git a/README.md b/README.md index 1a0f612..a452fde 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ It implements the Model Context Protocol specification, handling model context r ### Usage +#### Rails Controller Implement an `ApplicationController` which calls the `Server#handle` method, eg ```ruby @@ -76,6 +77,59 @@ module ModelContextProtocol end ``` +#### Stdio Transport + +If you want to build a simple command-line application, you can use the stdio transport: + +```ruby +#!/usr/bin/env ruby +require "model_context_protocol" +require "model_context_protocol/transports/stdio" + +# Create a simple tool +class ExampleTool < ModelContextProtocol::Tool + description "A simple example tool that echoes back its arguments" + input_schema type: "object", + properties: { + message: { type: "string" }, + }, + required: ["message"] + + class << self + def call(message:, context:) + ModelContextProtocol::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +# Set up the server +server = ModelContextProtocol::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +# Create and start the transport +transport = ModelContextProtocol::Transports::StdioTransport.new(server) +transport.open +``` + +You can run this script and then type in requests to the server at the command line. + +``` +$ ./stdio_server.rb +{"jsonrpc":"2.0","id":"1","result":"pong"} +{"jsonrpc":"2.0","id":"2","result":["ExampleTool"]} +{"jsonrpc":"2.0","id":"3","result":["ExampleTool"]} +``` + + + + +#### Testing without a transport + To see sample responses without setting up a client to hit the server, you can simply run ```ruby diff --git a/examples/stdio_server.rb b/examples/stdio_server.rb new file mode 100755 index 0000000..5e2934b --- /dev/null +++ b/examples/stdio_server.rb @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "model_context_protocol" +require "model_context_protocol/transports/stdio" + +# Create a simple tool +class ExampleTool < ModelContextProtocol::Tool + description "A simple example tool that echoes back its arguments" + input_schema type: "object", + properties: { + message: { type: "string" }, + }, + required: ["message"] + + class << self + def call(message:, context:) + ModelContextProtocol::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +# Set up the server +server = ModelContextProtocol::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +# Create and start the transport +transport = ModelContextProtocol::Transports::StdioTransport.new(server) +transport.open diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index 4654639..4df7372 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -28,7 +28,7 @@ module ModelContextProtocol @tools = tools.to_h { |t| [t.name_value, t] } @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources - @resource_index = resources.index_by(&:uri) + @resource_index = index_resources_by_uri(resources) @context = context @configuration = ModelContextProtocol.configuration.merge(configuration) @handlers = { @@ -205,5 +205,11 @@ module ModelContextProtocol def report_exception(exception, context = {}) configuration.exception_reporter.call(exception, context) end + + def index_resources_by_uri(resources) + resources.each_with_object({}) do |resource, hash| + hash[resource.uri] = resource + end + end end end diff --git a/lib/model_context_protocol/transport.rb b/lib/model_context_protocol/transport.rb new file mode 100644 index 0000000..fc6aca5 --- /dev/null +++ b/lib/model_context_protocol/transport.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Transport + def initialize(server) + @server = server + end + + def send_response(response) + raise NotImplementedError, "Subclasses must implement send_response" + end + + def open + raise NotImplementedError, "Subclasses must implement open" + end + + def close + raise NotImplementedError, "Subclasses must implement close" + end + + private + + def handle_request(request) + response = @server.handle(request) + send_response(response) if response + end + + def handle_json_request(request) + response = @server.handle_json(request) + send_response(response) if response + end + end +end diff --git a/lib/model_context_protocol/transports/stdio.rb b/lib/model_context_protocol/transports/stdio.rb new file mode 100644 index 0000000..1c158d0 --- /dev/null +++ b/lib/model_context_protocol/transports/stdio.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../transport" +require "json" + +module ModelContextProtocol + module Transports + class StdioTransport < Transport + def initialize(server) + @server = server + @open = false + super + end + + def open + @open = true + while @open && (line = $stdin.gets) + handle_json_request(line.strip) + end + end + + def close + @open = false + end + + def send_response(message) + json_message = message.is_a?(String) ? message : JSON.generate(message) + $stdout.puts(json_message) + $stdout.flush + end + end + end +end diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index 452becd..14744f8 100644 --- a/lib/model_context_protocol/version.rb +++ b/lib/model_context_protocol/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ModelContextProtocol - VERSION = "0.5.1" + VERSION = "0.5.2" end diff --git a/test/model_context_protocol/transports/stdio_transport_test.rb b/test/model_context_protocol/transports/stdio_transport_test.rb new file mode 100644 index 0000000..498b0ff --- /dev/null +++ b/test/model_context_protocol/transports/stdio_transport_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "test_helper" +require "model_context_protocol/transports/stdio" +require "json" + +module ModelContextProtocol + module Transports + class StdioTransportTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + setup do + configuration = ModelContextProtocol::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + @server = Server.new(name: "test_server", configuration: configuration) + @transport = StdioTransport.new(@server) + end + + test "initializes with server and closed state" do + server = @transport.instance_variable_get(:@server) + assert_equal @server.object_id, server.object_id + refute @transport.instance_variable_get(:@open) + end + + test "processes JSON-RPC requests from stdin and sends responses to stdout" do + request = { + jsonrpc: "2.0", + method: "ping", + id: "123", + } + input = StringIO.new(JSON.generate(request) + "\n") + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + thread = Thread.new { @transport.open } + sleep(0.1) + @transport.close + thread.join + + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_equal("123", response[:id]) + assert_equal({}, response[:result]) + refute(@transport.instance_variable_get(:@open)) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "sends string responses to stdout" do + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send_response("test response") + assert_equal("test response\n", output.string) + ensure + $stdout = original_stdout + end + end + + test "sends JSON responses to stdout" do + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + response = { key: "value" } + @transport.send_response(response) + assert_equal(JSON.generate(response) + "\n", output.string) + ensure + $stdout = original_stdout + end + end + + test "handles valid JSON-RPC requests" do + request = { + jsonrpc: "2.0", + method: "ping", + id: "123", + } + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send(:handle_request, JSON.generate(request)) + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_nil(response[:id]) + assert_nil(response[:result]) + ensure + $stdout = original_stdout + end + end + + test "handles invalid JSON requests" do + invalid_json = "invalid json" + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send(:handle_request, invalid_json) + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_nil(response[:id]) + assert_equal(-32600, response[:error][:code]) + assert_equal("Invalid Request", response[:error][:message]) + assert_equal("Request must be an array or a hash", response[:error][:data]) + ensure + $stdout = original_stdout + end + end + end + end +end