Commit graph ruby-sdk/test/mcp/server
Author SHA1 Message Date
Koichi ITO
d51d4a0f91
Merge pull request #479 from koic/http_modern_path
Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575
2026-08-04 00:26:12 +09:00
Koichi ITO
e591cbb804 Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575
## Motivation and Context

Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.

Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: an `Mcp-Session-Id` binds the request to an established legacy session
(POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic
(`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.

The modern path (`handle_modern`) is a single POST/JSON exchange:

- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
  and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
  the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
  `Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
  `-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
  including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
  whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
  broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.

The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).

Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.

Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.

## Breaking Changes

None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
2026-08-03 01:57:10 +09:00
Koichi ITO
09545f1272 Serve both lifecycle eras over stdio with an era lock per SEP-2575
## Motivation and Context

Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.

- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
  and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
  keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
  A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
  Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
  so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
  a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
  the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
  a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
  server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
  The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.

## Breaking Changes

None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
2026-08-03 01:54:06 +09:00
Koichi ITO
dc25610162 Support 2026-07-28 as the Latest Protocol Version
## Motivation and Context

Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK
does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25,
the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header,
and `server/discover` omits the version from `supportedVersions`.

Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once,
since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model:
negotiable through the legacy `initialize` handshake, and simultaneously the version of
the per-request modern lifecycle.

Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle
over the transports, and are being built separately. This change deliberately limits itself to
the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit.

Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle
when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate
needs rework on top of this change.

Closes #474

## How Has This Been Tested?

New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`),
a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation
(`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted
(`configuration_test.rb`).

`bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30.
The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins
the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO
to remove it when the framework catches up.

## Breaking Changes

Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks
and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported
and negotiable; clients that request it are unaffected.
2026-07-31 12:55:21 +09:00
Koichi ITO
991926a10d Lowercase response header names in StreamableHTTPTransport
## Motivation and Context

The Rack 3 SPEC requires response header names to be lowercase.
https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section:

> Header keys must not contain uppercase ASCII characters (A-Z).

https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers

However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`.
Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response
from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance.
HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected;
only in-process consumers that read the raw Rack headers hash see the new keys.

Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names
a protocol error on HTTP/2, so proxies and h2-capable servers already require it.
The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`,
so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention,
not a wire requirement.

Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name
a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers,
which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as
an `Access-Control-Expose-Headers` value, which browsers match case-insensitively;
and README/docs prose follows the MCP specification's documentation convention.
Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys.

AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing
invites reintroducing capitalized names in future transport work.

## How Has This Been Tested?

Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses.
Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop,
and rake conformance.

## Breaking Changes

Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport,
with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are
unaffected because header names are case-insensitive on the wire.
2026-07-16 11:45:48 +09:00
Koichi ITO
84090a88eb
Merge pull request #456 from koic/handle_initialize_request_without_id
Handle an initialize request sent without an ID
2026-07-15 12:01:59 +09:00
Koichi ITO
5425c9c4ee
Merge pull request #455 from koic/reject_notifications_for_unknown_sessions
Reject notifications carrying an unknown or expired session
2026-07-15 12:01:42 +09:00
Koichi ITO
ab4075c8c7 Fix RuboCop offenses detected with rubocop-shopify 3.0.0
## Motivation and Context

This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems.
Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer
disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`,
and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense
from the new `Style/ReduceToHash` cop.

This change fixes them as follows:

- Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7,
  where Regexp literals are not frozen.
- Remove inline disable directives that became redundant.
- Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names
  can reference class names such as `NoMethodError`.
- Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test
  since Mocha cannot stub methods on frozen objects.
- Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`.

Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0

## How Has This Been Tested?

`bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The conformance suite also passes against its baseline.

## Breaking Changes

None. Freezing these constants only affects code that mutates them in place, which has never been supported.
2026-07-15 01:46:51 +09:00
Koichi ITO
fc0c6b8f3f Handle an initialize request sent without an ID
## Motivation and Context

`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.

Two things then went wrong:

- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
  so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
  and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
  The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
  toward the `max_sessions` cap until the reaper reclaims them.

`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.

## How Has This Been Tested?

A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.

## Breaking Changes

None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
2026-07-14 01:09:18 +09:00
Koichi ITO
6236ea1b34 Reject notifications carrying an unknown or expired session
## Motivation and Context

In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject
a message whose session no longer exists (the response branch via `session_exists?`,
the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally.
A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id`
therefore passed the `missing_session_id` gate and the ownership gate (which returns true
when the session does not exist) and reached `dispatch_notification` with no session,
running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification
handlers this had no visible effect, but a custom notification handler would then run without
a live session behind it.

`handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches.
`session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime,
so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that
a notification with an unknown session ID is rejected with `404 Session not found`
and that a notification carrying a live session ID is still accepted with `202`.
The new rejection test fails against the previous code (it returned `202`) and passes now.

## Breaking Changes

None for a conforming client: a client that has completed `initialize` sends its notifications with
the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID,
previously accepted with `202`, now receives `404 Session not found`.
2026-07-14 01:07:25 +09:00
Koichi ITO
a793dde6cd
Merge pull request #440 from koic/mcp_2026_07_28_spec_associate_server_requests
Associate server-to-client requests with the originating client request per SEP-2260
2026-07-12 02:17:04 +09:00
Koichi ITO
3d442d0a65
Merge pull request #448 from koic/handle_non_object_json_rpc_messages
Handle non-object JSON-RPC messages without raising
2026-07-12 02:12:46 +09:00
Koichi ITO
6ddcc49865 Associate server-to-client requests with the originating client request per SEP-2260
## Motivation and Context

SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.

The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):

- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
  emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
  on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
  deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
  and never falls back to the GET stream.

Resolves #381.

## How Has This Been Tested?

- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
  `create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
  "attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
  each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
  The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
  the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
  is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
  stays clean; their assertions are unchanged.

## Breaking Changes

None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
2026-07-12 02:10:16 +09:00
Koichi ITO
ac12f91525 Send required initialize params in the mutex probe test helper
## Motivation and Context

`bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`:

```
NoMethodError: undefined method '[]=' for nil
  streamable_http_transport_test.rb:5119 (install_mutex_probe_stream)
```

This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests
that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449,
merged after but developed before it, added the "writes outside the mutex" tests,
whose `initialize_test_session` helper sends an `initialize` request with no params.
That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing
the probe stream raises `NoMethodError`.

The fix adds `params: initialize_params` to the helper's request body, matching the pattern used
by every other `initialize` request in the file via `InitializeParamsTestHelper`.

## How Has This Been Tested?

`bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The four previously failing tests are the regression coverage.

## Breaking Changes

None. Test-only change.
2026-07-12 01:53:12 +09:00
Koichi ITO
e81196e9de
Merge pull request #449 from koic/avoid_blocking_sse_writes_under_session_mutex
Perform SSE stream writes outside the session mutex
2026-07-12 01:43:45 +09:00
Koichi ITO
abd1070cfa Validate Required Params for initialize and resources/subscribe
## Motivation and Context

Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450

The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params,
and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with
these fields absent: `initialize` silently negotiated the server's default protocol version,
and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params,
matching the Python SDK, which rejects such requests during pydantic validation.
The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler`
and `resources_unsubscribe_handler`.

A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version,
as the spec's lifecycle section requires.

## How Has This Been Tested?

Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding.

## Breaking Changes

Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`,
or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params
instead of a success response. Such requests violate the MCP schema, but any code relying on
the previous lenient behavior (for example, test harnesses sending bare `initialize` requests)
must now include the required fields.
2026-07-11 22:36:34 +09:00
Koichi ITO
5b935eb031 Perform SSE stream writes outside the session mutex
## Motivation and Context

`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.

A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.

The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.

- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
  the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
  which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
  A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
  matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
  the return values (true/false for a session, the delivered count for a broadcast) are unchanged.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.

## Breaking Changes

None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
2026-07-11 17:17:59 +09:00
Koichi ITO
8875990909 Handle non-object JSON-RPC messages without raising
## Motivation and Context

A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:

- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
  which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
  or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
  so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
  The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
  which raises `NoMethodError` when the server emits a non-object frame.
  Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.

Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.

- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
  before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.

- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
  so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.

## How Has This Been Tested?

New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.

## Breaking Changes

None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
2026-07-11 17:06:37 +09:00
Koichi ITO
61233ed43b
Merge pull request #438 from koic/server_discover
Add `server/discover` and stateless lifecycle error codes per SEP-2575
2026-07-10 00:26:49 +09:00
Koichi ITO
772e0cb1f9 Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS
## Motivation and Context

Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:

- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
  before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
  in the reporter's measurement. The path runs before session validation,
  so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
  so a peer that never emits a newline grows a single String until the process is OOM-killed.
  This matters when a host pipes an untrusted server's stdout into an unconstrained process.

The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.

- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
  rejects a declared `Content-Length` over the cap before reading, and also bounds
  the actual read to one byte past the cap so a missing or spoofed `Content-Length`
  (chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
  `max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
  The constructor validates the new option as a positive Integer, since `nil` or a non-positive
  value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
  a separate advisory, so this change carries the remaining piece of the reported stdio fix:
  an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
  so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
  fails cleanly instead of parsing a truncated frame.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).

