diff --git a/AGENTS.md b/AGENTS.md index 6b269d7..cf15786 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,18 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing **MCP::Server** (`lib/mcp/server.rb`): -- Main server class handling JSON-RPC requests +- Main server class handling JSON-RPC requests and holding shared configuration (tools, prompts, resources, handlers, capabilities) - Implements MCP protocol methods: initialize, ping, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read - Supports custom method registration via `define_custom_method` - Handles instrumentation, exception reporting, and notifications - Uses JsonRpcHandler for request processing +**MCP::ServerSession** (`lib/mcp/server_session.rb`): + +- Per-connection state: client info, logging level +- Created by the transport layer for each client connection +- Delegates request handling to the shared `Server` + **MCP::Client** (`lib/mcp/client.rb`): - Client interface for communicating with MCP servers @@ -95,6 +101,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Protocol version 2025-03-26+ supports tool annotations (destructive_hint, idempotent_hint, etc.) - Validation is configurable via `configuration.validate_tool_call_arguments` +**Session Architecture**: + +- `Server` holds shared configuration (tools, prompts, resources, handlers) +- `ServerSession` holds per-connection state (client info, logging level) +- Both `StdioTransport` and `StreamableHTTPTransport` create a `ServerSession` per connection, making the session model transparent across transports +- Session-scoped notifications (`notify_progress`, `notify_log_message`) are sent only to the originating client via `ServerSession` +- Server-wide notifications (`notify_tools_list_changed`, etc.) broadcast to all sessions via `Server` + **Context Passing**: - `server_context` hash passed through tool/prompt calls for request-specific data diff --git a/README.md b/README.md index 6ac29be..9e8a096 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,15 @@ The server provides the following notification methods: - `notify_progress` - Send a progress notification for long-running operations - `notify_log_message` - Send a structured logging notification message +#### Session Scoping + +When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: + +- **`report_progress`** and **`notify_log_message`** called via `server_context` inside a tool handler are automatically sent only to the requesting client. +No extra configuration is needed. +- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, +as they represent server-wide state changes. These should be called on the `server` instance directly. + #### Notification Format Notifications follow the JSON-RPC 2.0 specification and use these method names: diff --git a/lib/mcp.rb b/lib/mcp.rb index 98551e7..79082c4 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -15,6 +15,7 @@ module MCP autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" autoload :Server, "mcp/server" + autoload :ServerSession, "mcp/server_session" autoload :Tool, "mcp/tool" class << self diff --git a/lib/mcp/progress.rb b/lib/mcp/progress.rb index 7d27a51..7c36a94 100644 --- a/lib/mcp/progress.rb +++ b/lib/mcp/progress.rb @@ -2,15 +2,15 @@ module MCP class Progress - def initialize(server:, progress_token:) - @server = server + def initialize(notification_target:, progress_token:) + @notification_target = notification_target @progress_token = progress_token end def report(progress, total: nil, message: nil) return unless @progress_token - @server.notify_progress( + @notification_target.notify_progress( progress_token: @progress_token, progress: progress, total: total, diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 4ef4b51..ffcce58 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -111,15 +111,29 @@ def initialize( @transport = transport end - def handle(request) + # Processes a parsed JSON-RPC request and returns the response as a Hash. + # + # @param request [Hash] A parsed JSON-RPC request. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle` for session-scoped notification delivery. + # When `nil`, notifications broadcast to all sessions. + # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. + def handle(request, session: nil) JsonRpcHandler.handle(request) do |method| - handle_request(request, method) + handle_request(request, method, session: session) end end - def handle_json(request) + # Processes a JSON-RPC request string and returns the response as a JSON string. + # + # @param request [String] A JSON-RPC request as a JSON string. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle_json` for session-scoped notification delivery. + # When `nil`, notifications broadcast to all sessions. + # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. + def handle_json(request, session: nil) JsonRpcHandler.handle_json(request) do |method| - handle_request(request, method) + handle_request(request, method, session: session) end end @@ -279,11 +293,12 @@ def schema_contains_ref?(schema) end end - def handle_request(request, method) + def handle_request(request, method, session: nil) handler = @handlers[method] unless handler instrument_call("unsupported_method") do - add_instrumentation_data(client: @client) if @client + client = session&.client || @client + add_instrumentation_data(client: client) if client end return end @@ -293,6 +308,8 @@ def handle_request(request, method) ->(params) { instrument_call(method) do result = case method + when Methods::INITIALIZE + init(params, session: session) when Methods::TOOLS_LIST { tools: @handlers[Methods::TOOLS_LIST].call(params) } when Methods::PROMPTS_LIST @@ -303,10 +320,15 @@ def handle_request(request, method) { contents: @handlers[Methods::RESOURCES_READ].call(params) } when Methods::RESOURCES_TEMPLATES_LIST { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } + when Methods::TOOLS_CALL + call_tool(params, session: session) + when Methods::LOGGING_SET_LEVEL + configure_logging_level(params, session: session) else @handlers[method].call(params) end - add_instrumentation_data(client: @client) if @client + client = session&.client || @client + add_instrumentation_data(client: client) if client result rescue => e @@ -342,8 +364,14 @@ def server_info }.compact end - def init(params) - @client = params[:clientInfo] if params + def init(params, session: nil) + if params + if session + session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) + else + @client = params[:clientInfo] + end + end protocol_version = params[:protocolVersion] if params negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) @@ -371,7 +399,7 @@ def init(params) }.compact end - def configure_logging_level(request) + def configure_logging_level(request, session: nil) if capabilities[:logging].nil? raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) end @@ -381,6 +409,7 @@ def configure_logging_level(request) raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params) end + session&.configure_logging(logging_message_notification) @logging_message_notification = logging_message_notification {} @@ -390,7 +419,7 @@ def list_tools(request) @tools.values.map(&:to_h) end - def call_tool(request) + def call_tool(request, session: nil) tool_name = request[:name] tool = tools[tool_name] @@ -422,7 +451,7 @@ def call_tool(request) progress_token = request.dig(:_meta, :progressToken) - call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token) + call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session) rescue RequestHandlerError raise rescue => e @@ -491,12 +520,13 @@ def accepts_server_context?(method_object) parameters.any? { |type, name| type == :keyrest || name == :server_context } end - def call_tool_with_args(tool, arguments, context, progress_token: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil) args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) - progress = Progress.new(server: self, progress_token: progress_token) - server_context = ServerContext.new(context, progress: progress) + notification_target = session || self + progress = Progress.new(notification_target: notification_target, progress_token: progress_token) + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) tool.call(**args, server_context: server_context).to_h else tool.call(**args).to_h diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index d6d52dd..9d54cec 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -10,17 +10,19 @@ class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 def initialize(server) - @server = server + super(server) @open = false + @session = nil $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") - super end def open @open = true + @session = ServerSession.new(server: @server, transport: self) while @open && (line = $stdin.gets) - handle_json_request(line.strip) + response = @session.handle_json(line.strip) + send_response(response) if response end rescue Interrupt warn("\nExiting...") diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index f1a5d9d..e74d23e 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -10,7 +10,7 @@ module Transports class StreamableHTTPTransport < Transport def initialize(server, stateless: false) super(server) - # { session_id => { stream: stream_object } + # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession }`. @sessions = {} @mutex = Mutex.new @@ -228,18 +228,25 @@ def response?(body) def handle_initialization(body_string, body) session_id = nil + server_session = nil unless @stateless session_id = SecureRandom.uuid + server_session = ServerSession.new(server: @server, transport: self, session_id: session_id) @mutex.synchronize do @sessions[session_id] = { stream: nil, + server_session: server_session, } end end - response = @server.handle_json(body_string) + response = if server_session + server_session.handle_json(body_string) + else + @server.handle_json(body_string) + end headers = { "Content-Type" => "application/json", @@ -255,16 +262,24 @@ def handle_accepted end def handle_regular_request(body_string, session_id) - unless @stateless - if session_id && !session_exists?(session_id) - return session_not_found_response + server_session = nil + stream = nil + + if session_id && !@stateless + @mutex.synchronize do + session = @sessions[session_id] + return session_not_found_response unless session + + server_session = session[:server_session] + stream = session[:stream] end end - response = @server.handle_json(body_string) - - # Stream can be nil since stateless mode doesn't retain streams - stream = get_session_stream(session_id) if session_id + response = if server_session + server_session.handle_json(body_string) + else + @server.handle_json(body_string) + end if stream send_response_to_stream(stream, response, session_id) diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index b7241a6..458d2cd 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -2,15 +2,41 @@ module MCP class ServerContext - def initialize(context, progress:) + def initialize(context, progress:, notification_target:) @context = context @progress = progress + @notification_target = notification_target end + # Reports progress for the current tool operation. + # The notification is automatically scoped to the originating session. + # + # @param progress [Numeric] Current progress value. + # @param total [Numeric, nil] Total expected value. + # @param message [String, nil] Human-readable status message. def report_progress(progress, total: nil, message: nil) @progress.report(progress, total: total, message: message) end + # Sends a progress notification scoped to the originating session. + # + # @param progress_token [String, Integer] The token identifying the operation. + # @param progress [Numeric] Current progress value. + # @param total [Numeric, nil] Total expected value. + # @param message [String, nil] Human-readable status message. + def notify_progress(progress_token:, progress:, total: nil, message: nil) + @notification_target.notify_progress(progress_token: progress_token, progress: progress, total: total, message: message) + end + + # Sends a log message notification scoped to the originating session. + # + # @param data [Object] The log data to send. + # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`). + # @param logger [String, nil] Logger name. + def notify_log_message(data:, level:, logger: nil) + @notification_target.notify_log_message(data: data, level: level, logger: logger) + end + def method_missing(name, ...) if @context.respond_to?(name) @context.public_send(name, ...) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb new file mode 100644 index 0000000..a1cbfd0 --- /dev/null +++ b/lib/mcp/server_session.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "methods" + +module MCP + # Holds per-connection state for a single client session. + # Created by the transport layer; delegates request handling to the shared `Server`. + class ServerSession + attr_reader :session_id, :client, :logging_message_notification + + def initialize(server:, transport:, session_id: nil) + @server = server + @transport = transport + @session_id = session_id + @client = nil + @client_capabilities = nil # TODO: Use for per-session capability validation. + @logging_message_notification = nil + end + + def handle(request) + @server.handle(request, session: self) + end + + def handle_json(request_json) + @server.handle_json(request_json, session: self) + end + + # Called by `Server#init` during the initialization handshake. + def store_client_info(client:, capabilities: nil) + @client = client + @client_capabilities = capabilities + end + + # Called by `Server#configure_logging_level`. + def configure_logging(logging_message_notification) + @logging_message_notification = logging_message_notification + end + + # Sends a progress notification to this session only. + def notify_progress(progress_token:, progress:, total: nil, message: nil) + params = { + "progressToken" => progress_token, + "progress" => progress, + "total" => total, + "message" => message, + }.compact + + send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params) + rescue => e + @server.report_exception(e, notification: "progress") + end + + # Sends a log message notification to this session only. + def notify_log_message(data:, level:, logger: nil) + effective_logging = @logging_message_notification || @server.logging_message_notification + return unless effective_logging&.should_notify?(level) + + params = { "data" => data, "level" => level } + params["logger"] = logger if logger + + send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params) + rescue => e + @server.report_exception(e, { notification: "log_message" }) + end + + private + + # TODO: When Ruby 2.7 support is dropped, replace with a direct call: + # `@transport.send_notification(method, params, session_id: @session_id)` and + # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`. + def send_to_transport(method, params) + if @session_id + @transport.send_notification(method, params, session_id: @session_id) + else + @transport.send_notification(method, params) + end + end + end +end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index a995c3a..99b8892 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -36,8 +36,8 @@ def handle_request(request) send_response(response) if response end - # Send a notification to the client - # Returns true if the notification was sent successfully + # 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 diff --git a/test/mcp/progress_test.rb b/test/mcp/progress_test.rb index 8477087..a0349b5 100644 --- a/test/mcp/progress_test.rb +++ b/test/mcp/progress_test.rb @@ -30,14 +30,14 @@ def handle_request(request); end end test "#report is a no-op when progress_token is nil" do - progress = Progress.new(server: @server, progress_token: nil) + progress = Progress.new(notification_target: @server, progress_token: nil) progress.report(50, total: 100, message: "halfway") assert_equal 0, @transport.notifications.size end test "#report sends notification when progress_token is present" do - progress = Progress.new(server: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @server, progress_token: "token-1") progress.report(50, total: 100, message: "halfway") assert_equal 1, @transport.notifications.size @@ -50,7 +50,7 @@ def handle_request(request); end end test "#report omits total and message when not provided" do - progress = Progress.new(server: @server, progress_token: "token-1") + progress = Progress.new(notification_target: @server, progress_token: "token-1") progress.report(50) assert_equal 1, @transport.notifications.size diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index aa65ccb..59d3abe 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -101,6 +101,38 @@ class StdioTransportTest < ActiveSupport::TestCase end end + test "open creates a ServerSession and processes requests through it" do + request = { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "stdio-client", version: "1.0" }, + }, + } + input = StringIO.new(JSON.generate(request) + "\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + # Verify a session was created. + session = @transport.instance_variable_get(:@session) + assert_instance_of(ServerSession, session) + + # Verify client info was stored on the session, not on the server. + assert_equal({ name: "stdio-client", version: "1.0" }, session.client) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + test "handles invalid JSON requests" do invalid_json = "invalid json" output = StringIO.new diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 4b346c4..a2abab6 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -586,8 +586,8 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase # 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) + @server.define_singleton_method(:handle_json) do |request, **kwargs| + result = original_handle_json.call(request, **kwargs) # Send notification while still in request context - broadcast to all sessions transport.send_notification("test_notification", { session: "current" }, **{}) result @@ -1357,29 +1357,289 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase assert_equal([], response[2]) end - test "handle_regular_request checks session existence under mutex" do - init_request = create_rack_request( + test "handle_regular_request returns 404 for unknown session_id" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "nonexistent-session", + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = @transport.handle_request(request) + assert_equal(404, response[0]) + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]) + end + + test "session-scoped log notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "secret", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, ) - init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] - @transport.expects(:session_exists?).with(session_id).returns(true) + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] - request = create_rack_request( + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1. + tool_request = create_rack_request( "POST", "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, - }, - { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { name: "log_tool", arguments: {} }, + }.to_json, ) - response = @transport.handle_request(request) - assert_equal(200, response[0]) + transport.handle_request(tool_request) + + # Session 1 should receive the log notification. + io1.rewind + assert_includes io1.read, "secret" + + # Session 2 should NOT receive the log notification. + io2.rewind + refute_includes io2.read, "secret" + end + + test "session-scoped progress notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100, message: "halfway") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1 with a progress token. + tool_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { + name: "progress_tool", + arguments: {}, + _meta: { progressToken: "token-1" }, + }, + }.to_json, + ) + transport.handle_request(tool_request) + + # Session 1 should receive the progress notification. + io1.rewind + assert_includes io1.read, "halfway" + + # Session 2 should NOT receive the progress notification. + io2.rewind + refute_includes io2.read, "halfway" + end + + test "each session stores its own client info independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + # Initialize session 1 with client "alpha". + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "alpha", version: "1.0" }, + }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + # Initialize session 2 with client "beta". + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "beta", version: "2.0" }, + }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Each session should have its own client info. + sessions = transport.instance_variable_get(:@sessions) + assert_equal({ name: "alpha", version: "1.0" }, sessions[session1][:server_session].client) + assert_equal({ name: "beta", version: "2.0" }, sessions[session2][:server_session].client) + end + + test "each session stores its own logging level independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + # Initialize two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "a" } }, + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["Mcp-Session-Id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: { protocolVersion: "2025-11-25", clientInfo: { name: "b" } }, + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["Mcp-Session-Id"] + + # Session 1 sets log level to "error". + set_level1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "3", + params: { level: "error" }, + }.to_json, + ) + transport.handle_request(set_level1) + + # Session 2 sets log level to "debug". + set_level2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session2 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "4", + params: { level: "debug" }, + }.to_json, + ) + transport.handle_request(set_level2) + + # Session 1 (error level) should not notify for "info", but should for "error". + session1_logging = transport.instance_variable_get(:@sessions)[session1][:server_session].logging_message_notification + refute session1_logging.should_notify?("info") + assert session1_logging.should_notify?("error") + + # Session 2 (debug level) should notify for both "info" and "debug". + session2_logging = transport.instance_variable_get(:@sessions)[session2][:server_session].logging_message_notification + assert session2_logging.should_notify?("info") + assert session2_logging.should_notify?("debug") end private diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 7d88c81..6fe7ea6 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -6,18 +6,18 @@ module MCP class ServerContextTest < ActiveSupport::TestCase test "ServerContext delegates method calls to the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_equal "test_user", server_context[:user] end test "ServerContext respond_to? returns true for methods on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_respond_to server_context, :[] assert_respond_to server_context, :keys @@ -25,18 +25,18 @@ class ServerContextTest < ActiveSupport::TestCase test "ServerContext respond_to? returns false for methods not on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) refute_respond_to server_context, :nonexistent_method end test "ServerContext raises NoMethodError for methods not on the underlying context" do context = { user: "test_user" } - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_raises(NoMethodError) { server_context.nonexistent_method } end @@ -46,9 +46,9 @@ class ServerContextTest < ActiveSupport::TestCase def context.custom_method "custom_value" end - progress = Progress.new(server: mock, progress_token: nil) + progress = Progress.new(notification_target: mock, progress_token: nil) - server_context = ServerContext.new(context, progress: progress) + server_context = ServerContext.new(context, progress: progress, notification_target: mock) assert_equal "custom_value", server_context.custom_method end @@ -57,7 +57,7 @@ def context.custom_method progress = mock progress.expects(:report).with(50, total: 100, message: nil).once - server_context = ServerContext.new(nil, progress: progress) + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) server_context.report_progress(50, total: 100) end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index b4388aa..82bb9bd 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -175,5 +175,23 @@ def send_notification(method, params = nil) assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method] assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method] end + + test "server.notify_log_message works after logging/setLevel via session" do + session = ServerSession.new(server: @server, transport: @mock_transport) + + # Client sends logging/setLevel through session. + @server.handle( + { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } }, + session: session, + ) + + # Server-level broadcast should still work because logging level + # is stored on both the session and the server. + @server.notify_log_message(data: "broadcast log", level: "info") + + log_notifications = @mock_transport.notifications.select { |n| n[:method] == Methods::NOTIFICATIONS_MESSAGE } + assert_equal 1, log_notifications.size + assert_equal "broadcast log", log_notifications.first[:params]["data"] + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 012a1de..f452948 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -206,6 +206,20 @@ class ServerTest < ActiveSupport::TestCase assert_instrumentation_data({ method: "ping" }) end + test "unsupported method instrumentation includes client from session" do + session = ServerSession.new(server: @server, transport: mock) + session.store_client_info(client: { name: "session-client", version: "1.0" }) + + request = { + jsonrpc: "2.0", + method: "does/not/exist", + id: 1, + } + + @server.handle(request, session: session) + assert_instrumentation_data({ method: "unsupported_method", client: { name: "session-client", version: "1.0" } }) + end + test "#handle returns nil for notification requests" do request = { jsonrpc: "2.0",