## Motivation and Context
The Ruby SDK uses a 1-Server-to-many-sessions model where session-specific state
(`@client`, `@logging_message_notification`) is stored on the shared `Server` instance.
This causes the last client to connect to overwrite state for all sessions,
and requires `session_id` to be threaded through method signatures for session-scoped notifications.
The Python SDK avoids this by creating a `ServerSession` per connection that wraps a shared `Server`.
Each session holds its own state and naturally scopes notifications through its own stream.
### Changes
- Added `MCP::ServerSession` class holding per-session state: client info,
logging configuration, and a `transport` reference for session-scoped
notification delivery.
- `StreamableHTTPTransport#handle_initialization` creates a `ServerSession`
per `initialize` request and stores it in the session hash.
- `handle_regular_request` routes requests through the session's
`ServerSession` instead of the shared `Server` directly.
- `Server#handle` and `Server#handle_json` accept an optional `session:`
keyword (`ServerSession` instance).
- `Server#init` stores client info on the session when available.
- `Server#configure_logging_level` stores logging config on the session.
- `ServerContext` and `Progress` accept a `notification_target:` which
can be either a `ServerSession` (session-scoped) or a `Server` (broadcast).
No `session_id:` parameter needed.
- `ServerContext#notify_progress` and `#notify_log_message` delegate to
the `notification_target` without `session_id` threading.
- `StdioTransport` creates a single `ServerSession` on `open`,
making the session model transparent across both transports.
### Design Decision
This follows the Python SDK's "shared `Server` + per-connection `Session`" pattern.
The `Server` holds configuration (tools, prompts, resources, handlers).
Each `ServerSession` holds per-connection state (client info, logging level, stream writer).
Notifications from `ServerSession` go only to that session's stream.
## How Has This Been Tested?
All existing tests pass. All conformance tests pass. Added tests verifying:
- Session-scoped log notification is sent only to the originating session.
- Session-scoped progress notification is sent only to the originating session.
- Each session stores its own client info independently.
- Each session stores its own logging level independently.
- `StdioTransport#open` creates a `ServerSession` and stores client info on it.
## Breaking Change
None for end users.
The public API (`Server.new`, `define_tool`,`server_context.report_progress`,
`server_context.notify_log_message`, etc.) is unchanged. The following internal API
changes affect only SDK internals:
- `ServerContext.new` now requires `notification_target:` instead of just`progress:`.
- `Progress.new` now takes `notification_target:` instead of `server:`.
- `Server#handle` and `Server#handle_json` accept an optional `session:` keyword.
## Motivation and Context
`server_context_with_meta` reads `@server_context` (the instance variable)
directly instead of calling the `server_context` accessor method.
This prevents subclasses from overriding `server_context`. Ffor example,
to provide thread-local context in multi-threaded servers like Puma.
Fixes#271.
## How Has This Been Tested?
Added a regression test and passed.
## Breaking Changes
None. This restores the expected behavior of `attr_accessor :server_context`.
## Motivation and Context
The following public methods allow replacing the SDK's built-in request handlers:
- `tools_call_handler`
- `tools_list_handler`
- `resources_list_handler`
- `resources_templates_list_handler`
- `prompts_list_handler`
- `prompts_get_handler`
These were introduced in Internal Release 0.2.0 (6004f42) as part
of "allow setting handlers on Server", before declarative APIs (`define_tool`, `Prompt` class, `Resource` class) were
fully established.
Now that those declarative APIs exist, these handler overrides are redundant: none are documented in the README.md,
and no usage exists outside of the SDK's own tests.
These handler overrides also create architectural problems. Since custom handlers cannot receive session context,
they would not work correctly with a per-session architecture, such as session-scoped notifications.
### Changes
- Removed `tools_call_handler`, `tools_list_handler`, `resources_list_handler`,
`resources_templates_list_handler`, `prompts_list_handler`, and `prompts_get_handler`
from `Server`.
- Removed the associated tests.
- Retained `resources_read_handler` (documented in the README.md and actively used
in examples and conformance server, because resource reading is application-specific).
## Breaking Change
The following methods are removed without a deprecation period:
`tools_call_handler`, `tools_list_handler`, `resources_list_handler`,
`resources_templates_list_handler`, `prompts_list_handler`,
`prompts_get_handler`.
These were never documented in the README.md and have no known usage
outside of the SDK's own tests. As a result, little to no impact on
users is expected, and a deprecation warning would have no practical
audience. Users who relied on these should use `define_tool`,
prompt/resource registration, or `resources_read_handler` instead.
An attacker who obtains a valid session ID could issue a GET request to
establish a new SSE stream, silently replacing the victim's stream. All
subsequent server-to-client messages (tool responses, notifications)
would then be delivered to the attacker instead of the legitimate client,
with no indication to the victim.
The fix mirrors the Python SDK's approach: when a GET request arrives for
a session that already has an active SSE stream, respond with HTTP 409
Conflict ("Only one SSE stream is allowed per session") and leave the
existing connection intact. Additionally, `store_stream_for_session`
checks for an existing stream under the mutex before storing a new one,
closing the TOCTOU window between the HTTP-level check and the deferred
stream assignment.
Note: exploiting this requires prior knowledge of the session ID
(a `SecureRandom.uuid`), so the practical severity is low in typical local
MCP deployments. The change is still worthwhile as defense-in-depth,
especially for multi-user or network-exposed server deployments.
## Motivation and Context
`Client#call_tool` previously required an `MCP::Client::Tool` object to call a tool.
This made the API harder to use compared to other MCP SDK implementations,
which accept a tool name string directly:
- Python SDK: `session.call_tool(name, arguments)` accepts a name string.
https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L370
- TypeScript SDK: `client.callTool({ name, arguments })` accepts a name string.
ccb78f2/packages/client/src/client/client.ts (L834)
## How Has This Been Tested?
Updated existing tests and added new tests.
## Breaking Change
No. This change adds a `name:` parameter so callers can pass a tool name string
directly without looking up a `Tool` object first. The existing `tool:` parameter
continues to work for backward compatibility.
Some new features and fixes have been added, and it seems like a good time to cut a release.
Subsequent feature proposals can continue to be incorporated into future releases.
## Motivation and Context
The MCP specification requires that when a server receives a request containing
a session ID that is no longer valid, it MUST respond with HTTP 404 Not Found.
`handle_delete` was unconditionally calling `cleanup_session` and returning 200
even for nonexistent session IDs.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
> The server MAY terminate the session at any time, after which it MUST respond
> to requests containing that session ID with HTTP 404 Not Found.
## How Has This Been Tested?
Added tests for DELETE with invalid session ID, and lifecycle tests for
delete-then-POST and delete-then-DELETE scenarios.
## Breaking Change
DELETE requests with an invalid session ID now return HTTP 404 "Session not found"
instead of HTTP 200.
However, this change is considered a bug fix because it brings the behavior into
compliance with the MCP specification.
## Motivation and Context
The `MCP::Client` currently only supports HTTP transport (`MCP::Client::HTTP`).
To achieve parity with other MCP SDKs (Python, TypeScript, C#) which provide
stdio client transports for spawning and communicating with server subprocesses,
the Ruby SDK needs a stdio client transport.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio
This enables building MCP clients that launch server processes and communicate
via stdin/stdout using newline-delimited JSON-RPC 2.0 messages.
## How Has This Been Tested?
Added tests for process spawning, protocol initialization handshake,
notification handling, error handling, and transport close lifecycle.
## Additional context
Enabled the `Lint/IncompatibleIoSelectWithFiberScheduler` cop in `.rubocop.yml`
to detect accidental use of `IO.select` instead of `IO#wait_readable`.
## Motivation and Context
`handle_delete` had an inline error response for "Missing session ID" that
was identical to the existing `missing_session_id_response` helper method.
`handle_get` already uses this helper for the same purpose.
## How Has This Been Tested?
All existing tests pass. The behavior is unchanged.
## Breaking Change
None.
## Motivation and Context
`handle_regular_request` was checking `@sessions.key?(session_id)` directly
without holding `@mutex`, while concurrent threads could modify `@sessions`
via `cleanup_session` or `handle_delete`. This created a TOCTOU race where
the check could pass but the session could be deleted before subsequent use.
The class already provides a mutex-protected `session_exists?` helper, and
`handle_get` already uses it. This change makes `handle_regular_request`
consistent with `handle_get`.
## How Has This Been Tested?
Added a test that verifies `handle_regular_request` delegates to the
mutex-protected `session_exists?` helper instead of accessing
`@sessions` directly.
All existing tests pass.
## Breaking Change
None.
## Motivation and Context
The MCP specification requires that when a server receives a request containing
a session ID that is no longer valid, it MUST respond with HTTP 404 Not Found.
The `handle_get` path already returned 404 correctly, but `handle_regular_request`
(the POST path) was returning 400 "Invalid session ID" instead.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
> The server MAY terminate the session at any time, after which it MUST respond
> to requests containing that session ID with HTTP 404 Not Found.
## How Has This Been Tested?
Added a test for POST requests with an invalid session ID, verifying that the
response is 404 with "Session not found" error message. All existing tests pass.
## Breaking Change
POST requests with an invalid session ID now return HTTP 404 "Session not found"
instead of HTTP 400 "Invalid session ID". Clients that match on the 400 status
code or the old error message will need to be updated.
However, this change is considered a bug fix because it brings the behavior into compliance with
the MCP specification.
Notifications never reach `handle_regular_request` because `handle_post`
checks `notification?(body)` first and returns 202 via `handle_accepted`.
Since only notifications cause `handle_json` to return `nil`, `response`
is always non-`nil` here.
## Motivation and Context
`lib/mcp.rb` currently declares all `require_relative` statements in one place.
As a result, Server and Client are both loaded, the stdio and Streamable HTTP
transports are both loaded, and Tool, Prompt, and Resource are all loaded,
regardless of which components are actually used.
These components are selected by the user depending on their use case,
so loading everything upfront is unnecessary.
Using `autoload` for selected constants defers loading until the constant
is actually referenced, so only the subsystems required by the user are loaded.
## How Has This Been Tested?
All existing tests pass unchanged.
## Breaking Change
None. This change only defers loading of unused components.
## Motivation and Context
The MCP specification defines `notifications/progress` for reporting incremental progress
during long-running tool operations. The Ruby SDK did not yet support this, so tools had no way to
send progress updates to clients. This caused the `tools-call-with-progress` conformance test to fail.
This adds an `MCP::ServerContext` wrapper that provides a `report_progress` method on
`server_context`. Tools call `server_context.report_progress(value, total:, message:)` to send
notifications back to the client. The method is a no-op when no `progressToken` was provided,
so tools can call it unconditionally without checking for a token.
This design is consistent with the TypeScript and Python SDKs, which provide progress
reporting through a context object rather than a separate parameter.
On the client side, `Client#call_tool` now accepts a `progress_token:` parameter that sets
`_meta.progressToken` in the request.
Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/progress
## How Has This Been Tested?
Added comprehensive tests in `test/mcp/server_progress_test.rb` covering `notify_progress`,
tool integration with progress, block-defined tools, `**kwargs` tools, and no-op behavior
without a token.
Added client tests for `progress_token:` parameter inclusion and omission.
Added `test/mcp/server_context_test.rb` test for non-Hash `server_context` preservation
when `_meta` is present.
Conformance: `tools-call-with-progress` removed from expected failures.
## Breaking Change
`server_context` is now always an `MCP::ServerContext` instance when passed to tools,
even if the server has no context configured. Code that checks `server_context ?` to
detect a nil context will need to be updated, as `ServerContext` is always truthy.
`ServerContext` delegates method calls to the underlying context object via
`method_missing`, so `server_context[:key]` and other accessor patterns continue to work.
In practice, this is unlikely to affect users. A tool that declares `server_context:`
is opting in to use the context; if the server has no context configured, that is a
server configuration issue rather than something the tool should handle with a nil check.
## Summary
When using browser-based MCP clients such as MCP Inspector, connections to
the example HTTP server failed due to two issues:
1. CORS preflight (OPTIONS) requests returned 405 Method Not Allowed,
causing browsers to block all cross-origin requests.
2. `Accept: */*` (commonly sent by browsers and fetch API) returned 406 Not Acceptable
because the transport required the Accept header to explicitly list `application/json`
and `text/event-stream`.
Add `rack-cors` middleware to the example HTTP servers so that preflight requests are handled correctly
and `Mcp-Session-Id` is exposed to browser clients. Also fix `validate_accept_header` in `StreamableHTTPTransport`
to treat `*/*` as satisfying all required Accept types.
Fixes#141.
## Repro Steps
### 1. Start the example server (Terminal 1)
```console
$ bundle exec ruby examples/streamable_http_server.rb
```
### 2. Start MCP Inspector (Terminal 2)
```console
$ npx @modelcontextprotocol/inspector
```
### 3. Open the Inspector Web UI (http://localhost:6274) in a browser
- Set Transport Type to "Streamable HTTP"
- Set URL to http://localhost:9393
- Disable the Authorization header toggle (the example server does not require authentication)
- Click "Connect"
Before this change, the connection fails due to CORS or 406 errors.
After this change, the connection succeeds and tools are listed.
These steps have been added to examples/README.md.
Some fixes have been identified during the implementation of the conformance infrastructure.
It should be possible to proceed with a release at this stage.
`Errno::ECONNRESET` is a `SystemCallError`, not an `IOError`, so it was
not caught by the existing `rescue IOError, Errno::EPIPE` clauses. When
a client disconnected with a TCP RST during SSE notification broadcast,
the unhandled exception aborted the entire broadcast loop, causing
notifications to be lost for all remaining sessions.
This caused the `tools-call-with-logging` conformance scenario to fail
in CI (#248), where stale sessions from prior scenarios triggered
`ECONNRESET` on the first notification write, aborting the broadcast
and losing log notifications.
Add `Errno::ECONNRESET` to all four rescue clauses in
`StreamableHTTPTransport`: `send_notification` (targeted and broadcast),
`send_response_to_stream`, and `send_keepalive_ping`.
`Prompt#validate_arguments!` called `required_args` which does `arguments_value.filter_map`,
crashing with `NoMethodError` when `arguments_value` is `nil` (the default for prompts without
declared arguments). Additionally, passing `nil` as args caused a crash on `args.keys`.
Fix by defaulting `arguments_value` to `[]` and guarding against `nil` args.
The no-op handlers for `completion/complete`, `resources/subscribe`,
and `resources/unsubscribe` returned `nil`.
When a client accesses the expected response structure (e.g., `result[:completion][:values]`),
a `nil` result raises `NoMethodError`.
The MCP spec requires specific response structures even for no-op handlers.
Returning spec-compliant defaults ensures that clients receive valid responses without
requiring servers to explicitly override each handler.
`completion/complete` now returns `{ completion: { values: [], hasMore: false } }`.
ref: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion
`resources/subscribe` and `resources/unsubscribe` now return `{}` (empty result).
ref: https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions
This is a fix for a similar bug that remained, as in #235.
`Content::Image#to_h` returned `mime_type` (snake_case), but the MCP spec
requires `mimeType` (camelCase) for the MIME type field in image content.