## Breaking Changes

None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
2026-07-07 22:03:41 +09:00
Koichi ITO
3546660531 Add a Session-Ownership Hook to StreamableHTTPTransport
## Motivation and Context

`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.

Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:

- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
  on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
  with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
  not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
  a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
  `initialize` is exempt because it establishes the session. An application that has an authenticated principal
  uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
  is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
  both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
  is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
  Enforcing ownership against a determined attacker requires the validator.

Stateless mode retains no sessions, so neither check applies to it.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.

## Breaking Changes

None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
2026-07-07 21:41:29 +09:00
Koichi ITO
dfdd4efc24 Merge upstream modelcontextprotocol/ruby-sdk main
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
2026-07-07 21:28:11 +09:00
Koichi ITO
cfccb7ca25
Merge commit from fork
Validate `Host` and `Origin` Headers to Prevent DNS Rebinding per MCP 2025-11-25
2026-07-07 21:16:03 +09:00
Koichi ITO
c8930758ad
Merge commit from fork
Bound stdio frame reads with max_line_bytes
2026-07-07 21:10:07 +09:00
Koichi ITO
267b8fa628 Bound stdio frame reads with max_line_bytes
## Motivation and Context

CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.

This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.

Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.

Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.

## How Has This Been Tested?

Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.

Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.

Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.

The full test suite and RuboCop pass.

## Breaking Changes

None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
2026-07-05 21:30:28 +09:00
Koichi ITO
afb968c468 Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
## Motivation and Context

In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.

Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):

- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
  and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
  retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
  an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
  the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
  the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
  and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
  established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
  while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
  which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
  The value is validated as a positive Integer or `nil`.

## How Has This Been Tested?

New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.

## Breaking Changes

Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
2026-07-05 19:39:56 +09:00
Koichi ITO
c145876bb9 Add server/discover and stateless lifecycle error codes per SEP-2575
## Motivation and Context

SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:

- The `server/discover` method, which servers MUST implement: sessionless capability discovery
  returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
  The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
  and idempotent: it stores no client info, never marks a session initialized, and responds regardless
  of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
  `serverInfo` is returned unfiltered because discovery precedes version negotiation;
  the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
  On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
  and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
  a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
  and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
  (that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
  `URLElicitationRequiredError` is intentionally left untouched.

The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.

Part of #389.

## How Has This Been Tested?

- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
  server info, and instructions; it responds before `initialize` and with empty capabilities;
  it does not mark the session initialized (a subsequent `initialize` still succeeds);
  `instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
  now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
  returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
