## Motivation and Context
The MCP specification defines `sampling/createMessage` to request LLM completions from the client.
The Ruby SDK did not yet support server-to-client requests,
so the server had no way to ask the client to sample an LLM.
This caused the `tools-call-sampling` conformance test to fail.
This adds `Server#create_sampling_message` to send a `sampling/createMessage` request
to the client via the transport layer. The method validates that the client declared
the `sampling` capability during initialization, and optionally checks the
`sampling.tools` capability when tools or tool_choice parameters are provided.
On the transport side, `Transport#send_request` is introduced as an abstract method,
with implementations in both `StdioTransport` and `StreamableHTTPTransport`.
Each transport assigns a unique request ID, sends the JSON-RPC request to the client,
and blocks until the client responds.
Client capabilities are stored per session via `ServerSession`, so validation is scoped
to the originating client. `ServerSession#create_sampling_message` uses
`build_sampling_params` for capability validation and sends the request directly through
the transport with the session ID, following the same pattern as `notify_progress`.
`Server#create_sampling_message` is the public API for single-client transports
(e.g., StdioTransport).
For multi-client transports (e.g., StreamableHTTPTransport),
`ServerSession#create_sampling_message` must be used to route requests to the correct client.
Tools using `server_context.create_sampling_message` automatically route to the correct session
regardless of transport.
`StreamableHTTPTransport#send_request` requires `session_id` to prevent broadcasting sampling requests
to all connected clients. Missing or invalid session IDs raise explicit errors instead of silently failing.
The `include_context` parameter is soft-deprecated in the MCP specification but is included
for compatibility with existing clients that declare the `sampling.context` capability.
Python and TypeScript SDKs also retain this parameter.
Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/sampling
## How Has This Been Tested?
Added comprehensive tests in `test/mcp/server_sampling_test.rb` covering required and optional parameters,
capability validation, error handling, nil-param omission, per-session capability isolation,
and HTTP init capability scoping.
Added transport tests in `test/mcp/server/transports/stdio_transport_test.rb` and
`test/mcp/server/transports/streamable_http_transport_test.rb` for `send_request` round-trip behavior,
error responses, stateless mode rejection, missing session_id, invalid session_id, and no-active-stream error.
Conformance: `tools-call-sampling` is removed from expected failures. The conformance server's `TestSampling`
tool now calls `server_context.create_sampling_message` instead of returning a stub error.
This aligns the Ruby SDK with the MCP specification and other SDK implementations.
## Breaking Changes
None for end users.
`Transport#send_request` is a new abstract method. `ServerSession#create_sampling_message` is a new method.
## Motivation and Context
`cleanup_session_unsafe` was closing streams inside the mutex, which could block all session
operations if `stream.close` is slow or blocks (e.g., waiting for network I/O).
The original `reap_expired_sessions` avoided this by closing streams outside the mutex,
but the other 6 call sites of `cleanup_session_unsafe` did not, creating an inconsistency.
This change separates stream closing from session deletion. `cleanup_session_unsafe`
now only removes the session from `@sessions` and returns it.
All callers close streams outside the mutex via `close_stream_safely`,
ensuring consistent behavior and preventing mutex contention from blocking I/O.
## How Has This Been Tested?
Added tests that verify streams are closed outside the mutex by using `Mutex#try_lock`
inside mock stream close callbacks:
- `reap_expired_sessions closes stream outside mutex`
- `send_notification closes stream outside mutex on write error`
## Breaking Changes
None. This is an internal improvement with no change to public API behavior.
## Motivation and Context
`StreamableHTTPTransport` stores session and SSE stream state in memory using
instance variables (`@sessions`, `@mutex`). This design requires all HTTP requests
to be handled within the same process to share state.
Process-based servers like Unicorn fork separate worker processes that do not share memory,
which breaks session management and SSE connections.
This requirement is not obvious from the current documentation.
## How Has This Been Tested?
Documentation-only change.
## Breaking Changes
None.
## Motivation and Context
The MCP specification defines `completion/complete` for providing autocompletion
suggestions for prompt arguments and resource template URIs.
The Ruby SDK previously had only a no-op handler that returned empty results,
with no way for users to define custom completion logic.
This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which support user-defined completion handlers.
## How Has This Been Tested?
Server tests cover: default handler, custom handler for `ref/prompt` and `ref/resource`,
context argument passing, 100-item truncation, and error responses for nonexistent prompts,
nonexistent resource templates, and invalid ref types.
Client tests cover: request structure, context parameter inclusion,
and fallback when result is missing.
## Breaking Changes
None. The existing default no-op handler behavior is preserved.
The `completion_handler` method is purely additive. The only behavioral change
is that `completion/complete` requests now validate that the referenced prompt
or resource template exists before calling the handler,
returning an `invalid_params` error for unknown references.
This makes `LICENSE` and `README.md` available to RDoc documentation.
The files remain included in the gem package because `Gem::Specification#files`
automatically merges `extra_rdoc_files`.
## Motivation and Context
Replace bare `rescue` with explicit `rescue StandardError` in stream cleanup to clarify intent,
with a comment explaining why.
In Ruby, `rescue` without an exception class is equivalent to `rescue StandardError`,
so this is a readability improvement with no behavior change.
## How Has This Been Tested?
Existing tests pass.
## Breaking Changes
None.
Per the MCP spec, invalid arguments and server errors are protocol
errors (JSON-RPC error responses), not tool execution errors
(isError: true). PR #165 moved all error cases to isError: true,
and PR #231 only restored protocol errors for unknown tools.
This fixes the remaining two cases: invalid arguments (-32602) and
server errors (-32603).
Ref: https://modelcontextprotocol.io/specification/2024-11-05/server/tools#error-handling
## Motivation and Context
Client methods (`tools`, `resources`, `resource_templates`, `prompts`, `read_resource`, `get_prompt`, `call_tool`)
silently swallowed JSON-RPC error responses by falling back to empty defaults. This did not conform to JSON-RPC 2.0,
which clearly distinguishes error responses from success responses.
Both the Python SDK (`MCPError`) and TypeScript SDK (`ProtocolError`) raise exceptions on error responses.
The Ruby SDK was the only one silently discarding them.
Introduces a common `request` method that handles JSON-RPC request construction and error checking in one place,
replacing the duplicated `transport.send_request` calls across all client methods.
## How Has This Been Tested?
Added tests for each affected method verifying that `ServerError` is raised with the correct `code`, `message`,
and `data` attributes.
## Breaking Changes
The previous behavior of silently returning empty defaults on server errors did not conform to JSON-RPC 2.0.
Error responses should be surfaced to callers, not swallowed.
Callers that relied on the silent fallback behavior will need to rescue `MCP::Client::ServerError`.
Background threads simulating server responses could race with the `ensure` block
that closes IO pipes, intermittently producing this warning depending on thread scheduling:
```console
#<Thread:0x00007fb69290cbd0 test/mcp/client/stdio_test.rb:28 run>
terminated with exception (report_on_exception is true):
test/mcp/client/stdio_test.rb:55:in `write': stream closed in another thread (IOError)
```
Store each background thread in a `server_thread` variable and call `server_thread&.join(1)`
at the top of each `ensure` block to wait for the thread to finish before closing pipes.
Replace custom `deep_transform_keys` in `Tool::Schema` and
string key access in `StreamableHTTPTransport` with
the `symbolize_names: true` option of `JSON.parse`.
This aligns `StreamableHTTPTransport` with `StdioTransport`,
which already uses `symbolize_names: true`.
This release focuses primarily on changes related to the internal session architecture.
It also removes some publicly exposed methods that are effectively obsolete.
## Motivation and Context
`Server#notify_progress` broadcasts progress notifications to all connected clients.
Progress notifications are tied to a specific request's `progressToken` and have no
meaningful broadcast use case. Neither the Python SDK nor the TypeScript SDK provides
a server-level progress broadcast capability.
The MCP specification requires that progress notifications only reference tokens
provided in an active request:
> Progress notifications MUST only reference tokens that:
> - Were provided in an active request
> - Are associated with an in-progress operation
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
Broadcasting progress to all clients does not comply with this requirement,
since clients that did not originate the request have no matching `progressToken`.
The introduction of `ServerSession` for per-connection state (#275) made
this removal possible: `Progress` and `ServerContext` now use `notification_target:`
(a `ServerSession`) to route notifications to the originating session only.
### Changes
- Removed `Server#notify_progress` entirely. The method is no longer needed as
`notification_target` in `call_tool_with_args` now passes `session` directly
(nil when no session is available, in which case `Progress#report` and
`ServerContext#notify_log_message` are no-ops).
- Removed `ServerContext#notify_progress` as well. Progress notifications should
only be sent via `server_context.report_progress`, which enforces the correct
`progressToken` from the originating request.
- Added nil guards to `Progress#report` and `ServerContext#notify_log_message`
for when no session is available.
- Rewrote progress tests to use `ServerSession#handle` instead of
`Server#handle`, reflecting that progress notifications are always session-scoped.
- Removed 4 tests that called `server.notify_progress` directly as a public broadcast API.
- Removed `notify_progress` from the README.md notification methods list.
- Removed the "Server-Side: Direct `notify_progress` Usage" section from the README.md.
Progress notifications should be sent via `server_context.report_progress` inside tool handlers,
which automatically scopes them to the originating client session.
## Breaking Changes
Progress notifications are scoped to a specific request via `progressToken` per the MCP specification,
so the broadcast behavior of `Server#notify_progress` was a spec violation.
This is treated as a bug fix and is made without a deprecation period.
This aligns the Ruby SDK with the Python and TypeScript SDKs, neither of which provides
a server-level progress broadcast API.
`Server#notify_progress` and `ServerContext#notify_progress` are no longer available.
Users should use `server_context.report_progress` inside tool handlers instead, which
provides session-scoped delivery with the correct `progressToken`.
This feature was only recently introduced in mcp 0.9.0:
https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.9.0
So an early release should help limit its impact on adoption.
## Motivation and Context
The MCP specification recommends expiring session IDs to reduce session hijacking risks:
https://modelcontextprotocol.io/specification/latest/basic/security_best_practices#session-hijacking
Several other SDKs (Go, C#, PHP, Python) already implement session expiry controls, but
the Ruby SDK did not. Sessions persisted indefinitely unless explicitly deleted or
a stream error occurred, leaving abandoned sessions to accumulate in memory.
This adds a `session_idle_timeout:` option to `StreamableHTTPTransport#initialize`. When
set, sessions that receive no HTTP requests for the specified duration (in seconds) are
automatically expired. Expired sessions return 404 on subsequent requests (GET and POST),
matching the MCP specification's behavior for terminated sessions. Each request resets
the idle timer, so actively used sessions are not interrupted.
A background reaper thread periodically cleans up expired sessions to handle orphaned
sessions that receive no further requests. The reaper only starts when
`session_idle_timeout` is configured.
The default is `nil` (no expiry) for backward compatibility, consistent with the Python
SDK's approach. The Python SDK recommends 1800 seconds (30 minutes) for most deployments:
https://github.com/modelcontextprotocol/python-sdk/pull/2022Resolves#265.
## How Has This Been Tested?
Added tests for session expiry, idle timeout reset, reaper thread lifecycle, input
validation, and default behavior in `streamable_http_transport_test.rb`. All existing
tests continue to pass.
## Breaking Change
None. The default value of `session_idle_timeout` is `nil`, which preserves the existing
behavior of sessions never expiring. The new `last_active_at` field in the internal
session hash is not part of the public API. Existing code that instantiates
`StreamableHTTPTransport.new(server)` or
`StreamableHTTPTransport.new(server, stateless: true)` continues to work without changes.
## 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.
Per the MCP specification (Streamable HTTP > Session Management):
> Servers that require a session ID SHOULD respond to requests without an `Mcp-Session-Id` header
> (other than initialization) with HTTP 400 Bad Request.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
Previously, non-initialize POST requests without `Mcp-Session-Id` in stateful mode were processed
with HTTP 200 (for regular requests) or HTTP 202 (for notifications/responses).
This change adds an explicit check in `handle_post` to return HTTP 400 Bad Request when the session
ID is missing in stateful mode for all non-initialize requests, aligning with the specification.
## 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.