From 600c96f66a0d5199f89c1b4828eb23f54023aa3f Mon Sep 17 00:00:00 2001 From: Tyler Rowsell Date: Thu, 22 May 2025 15:24:10 -0400 Subject: [PATCH 01/21] Validate ToolCall Args against Schema (cherry picked from commit eeb287117535dfb43785704171625323f01d5bda) --- lib/mcp/configuration.rb | 18 ++++- lib/mcp/server.rb | 11 ++- lib/mcp/tool/input_schema.rb | 31 ++++++++ mcp.gemspec | 1 + test/mcp/configuration_test.rb | 40 ++++++++++ test/mcp/server_test.rb | 118 +++++++++++++++++++++++++++++ test/mcp/tool/input_schema_test.rb | 24 ++++++ test/mcp/tool_test.rb | 35 +++++++++ 8 files changed, 275 insertions(+), 3 deletions(-) diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 39eeebe..5a03d24 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -4,12 +4,18 @@ module MCP class Configuration DEFAULT_PROTOCOL_VERSION = "2024-11-05" - attr_writer :exception_reporter, :instrumentation_callback, :protocol_version + attr_writer :exception_reporter, :instrumentation_callback, :protocol_version, :validate_tool_call_arguments - def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil) + def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil, + validate_tool_call_arguments: true) @exception_reporter = exception_reporter @instrumentation_callback = instrumentation_callback @protocol_version = protocol_version + unless validate_tool_call_arguments.is_a?(TrueClass) || validate_tool_call_arguments.is_a?(FalseClass) + raise ArgumentError, "validate_tool_call_arguments must be a boolean" + end + + @validate_tool_call_arguments = validate_tool_call_arguments end def protocol_version @@ -36,6 +42,12 @@ module MCP !@instrumentation_callback.nil? end + attr_reader :validate_tool_call_arguments + + def validate_tool_call_arguments? + !!@validate_tool_call_arguments + end + def merge(other) return self if other.nil? @@ -54,11 +66,13 @@ module MCP else @protocol_version end + validate_tool_call_arguments = other.validate_tool_call_arguments Configuration.new( exception_reporter:, instrumentation_callback:, protocol_version:, + validate_tool_call_arguments:, ) end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 6dde615..41b8482 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -150,7 +150,7 @@ module MCP report_exception(e, { request: request }) if e.is_a?(RequestHandlerError) add_instrumentation_data(error: e.error_type) - raise e + raise StandardError, e.message end add_instrumentation_data(error: :internal_error) @@ -213,6 +213,15 @@ module MCP ) end + if configuration.validate_tool_call_arguments && tool.input_schema + begin + tool.input_schema.validate_arguments(arguments) + rescue Tool::InputSchema::ValidationError => e + add_instrumentation_data(error: :invalid_schema) + raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema) + end + end + begin call_params = tool_call_parameters(tool) diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 4683b7e..56510ef 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -1,13 +1,18 @@ # frozen_string_literal: true +require "json-schema" + module MCP class Tool class InputSchema + class ValidationError < StandardError; end + attr_reader :properties, :required def initialize(properties: {}, required: []) @properties = properties @required = required.map(&:to_sym) + validate_schema! end def to_h @@ -21,6 +26,32 @@ module MCP def missing_required_arguments(arguments) (required - arguments.keys.map(&:to_sym)) end + + def validate_arguments(arguments) + errors = JSON::Validator.fully_validate(to_h, arguments) + if errors.any? + raise ValidationError, "Invalid arguments: #{errors.join(", ")}" + end + end + + private + + def validate_schema! + schema = to_h + begin + schema_reader = JSON::Schema::Reader.new( + accept_uri: false, + accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) }, + ) + metaschema = JSON::Validator.validator_for_name("draft4").metaschema + errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) + if errors.any? + raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" + end + rescue StandardError => e + raise ArgumentError, "Invalid JSON Schema: #{e.message}" + end + end end end end diff --git a/mcp.gemspec b/mcp.gemspec index c598f45..7543bfc 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency("json_rpc_handler", "~> 0.1") + spec.add_dependency("json-schema", "~> 4.1") spec.add_development_dependency("activesupport") spec.add_development_dependency("sorbet-static-and-runtime") end diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 71a1c27..9a5ad08 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -61,5 +61,45 @@ module MCP merged = config3.merge(config1) assert_equal "2025-03-27", merged.protocol_version end + + test "defaults validate_tool_call_arguments to true" do + config = Configuration.new + assert config.validate_tool_call_arguments + end + + test "can set validate_tool_call_arguments to false" do + config = Configuration.new(validate_tool_call_arguments: false) + refute config.validate_tool_call_arguments + end + + test "validate_tool_call_arguments? returns false when set" do + config = Configuration.new(validate_tool_call_arguments: false) + refute config.validate_tool_call_arguments? + end + + test "validate_tool_call_arguments? returns true when not set" do + config = Configuration.new + assert config.validate_tool_call_arguments? + end + + test "merge preserves validate_tool_call_arguments from other config" do + config1 = Configuration.new(validate_tool_call_arguments: false) + config2 = Configuration.new + merged = config1.merge(config2) + assert merged.validate_tool_call_arguments? + end + + test "merge preserves validate_tool_call_arguments from self when other not set" do + config1 = Configuration.new(validate_tool_call_arguments: false) + config2 = Configuration.new + merged = config2.merge(config1) + refute merged.validate_tool_call_arguments + end + + test "raises ArgumentError when validate_tool_call_arguments is not a boolean" do + assert_raises(ArgumentError) do + Configuration.new(validate_tool_call_arguments: "true") + end + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 6dc56ee..267f4ca 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -839,5 +839,123 @@ module MCP refute_includes server_without_resources.capabilities, :resources end + + test "tools/call validates arguments against input schema when validate_tool_call_arguments is true" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "test_tool", + arguments: { message: 123 }, + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32603, response[:error][:code]) + assert_includes response[:error][:data], "Invalid arguments" + end + + test "tools/call skips argument validation when validate_tool_call_arguments is false" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: false), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "test_tool", + arguments: { message: 123 }, + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert response[:result], "Expected result key in response" + assert_equal "text", response[:result][:content][0][:type] + assert_equal "OK", response[:result][:content][0][:content] + end + + test "tools/call validates arguments with complex types" do + server = Server.new( + tools: [ComplexTypesTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "complex_types_tool", + arguments: { + numbers: [1, 2, 3], + strings: ["a", "b", "c"], + objects: [{ name: "test" }], + }, + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert response[:result], "Expected result key in response" + assert_equal "text", response[:result][:content][0][:type] + assert_equal "OK", response[:result][:content][0][:content] + end + + class TestTool < Tool + tool_name "test_tool" + description "a test tool for testing" + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) + + class << self + def call(message:, server_context: nil) + Tool::Response.new([{ type: "text", content: "OK" }]) + end + end + end + + class ComplexTypesTool < Tool + tool_name "complex_types_tool" + description "a test tool with complex types" + input_schema({ + properties: { + numbers: { type: "array", items: { type: "number" } }, + strings: { type: "array", items: { type: "string" } }, + objects: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + }, + required: ["numbers", "strings", "objects"], + }) + + class << self + def call(numbers:, strings:, objects:, server_context: nil) + Tool::Response.new([{ type: "text", content: "OK" }]) + end + end + end end end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 56463bd..0dd5c4a 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "mcp/tool/input_schema" module MCP class Tool @@ -27,6 +28,29 @@ module MCP input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" }) end + + test "valid schema initialization" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: [:foo] }, schema.to_h) + end + + test "invalid schema raises argument error" do + assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo]) + end + end + + test "validate arguments with valid data" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + assert_nil(schema.validate_arguments({ foo: "bar" })) + end + + test "validate arguments with invalid data" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + assert_raises(InputSchema::ValidationError) do + schema.validate_arguments({ foo: 123 }) + end + end end end end diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index f53352b..f9551f1 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -82,6 +82,23 @@ module MCP assert_equal expected, tool.input_schema.to_h end + test "raises detailed error message for invalid schema" do + error = assert_raises(ArgumentError) do + Class.new(MCP::Tool) do + input_schema( + properties: { + count: { type: "integer", minimum: "not a number" }, + }, + required: [:count], + ) + end + end + + assert_includes error.message, "Invalid JSON Schema" + assert_includes error.message, "#/properties/count/minimum" + assert_includes error.message, "string did not match the following type: number" + end + test ".define allows definition of simple tools with a block" do tool = Tool.define(name: "mock_tool", description: "a mock tool for testing") do |_| Tool::Response.new([{ type: "text", content: "OK" }]) @@ -226,5 +243,23 @@ module MCP assert_equal response.content, [{ type: "text", content: "OK" }] assert_equal response.is_error, false end + + test "input_schema rejects any $ref in schema" do + schema_with_ref = { + properties: { + foo: { "$ref" => "#/definitions/bar" }, + }, + required: [], + definitions: { + bar: { type: "string" }, + }, + } + error = assert_raises(ArgumentError) do + Class.new(MCP::Tool) do + input_schema schema_with_ref + end + end + assert_match(/Invalid JSON Schema/, error.message) + end end end From 934a5e8db4d31b44bcf70cf8001200995a2bc30d Mon Sep 17 00:00:00 2001 From: Tyler Rowsell Date: Mon, 9 Jun 2025 10:02:29 -0400 Subject: [PATCH 02/21] Remove rescue block and add test for error bubbling in InputSchema validation --- lib/mcp/tool/input_schema.rb | 20 ++++++++------------ test/mcp/tool/input_schema_test.rb | 10 ++++++++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 56510ef..4d1c46f 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -38,18 +38,14 @@ module MCP def validate_schema! schema = to_h - begin - schema_reader = JSON::Schema::Reader.new( - accept_uri: false, - accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) }, - ) - metaschema = JSON::Validator.validator_for_name("draft4").metaschema - errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) - if errors.any? - raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" - end - rescue StandardError => e - raise ArgumentError, "Invalid JSON Schema: #{e.message}" + schema_reader = JSON::Schema::Reader.new( + accept_uri: false, + accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) }, + ) + metaschema = JSON::Validator.validator_for_name("draft4").metaschema + errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) + if errors.any? + raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" end end end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 0dd5c4a..75d1446 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -51,6 +51,16 @@ module MCP schema.validate_arguments({ foo: 123 }) end end + + test "unexpected errors bubble up from validate_arguments" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + + JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do + assert_raises(RuntimeError) do + schema.validate_arguments({ foo: "bar" }) + end + end + end end end end From bae35facf6b3dea9db1632e6d25f778cc3d6e41e Mon Sep 17 00:00:00 2001 From: Tyler Rowsell Date: Mon, 9 Jun 2025 13:32:12 -0400 Subject: [PATCH 03/21] Change Error Handling --- lib/mcp/server.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 41b8482..1bd9430 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -150,7 +150,7 @@ module MCP report_exception(e, { request: request }) if e.is_a?(RequestHandlerError) add_instrumentation_data(error: e.error_type) - raise StandardError, e.message + raise e end add_instrumentation_data(error: :internal_error) From 8dee60cfc2f12d337fd44828a772ced02995917f Mon Sep 17 00:00:00 2001 From: Tyler Rowsell Date: Mon, 9 Jun 2025 13:32:20 -0400 Subject: [PATCH 04/21] Fix: Disallow $ref in tool input schemas --- lib/mcp/tool/input_schema.rb | 14 ++++++++++++++ test/mcp/tool/input_schema_test.rb | 12 ++++++++++++ test/mcp/tool_test.rb | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 4d1c46f..b56b640 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -37,6 +37,7 @@ module MCP private def validate_schema! + check_for_refs! schema = to_h schema_reader = JSON::Schema::Reader.new( accept_uri: false, @@ -48,6 +49,19 @@ module MCP raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" end end + + def check_for_refs!(obj = properties) + case obj + when Hash + if obj.key?("$ref") || obj.key?(:$ref) + raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas" + end + + obj.each_value { |value| check_for_refs!(value) } + when Array + obj.each { |item| check_for_refs!(item) } + end + end end end end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 75d1446..791f955 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -61,6 +61,18 @@ module MCP end end end + + test "rejects schemas with $ref references" do + assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo]) + end + end + + test "rejects schemas with symbol $ref references" do + assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo]) + end + end end end end diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index f9551f1..e87c826 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -249,7 +249,7 @@ module MCP properties: { foo: { "$ref" => "#/definitions/bar" }, }, - required: [], + required: ["foo"], definitions: { bar: { type: "string" }, }, From 3e03a2af6642fe4a6954f9aae80a08a493770027 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Wed, 26 Mar 2025 22:17:20 -0400 Subject: [PATCH 05/21] Implement StreamableHTTP transport Co-authored-by: Topher Bullock --- README.md | 1 + examples/README.md | 174 +++++++ examples/http_client.rb | 185 +++++++ examples/http_server.rb | 169 ++++++ examples/sse_test_client.rb | 207 ++++++++ examples/sse_test_server.rb | 172 +++++++ lib/mcp.rb | 1 + lib/mcp/server.rb | 14 +- lib/mcp/server/transports/stdio_transport.rb | 11 + .../transports/streamable_http_transport.rb | 295 +++++++++++ lib/mcp/transport.rb | 33 +- mcp.gemspec | 2 + .../streamable_http_transport_test.rb | 483 ++++++++++++++++++ test/mcp/transport_test.rb | 66 +++ 14 files changed, 1807 insertions(+), 6 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/http_client.rb create mode 100644 examples/http_server.rb create mode 100644 examples/sse_test_client.rb create mode 100644 examples/sse_test_server.rb create mode 100644 lib/mcp/server/transports/streamable_http_transport.rb create mode 100644 test/mcp/server/transports/streamable_http_transport_test.rb create mode 100644 test/mcp/transport_test.rb diff --git a/README.md b/README.md index 84eb893..e12e18a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ It implements the Model Context Protocol specification, handling model context r - Manages tool registration and invocation - Supports prompt registration and execution - Supports resource registration and retrieval +- Supports stdio & StreamableHTTP (including SSE) transports ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..19ecb99 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,174 @@ +# MCP Ruby Examples + +This directory contains examples of how to use the Model Context Protocol (MCP) Ruby library. + +## Available Examples + +### 1. STDIO Server (`stdio_server.rb`) +A simple server that communicates over standard input/output. This is useful for desktop applications and command-line tools. + +**Usage:** +```bash +ruby examples/stdio_server.rb +{"jsonrpc":"2.0","id":0,"method":"tools/list"} +``` + +### 2. HTTP Server (`http_server.rb`) +A standalone HTTP server built with Rack that implements the MCP StreamableHTTP transport protocol. This demonstrates how to create a web-based MCP server with session management and Server-Sent Events (SSE) support. + +**Features:** +- HTTP transport with Server-Sent Events (SSE) for streaming +- Session management with unique session IDs +- Example tools, prompts, and resources +- JSON-RPC 2.0 protocol implementation +- Full MCP protocol compliance + +**Usage:** +```bash +ruby examples/http_server.rb +``` + +The server will start on `http://localhost:9292` and provide: +- **Tools**: + - `ExampleTool` - adds two numbers + - `echo` - echoes back messages +- **Prompts**: `ExamplePrompt` - echoes back arguments as a prompt +- **Resources**: `test_resource` - returns example content + +### 3. HTTP Client Example (`http_client.rb`) +A client that demonstrates how to interact with the HTTP server using all MCP protocol methods. + +**Usage:** +1. Start the HTTP server in one terminal: + ```bash + ruby examples/http_server.rb + ``` + +2. Run the client example in another terminal: + ```bash + ruby examples/http_client.rb + ``` + +The client will demonstrate: +- Session initialization +- Ping requests +- Listing and calling tools +- Listing and getting prompts +- Listing and reading resources +- Session cleanup + +### 4. SSE Test Server (`sse_test_server.rb`) +A specialized HTTP server designed to test and demonstrate Server-Sent Events (SSE) functionality in the MCP protocol. + +**Features:** +- Tools specifically designed to trigger SSE notifications +- Real-time progress updates and notifications +- Detailed SSE-specific logging + +**Available Tools:** +- `NotificationTool` - Send custom SSE notifications with optional delays +- `echo` - Simple echo tool for basic testing + +**Usage:** +```bash +ruby examples/sse_test_server.rb +``` + +The server will start on `http://localhost:9393` and provide detailed instructions for testing SSE functionality. + +### 5. SSE Test Client (`sse_test_client.rb`) +An interactive client that connects to the SSE stream and provides a menu-driven interface for testing SSE functionality. + +**Features:** +- Automatic SSE stream connection +- Interactive menu for triggering various SSE events +- Real-time display of received SSE notifications +- Session management + +**Usage:** +1. Start the SSE test server in one terminal: + ```bash + ruby examples/sse_test_server.rb + ``` + +2. Run the SSE test client in another terminal: + ```bash + ruby examples/sse_test_client.rb + ``` + +The client will: +- Initialize a session automatically +- Connect to the SSE stream +- Provide an interactive menu to trigger notifications +- Display all received SSE events in real-time + +### Testing SSE with cURL + +You can also test SSE functionality manually using cURL: + +1. Initialize a session: +```bash +SESSION_ID=$(curl -D - -s -o /dev/null -X POST http://localhost:9393 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' |grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') +``` + +2. Connect to SSE stream (in one terminal): +```bash +curl -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393 +``` + +3. Trigger notifications (in another terminal): +```bash +# Send immediate notification +curl -X POST http://localhost:9393 \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' +``` + +## StreamableHTTP Transport Details + +### Protocol Flow + +The HTTP server implements the MCP StreamableHTTP transport protocol: + +1. **Initialize Session**: + - Client sends POST request with `initialize` method + - Server responds with session ID in `Mcp-Session-Id` header + +2. **Establish SSE Connection** (optional): + - Client sends GET request with `Mcp-Session-Id` header + - Server establishes Server-Sent Events stream for notifications + +3. **Send Requests**: + - Client sends POST requests with JSON-RPC 2.0 format + - Server processes and responds with results + +4. **Close Session**: + - Client sends DELETE request with `Mcp-Session-Id` header + +### Example cURL Commands + +Initialize a session: +```bash +curl -X POST http://localhost:9292 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +List tools (using the session ID from initialization): +```bash +curl -X POST http://localhost:9292 \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: YOUR_SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' +``` + +Call a tool: +```bash +curl -X POST http://localhost:9292 \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: YOUR_SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"ExampleTool","arguments":{"a":5,"b":3}}}' +``` diff --git a/examples/http_client.rb b/examples/http_client.rb new file mode 100644 index 0000000..992b9fd --- /dev/null +++ b/examples/http_client.rb @@ -0,0 +1,185 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +# Simple HTTP client example for interacting with the MCP HTTP server +class MCPHTTPClient + def initialize(base_url = "http://localhost:9292") + @base_url = base_url + @session_id = nil + end + + def send_request(method, params = nil, id = nil) + uri = URI(@base_url) + http = Net::HTTP.new(uri.host, uri.port) + + request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path) + request["Content-Type"] = "application/json" + request["Mcp-Session-Id"] = @session_id if @session_id + + body = { + jsonrpc: "2.0", + method: method, + params: params, + id: id || rand(10000), + }.compact + + request.body = body.to_json + + response = http.request(request) + + # Store session ID if provided + if response["Mcp-Session-Id"] + @session_id = response["Mcp-Session-Id"] + puts "Session ID: #{@session_id}" + end + + JSON.parse(response.body) + end + + def initialize_session + puts "=== Initializing session ===" + result = send_request("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { + name: "example_client", + version: "1.0", + }, + }) + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def ping + puts "=== Sending ping ===" + result = send_request("ping") + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def list_tools + puts "=== Listing tools ===" + result = send_request("tools/list") + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def call_tool(name, arguments) + puts "=== Calling tool: #{name} ===" + result = send_request("tools/call", { + name: name, + arguments: arguments, + }) + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def list_prompts + puts "=== Listing prompts ===" + result = send_request("prompts/list") + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def get_prompt(name, arguments) + puts "=== Getting prompt: #{name} ===" + result = send_request("prompts/get", { + name: name, + arguments: arguments, + }) + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def list_resources + puts "=== Listing resources ===" + result = send_request("resources/list") + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def read_resource(uri) + puts "=== Reading resource: #{uri} ===" + result = send_request("resources/read", { + uri: uri, + }) + puts "Response: #{JSON.pretty_generate(result)}" + puts + result + end + + def close_session + return unless @session_id + + puts "=== Closing session ===" + uri = URI(@base_url) + http = Net::HTTP.new(uri.host, uri.port) + + request = Net::HTTP::Delete.new(uri.path.empty? ? "/" : uri.path) + request["Mcp-Session-Id"] = @session_id + + response = http.request(request) + result = JSON.parse(response.body) + puts "Response: #{JSON.pretty_generate(result)}" + puts + + @session_id = nil + result + end +end + +# Main script +if __FILE__ == $PROGRAM_NAME + puts "MCP HTTP Client Example" + puts "Make sure the HTTP server is running (ruby examples/http_server.rb)" + puts "=" * 50 + puts + + client = MCPHTTPClient.new + + begin + # Initialize session + client.initialize_session + + # Test ping + client.ping + + # List available tools + client.list_tools + + # Call the example_tool (note: snake_case name) + client.call_tool("example_tool", { a: 5, b: 3 }) + + # Call the echo tool + client.call_tool("echo", { message: "Hello from client!" }) + + # List prompts + client.list_prompts + + # Get a prompt (note: snake_case name) + client.get_prompt("example_prompt", { message: "This is a test message" }) + + # List resources + client.list_resources + + # Read a resource + client.read_resource("test_resource") + rescue => e + puts "Error: #{e.message}" + puts e.backtrace + ensure + # Clean up session + client.close_session + end +end diff --git a/examples/http_server.rb b/examples/http_server.rb new file mode 100644 index 0000000..52a48e1 --- /dev/null +++ b/examples/http_server.rb @@ -0,0 +1,169 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "mcp/transports/http" +require "rack" +require "rackup" +require "json" +require "logger" + +# Create a simple tool +class ExampleTool < MCP::Tool + description "A simple example tool that adds two numbers" + input_schema( + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + ) + + class << self + def call(a:, b:) + MCP::Tool::Response.new([{ + type: "text", + text: "The sum of #{a} and #{b} is #{a + b}", + }]) + end + end +end + +# Create a simple prompt +class ExamplePrompt < MCP::Prompt + description "A simple example prompt that echoes back its arguments" + arguments [ + MCP::Prompt::Argument.new( + name: "message", + description: "The message to echo back", + required: true, + ), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new(args[:message]), + ), + ], + ) + end + end +end + +# Set up the server +server = MCP::Server.new( + name: "example_http_server", + tools: [ExampleTool], + prompts: [ExamplePrompt], + resources: [ + MCP::Resource.new( + uri: "test_resource", + name: "Test resource", + description: "Test resource that echoes back the uri as its content", + mime_type: "text/plain", + ), + ], +) + +server.define_tool( + name: "echo", + description: "A simple example tool that echoes back its arguments", + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, +) do |message:| + MCP::Tool::Response.new( + [ + { + type: "text", + text: "Hello from echo tool! Message: #{message}", + }, + ], + ) +end + +server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: "text/plain", + text: "Hello from HTTP server resource!", + }] +end + +# Create the StreamableHTTP transport +server.transport = MCP::Transports::HTTP.new(server) + +# Create a logger for MCP-specific logging +mcp_logger = Logger.new($stdout) +mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| + "[MCP] #{msg}\n" +end + +# Create a Rack application with logging +app = proc do |env| + request = Rack::Request.new(env) + + # Log MCP-specific details for POST requests + if request.post? + body = request.body.read + request.body.rewind + begin + parsed_body = JSON.parse(body) + mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") + mcp_logger.debug("Request body: #{JSON.pretty_generate(parsed_body)}") + rescue JSON::ParserError + mcp_logger.warn("Request body (raw): #{body}") + end + end + + # Handle the request + puts "request: #{request.inspect}" + response = server.handle_rack_request(request) + + # Log the MCP response details + _, _, body = response + if body.is_a?(Array) && !body.empty? + begin + parsed_response = JSON.parse(body.first) + if parsed_response["error"] + mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") + else + mcp_logger.info("Response: #{parsed_response["result"] ? "success" : "empty"} (id: #{parsed_response["id"]})") + end + mcp_logger.debug("Response body: #{JSON.pretty_generate(parsed_response)}") + rescue JSON::ParserError + mcp_logger.warn("Response body (raw): #{body}") + end + end + + response +end + +# Wrap the app with Rack middleware +rack_app = Rack::Builder.new do + # Use CommonLogger for standard HTTP request logging + use(Rack::CommonLogger, Logger.new($stdout)) + + # Add other useful middleware + use(Rack::ShowExceptions) + + run(app) +end + +# Start the server +puts "Starting MCP HTTP server on http://localhost:9292" +puts "Use POST requests to initialize and send JSON-RPC commands" +puts "Example initialization:" +puts ' curl -X POST http://localhost:9292 -H "Content-Type: application/json" -d \'{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}\'' +puts "" +puts "The server will return a session ID in the Mcp-Session-Id header." +puts "Use this session ID for subsequent requests." +puts "" +puts "Press Ctrl+C to stop the server" + +# Run the server +# Use Rackup to run the server +Rackup::Handler.get("puma").run(rack_app, Port: 9292, Host: "localhost") diff --git a/examples/sse_test_client.rb b/examples/sse_test_client.rb new file mode 100644 index 0000000..cd24ab3 --- /dev/null +++ b/examples/sse_test_client.rb @@ -0,0 +1,207 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "net/http" +require "uri" +require "json" +require "logger" + +# Logger for client operations +logger = Logger.new($stdout) +logger.formatter = proc do |severity, datetime, _progname, msg| + "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" +end + +# Server configuration +SERVER_URL = "http://localhost:9393/mcp" +PROTOCOL_VERSION = "2024-11-05" + +# Helper method to make JSON-RPC requests +def make_request(session_id, method, params = {}, id = nil) + uri = URI(SERVER_URL) + http = Net::HTTP.new(uri.host, uri.port) + + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Mcp-Session-Id"] = session_id if session_id + + body = { + jsonrpc: "2.0", + method: method, + params: params, + id: id || SecureRandom.uuid, + } + + request.body = body.to_json + response = http.request(request) + + { + status: response.code, + headers: response.to_hash, + body: JSON.parse(response.body), + } +rescue => e + { error: e.message } +end + +# Connect to SSE stream +def connect_sse(session_id, logger) + uri = URI(SERVER_URL) + + logger.info("Connecting to SSE stream...") + + Net::HTTP.start(uri.host, uri.port) do |http| + request = Net::HTTP::Get.new(uri) + request["Mcp-Session-Id"] = session_id + request["Accept"] = "text/event-stream" + request["Cache-Control"] = "no-cache" + + http.request(request) do |response| + if response.code == "200" + logger.info("SSE stream connected successfully") + + response.read_body do |chunk| + chunk.split("\n").each do |line| + if line.start_with?("data: ") + data = line[6..-1] + begin + logger.info("SSE data: #{data}") + rescue JSON::ParserError + logger.debug("Non-JSON SSE data: #{data}") + end + elsif line.start_with?(": ") + logger.debug("SSE keepalive received: #{line}") + end + end + end + else + logger.error("Failed to connect to SSE: #{response.code} #{response.message}") + end + end + end +rescue Interrupt + logger.info("SSE connection interrupted by user") +rescue => e + logger.error("SSE connection error: #{e.message}") +end + +# Main client flow +def main + logger = Logger.new($stdout) + logger.formatter = proc do |severity, datetime, _progname, msg| + "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" + end + + puts "=== MCP SSE Test Client ===" + puts "" + + # Step 1: Initialize session + logger.info("Initializing session...") + + init_response = make_request( + nil, + "initialize", + { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: "sse-test-client", + version: "1.0", + }, + }, + "init-1", + ) + + if init_response[:error] + logger.error("Failed to initialize: #{init_response[:error]}") + exit(1) + end + + session_id = init_response[:headers]["mcp-session-id"]&.first + + if session_id.nil? + logger.error("No session ID received") + exit(1) + end + + logger.info("Session initialized: #{session_id}") + logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}") + + # Step 2: Start SSE connection in a separate thread + sse_thread = Thread.new { connect_sse(session_id, logger) } + + # Give SSE time to connect + sleep(1) + + # Step 3: Interactive menu + loop do + puts "\n=== Available Actions ===" + puts "1. Send custom notification" + puts "2. Test echo" + puts "3. List tools" + puts "0. Exit" + puts "" + print("Choose an action: ") + + choice = gets.chomp + + case choice + when "1" + print("Enter notification message: ") + message = gets.chomp + print("Enter delay in seconds (0 for immediate): ") + delay = gets.chomp.to_f + + response = make_request( + session_id, + "tools/call", + { + name: "notification_tool", + arguments: { + message: message, + delay: delay, + }, + }, + ) + + if response[:body]["result"] + logger.info("Notification tool response: #{response[:body]["result"]["content"]}") + else + logger.error("Error: #{response[:body]["error"]}") + end + + when "2" + print("Enter message to echo: ") + message = gets.chomp + make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } }) + + when "3" + make_request(session_id, "tools/list") + + when "0" + logger.info("Exiting...") + break + + else + puts "Invalid choice" + end + end + + # Clean up + sse_thread.kill if sse_thread.alive? + + # Close session + logger.info("Closing session...") + make_request(session_id, "close") + logger.info("Session closed") +rescue Interrupt + logger.info("Client interrupted by user") +rescue => e + logger.error("Client error: #{e.message}") + logger.error(e.backtrace.join("\n")) +end + +# Run the client +if __FILE__ == $PROGRAM_NAME + main +end diff --git a/examples/sse_test_server.rb b/examples/sse_test_server.rb new file mode 100644 index 0000000..d178ccf --- /dev/null +++ b/examples/sse_test_server.rb @@ -0,0 +1,172 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "mcp/transports/http" +require "rack" +require "rackup" +require "json" +require "logger" + +# Create a logger for SSE-specific logging +sse_logger = Logger.new($stdout) +sse_logger.formatter = proc do |severity, datetime, _progname, msg| + "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" +end + +# Tool that returns a response that will be sent via SSE if a stream is active +class NotificationTool < MCP::Tool + tool_name "notification_tool" + description "Returns a notification message that will be sent via SSE if stream is active" + input_schema( + properties: { + message: { type: "string", description: "Message to send via SSE" }, + delay: { type: "number", description: "Delay in seconds before returning (optional)" }, + }, + required: ["message"], + ) + + class << self + attr_accessor :logger + + def call(message:, delay: 0) + sleep(delay) if delay > 0 + + logger&.info("Returning notification message: #{message}") + + MCP::Tool::Response.new([{ + type: "text", + text: "Notification: #{message} (timestamp: #{Time.now.iso8601})", + }]) + end + end +end + +# Create the server +server = MCP::Server.new( + name: "sse_test_server", + tools: [NotificationTool], + prompts: [], + resources: [], +) + +# Set logger for tools +NotificationTool.logger = sse_logger + +# Add a simple echo tool for basic testing +server.define_tool( + name: "echo", + description: "Simple echo tool", + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, +) do |message:| + MCP::Tool::Response.new([{ type: "text", text: "Echo: #{message}" }]) +end + +# Create the StreamableHTTP transport +server.transport = MCP::Transports::HTTP.new(server) + +# Create a logger for MCP request/response logging +mcp_logger = Logger.new($stdout) +mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| + "[MCP] #{msg}\n" +end + +# Create the Rack application +app = proc do |env| + request = Rack::Request.new(env) + + # Log request details + if request.post? + body = request.body.read + request.body.rewind + begin + parsed_body = JSON.parse(body) + mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") + + # Log SSE-specific setup + if parsed_body["method"] == "initialize" + sse_logger.info("New client initializing session") + end + rescue JSON::ParserError + mcp_logger.warn("Invalid JSON in request") + end + elsif request.get? + session_id = request.env["HTTP_MCP_SESSION_ID"] || + Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"] + sse_logger.info("SSE connection request for session: #{session_id}") + end + + # Handle the request + response = server.handle_rack_request(request) + + # Log response details + status, headers, body = response + if body.is_a?(Array) && !body.empty? && request.post? + begin + parsed_response = JSON.parse(body.first) + if parsed_response["error"] + mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") + elsif parsed_response["accepted"] + # Response was sent via SSE + sse_logger.info("Response sent via SSE stream") + else + mcp_logger.info("Response: success (id: #{parsed_response["id"]})") + + # Log session ID for initialization + if headers["Mcp-Session-Id"] + sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") + end + end + rescue JSON::ParserError + mcp_logger.warn("Invalid JSON in response") + end + elsif request.get? && status == 200 + sse_logger.info("SSE stream established") + end + + response +end + +# Build the Rack application with middleware +rack_app = Rack::Builder.new do + use(Rack::CommonLogger, Logger.new($stdout)) + use(Rack::ShowExceptions) + run(app) +end + +# Print usage instructions +puts "=== MCP SSE Test Server ===" +puts "" +puts "Starting server on http://localhost:9393" +puts "" +puts "Available Tools:" +puts "1. NotificationTool - Returns messages that are sent via SSE when stream is active" +puts "2. echo - Simple echo tool" +puts "" +puts "Testing SSE:" +puts "" +puts "1. Initialize session:" +puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" \\' +puts ' -d \'{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}\'' +puts "" +puts "2. Connect SSE stream (use the session ID from step 1):" +puts ' curl -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393' +puts "" +puts "3. In another terminal, test tools (responses will be sent via SSE if stream is active):" +puts "" +puts " Echo tool:" +puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" -H "Mcp-Session-Id: YOUR_SESSION_ID" \\' +puts ' -d \'{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}\'' +puts "" +puts " Notification tool (with 2 second delay):" +puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" -H "Mcp-Session-Id: YOUR_SESSION_ID" \\' +puts ' -d \'{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}\'' +puts "" +puts "Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {\"accepted\": true}" +puts "" +puts "Press Ctrl+C to stop the server" +puts "" + +# Start the server +Rackup::Handler.get("puma").run(rack_app, Port: 9393, Host: "localhost") diff --git a/lib/mcp.rb b/lib/mcp.rb index eff9de8..84ccce4 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -13,6 +13,7 @@ require_relative "mcp/resource/contents" require_relative "mcp/resource/embedded" require_relative "mcp/resource_template" require_relative "mcp/server" +require_relative "mcp/server/transports/streamable_http_transport" require_relative "mcp/server/transports/stdio_transport" require_relative "mcp/string_utils" require_relative "mcp/tool" diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 2c39fb0..445580a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -24,7 +24,7 @@ module MCP include Instrumentation attr_writer :capabilities - attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration + attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration, :transport def initialize( name: "model_context_protocol", @@ -35,7 +35,8 @@ module MCP resource_templates: [], server_context: nil, configuration: nil, - capabilities: nil + capabilities: nil, + transport: nil ) @name = name @version = version @@ -68,6 +69,7 @@ module MCP Methods::COMPLETION_COMPLETE => ->(_) {}, Methods::LOGGING_SET_LEVEL => ->(_) {}, } + @transport = transport end def capabilities @@ -96,6 +98,14 @@ module MCP @prompts[prompt.name_value] = prompt end + def handle_rack_request(request) + @transport.handle_request(request) + end + + def close + @transport.close + end + def resources_list_handler(&block) @capabilities.support_resources @handlers[Methods::RESOURCES_LIST] = block diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index d4aae27..fdf2610 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -8,6 +8,11 @@ module MCP module Transports class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 + class << self + def register + super("stdio", self) + end + end def initialize(server) @server = server @@ -37,6 +42,12 @@ module MCP $stdout.puts(json_message) $stdout.flush 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/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb new file mode 100644 index 0000000..0fcca7d --- /dev/null +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require_relative "../../transport" +require "json" +require "securerandom" + +module MCP + class Server + module Transports + class StreamableHTTPTransport < Transport + class << self + def register + super("http", self) + end + end + + def initialize(server) + super + # { session_id => { stream: stream_object } + @sessions = {} + @mutex = Mutex.new + end + + def handle_request(request) + case request.env["REQUEST_METHOD"] + when "POST" + handle_post(request) + when "GET" + handle_get(request) + when "DELETE" + handle_delete(request) + else + [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]] + end + end + + def close + @mutex.synchronize do + @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) } + end + end + + def send_notification(notification, session_id: nil) + @mutex.synchronize do + if session_id + # Send to specific session + session = @sessions[session_id] + return false unless session && session[:stream] + + begin + send_to_stream(session[:stream], notification) + true + rescue IOError, Errno::EPIPE => e + MCP.configuration.exception_reporter.call( + e, + { session_id: session_id, error: "Failed to send notification" }, + ) + cleanup_session_unsafe(session_id) + false + end + else + # Broadcast to all connected SSE sessions + sent_count = 0 + failed_sessions = [] + + @sessions.each do |sid, session| + next unless session[:stream] + + begin + send_to_stream(session[:stream], notification) + sent_count += 1 + rescue IOError, Errno::EPIPE => e + MCP.configuration.exception_reporter.call( + e, + { session_id: sid, error: "Failed to send notification" }, + ) + failed_sessions << sid + end + end + + # Clean up failed sessions + failed_sessions.each { |sid| cleanup_session_unsafe(sid) } + + sent_count + end + end + end + + private + + def send_to_stream(stream, data) + message = data.is_a?(String) ? data : data.to_json + stream.write("data: #{message}\n\n") + stream.flush if stream.respond_to?(:flush) + end + + def send_ping_to_stream(stream) + stream.write(": ping #{Time.now.iso8601}\n\n") + stream.flush if stream.respond_to?(:flush) + end + + def handle_post(request) + body_string = request.body.read + session_id = extract_session_id(request) + + body = parse_request_body(body_string) + return body unless body.is_a?(Hash) # Error response + + if body["method"] == "initialize" + handle_initialization(body_string, body) + else + handle_regular_request(body_string, session_id) + end + rescue StandardError => e + ModelContextProtocol.configuration.exception_reporter.call(e, { request: body_string }) + [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]] + end + + def handle_get(request) + session_id = extract_session_id(request) + + return missing_session_id_response unless session_id + return session_not_found_response unless session_exists?(session_id) + + setup_sse_stream(session_id) + end + + def handle_delete(request) + session_id = request.env["HTTP_MCP_SESSION_ID"] + + return [ + 400, + { "Content-Type" => "application/json" }, + [{ error: "Missing session ID" }.to_json], + ] unless session_id + + cleanup_session(session_id) + [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]] + end + + def cleanup_session(session_id) + @mutex.synchronize do + cleanup_session_unsafe(session_id) + end + end + + def cleanup_session_unsafe(session_id) + session = @sessions[session_id] + return unless session + + begin + session[:stream]&.close + rescue + nil + end + @sessions.delete(session_id) + end + + def extract_session_id(request) + request.env["HTTP_MCP_SESSION_ID"] + end + + def parse_request_body(body_string) + JSON.parse(body_string) + rescue JSON::ParserError + [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] + end + + def handle_initialization(body_string, body) + session_id = SecureRandom.uuid + + @mutex.synchronize do + @sessions[session_id] = { + stream: nil, + } + end + + response = @server.handle_json(body_string) + + headers = { + "Content-Type" => "application/json", + "Mcp-Session-Id" => session_id, + } + + [200, headers, [response]] + end + + def handle_regular_request(body_string, session_id) + # If session ID is provided, but not in the sessions hash, return an error + if session_id && !@sessions.key?(session_id) + return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]] + end + + response = @server.handle_json(body_string) + stream = get_session_stream(session_id) if session_id + + if stream + send_response_to_stream(stream, response, session_id) + else + [200, { "Content-Type" => "application/json" }, [response]] + end + end + + def get_session_stream(session_id) + @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) } + end + + def send_response_to_stream(stream, response, session_id) + message = JSON.parse(response) + send_to_stream(stream, message) + [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]] + rescue IOError, Errno::EPIPE => e + ModelContextProtocol.configuration.exception_reporter.call( + e, + { session_id: session_id, error: "Stream closed during response" }, + ) + cleanup_session(session_id) + [200, { "Content-Type" => "application/json" }, [response]] + end + + def session_exists?(session_id) + @mutex.synchronize { @sessions.key?(session_id) } + end + + def missing_session_id_response + [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]] + end + + def session_not_found_response + [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] + end + + def setup_sse_stream(session_id) + body = create_sse_body(session_id) + + headers = { + "Content-Type" => "text/event-stream", + "Cache-Control" => "no-cache", + "Connection" => "keep-alive", + } + + [200, headers, body] + end + + def create_sse_body(session_id) + proc do |stream| + store_stream_for_session(session_id, stream) + start_keepalive_thread(session_id) + end + end + + def store_stream_for_session(session_id, stream) + @mutex.synchronize do + if @sessions[session_id] + @sessions[session_id][:stream] = stream + else + stream.close + end + end + end + + def start_keepalive_thread(session_id) + Thread.new do + while session_active_with_stream?(session_id) + sleep(30) + send_keepalive_ping(session_id) + end + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { session_id: session_id }) + ensure + cleanup_session(session_id) + end + end + + def session_active_with_stream?(session_id) + @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] } + end + + def send_keepalive_ping(session_id) + @mutex.synchronize do + if @sessions[session_id] && @sessions[session_id][:stream] + send_ping_to_stream(@sessions[session_id][:stream]) + end + end + rescue IOError, Errno::EPIPE => e + MCP.configuration.exception_reporter.call( + e, + { session_id: session_id, error: "Stream closed" }, + ) + raise # Re-raise to exit the keepalive loop + end + end + end + end +end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index 7e4b63e..d3226fc 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -2,32 +2,57 @@ module MCP class Transport + class << self + def register(name, transport_class) + @transports ||= {} + @transports[name] = transport_class + end + + def get(name) + raise "Transport #{name} not found" unless @transports + + @transports.fetch(name) { raise "Transport #{name} not found" } + end + end + + # Initialize the transport with the server instance def initialize(server) @server = server end + # Send a response to the client def send_response(response) raise NotImplementedError, "Subclasses must implement send_response" end + # Open the transport connection def open raise NotImplementedError, "Subclasses must implement open" end + # Close the transport connection def close raise NotImplementedError, "Subclasses must implement close" end - private + # Handle a JSON request + # Returns a response that should be sent back to the client + def handle_json_request(request) + response = @server.handle_json(request) + send_response(response) if response + end + # Handle an incoming request + # Returns a response that should be sent back to the client 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 + # Send a notification to the client + # Returns true if the notification was sent successfully + def send_notification(method, params = nil) + raise NotImplementedError, "Subclasses must implement send_notification" end end end diff --git a/mcp.gemspec b/mcp.gemspec index c598f45..5c49985 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -28,6 +28,8 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency("json_rpc_handler", "~> 0.1") + spec.add_dependency("rack", ">= 2.0.0") spec.add_development_dependency("activesupport") + spec.add_development_dependency("puma", ">= 5.0.0") spec.add_development_dependency("sorbet-static-and-runtime") end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb new file mode 100644 index 0000000..eb0cf7e --- /dev/null +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -0,0 +1,483 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "rack" +require "mcp/server" +require "mcp/server/transports/streamable_http_transport" + +module MCP + class Server + module Transports + class StreamableHTTPTransportTest < ActiveSupport::TestCase + setup do + @server = Server.new( + name: "test_server", + tools: [], + prompts: [], + resources: [], + ) + @transport = StreamableHTTPTransport.new(@server) + end + + test "handles POST request with valid JSON-RPC message" do + # First create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Now make the ping request with the session ID + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "123", body["id"] + assert_empty(body["result"]) + end + + test "handles POST request with invalid JSON" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + "invalid json", + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Invalid JSON", body["error"] + end + + test "handles POST request with initialize method" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "application/json", response[1]["Content-Type"] + assert response[1]["Mcp-Session-Id"] + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "123", body["id"] + assert_equal "2024-11-05", body["result"]["protocolVersion"] + end + + test "handles GET request with valid session ID" do + # First create a session with initialize + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Then try to connect with GET + request = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["Content-Type"] + assert response[2].is_a?(Proc) # The body should be a Proc for streaming + end + + test "handles GET request with missing session ID" do + request = create_rack_request( + "GET", + "/", + {}, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "handles GET request with invalid session ID" do + request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => "invalid_id" }, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"] + end + + test "handles DELETE request with valid session ID" do + # First create a session with initialize + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Then try to delete it + request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert body["success"] + end + + test "handles DELETE request with missing session ID" do + request = create_rack_request( + "DELETE", + "/", + {}, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"] + end + + test "closes transport and cleans up session" do + # First create a session with initialize + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Then connect with GET + io = StringIO.new + request = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + }, + ) + response = @transport.handle_request(request) + # Call the body proc with our StringIO + response[2].call(io) if response[2].is_a?(Proc) + + # Give the background thread a moment to set up + sleep(0.01) + + # Verify session exists before closing + assert @transport.instance_variable_get(:@sessions).key?(session_id) + + # Close the transport without session context (closes all sessions) + @transport.close + + # Verify session was cleaned up + assert_empty @transport.instance_variable_get(:@sessions) + end + + test "sends notification to correct session with multiple active sessions" do + # Create first session + init_request1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response1 = @transport.handle_request(init_request1) + session_id1 = init_response1[1]["Mcp-Session-Id"] + + # Create second session + init_request2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + ) + init_response2 = @transport.handle_request(init_request2) + session_id2 = init_response2[1]["Mcp-Session-Id"] + + # Connect first session with GET + io1 = StringIO.new + get_request1 = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id1, + }, + ) + response1 = @transport.handle_request(get_request1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + # Connect second session with GET + io2 = StringIO.new + get_request2 = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id2, + }, + ) + response2 = @transport.handle_request(get_request2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + # Give the streams time to be fully set up + sleep(0.2) + + # Verify sessions are set up + assert @transport.instance_variable_get(:@sessions).key?(session_id1), "Session 1 not found in @sessions" + assert @transport.instance_variable_get(:@sessions).key?(session_id2), "Session 2 not found in @sessions" + + # Test that notifications go to the correct session based on the request context + # First, make a request as session 1 + request_as_session1 = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id1, + }, + { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, + ) + + # Monkey-patch handle_json on the server to send a notification when called + original_handle_json = @server.method(:handle_json) + transport = @transport # Capture the transport in a local variable + @server.define_singleton_method(:handle_json) do |request| + result = original_handle_json.call(request) + # Send notification while still in request context - broadcast to all sessions + notification = { jsonrpc: "2.0", method: "test_notification", params: { session: "current" } } + transport.send_notification(notification) + result + end + + # Handle request from session 1 + @transport.handle_request(request_as_session1) + + # Make a request as session 2 + request_as_session2 = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id2, + }, + { jsonrpc: "2.0", method: "ping", id: "890" }.to_json, + ) + + # Handle request from session 2 + @transport.handle_request(request_as_session2) + + # Check that each session received one notification + io1.rewind + output1 = io1.read + # Session 1 should have received two notifications (one from each request since we broadcast) + assert_equal 2, + output1.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count + + io2.rewind + output2 = io2.read + # Session 2 should have received two notifications (one from each request since we broadcast) + assert_equal 2, + output2.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count + end + + test "send_notification to specific session" do + # Create and initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Send notification to specific session + notification = { jsonrpc: "2.0", method: "test_notification", params: { message: "Hello" } } + result = @transport.send_notification(notification, session_id: session_id) + + assert result + + # Check the notification was received + io.rewind + output = io.read + assert_includes output, + "data: {\"jsonrpc\":\"2.0\",\"method\":\"test_notification\",\"params\":{\"message\":\"Hello\"}}" + end + + test "send_notification broadcasts to all sessions when no session_id" do + # Create two sessions + init_request1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response1 = @transport.handle_request(init_request1) + session_id1 = init_response1[1]["Mcp-Session-Id"] + + init_request2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + ) + init_response2 = @transport.handle_request(init_request2) + session_id2 = init_response2[1]["Mcp-Session-Id"] + + # Connect both sessions with SSE + io1 = StringIO.new + get_request1 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id1 }, + ) + response1 = @transport.handle_request(get_request1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get_request2 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id2 }, + ) + response2 = @transport.handle_request(get_request2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + # Give the streams time to set up + sleep(0.1) + + # Broadcast notification to all sessions + notification = { jsonrpc: "2.0", method: "broadcast", params: { message: "Hello everyone" } } + sent_count = @transport.send_notification(notification) + + assert_equal 2, sent_count + + # Check both sessions received the notification + io1.rewind + output1 = io1.read + assert_includes output1, + "data: {\"jsonrpc\":\"2.0\",\"method\":\"broadcast\",\"params\":{\"message\":\"Hello everyone\"}}" + + io2.rewind + output2 = io2.read + assert_includes output2, + "data: {\"jsonrpc\":\"2.0\",\"method\":\"broadcast\",\"params\":{\"message\":\"Hello everyone\"}}" + end + + test "send_notification returns false for non-existent session" do + result = @transport.send_notification({ message: "test" }, session_id: "non_existent") + refute result + end + + test "send_notification handles closed streams gracefully" do + # Create and initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Close the stream + io.close + + # Try to send notification + result = @transport.send_notification({ message: "test" }, session_id: session_id) + + # Should return false and clean up the session + refute result + + # Verify session was cleaned up + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + end + + private + + def create_rack_request(method, path, headers, body = nil) + env = { + "REQUEST_METHOD" => method, + "PATH_INFO" => path, + "rack.input" => StringIO.new(body.to_s), + }.merge(headers) + + Rack::Request.new(env) + end + end + end + end +end diff --git a/test/mcp/transport_test.rb b/test/mcp/transport_test.rb new file mode 100644 index 0000000..297631a --- /dev/null +++ b/test/mcp/transport_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/server" +require "mcp/transport" + +module MCP + class TransportTest < ActiveSupport::TestCase + class TestTransport < Transport + def handle_request(request) + [200, {}, ["OK"]] + end + + def send_request(method, params = nil) + true + end + + def close + true + end + end + + setup do + @server = Server.new( + name: "test_server", + tools: [], + prompts: [], + resources: [], + ) + @transport = TestTransport.new(@server) + # Clear transports registry before each test + Transport.instance_variable_set(:@transports, nil) + end + + teardown do + # Clear transports registry after each test + Transport.instance_variable_set(:@transports, nil) + end + + test "registers transport class" do + Transport.register("test", TestTransport) + assert_equal TestTransport, Transport.get("test") + end + + test "raises error for unknown transport" do + assert_raises(RuntimeError, "Transport unknown not found") { Transport.get("unknown") } + end + + test "initializes with server instance" do + assert_equal @server, @transport.instance_variable_get(:@server) + end + + test "handles request" do + response = @transport.handle_request(nil) + assert_equal [200, {}, ["OK"]], response + end + + test "sends request" do + assert @transport.send_request("test_method", { foo: "bar" }) + end + + test "closes connection" do + assert @transport.close + end + end +end From 4e4455772b553593fe721efa08ca13ac168d71a4 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Fri, 30 May 2025 16:23:27 -0400 Subject: [PATCH 06/21] StreamableHTTP -> Streamable HTTP --- README.md | 6 +++--- examples/README.md | 6 +++--- examples/http_server.rb | 2 +- examples/sse_test_server.rb | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e12e18a..bbccbe1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It implements the Model Context Protocol specification, handling model context r - Manages tool registration and invocation - Supports prompt registration and execution - Supports resource registration and retrieval -- Supports stdio & StreamableHTTP (including SSE) transports +- Supports stdio & Streamable HTTP (including SSE) transports ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities @@ -52,14 +52,14 @@ It implements the Model Context Protocol specification, handling model context r - Log Level - Resource subscriptions - Completions -- Complete StreamableHTTP implementation with streaming responses +- Complete Streamable HTTP implementation with streaming responses ### Usage #### Rails Controller When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming -[StreamableHTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport +[Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport requests. You can use the `Server#handle_json` method to handle requests. diff --git a/examples/README.md b/examples/README.md index 19ecb99..6d39e28 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,7 @@ ruby examples/stdio_server.rb ``` ### 2. HTTP Server (`http_server.rb`) -A standalone HTTP server built with Rack that implements the MCP StreamableHTTP transport protocol. This demonstrates how to create a web-based MCP server with session management and Server-Sent Events (SSE) support. +A standalone HTTP server built with Rack that implements the MCP Streamable HTTP transport protocol. This demonstrates how to create a web-based MCP server with session management and Server-Sent Events (SSE) support. **Features:** - HTTP transport with Server-Sent Events (SSE) for streaming @@ -127,11 +127,11 @@ curl -X POST http://localhost:9393 \ -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' ``` -## StreamableHTTP Transport Details +## Streamable HTTP Transport Details ### Protocol Flow -The HTTP server implements the MCP StreamableHTTP transport protocol: +The HTTP server implements the MCP Streamable HTTP transport protocol: 1. **Initialize Session**: - Client sends POST request with `initialize` method diff --git a/examples/http_server.rb b/examples/http_server.rb index 52a48e1..fba2e45 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -93,7 +93,7 @@ server.resources_read_handler do |params| }] end -# Create the StreamableHTTP transport +# Create the Streamable HTTP transport server.transport = MCP::Transports::HTTP.new(server) # Create a logger for MCP-specific logging diff --git a/examples/sse_test_server.rb b/examples/sse_test_server.rb index d178ccf..95e55e1 100644 --- a/examples/sse_test_server.rb +++ b/examples/sse_test_server.rb @@ -63,7 +63,7 @@ server.define_tool( MCP::Tool::Response.new([{ type: "text", text: "Echo: #{message}" }]) end -# Create the StreamableHTTP transport +# Create the Streamable HTTP transport server.transport = MCP::Transports::HTTP.new(server) # Create a logger for MCP request/response logging From 08be669b58cbda5757b6523651508ce029c3b6cb Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 2 Jun 2025 12:13:07 -0700 Subject: [PATCH 07/21] Move rack to a development dependency --- mcp.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp.gemspec b/mcp.gemspec index 5c49985..3ba36df 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -28,8 +28,8 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency("json_rpc_handler", "~> 0.1") - spec.add_dependency("rack", ">= 2.0.0") spec.add_development_dependency("activesupport") spec.add_development_dependency("puma", ">= 5.0.0") + spec.add_development_dependency("rack", ">= 2.0.0") spec.add_development_dependency("sorbet-static-and-runtime") end From 8fbb2e86aa4adc9908f34d39722ca99d0f847c28 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 2 Jun 2025 12:14:12 -0700 Subject: [PATCH 08/21] Remove transport-specific code from server --- examples/http_server.rb | 5 +++-- examples/sse_test_server.rb | 5 +++-- lib/mcp/server.rb | 8 -------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/examples/http_server.rb b/examples/http_server.rb index fba2e45..0bb337c 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -94,7 +94,8 @@ server.resources_read_handler do |params| end # Create the Streamable HTTP transport -server.transport = MCP::Transports::HTTP.new(server) +transport = MCP::Transports::HTTP.new(server) +server.transport = transport # Create a logger for MCP-specific logging mcp_logger = Logger.new($stdout) @@ -121,7 +122,7 @@ app = proc do |env| # Handle the request puts "request: #{request.inspect}" - response = server.handle_rack_request(request) + response = transport.handle_request(request) # Log the MCP response details _, _, body = response diff --git a/examples/sse_test_server.rb b/examples/sse_test_server.rb index 95e55e1..79929c8 100644 --- a/examples/sse_test_server.rb +++ b/examples/sse_test_server.rb @@ -64,7 +64,8 @@ server.define_tool( end # Create the Streamable HTTP transport -server.transport = MCP::Transports::HTTP.new(server) +transport = MCP::Transports::HTTP.new(server) +server.transport = transport # Create a logger for MCP request/response logging mcp_logger = Logger.new($stdout) @@ -98,7 +99,7 @@ app = proc do |env| end # Handle the request - response = server.handle_rack_request(request) + response = transport.handle_request(request) # Log response details status, headers, body = response diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 445580a..a366e83 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -98,14 +98,6 @@ module MCP @prompts[prompt.name_value] = prompt end - def handle_rack_request(request) - @transport.handle_request(request) - end - - def close - @transport.close - end - def resources_list_handler(&block) @capabilities.support_resources @handlers[Methods::RESOURCES_LIST] = block From 55b03fc7aaf16cb56c383ccdc9661fafd8e92064 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 2 Jun 2025 13:46:18 -0700 Subject: [PATCH 09/21] Add notification support Co-authored-by: Kevin Fischer --- README.md | 38 ++- lib/mcp/methods.rb | 5 + lib/mcp/server.rb | 24 ++ lib/mcp/server/transports/stdio_transport.rb | 16 +- .../stdio_notification_integration_test.rb | 249 ++++++++++++++++++ ...able_http_notification_integration_test.rb | 249 ++++++++++++++++++ test/mcp/server_notification_test.rb | 136 ++++++++++ 7 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 test/mcp/server/transports/stdio_notification_integration_test.rb create mode 100644 test/mcp/server/transports/streamable_http_notification_integration_test.rb create mode 100644 test/mcp/server_notification_test.rb diff --git a/README.md b/README.md index bbccbe1..eb7e3ed 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ It implements the Model Context Protocol specification, handling model context r - Supports prompt registration and execution - Supports resource registration and retrieval - Supports stdio & Streamable HTTP (including SSE) transports +- Supports notifications for list changes (tools, prompts, resources) ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities @@ -46,13 +47,46 @@ It implements the Model Context Protocol specification, handling model context r - `resources/read` - Retrieves a specific resource by name - `resources/templates/list` - Lists all registered resource templates and their schemas +### Notifications + +The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. + +#### Notification Methods + +The server provides three notification methods: +- `notify_tools_list_changed()` - Send a notification when the tools list changes +- `notify_prompts_list_changed()` - Send a notification when the prompts list changes +- `notify_resources_list_changed()` - Send a notification when the resources list changes + +#### Notification Format + +Notifications follow the JSON-RPC 2.0 specification and use these method names: +- `notifications/tools/list_changed` +- `notifications/prompts/list_changed` +- `notifications/resources/list_changed` + +#### Transport Support + +- **HTTP Transport**: Notifications are sent as Server-Sent Events (SSE) to all connected sessions +- **Stdio Transport**: Notifications are sent as JSON-RPC 2.0 messages to stdout + +#### Usage Example + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Transports::HTTP.new(server) +server.transport = transport + +# When tools change, notify clients +server.define_tool(name: "new_tool") { |**args| { result: "ok" } } +server.notify_tools_list_changed() +``` + ### Unsupported Features ( to be implemented in future versions ) -- Notifications - Log Level - Resource subscriptions - Completions -- Complete Streamable HTTP implementation with streaming responses ### Usage diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 4f0b0b2..9a2db26 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -21,6 +21,11 @@ module MCP SAMPLING_CREATE_MESSAGE = "sampling/createMessage" + # Notification methods + NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed" + NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed" + NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed" + class MissingRequiredCapabilityError < StandardError attr_reader :method attr_reader :capability diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a366e83..d246121 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -98,6 +98,30 @@ module MCP @prompts[prompt.name_value] = prompt end + def notify_tools_list_changed + return unless @transport + + @transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED) + rescue => e + report_exception(e, { notification: "tools_list_changed" }) + end + + def notify_prompts_list_changed + return unless @transport + + @transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED) + rescue => e + report_exception(e, { notification: "prompts_list_changed" }) + end + + def notify_resources_list_changed + return unless @transport + + @transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED) + rescue => e + report_exception(e, { notification: "resources_list_changed" }) + end + def resources_list_handler(&block) @capabilities.support_resources @handlers[Methods::RESOURCES_LIST] = block diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index fdf2610..53b2724 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -43,10 +43,18 @@ module MCP $stdout.flush end - def send_response(message) - json_message = message.is_a?(String) ? message : JSON.generate(message) - $stdout.puts(json_message) - $stdout.flush + def send_notification(method, params = nil) + notification = { + jsonrpc: "2.0", + method: method, + } + notification[:params] = params if params + + send_response(notification) + true + rescue => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" }) + false end end end diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb new file mode 100644 index 0000000..34516a0 --- /dev/null +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "mcp/server" +require "mcp/server/transports/stdio_transport" + +module MCP + class Server + module Transports + class StdioNotificationIntegrationTest < ActiveSupport::TestCase + class MockIO + attr_reader :output + + def initialize + @output = [] + @closed = false + end + + def puts(message) + @output << message + end + + def write(message) + @output << message + message.length + end + + def gets + nil # Simulate end of input + end + + def set_encoding(encoding) + # Mock implementation + end + + def flush + # Mock implementation + end + + def close + @closed = true + end + + def closed? + @closed + end + end + + setup do + @original_stdout = $stdout + @original_stdin = $stdin + + @mock_stdout = MockIO.new + @mock_stdin = MockIO.new + + $stdout = @mock_stdout + $stdin = @mock_stdin + + @server = Server.new( + name: "test_server", + tools: [], + prompts: [], + resources: [], + ) + @transport = StdioTransport.new(@server) + @server.transport = @transport + end + + teardown do + $stdout = @original_stdout + $stdin = @original_stdin + end + + test "server notification methods send JSON-RPC notifications through StdioTransport" do + # Test tools notification + @server.notify_tools_list_changed + + # Test prompts notification + @server.notify_prompts_list_changed + + # Test resources notification + @server.notify_resources_list_changed + + # Check the notifications were sent + assert_equal 3, @mock_stdout.output.size + + # Parse and verify each notification + notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) } + + assert_equal "2.0", notifications[0]["jsonrpc"] + assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0]["method"] + assert_nil notifications[0]["params"] + + assert_equal "2.0", notifications[1]["jsonrpc"] + assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1]["method"] + assert_nil notifications[1]["params"] + + assert_equal "2.0", notifications[2]["jsonrpc"] + assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"] + assert_nil notifications[2]["params"] + end + + test "notifications include params when provided" do + # Test the transport's send_notification directly with params + result = @transport.send_notification("test/notification", { data: "test_value" }) + + assert result + assert_equal 1, @mock_stdout.output.size + + notification = JSON.parse(@mock_stdout.output.first) + assert_equal "2.0", notification["jsonrpc"] + assert_equal "test/notification", notification["method"] + assert_equal({ "data" => "test_value" }, notification["params"]) + end + + test "server continues to work when stdout is closed" do + # Close stdout + @mock_stdout.close + + # Server notifications should not raise errors + assert_nothing_raised do + @server.notify_tools_list_changed + @server.notify_prompts_list_changed + @server.notify_resources_list_changed + end + end + + test "notifications work with dynamic tool additions" do + # Define a new tool + @server.define_tool( + name: "dynamic_tool", + description: "A dynamically added tool", + ) do |**args| + { result: "success" } + end + + # Clear previous output + @mock_stdout.output.clear + + # Manually trigger notification + @server.notify_tools_list_changed + + # Check the notification was sent + assert_equal 1, @mock_stdout.output.size + + notification = JSON.parse(@mock_stdout.output.first) + assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"] + + # Verify the tool was added to the server + assert @server.tools.key?("dynamic_tool") + end + + test "notifications are properly formatted JSON-RPC 2.0 messages" do + # Send a notification + @server.notify_prompts_list_changed + + # Verify format + assert_equal 1, @mock_stdout.output.size + output = @mock_stdout.output.first + + # Should be valid JSON + notification = JSON.parse(output) + + # Should have required JSON-RPC 2.0 fields + assert_equal "2.0", notification["jsonrpc"] + assert notification.key?("method") + refute notification.key?("id") # Notifications should not have an id + + # Method should be the expected notification type + assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"] + end + + test "multiple notifications are sent as separate JSON messages" do + # Send multiple notifications rapidly + 5.times do + @server.notify_tools_list_changed + end + + # Each should be a separate JSON message + assert_equal 5, @mock_stdout.output.size + + # All should be parseable as JSON + @mock_stdout.output.each do |msg| + notification = JSON.parse(msg) + assert_equal "2.0", notification["jsonrpc"] + assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"] + end + end + + test "transport handles errors gracefully" do + # Create a stdout that raises errors + error_stdout = Class.new(MockIO) do + def puts(message) + raise IOError, "Simulated IO error" + end + end.new + + $stdout = error_stdout + + # Notification should return false but not raise + result = @transport.send_notification("test/notification") + refute result + end + + test "server notification flow works end-to-end with StdioTransport" do + # This test verifies the complete integration from server to transport + + # Start with no output + assert_empty @mock_stdout.output + + # Add a prompt and notify + @server.define_prompt( + name: "test_prompt", + description: "Test prompt", + ) do |args, server_context:| + MCP::PromptResponse.new(messages: [{ role: "user", content: "Test" }]) + end + + # Manually trigger notification + @server.notify_prompts_list_changed + + # Verify notification was sent + assert_equal 1, @mock_stdout.output.size + notification = JSON.parse(@mock_stdout.output.first) + assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"] + + # Add a resource and notify + @server.resources = [ + MCP::Resource.new( + uri: "test://resource", + name: "Test Resource", + description: "A test resource", + mime_type: "text/plain", + ), + ] + + # Manually trigger notification + @server.notify_resources_list_changed + + # Verify both notifications were sent + assert_equal 2, @mock_stdout.output.size + second_notification = JSON.parse(@mock_stdout.output.last) + assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"] + end + end + end + end +end diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb new file mode 100644 index 0000000..89eca43 --- /dev/null +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "rack" +require "mcp/server" +require "mcp/server/transports/streamable_http_transport" + +module MCP + class Server + module Transports + class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase + setup do + @server = Server.new( + name: "test_server", + tools: [], + prompts: [], + resources: [], + ) + @transport = StreamableHTTPTransport.new(@server) + @server.transport = @transport + end + + test "server notification methods send SSE notifications through HTTP transport" do + # Initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Test tools notification + @server.notify_tools_list_changed + + # Test prompts notification + @server.notify_prompts_list_changed + + # Test resources notification + @server.notify_resources_list_changed + + # Check the notifications were received + io.rewind + output = io.read + + assert_includes output, "data: #{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}" + assert_includes output, "data: #{Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED}" + assert_includes output, "data: #{Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED}" + end + + test "notifications are broadcast to all connected sessions" do + # Create two sessions + init_request1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response1 = @transport.handle_request(init_request1) + session_id1 = init_response1[1]["Mcp-Session-Id"] + + init_request2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + ) + init_response2 = @transport.handle_request(init_request2) + session_id2 = init_response2[1]["Mcp-Session-Id"] + + # Connect both sessions with SSE + io1 = StringIO.new + get_request1 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id1 }, + ) + response1 = @transport.handle_request(get_request1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get_request2 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id2 }, + ) + response2 = @transport.handle_request(get_request2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + # Give the streams time to set up + sleep(0.1) + + # Send notification through server + @server.notify_tools_list_changed + + # Check both sessions received the notification + io1.rewind + output1 = io1.read + assert_includes output1, "data: #{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}" + + io2.rewind + output2 = io2.read + assert_includes output2, "data: #{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}" + end + + test "server continues to work when SSE connection is closed" do + # Initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Close the stream + io.close + + # Server notifications should not raise errors + assert_nothing_raised do + @server.notify_tools_list_changed + @server.notify_prompts_list_changed + @server.notify_resources_list_changed + end + end + + test "notifications work with dynamic tool additions" do + # Initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Define a new tool (simulating dynamic tool addition) + @server.define_tool( + name: "dynamic_tool", + description: "A dynamically added tool", + ) do |**args| + { result: "success" } + end + + # Manually trigger notification (since we removed the automatic triggers) + @server.notify_tools_list_changed + + # Check the notification was received + io.rewind + output = io.read + assert_includes output, "data: #{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}" + + # Verify the tool was added to the server + assert @server.tools.key?("dynamic_tool") + end + + test "SSE format is correct for notifications" do + # Initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Send a notification + @server.notify_tools_list_changed + + # Check SSE format + io.rewind + output = io.read + + # SSE format should be "data: \n\n" + assert_match(/data: #{Regexp.escape(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)}\n/, output) + end + + private + + def create_rack_request(method, path, headers, body = nil) + env = { + "REQUEST_METHOD" => method, + "PATH_INFO" => path, + "rack.input" => StringIO.new(body.to_s), + }.merge(headers) + + Rack::Request.new(env) + end + end + end + end +end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb new file mode 100644 index 0000000..217738d --- /dev/null +++ b/test/mcp/server_notification_test.rb @@ -0,0 +1,136 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerNotificationTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + class MockTransport < Transport + attr_reader :notifications + + def initialize(server) + super + @notifications = [] + end + + def send_notification(method, params = nil) + @notifications << { method: method, params: params } + true + end + + def send_response(response); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: configuration + ) + + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + end + + test "#notify_tools_list_changed sends notification through transport" do + @server.notify_tools_list_changed + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification[:method] + assert_nil notification[:params] + end + + test "#notify_prompts_list_changed sends notification through transport" do + @server.notify_prompts_list_changed + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification[:method] + assert_nil notification[:params] + end + + test "#notify_resources_list_changed sends notification through transport" do + @server.notify_resources_list_changed + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notification[:method] + assert_nil notification[:params] + end + + test "notification methods work without transport" do + server_without_transport = Server.new(name: "test_server") + + # Should not raise any errors + assert_nothing_raised do + server_without_transport.notify_tools_list_changed + server_without_transport.notify_prompts_list_changed + server_without_transport.notify_resources_list_changed + end + end + + test "notification methods handle transport errors gracefully" do + # Create a transport that raises errors + error_transport = Class.new(MockTransport) do + def send_notification(method, params = nil) + raise StandardError, "Transport error" + end + end.new(@server) + + @server.transport = error_transport + + # Mock the exception reporter + expected_contexts = [ + { notification: "tools_list_changed" }, + { notification: "prompts_list_changed" }, + { notification: "resources_list_changed" } + ] + + call_count = 0 + @server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context| + assert_kind_of StandardError, exception + assert_equal "Transport error", exception.message + assert_includes expected_contexts, context + call_count += 1 + true + end + + # Should not raise errors to the caller + assert_nothing_raised do + @server.notify_tools_list_changed + @server.notify_prompts_list_changed + @server.notify_resources_list_changed + end + + assert_equal 3, call_count + end + + test "multiple notification methods can be called in sequence" do + @server.notify_tools_list_changed + @server.notify_prompts_list_changed + @server.notify_resources_list_changed + + assert_equal 3, @mock_transport.notifications.size + + notifications = @mock_transport.notifications + assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method] + assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method] + assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method] + end + + test "notification method constants are defined correctly" do + assert_equal "notifications/tools/list_changed", Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED + assert_equal "notifications/prompts/list_changed", Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED + assert_equal "notifications/resources/list_changed", Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED + end + end +end \ No newline at end of file From e546d76991980988ca89840514f03585bfadd0e4 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Tue, 3 Jun 2025 12:19:17 -0700 Subject: [PATCH 10/21] Rubocop --- .../stdio_notification_integration_test.rb | 6 ++--- ...able_http_notification_integration_test.rb | 2 +- test/mcp/server_notification_test.rb | 24 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb index 34516a0..88b494e 100644 --- a/test/mcp/server/transports/stdio_notification_integration_test.rb +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -30,7 +30,7 @@ module MCP nil # Simulate end of input end - def set_encoding(encoding) + def set_encoding(encoding) # rubocop:disable Naming/AccessorMethodName # Mock implementation end @@ -131,7 +131,7 @@ module MCP @server.define_tool( name: "dynamic_tool", description: "A dynamically added tool", - ) do |**args| + ) do |**_args| { result: "success" } end @@ -213,7 +213,7 @@ module MCP @server.define_prompt( name: "test_prompt", description: "Test prompt", - ) do |args, server_context:| + ) do |_args, _server_context:| MCP::PromptResponse.new(messages: [{ role: "user", content: "Test" }]) end diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 89eca43..b3362e1 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -181,7 +181,7 @@ module MCP @server.define_tool( name: "dynamic_tool", description: "A dynamically added tool", - ) do |**args| + ) do |**_args| { result: "success" } end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 217738d..4750738 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -29,13 +29,13 @@ module MCP setup do configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback - + @server = Server.new( name: "test_server", version: "1.0.0", - configuration: configuration + configuration: configuration, ) - + @mock_transport = MockTransport.new(@server) @server.transport = @mock_transport end @@ -69,7 +69,7 @@ module MCP test "notification methods work without transport" do server_without_transport = Server.new(name: "test_server") - + # Should not raise any errors assert_nothing_raised do server_without_transport.notify_tools_list_changed @@ -85,16 +85,16 @@ module MCP raise StandardError, "Transport error" end end.new(@server) - + @server.transport = error_transport - + # Mock the exception reporter expected_contexts = [ { notification: "tools_list_changed" }, { notification: "prompts_list_changed" }, - { notification: "resources_list_changed" } + { notification: "resources_list_changed" }, ] - + call_count = 0 @server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context| assert_kind_of StandardError, exception @@ -103,14 +103,14 @@ module MCP call_count += 1 true end - + # Should not raise errors to the caller assert_nothing_raised do @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed end - + assert_equal 3, call_count end @@ -120,7 +120,7 @@ module MCP @server.notify_resources_list_changed assert_equal 3, @mock_transport.notifications.size - + notifications = @mock_transport.notifications assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method] assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method] @@ -133,4 +133,4 @@ module MCP assert_equal "notifications/resources/list_changed", Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED end end -end \ No newline at end of file +end From 711216f66a1ebfa6ec65015bd71837ccafec5aa8 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Tue, 3 Jun 2025 22:40:58 -0700 Subject: [PATCH 11/21] Fix examples --- examples/http_server.rb | 4 ++-- examples/sse_test_server.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/http_server.rb b/examples/http_server.rb index 0bb337c..1decdfd 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -3,7 +3,7 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "mcp" -require "mcp/transports/http" +require "mcp/server/transports/streamable_http_transport" require "rack" require "rackup" require "json" @@ -94,7 +94,7 @@ server.resources_read_handler do |params| end # Create the Streamable HTTP transport -transport = MCP::Transports::HTTP.new(server) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) server.transport = transport # Create a logger for MCP-specific logging diff --git a/examples/sse_test_server.rb b/examples/sse_test_server.rb index 79929c8..4e49758 100644 --- a/examples/sse_test_server.rb +++ b/examples/sse_test_server.rb @@ -3,7 +3,7 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "mcp" -require "mcp/transports/http" +require "mcp/server/transports/streamable_http_transport" require "rack" require "rackup" require "json" @@ -64,7 +64,7 @@ server.define_tool( end # Create the Streamable HTTP transport -transport = MCP::Transports::HTTP.new(server) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) server.transport = transport # Create a logger for MCP request/response logging From 9a23141ac73a0f6176fea2db7f8c7d317b4def86 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 9 Jun 2025 14:12:49 -0700 Subject: [PATCH 12/21] Add rackup as a development dependency --- mcp.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp.gemspec b/mcp.gemspec index 3ba36df..1228881 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -31,5 +31,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency("activesupport") spec.add_development_dependency("puma", ">= 5.0.0") spec.add_development_dependency("rack", ">= 2.0.0") + spec.add_development_dependency("rackup", ">= 2.1.0") spec.add_development_dependency("sorbet-static-and-runtime") end From 580029954ffe263f4ea1e6da2151da0610c5721c Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 9 Jun 2025 14:13:49 -0700 Subject: [PATCH 13/21] Cleanup logs for examples/http_server.rb --- examples/http_server.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/http_server.rb b/examples/http_server.rb index 1decdfd..1f5abac 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -121,7 +121,6 @@ app = proc do |env| end # Handle the request - puts "request: #{request.inspect}" response = transport.handle_request(request) # Log the MCP response details From 91af9ad19a0e1131a82d0f1f02a70b74a8ae3d62 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Mon, 9 Jun 2025 14:14:41 -0700 Subject: [PATCH 14/21] Gracefully handle POST requests with nil bodies --- lib/mcp/server/transports/streamable_http_transport.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 0fcca7d..ee3244f 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -162,7 +162,7 @@ module MCP def parse_request_body(body_string) JSON.parse(body_string) - rescue JSON::ParserError + rescue JSON::ParserError, TypeError [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end From 16ab4bac8197ea509bf1f843febd39f15a1ed2b6 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 8 Jun 2025 15:23:46 +0900 Subject: [PATCH 15/21] Add a migration path for `Server::Transports::StdioTransport` Follow-up to #27. This PR adds a migration path for `Server::Transports::StdioTransport`. e.g., ```console $ cat /tmp/example.rb require "mcp/transports/stdio" MCP::Transports::StdioTransport ``` This change displays the following warning instead of raising an error for such cases, allowing execution to proceed: ```console $ bundle exec ruby /tmp/example.rb /tmp/example.rb:1: warning: Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`. Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`. This API is deprecated and will be removed in a future release. ``` Currently (v0.1.0), it results in an error without any warning. This MCP gem is not yet close to version 1.0, but introducing breaking changes to a published interface is undesirable. This compatibility code should be removed at some point after a few releases. Closes #50. --- lib/mcp/transports/stdio.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 lib/mcp/transports/stdio.rb diff --git a/lib/mcp/transports/stdio.rb b/lib/mcp/transports/stdio.rb new file mode 100644 index 0000000..3050f26 --- /dev/null +++ b/lib/mcp/transports/stdio.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "../server/transports/stdio_transport" + +warn <<~MESSAGE, uplevel: 3 + Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`. + Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`. + This API is deprecated and will be removed in a future release. +MESSAGE + +module MCP + module Transports + StdioTransport = Server::Transports::StdioTransport + end +end From 1b87b3ffde7826e12d6666f82d66dc588f26f4eb Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 11 Jun 2025 00:04:35 +0900 Subject: [PATCH 16/21] [Doc] Update the MCP spec URL https://spec.modelcontextprotocol.io/specification/2024-11-05/ URL is redirected to https://modelcontextprotocol.io/specification/2025-03-26 This update avoids unnecessary redirects from the old URL to the new URL. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 84eb893..eda7dfc 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ This will make all new server instances use the specified protocol version inste MCP::Server.protocol_version = nil ``` -Be sure to check the [MCP spec](https://spec.modelcontextprotocol.io/specification/2024-11-05/) for the protocol version to understand the supported features for the version being set. +Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/2025-03-26) for the protocol version to understand the supported features for the version being set. ### Exception Reporting From 2942d456cc1cf040a51b2a9677121ce1fda797ab Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Tue, 10 Jun 2025 12:54:21 -0700 Subject: [PATCH 17/21] JSON body fix in examples/http_server.rb --- examples/http_server.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/http_server.rb b/examples/http_server.rb index 1f5abac..811775c 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -125,7 +125,7 @@ app = proc do |env| # Log the MCP response details _, _, body = response - if body.is_a?(Array) && !body.empty? + if body.is_a?(Array) && !body.empty? && body.first begin parsed_response = JSON.parse(body.first) if parsed_response["error"] From 4d79263a8701733d19173a2d3caf7eaa2526ebb1 Mon Sep 17 00:00:00 2001 From: Riley Shott Date: Wed, 11 Jun 2025 13:44:54 -0700 Subject: [PATCH 18/21] Addressing feedback --- ...t_client.rb => streaming_http_test_client.rb} | 0 ...t_server.rb => streaming_http_test_server.rb} | 2 +- lib/mcp/server/transports/stdio_transport.rb | 5 ----- .../transports/streamable_http_transport.rb | 6 ------ lib/mcp/transport.rb | 13 ------------- test/mcp/server_notification_test.rb | 6 ------ test/mcp/transport_test.rb | 16 ---------------- 7 files changed, 1 insertion(+), 47 deletions(-) rename examples/{sse_test_client.rb => streaming_http_test_client.rb} (100%) rename examples/{sse_test_server.rb => streaming_http_test_server.rb} (99%) diff --git a/examples/sse_test_client.rb b/examples/streaming_http_test_client.rb similarity index 100% rename from examples/sse_test_client.rb rename to examples/streaming_http_test_client.rb diff --git a/examples/sse_test_server.rb b/examples/streaming_http_test_server.rb similarity index 99% rename from examples/sse_test_server.rb rename to examples/streaming_http_test_server.rb index 4e49758..2ff9d96 100644 --- a/examples/sse_test_server.rb +++ b/examples/streaming_http_test_server.rb @@ -137,7 +137,7 @@ rack_app = Rack::Builder.new do end # Print usage instructions -puts "=== MCP SSE Test Server ===" +puts "=== MCP Streaming HTTP Test Server ===" puts "" puts "Starting server on http://localhost:9393" puts "" diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index 53b2724..702af08 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -8,11 +8,6 @@ module MCP module Transports class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 - class << self - def register - super("stdio", self) - end - end def initialize(server) @server = server diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index ee3244f..4c91f4c 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -8,12 +8,6 @@ module MCP class Server module Transports class StreamableHTTPTransport < Transport - class << self - def register - super("http", self) - end - end - def initialize(server) super # { session_id => { stream: stream_object } diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index d3226fc..a995c3a 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -2,19 +2,6 @@ module MCP class Transport - class << self - def register(name, transport_class) - @transports ||= {} - @transports[name] = transport_class - end - - def get(name) - raise "Transport #{name} not found" unless @transports - - @transports.fetch(name) { raise "Transport #{name} not found" } - end - end - # Initialize the transport with the server instance def initialize(server) @server = server diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 4750738..af35936 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -126,11 +126,5 @@ module MCP assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method] assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method] end - - test "notification method constants are defined correctly" do - assert_equal "notifications/tools/list_changed", Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED - assert_equal "notifications/prompts/list_changed", Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED - assert_equal "notifications/resources/list_changed", Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED - end end end diff --git a/test/mcp/transport_test.rb b/test/mcp/transport_test.rb index 297631a..dc94881 100644 --- a/test/mcp/transport_test.rb +++ b/test/mcp/transport_test.rb @@ -28,22 +28,6 @@ module MCP resources: [], ) @transport = TestTransport.new(@server) - # Clear transports registry before each test - Transport.instance_variable_set(:@transports, nil) - end - - teardown do - # Clear transports registry after each test - Transport.instance_variable_set(:@transports, nil) - end - - test "registers transport class" do - Transport.register("test", TestTransport) - assert_equal TestTransport, Transport.get("test") - end - - test "raises error for unknown transport" do - assert_raises(RuntimeError, "Transport unknown not found") { Transport.get("unknown") } end test "initializes with server instance" do From dc38c9f84a33d5c43d2a86f18d94dba09e4f824d Mon Sep 17 00:00:00 2001 From: dak2 Date: Sun, 15 Jun 2025 14:50:47 +0900 Subject: [PATCH 19/21] Rename to `MCP` when handling exceptions in Streamable HTTP transport The reason for the change is that a NameError occurs when a POST request is made while the stream is closed because ModelContextProtocol is undefined. I have also added test cases to check the behaviour when IOError and Errno::EPIPE occurs. --- .../transports/streamable_http_transport.rb | 4 +- .../streamable_http_transport_test.rb | 100 ++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4c91f4c..e8c334b 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -106,7 +106,7 @@ module MCP handle_regular_request(body_string, session_id) end rescue StandardError => e - ModelContextProtocol.configuration.exception_reporter.call(e, { request: body_string }) + MCP.configuration.exception_reporter.call(e, { request: body_string }) [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]] end @@ -204,7 +204,7 @@ module MCP send_to_stream(stream, message) [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]] rescue IOError, Errno::EPIPE => e - ModelContextProtocol.configuration.exception_reporter.call( + MCP.configuration.exception_reporter.call( e, { session_id: session_id, error: "Stream closed during response" }, ) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index eb0cf7e..536f6cf 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -113,6 +113,106 @@ module MCP assert response[2].is_a?(Proc) # The body should be a Proc for streaming end + test "handles POST request when IOError raised" do + # Create and initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Connect with SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Close the stream + io.close + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + # This should handle IOError and return the original response + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + # Verify session was cleaned up + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + end + + test "handles POST request when Errno::EPIPE raised" do + # Create and initialize a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + # Create a pipe to simulate EPIPE condition + reader, writer = IO.pipe + + # Connect with SSE using the writer end of the pipe + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(writer) if response[2].is_a?(Proc) + + # Give the stream time to set up + sleep(0.1) + + # Close the reader end to break the pipe - this will cause EPIPE on write + reader.close + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, + ) + + # This should handle Errno::EPIPE and return the original response + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + # Verify session was cleaned up + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + + begin + writer.close + rescue + nil + end + end + test "handles GET request with missing session ID" do request = create_rack_request( "GET", From ce1c8719cfc543b7e0aaac76202798d26e3a301a Mon Sep 17 00:00:00 2001 From: Sam Bostock Date: Tue, 17 Jun 2025 02:07:25 -0400 Subject: [PATCH 20/21] Remove duplicate `method` instrumentation `handle_request` already adds the `method` to the instrumentation data, so there is no need to repeat this. Moreover, consumers can override these anyway. --- lib/mcp/server.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 2c39fb0..45618bb 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -176,7 +176,6 @@ module MCP end def init(request) - add_instrumentation_data(method: Methods::INITIALIZE) { protocolVersion: configuration.protocol_version, capabilities: capabilities, @@ -185,12 +184,10 @@ module MCP end def list_tools(request) - add_instrumentation_data(method: Methods::TOOLS_LIST) @tools.map { |_, tool| tool.to_h } end def call_tool(request) - add_instrumentation_data(method: Methods::TOOLS_CALL) tool_name = request[:name] tool = tools[tool_name] unless tool @@ -224,12 +221,10 @@ module MCP end def list_prompts(request) - add_instrumentation_data(method: Methods::PROMPTS_LIST) @prompts.map { |_, prompt| prompt.to_h } end def get_prompt(request) - add_instrumentation_data(method: Methods::PROMPTS_GET) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt @@ -246,21 +241,16 @@ module MCP end def list_resources(request) - add_instrumentation_data(method: Methods::RESOURCES_LIST) - @resources.map(&:to_h) end # Server implementation should set `resources_read_handler` to override no-op default def read_resource_no_content(request) - add_instrumentation_data(method: Methods::RESOURCES_READ) add_instrumentation_data(resource_uri: request[:uri]) [] end def list_resource_templates(request) - add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST) - @resource_templates.map(&:to_h) end From 10cb6c0eb9a9f76b2df559de39d83159f9013b16 Mon Sep 17 00:00:00 2001 From: dak2 Date: Tue, 17 Jun 2025 22:51:57 +0900 Subject: [PATCH 21/21] Rename the example file names related to SSE. This PR renamed the files related to the SSE example and fixed the README.md. Because missing files were specified in README.md. And also, I removed the word 'test' from the file names related to the SSE example. Other examples file names do not contain the word `test`. --- examples/README.md | 10 +++++----- ...g_http_test_client.rb => streamable_http_client.rb} | 0 ...g_http_test_server.rb => streamable_http_server.rb} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename examples/{streaming_http_test_client.rb => streamable_http_client.rb} (100%) rename examples/{streaming_http_test_server.rb => streamable_http_server.rb} (100%) diff --git a/examples/README.md b/examples/README.md index 6d39e28..f566f9c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,7 +57,7 @@ The client will demonstrate: - Listing and reading resources - Session cleanup -### 4. SSE Test Server (`sse_test_server.rb`) +### 4. Streamable HTTP Server (`streamable_http_server.rb`) A specialized HTTP server designed to test and demonstrate Server-Sent Events (SSE) functionality in the MCP protocol. **Features:** @@ -71,12 +71,12 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Usage:** ```bash -ruby examples/sse_test_server.rb +ruby examples/streamable_http_server.rb ``` The server will start on `http://localhost:9393` and provide detailed instructions for testing SSE functionality. -### 5. SSE Test Client (`sse_test_client.rb`) +### 5. Streamable HTTP Client (`streamable_http_client.rb`) An interactive client that connects to the SSE stream and provides a menu-driven interface for testing SSE functionality. **Features:** @@ -88,12 +88,12 @@ An interactive client that connects to the SSE stream and provides a menu-driven **Usage:** 1. Start the SSE test server in one terminal: ```bash - ruby examples/sse_test_server.rb + ruby examples/streamable_http_server.rb ``` 2. Run the SSE test client in another terminal: ```bash - ruby examples/sse_test_client.rb + ruby examples/streamable_http_client.rb ``` The client will: diff --git a/examples/streaming_http_test_client.rb b/examples/streamable_http_client.rb similarity index 100% rename from examples/streaming_http_test_client.rb rename to examples/streamable_http_client.rb diff --git a/examples/streaming_http_test_server.rb b/examples/streamable_http_server.rb similarity index 100% rename from examples/streaming_http_test_server.rb rename to examples/streamable_http_server.rb