2026-07-04 20:24:37 +09:00
Koichi ITO
6d42cf7826 Fix an incorrect result: null response to an id-bearing notification message
## Motivation and Context

A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.

Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.

Fixes #434.

## How Has This Been Tested?

Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.

## Breaking Changes

None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
2026-07-01 01:55:15 +09:00
Koichi ITO
ba543083a7 Validate Host and Origin Headers to Prevent DNS Rebinding per MCP 2025-11-25
## Motivation and Context

MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.

The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:

- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
  compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
  This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
  A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
  case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
  `Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.

A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:

- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
  (`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through

## Breaking Changes

`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.

Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
2026-06-28 12:06:13 +09:00
Koichi ITO
eb6ff5d805 Isolate Stateless Requests in Ephemeral Sessions per SEP-2567
## Motivation and Context

SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release)
makes MCP sessionless: every Streamable HTTP POST must be fully self-contained,
with no protocol-level session state shared between requests.

`StreamableHTTPTransport` already implements the SEP's transport surface via
`stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405,
DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak:
stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and
`@client_capabilities` onto the shared `Server` instance.
Concurrent stateless requests could therefore observe another client's identity,
and the data persisted across requests, which is exactly what SEP-2567 forbids.
The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack)
solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture:

- Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now
  run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`),
  so client info, logging level, and initialized state live only for the duration of that POST.
  Repeated `initialize` requests are naturally permitted because each POST gets a fresh,
  never-initialized session.
- `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising.
  With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message`
  would otherwise route every call into the exception reporter; non-delivery matches how
  these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises,
  as those are genuinely unsupported without a stream.

Resolves #388.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:

- a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched
  (the leak regression)
- repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header
- a tool calling `server_context.report_progress` under stateless mode returns its result normally and
  the exception reporter is never invoked
- the existing "stateless mode does not support server-sent events" test is updated to assert
  the new `false` return instead of the removed raise

All other existing stateless-mode tests pass unchanged.

## Breaking Changes

`StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`.
The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation,
and the boolean return matches the method's documented semantics in every other non-delivery case.
Default (session-oriented) mode is unchanged.
2026-06-16 11:13:15 +09:00
Koichi ITO
2c62139154 Support Capability Extensions per SEP-2133
## Motivation and Context

SEP-2133 (modelcontextprotocol/modelcontextprotocol#2133, merged for
the 2026-07-28 spec release) establishes the extensions framework for MCP:
both `ClientCapabilities` and `ServerCapabilities` gain an optional `extensions`
member keyed by reverse-DNS extension identifiers
(e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`), with arbitrary
extension-defined configuration objects as values and an empty object meaning
"supported with no settings". Official extensions such as MCP Apps (SEP-1865)
and the Tasks extension (SEP-2663) are layered on this field.

This mirrors the shape the TypeScript SDK merged (typescript-sdk#1630):
plain declaration and passthrough, no registrar API and no key-format validation
(the naming convention is documented instead).

- `MCP::Server::Capabilities` gains `support_extensions` (repeated calls merge),
  accepts an `:extensions` key in its constructor hash, and includes `extensions`
  in `to_h`. The class was previously dead code: it was never required and
  `Server.new` only took plain hashes. It is now required by `lib/mcp/server.rb`,
  and `Server.new(capabilities:)` accepts either a plain Hash (as before) or
  a `Capabilities` instance.
- Server-declared extensions appear in the `initialize` result;
  extensions the client declares during `initialize` were already stored verbatim and
  are readable via `Server#client_capabilities[:extensions]` and
  `ServerSession#client_capabilities[:extensions]` (now covered by tests).
- `MCP::Client#connect(capabilities:)` already passes capabilities through verbatim;
  its documentation now covers the `extensions` member.

Resolves #378.

## How Has This Been Tested?

- New `test/mcp/server/capabilities_test.rb` covers the previously untested
  `Capabilities` class: empty default, constructor round-trip, extensions via
  constructor and `support_extensions`, merge semantics, nil tolerance,
  and omission from `to_h` when never declared.
- New tests in `test/mcp/server_test.rb` assert that the `initialize` result
  carries declared extensions (from both a plain hash and a `Capabilities` instance)
  and that client-declared extensions are readable via `Server#client_capabilities`
  and `ServerSession#client_capabilities`.

## Breaking Changes

None. `extensions` is additive and appears on the wire only when declared;
servers constructed with plain capability hashes behave exactly as before.
2026-06-14 17:20:45 +09:00
Koichi ITO
3d7046ab1a
Merge pull request #392 from koic/fallback_missing_protocol_version_in_streamable_http_transport
Default missing `MCP-Protocol-Version` to 2025-03-26 in StreamableHTTPTransport
2026-06-14 00:15:55 +09:00
Koichi ITO
4322ec0886 Default missing MCP-Protocol-Version to 2025-03-26 in StreamableHTTPTransport
## Motivation and Context

MCP 2025-11-25 (basic/transports#protocol-version-header) specifies:

> For backwards compatibility, if the server does not receive an
> `MCP-Protocol-Version` header, and has no other way to identify the
> version - for example, by relying on the protocol version negotiated
> during initialization - the server SHOULD assume protocol version `2025-03-26`.

PR #347 satisfied only the MUST requirement (reject invalid/unsupported
values with 400). The SHOULD requirement to default a missing header to
`2025-03-26` was deferred to this follow-up.

This change brings the Ruby SDK in line with the Python SDK
(`src/mcp/server/streamable_http.py`), which falls back to
`DEFAULT_NEGOTIATED_VERSION` and then revalidates against the supported list.

## Behavior

A new constant `MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION`
holds `"2025-03-26"`. `validate_protocol_version_header` now substitutes
this value when the `MCP-Protocol-Version` header is absent, then runs
the same supported-version check. Because `"2025-03-26"` is currently in
`SUPPORTED_STABLE_PROTOCOL_VERSIONS`, missing headers continue to pass
through as before. Once `"2025-03-26"` is dropped from the supported
list in a future change, missing headers will start returning 400 with
a message naming the defaulted value.

## How Has This Been Tested?

Existing test `POST request without MCP-Protocol-Version header
succeeds` continues to pass (observable behavior unchanged today).
Added a focused regression test that stubs the supported-version list
to exclude `"2025-03-26"` and verifies that a header-less request is
rejected with the defaulted value reported in the error message;
this exercises the fallback branch directly.

## Breaking Changes

None today. The fallback is future-proofing: when `"2025-03-26"` is
eventually dropped, header-less requests will start returning 400.
That is the intended spec-driven behavior.
2026-06-07 10:18:33 +09:00
Koichi ITO
c91702899d Use JSON-RPC error envelope for StreamableHTTPTransport errors
## Motivation and Context

PR #347 introduced a single JSON-RPC error envelope response for
unsupported `MCP-Protocol-Version` headers, while the rest of the
transport-level error responses (Accept, Content-Type, Invalid JSON,
session management, method not allowed, internal server error) still
returned plain JSON of the form `{ "error": "..." }`. The Python SDK
(`src/mcp/server/streamable_http.py`) and TypeScript SDK
(`packages/server/src/server/streamableHttp.ts`) consistently use
a JSON-RPC error envelope for all transport-level errors.
Unify the Ruby SDK with them.

## Behavior

All transport-level error responses now return a JSON-RPC error envelope:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32600,
    "message": "..."
  }
}
```

Affected helpers in `lib/mcp/server/transports/streamable_http_transport.rb`:

- `validate_content_type` (HTTP 415)
- `not_acceptable_response` (HTTP 406)
- `parse_request_body` error (HTTP 400, `PARSE_ERROR` `-32700`)
- `method_not_allowed_response` (HTTP 405)
- `missing_session_id_response` (HTTP 400)
- `session_not_found_response` (HTTP 404)
- `session_already_connected_response` (HTTP 409)
- Internal server error fallback in `handle_post` (HTTP 500, `INTERNAL_ERROR` `-32603`)
- `validate_protocol_version_header` (HTTP 400, dedup with shared helper)

A new private helper `json_rpc_error_response(status:, code:, message:)`
centralizes the envelope construction.

The "Invalid JSON" message wording is updated to "Parse error: Invalid JSON"
to match the JSON-RPC 2.0 `PARSE_ERROR` semantics.

## How Has This Been Tested?

Updated all affected tests in `test/mcp/server/transports/streamable_http_transport_test.rb` to
assert the new envelope shape (`body["error"]["message"]` instead of `body["error"]`).

## Breaking Changes

Clients that parsed the previous plain `{"error": "..."}` shape will need to read
`body["error"]["message"]` (or `body["error"]["code"]`).
The HTTP status codes are unchanged, only the response body structure changed.
2026-06-04 10:52:14 +09:00
Koichi ITO
9b89620257 Fix case-sensitive Accept header comparison
## Motivation and Context

RFC 9110 defines media type comparison as case-insensitive,
but `parse_accept_header` compared types without normalizing case.
This caused `Application/JSON` or `TEXT/Event-Stream` to be rejected
with 406 Not Acceptable.

## How Has This Been Tested?

Added tests for mixed-case Accept headers:

- `Accept: Application/JSON, Text/Event-Stream` succeeds for POST
- `Accept: TEXT/EVENT-STREAM` succeeds for GET SSE

## Breaking Changes

None. This is a bug fix bringing behavior into conformance with RFC 9110.
2026-05-23 21:36:27 +09:00
Koichi ITO
7db22f4d5b
Reject duplicate initialize requests (#350)
## Motivation and Context

The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized.
On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities`
(including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh
`Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session.

MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase
MUST be the first interaction between client and server;
re-initialization on an established session is not part of the defined lifecycle.
TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600`
("Invalid Request: Server already initialized"), and Python SDK does not mint a new session
on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK.

- `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and
  set by `mark_initialized!` after a successful `initialize` response.
- `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when
  the session is already initialized, which the existing error mapping converts
  to JSON-RPC `-32600 Invalid Request`.
- `StreamableHTTPTransport#handle_post` short-circuits at the transport layer:
  duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC
  `-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry
  instead of misreporting it as a duplicate).
- `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id`
  header when the first `initialize` fails before `mark_initialized!` is reached,
  so retries do not collide with an orphaned ID.
- Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`)
  are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to
  an unparseable Rack response.

## How Has This Been Tested?

- Server tests: a second `initialize` on the same `ServerSession` returns
  `code: -32600` and the original `clientInfo` is preserved.
- Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id`
  returns HTTP 400 + `-32600` and the original session remains usable for
  subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired
  session is evicted on duplicate `initialize` and returns 404; a failed
  `initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and
  leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`.
- Stdio tests: a second `initialize` on the same stdio session returns
  `code: -32600` and the original `clientInfo` is preserved.

## Breaking Changes

Clients that previously sent `initialize` more than once on the same session
now receive a JSON-RPC error with `code: -32600` for the second and later requests
instead of silently overwriting session state (stdio) or being re-issued
a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification
(single `initialize` per session) are unaffected.

Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return
HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response.
The previous behavior produced an unparseable response, so this is unlikely to
affect any working client.

Closes #349.
2026-05-18 00:00:23 -04:00
Koichi ITO
9caba3085d
Merge pull request #354 from koic/reject_non_hash_post_body_in_streamable_http_transport
Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport`
2026-05-18 09:23:57 +09:00
Koichi ITO
f62d813625 Reject non-Hash JSON-RPC bodies in StreamableHTTPTransport
## Motivation and Context

MCP 2025-11-25 does not support JSON-RPC batch (batch support was removed in 2025-06-18),
so request bodies are expected to be a single JSON-RPC message object. The previous code path:

```ruby
body = parse_request_body(body_string)
return body unless body.is_a?(Hash)
```

returned the parsed JSON value as if it were a Rack response when
the body was a JSON array (former batch shape), a string, a number,
or any other non-Hash JSON value. This produced a malformed Rack response
instead of a proper HTTP 400.

This was a pre-existing bug exposed during follow-up review of #347
(`MCP-Protocol-Version` header validation), which surfaced it but did not fix
the broken response shape for non-Hash bodies sent with a valid header.

### Behavior

- `parse_request_body` now raises a private `InvalidJsonError` on parse
  failure instead of returning a Rack response tuple. The overloaded
  return type and the `parse_error_tuple?` discriminator are gone.
- `handle_post` rescues `InvalidJsonError` and returns the same plain
  JSON 400 response as before (`{"error":"Invalid JSON"}`), so parse
  error handling is observably unchanged.
- Non-Hash parsed bodies (arrays, strings, numbers, booleans, null) now
  return HTTP 400 with a plain JSON body explaining that the body must
  be a single JSON-RPC message object, instead of producing a malformed
  Rack response.

The error response shape stays in the existing plain JSON style for
consistency with `validate_content_type`, `not_acceptable_response`,
and the other transport-level error helpers. Unifying all of these to
a JSON-RPC error envelope (matching the Python and TypeScript SDKs) is
deferred to a separate follow-up.

### SDK Comparison

- Python SDK (`src/mcp/server/streamable_http.py`): Already rejects
  non-Hash bodies (Array, primitive) with HTTP 400. Uses a JSON-RPC
  error envelope with `INVALID_PARAMS`.
- TypeScript SDK (`packages/server/src/server/streamableHttp.ts`):
  As of `main`, still processes JSON arrays as JSON-RPC batches and
  has not yet caught up with the 2025-06-18 batch removal. Primitives
  fail schema validation and return 400 with a JSON-RPC error envelope.

This change brings the Ruby SDK in line with the current MCP spec
(no batch). The Ruby SDK is now closer to the spec than the TypeScript
SDK's `main` branch on this specific point.

## How Has This Been Tested?

Existing regression test `test "handles POST request with invalid
JSON"` continues to pass unchanged (plain JSON response preserved).
Added new regression tests:

- POST with a JSON array body returns 400 with a clear error message
- POST with a non-object JSON body (e.g. `"foo"`) returns 400

`bundle exec rake test` and `bundle exec rake rubocop` both pass.

## Breaking Changes

None for compliant clients sending single JSON-RPC message objects.
Clients that were sending JSON arrays (batch requests, no longer
supported as of MCP 2025-11-25) or other non-object bodies will now
receive a proper HTTP 400 with a JSON error body instead of
the previous malformed Rack response; no client could have relied on
the broken pre-existing behavior.
2026-05-18 00:56:28 +09:00
Koichi ITO
711f064088 Document initialize body protocolVersion is authoritative on Streamable HTTP
## Motivation and Context

When an `initialize` POST carries an `MCP-Protocol-Version` HTTP header that disagrees with
`params.protocolVersion` in the JSON-RPC body, the Streamable HTTP server accepts the request
and negotiates using the body value. The behavior was already encoded in `handle_post`
(the header check is skipped on `initialize`) but it was not stated in the code and there was
no regression test for the specific mismatch case.

MCP 2025-06-18 / 2025-11-25 only requires `MCP-Protocol-Version` on requests subsequent to
`initialize`, and the spec does not require the header and body to agree on the initial `initialize`.
All official MCP SDKs (TypeScript, Python, Rust, Java, Go, PHP) accept the mismatch and
treat the JSON-RPC body as authoritative.

## How Has This Been Tested?

Streamable HTTP tests: two new regression tests confirm that on an `initialize` POST
with a supported but mismatched `MCP-Protocol-Version` header, the server returns
HTTP 200 and the response's `result.protocolVersion` matches the JSON-RPC body value,
in both mismatch directions (header older / body newer, and header newer / body older).

## Breaking Changes

None. The behavior is unchanged: `initialize` requests with a mismatched `MCP-Protocol-Version` header
are still accepted, and the JSON-RPC body `params.protocolVersion` continues to drive negotiation.
This commit only adds an explanatory comment and regression tests.

Closes #351.
2026-05-17 00:03:49 +09:00
Koichi ITO
9c2e438d4a Validate MCP-Protocol-Version header in StreamableHTTPTransport
## Motivation and Context

MCP 2025-11-25 (basic/transports#protocol-version-header) requires servers
to respond with `400 Bad Request` when receiving a request with an invalid
or unsupported `MCP-Protocol-Version` header:

> If the server receives a request with an invalid or unsupported
> `MCP-Protocol-Version`, it MUST respond with `400 Bad Request`.

The Ruby SDK was not reading the header at all, accepting any value
(including malformed strings like `not-a-version` and unsupported versions
like `1900-01-01`) and dispatching requests normally with HTTP 200.

## Behavior

`StreamableHTTPTransport` now validates the `MCP-Protocol-Version` header
on POST (non-initialize), GET, and DELETE requests. Missing headers are
accepted as before; the spec SHOULD requirement to default to `2025-03-26`
is intentionally left for a follow-up. The `initialize` POST is exempt
because the client does not know the negotiated version until the response
arrives, matching the Python (`src/mcp/server/streamable_http.py`) and
TypeScript (`packages/server/src/server/streamableHttp.ts`) SDKs.

The error response uses a JSON-RPC envelope with `code: -32600`
(`INVALID_REQUEST`) to match the Python SDK shape:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32600,
    "message": "Bad Request: Unsupported protocol version: <value>. Supported versions: ..."
  }
}
```

Validation order is session then protocol version, matching the Python and
TypeScript SDKs.

`handle_post` was reorganized so that non-Hash request bodies (e.g., JSON
arrays, which MCP 2025-11-25 no longer supports as batches) also pass
through the header check. The pre-existing broken response for Array
bodies sent with a valid header is unchanged and is tracked as a separate
follow-up.

## How Has This Been Tested?

Added regression tests covering:

- POST `initialize` ignores the header (bypass)
- POST non-initialize with unsupported / malformed / valid / missing values
- POST array body with unsupported value returns 400
- GET with unsupported / missing values
- DELETE with unsupported value in both stateful and stateless modes
- DELETE validates session before protocol version

`bundle exec rake test` and `bundle exec rake rubocop` both pass.

## Breaking Changes

Strictly additive for compliant clients. Third-party clients sending stale
or malformed `MCP-Protocol-Version` values will now receive `400` instead
of `200`, which is the intended spec behavior.

Fixes #346.
2026-05-14 01:03:21 +09:00
Koichi ITO
a97e391353 Support notifications/cancelled per MCP specification
## Motivation and Context

The MCP specification defines `notifications/cancelled` so either party can stop
a previously-issued in-flight request. The Ruby SDK declared the method constant
but had no receive logic, no in-flight tracking, and no way for handlers to participate
in cancellation; cancellation notifications fell through to the unsupported-method path.

This PR implements the **server-side** half of the spec. The server stops processing
the targeted request cooperatively and suppresses its JSON-RPC response, matching
the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`.
Cancellation is observable by every user-overridable request handler:

- `Tool.call` (tools/call)
- prompt templates (prompts/get)
- blocks registered via `resources_read_handler` / `completion_handler` /
  `resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method`

Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?`
or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work.
Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged.

On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n
ested server-to-client requests (`sampling/createMessage`, `elicitation/create`);
the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to
the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`,
so it deliberately does not propagate nested cancellation.
Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`.

The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe
across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable,
satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications
are silently ignored per the spec.

`MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation

## How Has This Been Tested?

`test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract.

`test/mcp/server_cancellation_test.rb` covers end-to-end cancellation:

- A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled`
  and asserts the handler observed cancellation and the JSON-RPC response was suppressed.
  Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method)
  has its own regression test.
- `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored.
- The `reason` propagates into the `cancellation_reason` instrumentation field.
- Custom transports implementing only the abstract `(method, params = nil)` contract keep working;
  the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`)
  are silently dropped when the transport's signature does not declare them.

`StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion
(so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race
when a real response and a cancel arrive concurrently.

## Breaking Change

None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`),
and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs.
The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures,
and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches
client-originated notifications through `ServerSession#handle_json` before returning 202;
previously these were accepted without dispatch, but the existing handlers for `notifications/initialized`
and `notifications/progress` were already no-ops, so no user-visible effect is expected.
Adding `server_context:` to a handler block is strictly opt-in.
2026-05-03 04:02:22 +09:00
Koichi ITO
017b0bb4c9 Support JSON response mode for StreamableHTTPTransport
## Motivation and Context

The MCP Streamable HTTP specification allows servers to return POST responses as either
`text/event-stream` (SSE) or `application/json`:

> If the input is a JSON-RPC request, the server MUST either return `Content-Type: text/event-stream`,
> to initiate an SSE stream, or `Content-Type: application/json`, to return one JSON object.

See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server

The TypeScript and Python SDKs support a configurable JSON response mode
via `enableJsonResponse` / `is_json_response_enabled`.

JSON response mode is suitable for simple tool servers that do not need server-initiated requests.
It avoids SSE framing overhead and returns a single JSON object for the POST response.

## Behavior

- POST responses use `Content-Type: application/json` and return a single JSON object.
- The POST `Accept` header requirement is relaxed to `application/json` only
  (matching the Python SDK's lenient behavior).
- Request-scoped notifications (`progress`, `log`) cannot ride along with the single-object response
  and are silently dropped.
- Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`)
  and broadcast notifications (`tools/list_changed`, etc.) continue to flow to clients connected
  to the GET SSE stream.
- All server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`)
  raise an error in JSON response mode.
- Combines with `stateless: true` for simple single-response servers without session tracking.

## How Has This Been Tested?

Added tests for JSON response mode:

- POST request returns `application/json` response
- Accept header validation requires only `application/json`
- Returns 406 when Accept header is missing
- Accepts wildcard `*/*` in Accept header
- Request-scoped notifications (progress, log) during tool execution are silently dropped
- Request-scoped notifications do not leak to GET SSE even when connected
- Session-scoped standalone notifications are delivered via GET SSE
- Broadcast notifications are delivered via GET SSE
- `send_request` raises for `sampling/createMessage`, `roots/list`, `elicitation/create`
- Combined with `stateless: true`: POST returns JSON without a session ID, GET returns 405

## Breaking Changes

None. JSON response mode is opt-in via `enable_json_response: true`.
The default behavior (SSE responses) is unchanged.
2026-04-22 08:30:28 +09:00
Koichi ITO
b97b922a36 Support pagination per MCP specification
## Motivation and Context

The MCP specification defines cursor-based pagination for list operations that
may return large result sets:
https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination

Pagination allows servers to yield results in smaller chunks rather than all at once,
which is especially important when connecting to external services over the internet.

The Ruby SDK previously returned complete arrays for all list endpoints
(`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`)
without pagination support. This adds cursor-based pagination.

### Server-side

A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`)
that provides the `paginate` helper for slicing a collection by cursor and
a `cursor_from` helper that extracts the cursor from the request params
while rejecting non-Hash inputs with `-32602 Invalid params` per the specification.

`MCP::Server.new` accepts a new `page_size:` keyword argument.
When `nil` (the default), all items are returned in a single response,
preserving the existing behavior. When set to a positive integer,
list responses include a `nextCursor` field if more pages are available.

`Server#page_size=` is a validating setter that rejects anything other than
`nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs.
The constructor routes through the setter, so both `Server.new(page_size: 0)`
and `server.page_size = -1` raise.

Cursors are string tokens carrying a zero-based offset, treated as opaque by
clients. Cursor inputs are validated to be strings per the MCP spec;
invalid cursors (non-string, non-numeric, negative, or out-of-range) raise
`RequestHandlerError` with error code `-32602 (Invalid params)` per the spec.

### Client-side: two complementary APIs

The SDK exposes two API shapes for listing, each serving a different use case:

1. **Single-page with cursor** (`client.list_tools(cursor:)`,
   `list_resources`, `list_resource_templates`, `list_prompts`): each call issues
   one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.)
   carrying the page items and an optional `next_cursor`.
   These methods match the single-page-with-cursor convention used by the Python SDK
   (`session.list_tools(params=PaginatedRequestParams(cursor=...))`)
   and the TypeScript SDK (`client.listTools({ cursor })`). The method name and
   the `cursor` parameter name are identical across the three SDKs,
   so pagination code translates directly between languages.

2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`,
   `client.prompts`): auto-iterates through all pages and returns a plain array of items,
   guaranteeing the full collection regardless of the server's `page_size` setting.
   The auto-pagination loop tracks previously seen cursors in a set and exits if the server
   revisits any of them, guarding against both immediate repeats and multi-cursor cycles
   (e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods,
   though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility.
   Future rename to `list_all_*` is left as a TODO in the source.

Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` /
`ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes
its page items (e.g. `result.tools`), an optional `next_cursor` for continuation,
and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field
so servers that decorate list responses do not lose metadata through the client.

### Ruby-specific ergonomics

`Server.new(page_size:)` and the `Pagination` module are Ruby-specific.
The Python and TypeScript SDKs do not expose a built-in `page_size` helper;
developers there re-implement cursor slicing inside each handler.
In Ruby, setting `page_size:` on the server is enough for the SDK to handle
the slicing across the four built-in list endpoints.

### README

A Pagination section documents both the server-side `page_size:` option and
the client-side iteration patterns, including an explicit note that each `list_*` call
is a single JSON-RPC round trip whose response size depends on the server's `page_size`,
and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return
the complete collection when needed.

## How Has This Been Tested?

- Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`),
  server-side pagination (`test/mcp/server_test.rb`), and client-side pagination
  (`test/mcp/client_test.rb`).
- Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes.

## Breaking Changes

None. This is a new feature addition. Existing code continues to work unchanged:
`page_size` defaults to `nil` on the server (returns all items, no `nextCursor`),
and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods
continue to return complete arrays by transparently iterating through pages.
2026-04-18 18:23:30 +09:00
Koichi ITO
66d0722e41
Merge pull request #263 from koic/make_streamable_http_transport_a_standard_rack_app
Make `StreamableHTTPTransport` a Rack application
2026-04-15 11:01:40 +09:00
Koichi ITO
17334c2e8b
Merge pull request #304 from koic/validate_post_content_type
Validate Content-Type on POST requests
2026-04-10 11:26:13 +09:00
Koichi ITO
af99a303e1 Make StreamableHTTPTransport a Rack application
## Motivation and Context

Add `call(env)` to `StreamableHTTPTransport`, making it a Rack application
that works with `mount`, `run`, and Rack middleware.

Refactor examples to use Rack middleware classes for MCP logging instead of proc wrappers,
demonstrating idiomatic Rack composition with the new `run(transport)` pattern.

Update README.md with mount and controller integration patterns.

Closes #59, #60

## How Has This Been Tested?

Added tests for `call(env)` as a Rack app. All tests pass.

## Breaking Change

No breaking changes. All existing APIs are preserved:

- `StreamableHTTPTransport.new(server)` continues to work as before.
- `handle_request(request)` is unchanged. The new `call(env)` is a purely additive public method.
2026-04-10 04:55:25 +09:00
Koichi ITO
de38804ed7 Auto-set server.transport in Transport#initialize
## Motivation and Context

Transport subclasses (StreamableHTTPTransport, StdioTransport) already
receive the server in their constructor, but users must manually call
`server.transport = transport` after creating a transport instance.
This is boilerplate that can be eliminated by setting the connection
automatically in `Transport#initialize`.

## How Has This Been Tested?

All existing tests pass. Redundant `server.transport = transport` lines
were removed from tests, examples, README.md, and conformance server.

## Breaking Changes

None. Existing code that manually sets `server.transport = transport`
continues to work (idempotent assignment).
2026-04-09 12:08:55 +09:00
Koichi ITO
81e81c359e Validate Content-Type on POST requests
## Motivation and Context

The MCP Streamable HTTP specification requires POST request bodies to
be JSON-RPC messages with `Content-Type: application/json`. The Ruby
SDK did not validate Content-Type, accepting requests with any or no
Content-Type. The Python SDK validates this and returns 415
Unsupported Media Type for non-JSON Content-Types.

## How Has This Been Tested?

Added tests for Content-Type validation:

- POST without Content-Type returns 415
- POST with `Content-Type: text/plain` returns 415
- POST with `Content-Type: application/json; charset=utf-8` succeeds

## Breaking Changes

POST requests without `Content-Type: application/json` now return
415 Unsupported Media Type. This is a spec compliance fix.
2026-04-08 19:40:55 +09:00
Koichi ITO
65069ed1cc Rename session[:stream] to session[:get_sse_stream]
Follow-up to #294.

The generic `:stream` key does not convey that it refers to the GET SSE stream,
making it harder to distinguish from `session[:post_request_streams]` introduced in #294.
2026-04-05 03:51:55 +09:00
Koichi ITO
8278ca6586 Support POST response SSE streams for server-to-client messages
## Motivation and Context

The MCP Streamable HTTP specification defines that servers can return POST responses
as SSE streams and send server-to-client JSON-RPC requests and notifications through them:

> If the input is a JSON-RPC request, the server MUST either return
> `Content-Type: text/event-stream`, to initiate an SSE stream, or
> `Content-Type: application/json`, to return one JSON object.

If the server initiates an SSE stream:

> The server MAY send JSON-RPC requests and notifications before
> sending the JSON-RPC response. These messages SHOULD relate to the
> originating client request.

See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server

Previously, when a GET SSE stream was connected, the Ruby SDK sent POST response bodies through
the GET stream and returned a 202 HTTP status for the POST itself. When no GET SSE stream was
connected, the SDK correctly returned `application/json` responses. The 202 behavior with GET SSE
was non-compliant with the specification, which requires POST requests to return `text/event-stream`
or `application/json`. Additionally, since GET SSE is optional per the specification, clients that
did not establish a GET SSE connection could not receive server-to-client messages (e.g.,
`sampling/createMessage`, log notifications) during request processing.

With this change, `handle_regular_request` always returns the POST response as an SSE stream for stateful sessions.
Each POST response stream is stored in `session[:post_request_streams]` keyed by `related_request_id` (the JSON-RPC request ID),
enabling correct routing when multiple POST requests are processed concurrently on the same session.
Server-to-client messages with a `related_request_id` are routed to the originating POST response stream only;
when `related_request_id` is nil, the GET SSE stream is used.

The TypeScript and Python SDKs already support this pattern.

### Internal Changes

`JsonRpcHandler.handle` and `JsonRpcHandler.handle_json` now pass both `method_name` and `request_id` to the method finder block.
This allows `Server#handle_request` to receive `related_request_id` directly from the protocol layer.
Without this, `related_request_id` would need to be relayed as a keyword argument through `Server#handle_json`,
`ServerSession#handle_json`, and `dispatch_handle_json`, unnecessarily exposing it on public method signatures.
This follows the same design as the TypeScript and Python SDKs, where the protocol layer extracts the request ID
and propagates it to the handler context.

## How Has This Been Tested?

Added tests for POST response stream:

- `send_request` via POST response stream (sampling with and without GET SSE)
- `send_notification` via POST response stream (logging without GET SSE)
- `progress` notification via POST response stream (without GET SSE)
- POST request returns SSE response even with GET SSE connected
- Session-scoped notifications (log, progress) are sent to POST
  response stream, not GET SSE stream
- `active_stream` does not fall back to GET SSE when
  `related_request_id` is given but request stream is missing

Updated existing tests to handle SSE response format where applicable.

## Breaking Changes (spec compliance fix)

POST responses for JSON-RPC requests in stateful sessions now return `Content-Type: text/event-stream`
instead of being sent through the GET SSE stream with a 202 HTTP status. Clients that relied on receiving responses
via the GET SSE stream will need to read the POST response body instead. This is a spec compliance fix:
the MCP specification requires POST requests to return `text/event-stream` or `application/json`,
not 202 with the response on a separate GET stream.
2026-04-04 19:20:31 +09:00