diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a9b593..76578ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,16 +4,22 @@ on: [push, pull_request] jobs: test: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: entry: - - { ruby: 3.2, allowed-failure: false } - - { ruby: 3.3, allowed-failure: false } - - { ruby: 3.4, allowed-failure: false } - - { ruby: head, allowed-failure: true } + - { ruby: '2.7.0', allowed-failure: false } # Minimum supported Ruby version. + - { ruby: '3.0', allowed-failure: false } + - { ruby: '3.1', allowed-failure: false } + - { ruby: '3.2', allowed-failure: false } + - { ruby: '3.3', allowed-failure: false } + - { ruby: '3.4', allowed-failure: false } + - { ruby: '4.0', allowed-failure: false } + - { ruby: 'head', allowed-failure: true } name: Test Ruby ${{ matrix.entry.ruby }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.entry.ruby }} @@ -23,11 +29,26 @@ jobs: rubocop: runs-on: ubuntu-latest + permissions: + contents: read name: RuboCop steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 # Specify the oldest supported Ruby version. + ruby-version: 4.0 # Specify the latest supported Ruby version. bundler-cache: true - run: bundle exec rake rubocop + + yard: + runs-on: ubuntu-latest + permissions: + contents: read + name: YARD Documentation + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 4.0 # Specify the latest supported Ruby version. + bundler-cache: true + - run: bundle exec yard --no-output diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..ff178c2 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,29 @@ +name: Conformance Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' # Specify the latest supported Ruby version. + bundler-cache: true + - uses: actions/setup-node@v7 + with: + node-version: '24' # Specify the latest Node.js version. + - run: bundle exec rake conformance:test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 142cba4..e343d22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,42 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - ruby-version: 3.4 + ruby-version: 4.0 - uses: rubygems/release-gem@v1 + + publish_gh_pages: + if: github.repository_owner == 'modelcontextprotocol' + name: Publish Documentation to GitHub Pages + runs-on: ubuntu-latest + needs: [publish_gem] + + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Get version tag + id: version + run: | + git fetch --tags + TAG=$(git describe --tags --exact-match HEAD) + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + - name: Generate GitHub Pages + run: ./bin/generate-gh-pages.sh ${{ steps.version.outputs.tag }} + + - name: Push to gh-pages + run: git push origin gh-pages diff --git a/.gitignore b/.gitignore index 750a5a9..de8444e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .ruby-version +/*.gem /.bundle/ /.yardoc /_yardoc/ @@ -8,3 +9,7 @@ /spec/reports/ /tmp/ Gemfile.lock +docs/_site/ +docs/.jekyll-cache/ +docs/.jekyll-metadata +docs/_data/versions.yml diff --git a/.rubocop.yml b/.rubocop.yml index fb1a5cb..aefa67a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,8 +5,19 @@ plugins: - rubocop-minitest - rubocop-rake +AllCops: + TargetRubyVersion: 2.7 + SuggestExtensions: false + Gemspec/DevelopmentDependencies: Enabled: true +Lint/IncompatibleIoSelectWithFiberScheduler: + Enabled: true + Minitest/LiteralAsActualArgument: Enabled: true + +Naming/MethodName: + AllowedPatterns: + - '\Atest_' diff --git a/AGENTS.md b/AGENTS.md index 47cae9f..6635c3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,18 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing ## Dev environment setup -- Ruby 3.2.0+ required +- Ruby 3.2.0+ required to run the full test suite, including all Sorbet-related features - Run `bundle install` to install dependencies -- Dependencies: `json_rpc_handler` ~> 0.1, `json-schema` >= 4.1 +- Dependencies: `json-schema` >= 4.1 - Schema validation ## Build and test commands - `bundle install` - Install dependencies - `rake test` - Run all tests - `rake rubocop` - Run linter -- `rake` - Run tests and linting (default task) +- `rake` - Run linting, tests, and the conformance suite (default task) +- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md) +- `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem @@ -32,6 +34,15 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Use frozen string literals - Follow Ruby community conventions - Keep dependencies minimal +- Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only + +## Documentation + +- User-facing documentation lives in `docs/`, one page per topic, published at https://ruby.sdk.modelcontextprotocol.io (deploys only when a release is published) +- Pages live in the `docs/_server/`, `docs/_client/`, and `docs/_extensions/` collections +- Keep README.md slim: quick start and pointers only; document features on the relevant docs page +- Internal links are absolute and extensionless (e.g. `/server/tools/`); front matter is followed by a blank line before the h1 +- Callout tiers: `.note` (supplementary), `.important` (spec constraints), `.warning` (deprecated features) ## Commit message conventions @@ -56,12 +67,18 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing **MCP::Server** (`lib/mcp/server.rb`): -- Main server class handling JSON-RPC requests +- Main server class handling JSON-RPC requests and holding shared configuration (tools, prompts, resources, handlers, capabilities) - Implements MCP protocol methods: initialize, ping, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read - Supports custom method registration via `define_custom_method` - Handles instrumentation, exception reporting, and notifications - Uses JsonRpcHandler for request processing +**MCP::ServerSession** (`lib/mcp/server_session.rb`): + +- Per-connection state: client info, logging level +- Created by the transport layer for each client connection +- Delegates request handling to the shared `Server` + **MCP::Client** (`lib/mcp/client.rb`): - Client interface for communicating with MCP servers @@ -95,25 +112,21 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Protocol version 2025-03-26+ supports tool annotations (destructive_hint, idempotent_hint, etc.) - Validation is configurable via `configuration.validate_tool_call_arguments` +**Session Architecture**: + +- `Server` holds shared configuration (tools, prompts, resources, handlers) +- `ServerSession` holds per-connection state (client info, logging level) +- Both `StdioTransport` and `StreamableHTTPTransport` create a `ServerSession` per connection, making the session model transparent across transports +- Session-scoped notifications (`notify_progress`, `notify_log_message`) are sent only to the originating client via `ServerSession` +- Server-wide notifications (`notify_tools_list_changed`, etc.) broadcast to all sessions via `Server` + **Context Passing**: - `server_context` hash passed through tool/prompt calls for request-specific data - Methods can accept `server_context:` keyword argument for accessing context -### Dependencies - -- `json_rpc_handler` ~> 0.1 - JSON-RPC 2.0 message handling -- `json-schema` >= 4.1 - Schema validation -- Ruby 3.2.0+ required - ### Integration patterns -- **Rails controllers**: Use `server.handle_json(request.body.read)` for HTTP endpoints +- **Rails/Rack apps**: Mount `StreamableHTTPTransport` as a Rack app (e.g., `mount transport => "/mcp"`) - **Command-line tools**: Use `StdioTransport.new(server).open` for CLI applications -- **HTTP services**: Use `StreamableHttpTransport` for web-based servers - -### Component definition patterns - -1. **Class inheritance**: `class MyTool < MCP::Tool` -2. **Define methods**: `MCP::Tool.define(name: "my_tool") { ... }` -3. **Server registration**: `server.define_tool(name: "my_tool") { ... }` \ No newline at end of file +- **HTTP services**: Use `StreamableHTTPTransport` for web-based servers diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e9942..88ec62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,513 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2026-09-09 + +This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse` +as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash, +which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error. +The client now parses response bodies itself, the way it already handled streamed ones, and no longer +registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support +now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did. + +### Fixed + +- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546) + +## [1.5.0] - 2026-09-05 + +This release makes the client answer a server's `ping` with the empty result the specification requires. +Earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio, +so a server that checks liveness dropped their long-lived sessions. On the OAuth side, +the embedding application can refuse an authorization request through `authorization_request_validator`, +and stored tokens are refreshed only against the authorization server that issued them: +when the server named for a session differs, the client reauthorizes instead of refreshing, +and the validator sees the newly named server. Tokens stored by earlier releases carry no issuer and keep refreshing. + +### Added + +- Add `authorization_request_validator` to let the embedding application refuse an authorization request (#539) + +### Fixed + +- Answer server-to-client pings in the client (#541) + +## [1.4.0] - 2026-08-28 + +This release repairs the `subscriptions/listen` stream. A host that buffers its responses, +the documented Rails controller pattern among them, can decline the method with +`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body +it cannot render, and the stream's ordering guarantees are enforced: no notification reaches +a client before the acknowledgement, and none follows the graceful closing result. + +### Added + +- Add `serve_subscriptions_listen:` for hosts that cannot hold an SSE stream open (#533) + +### Fixed + +- Deliver `subscriptions/listen` notifications only after the acknowledgement (#532) +- Serialize `subscriptions/listen` writes so the graceful result is the final message (#535) + +## [1.3.0] - 2026-08-22 + +User-facing documentation now lives on the documentation site at https://ruby.sdk.modelcontextprotocol.io, +and README.md keeps the quick start. Two entries under "Changed" reject traffic that earlier releases accepted +and ship in a minor release under the exceptions described in [VERSIONING.md](VERSIONING.md). + +### Added + +- Add a `resources_list_handler` for context-dependent resource lists (#509) +- Pass a handler-returned `_meta` through the subscribe result (#510) + +### Changed + +- Bound OAuth response bodies in the client (#520) +- Reject duplicate in-flight JSON-RPC request ids (#521) +- Move the documentation from README.md to the documentation site (#523) + +## [1.2.0] - 2026-08-15 + +This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with +the SEP-2322, SEP-2549, and SEP-2243 features that revision builds on. Several entries under "Changed" +are incompatible with 1.1.0 and ship in a minor release under the spec-conformance and security exceptions +described in [VERSIONING.md](VERSIONING.md). + +### Added + +- Handle the SEP-2575 modern request envelope in the server core (#475) +- Serve both lifecycle eras over stdio with an era lock per SEP-2575 (#478) +- Serve the sessionless modern path over Streamable HTTP per SEP-2575 (#479) +- Finalize `server/discover` and add client modern lifecycle support per SEP-2575 (#480) +- Let handlers return multi round-trip `input_required` results per SEP-2322 (#481) +- Add `MCP::Elicitation::EnumSchema` builders per SEP-1330 (#482) +- Stamp the required `resultType` on modern results per SEP-2322 (#487) +- Enforce the modern lifecycle admission rules per SEP-2575 (#489) +- Stream modern request notifications and honor the envelope `logLevel` per SEP-2575 (#490) +- Expose the user-defined `server_context` in instrumentation data (#493) +- Serve the `subscriptions/listen` notification stream per SEP-2575 (#495) +- Add opt-in `requestState` sealing via `MCP::Server::RequestStateSecurity` (#496) +- Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243 (#498) +- Stamp the required cache hints on modern cacheable results per SEP-2549 (#499) +- Drive multi round-trip `input_required` results on the client per SEP-2322 (#500) +- Fulfill `input_required` results on the legacy wire per SEP-2322 (#501) + +### Changed + +- Align modern envelope validation with the finalized specification (#491) +- Require the `Mcp-Method` header on the modern path (#492) +- Bound server-to-client requests with a timeout (#502) +- Refuse server-to-client requests in the modern lifecycle per SEP-2575 (#503) +- Bound the total wait across SSE reconnection attempts (#504) +- Bound automatic pagination in the MCP client (#505) +- Reject modern-removed methods before the connection era locks (#511) +- Stop negotiating modern protocol versions through the `initialize` handshake (#516) + +### Deprecated + +- Warn on modern client connects that declare the Roots or Sampling capabilities deprecated per SEP-2577 (#406, #516) + +### Fixed + +- Stop leaking exception messages to clients via JSON-RPC error data (#486) +- Return Invalid Params for unknown prompts and missing prompt arguments (#517) +- Restrict OAuth discovery to same-origin metadata URLs and refuse private-network destinations + +## [1.1.0] - 2026-08-01 + +### Added + +- Support 2026-07-28 as the Latest Protocol Version (#476) +- Expose server tool annotations on MCP::Client::Tool (#445) + +### Fixed + +- Preserve explicit tool response content (#469) + +## [1.0.0] - 2026-07-24 + +First stable release. The public API is now stable: breaking changes ship only in major releases, +apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md). + +### Added + +- Add `ROADMAP.md` outlining the path toward SEP-1730 (#465) +- Add `VERSIONING.md` documenting the versioning and breaking-change policy (#466) + +## [0.25.0] - 2026-07-18 + +### Added + +- Support SEP-990 Cross-App Access via ID-JAG and the `jwt-bearer` grant (#454) +- Support client-side sampling via `MCP::Client#on_sampling` (#458) + +### Fixed + +- Reject notifications carrying an unknown or expired session (#455) +- Handle an `initialize` request sent without an ID (#456) +- Bound client-side message buffering in the HTTP transport (#459) +- Lowercase response header names in `StreamableHTTPTransport` (#460) + +## [0.24.0] - 2026-07-12 + +### Added + +- Support SSE reconnection per SEP-1699 in the HTTP client transport (#426) +- Validate the RFC 9207 `iss` authorization response parameter per SEP-2468 (#431) +- Support `private_key_jwt` client authentication for the `client_credentials` grant (#432) +- Recognize multi round-trip `input_required` results per SEP-2322 (#433) +- Add opt-in `ttlMs` / `cacheScope` cache hints to List and Read results per SEP-2549 (#436) +- Add `server/discover` and stateless lifecycle error codes per SEP-2575 (#438) +- Associate server-to-client requests with the originating client request per SEP-2260 (#440) +- Add server-side MCP Apps helpers per SEP-1865 (#441) +- Support client-side elicitation with SEP-1034 schema defaults (#443) +- Support class-based Resource and ResourceTemplate definitions (#447) + +### Changed + +- Bind stored client credentials to the authorization server issuer per SEP-2352 (#439) + +### Fixed + +- Handle non-object JSON-RPC messages without raising (#448) +- Perform SSE stream writes outside the session mutex (#449) +- Validate required params for `initialize` and `resources/subscribe` (#451) + +## [0.23.0] - 2026-07-07 + +### Added + +- Add a session-ownership hook to `StreamableHTTPTransport` + +### Changed + +- Require calling `MCP::Client#connect` before sending requests on stdio transport (#427) + +### Deprecated + +- Annotate Roots, Sampling, and Logging APIs as deprecated per SEP-2577 (#429) + +### Fixed + +- Fix an incorrect `result: null` response to an id-bearing notification message (#435) +- Validate `Host` and `Origin` headers to prevent DNS rebinding per MCP 2025-11-25 +- Bound stateful session retention to prevent an initialize-flood DoS +- Bound stdio frame reads with `max_line_bytes` +- Bound request body and frame reads to prevent memory-exhaustion DoS + +## [0.22.0] - 2026-06-27 + +### Added + +- Add `audience` role validation for `MCP::Annotations` per MCP specification (#422) +- Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification (#423) +- Support client-side `notifications/cancelled` per MCP specification (#425) + +### Changed + +- Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106 (#417) + +### Fixed + +- Fix a `SyntaxError` on Ruby 2.7.0 caused by arguments forwarding syntax (#419) + +## [0.21.0] - 2026-06-20 + +### Added + +- Support capability extensions per SEP-2133 (#405) +- Add range validation for `MCP::Annotations#priority` per MCP specification (#410) +- Isolate stateless requests in ephemeral sessions per SEP-2567 (#415) + +### Changed + +- Set OIDC `application_type` on Dynamic Client Registration per SEP-837 (#408) +- Fall back to legacy 2025-03-26 OAuth discovery for servers without PRM (#414) + +## [0.20.0] - 2026-06-14 + +### Added + +- Support W3C Trace Context Propagation via `_meta` per SEP-414 (#397) +- Support OAuth `client_credentials` grant in OAuth client (#399) +- Add `annotations` field to `MCP::Resource` and `MCP::ResourceTemplate` per MCP specification (#403) + +### Changed + +- Re-run OAuth flow on 403 `insufficient_scope` (step-up) (#368) +- Speed up `Tool::Schema` validation by 5x to 100x (#369) +- Use JSON-RPC error envelope for `StreamableHTTPTransport` errors (#371) +- Pin RFC 8414 default well-known suffix per SEP-2351 (#395) +- Default missing `MCP-Protocol-Version` to `2025-03-26` in `StreamableHTTPTransport` (#392) + +### Fixed + +- Preserve the request ID in invalid request error responses (#400) +- Standardize Resource Not Found errors on -32602 with URI data per SEP-2164 (#402) + +## [0.19.0] - 2026-06-13 + +### Added + +- Support Client ID Metadata Documents in OAuth client (#361) +- Request `offline_access` scope when supported (#365) +- Add `size` field to `MCP::Resource` per MCP specification (#393) + +## [0.18.0] - 2026-05-30 + +### Added + +- Support server-to-client `ping` per MCP specification (#358) + +### Changed + +- Warn on implicit stdio initialization (#338) +- Cache `Tool::Schema` validation to avoid re-validating identical schemas (#363) + +### Fixed + +- Fix case-sensitive `Accept` header comparison (#359) + +## [0.17.0] - 2026-05-19 + +### Added + +- Add OAuth 2.1 client support for MCP authorization flow (#353) + +### Fixed + +- Validate `MCP-Protocol-Version` header in `StreamableHTTPTransport` (#347) +- Reject duplicate `initialize` requests (#350) +- Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport` (#354) + +## [0.16.0] - 2026-05-14 + +### Added + +- Add opt-in tool output validation (#344) + +### Changed + +- Advertise JSON Schema 2020-12 dialect on emitted tool schemas (#342) + +### Fixed + +- Preserve client tool output schemas (#343) +- Fix missing `output_schema` argument in `define_tool` API (#301) + +## [0.15.0] - 2026-05-05 + +### Added + +- Support `notifications/cancelled` per MCP specification (#332) +- Add client-level `connect` for initialize handshake (#327) +- Add client-level `connect` handshake to stdio transport (#336) + +### Changed + +- Return tool argument validation failures as tool execution errors (#333) + +### Removed + +- Remove obsolete `MCP::Transports` module (#331) + +## [0.14.0] - 2026-04-24 + +### Added + +- Support pagination per MCP specification (#320) +- Support resource subscriptions per MCP specification (#313) +- Add `roots/list` and `notifications/roots/list_changed` support (#315) +- Support JSON response mode for `StreamableHTTPTransport` (#328) +- Add HTTP client close for explicit session termination (#326) +- Track `Mcp-Session-Id` and protocol version in HTTP client (#325) +- Support `ping` client API per MCP specification (#324) + +### Fixed + +- Handle 202 Accepted response in HTTP client (#323) + +### Changed + +- Parse SSE responses in HTTP client via `event_stream_parser` (#322) + +## [0.13.0] - 2026-04-16 + +### Added + +- Make `StreamableHTTPTransport` a Rack application (#263) +- Support `elicitation/create` per MCP specification (#312) +- Add `around_request` hook for request instrumentation (#309) +- Add `_meta` field to resource, content, and result classes (#310) + +### Removed + +- Remove `Server#create_sampling_message` direct call (#311) + +## [0.12.0] - 2026-04-11 + +### Added + +- Support customizing the Faraday client in `MCP::Client::HTTP` (#306) + +### Changed + +- Auto-set `server.transport` in `Transport#initialize` (#305) + +### Fixed + +- Validate Content-Type on POST requests (#304) + +## [0.11.0] - 2026-04-06 + +### Added + +- Support `sampling/createMessage` per MCP specification (#282) +- Support `completion/complete` per MCP specification (#289) + +### Fixed + +- Support POST response SSE streams for server-to-client messages (#294) +- Return protocol errors for invalid arguments and server errors (#285) +- Fix client methods silently swallowing JSON-RPC error responses (#281) +- Close streams outside mutex in session cleanup (#291) + +## [0.10.0] - 2026-03-30 + +### Added + +- Session expiry controls for `StreamableHTTPTransport` via `session_idle_timeout:` option (#268) + +### Changed + +- `ServerSession` for per-connection state (#275) + +### Removed + +- Remove `Server#notify_progress` broadcast API (#276) +- Remove undocumented handler override methods (#270) + +### Fixed + +- Reject POST requests without session ID in stateful mode (#274) + +## [0.9.2] - 2026-03-27 + +### Fixed + +- Use accessor method in `server_context_with_meta` instead of ivar (#273) +- Reject duplicate SSE connections with 409 to prevent stream hijacking + +## [0.9.1] - 2026-03-23 + +### Added + +- Allow `Client#call_tool` to accept a tool name (#266) + +### Fixed + +- Return 404 for invalid session ID in `handle_delete` (#261) + +## [0.9.0] - 2026-03-20 + +### Added + +- `MCP::Client::Stdio` transport (#262) +- Progress notifications per MCP specification (#254) +- Automatic `_meta` parameter extraction support (#172) +- CORS and Accept wildcard support for browser-based MCP clients (#253) + +### Changed + +- Use `autoload` to defer loading of unused subsystems (#255) +- Reduce release package size (#239) + +### Fixed + +- Return 404 for invalid session ID in `handle_regular_request` (#257) +- Use mutex-protected `session_exists?` in `handle_regular_request` (#258) + +## [0.8.0] - 2026-03-03 + +### Added + +- `Content::EmbeddedResource` class for embedded resource content type (#244) +- `Content::Audio` class for audio content type (#243) +- `$ref` support in `Tool::Schema` for protocol version 2025-11-25 (#242) +- MCP conformance test suite (#248) + +### Fixed + +- Handle `Errno::ECONNRESET` in SSE stream operations (#249) +- Fix default handler return values to comply with MCP spec (#247) +- Fix `Prompt#validate_arguments!` crash when arguments are `nil` (#246) +- Return 202 Accepted for SSE responses per MCP spec (#245) +- Fix `Content::Image#to_h` to return `mimeType` (camelCase) per MCP spec (#241) + +## [0.7.1] - 2026-02-21 + +### Fixed + +- Fix `Resource::Contents#to_h` to use correct property names per MCP spec (#235) +- Return JSON-RPC protocol errors for unknown tool calls (#231) +- Fix `logging/setLevel` to return empty hash per MCP specification (#230) + +## [0.7.0] - 2026-02-14 + +### Added + +- `logging` support (#103) +- Protocol version negotiation to server initialization (#223) +- Tool arguments to instrumentation data (#218) +- Client info to instrumentation callback (#221) +- `resource_templates` to `MCP::Client` (#225) + +### Changed + +- Extract `MCP::Annotations` into a dedicated file (#224) + +### Fixed + +- `Resource::Embedded` not setting `@resource` in `initialize` (#220) + +## [0.6.0] - 2026-01-16 + +### Changed + +- Update licensing to Apache 2.0 for new contributions (#213) + +### Fixed + +- Omit `icons` from responses when empty or nil to reduce context window usage (#212) + +## [0.5.0] - 2026-01-11 + +### Added + +- Protocol specification version "2025-11-25" support (#184) +- `icons` parameter support (#205) +- `websiteUrl` parameter in `serverInfo` (#188) +- `description` parameter in `serverInfo` (#201) +- `additionalProperties` support for schema validation (#198) +- "Draft" protocol version to supported versions (#179) +- `stateless` mode for high availability (#101) +- Exception messages for tool call errors (#194) +- Elicitation skeleton (#178) +- `prompts/list` and `prompts/get` support to client (#163) +- Accept header validation for HTTP client transport (#207) +- Ruby 2.7 - Ruby 3.1 support (#206) + +### Changed + +- Make tool names stricter (#204) + +### Fixed + +- Symlink path comparison in schema validation (#193) +- Duplicate tool names across namespaces now raise an error (#199) +- Tool error handling to follow MCP spec (#165) +- XSS vulnerability in json_rpc_handler (#175) + ## [0.4.0] - 2025-10-15 ### Added @@ -84,4 +591,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2025-05-30 -Initial release +Initial release in collaboration with Shopify diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Gemfile b/Gemfile index 4adaeee..0aa50ee 100644 --- a/Gemfile +++ b/Gemfile @@ -6,20 +6,26 @@ source "https://rubygems.org" gemspec # Specify development dependencies below -gem "rubocop-minitest", require: false +gem "rubocop-minitest", ">= 0.39.0", require: false gem "rubocop-rake", require: false -gem "rubocop-shopify", require: false +gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1" gem "puma", ">= 5.0.0" -gem "rack", ">= 2.0.0" +gem "rack-cors" gem "rackup", ">= 2.1.0" gem "activesupport" -gem "debug" +# Fix io-console install error when Ruby 3.0. +gem "debug" if RUBY_VERSION >= "3.1" +# Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735). +gem "i18n", "!= 1.15.0" gem "rake", "~> 13.0" -gem "sorbet-static-and-runtime" +gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0" +gem "yard", "~> 0.9" +gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1" group :test do + gem "event_stream_parser", ">= 1.0" gem "faraday", ">= 2.0" gem "minitest", "~> 5.1", require: false gem "mocha" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a93985 --- /dev/null +++ b/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 07cf111..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2025 TODO: Write your name - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/README.md b/README.md index f06da0f..7a71fb2 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ -# MCP Ruby SDK [![Gem Version](https://img.shields.io/gem/v/mcp)](https://rubygems.org/gems/mcp) [![MIT licensed](https://img.shields.io/badge/license-MIT-green)](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE.txt) [![CI](https://github.com/modelcontextprotocol/ruby-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/modelcontextprotocol/ruby-sdk/actions/workflows/ci.yml) +# MCP Ruby SDK [![Gem Version](https://img.shields.io/gem/v/mcp)](https://rubygems.org/gems/mcp) [![Apache 2.0 licensed](https://img.shields.io/badge/license-Apache%202.0-blue)](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) [![CI](https://github.com/modelcontextprotocol/ruby-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/modelcontextprotocol/ruby-sdk/actions/workflows/ci.yml) The official Ruby SDK for Model Context Protocol servers and clients. +Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io. + +## Features + +- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination + ## Installation Add this line to your application's Gemfile: ```ruby -gem 'mcp' +gem "mcp" ``` And then execute: @@ -24,157 +33,14 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. -## Building an MCP Server +## Quick Start -The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. -It implements the Model Context Protocol specification, handling model context requests and responses. +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. -### Key Features +### MCP Server -- Implements JSON-RPC 2.0 message handling -- Supports protocol initialization and capability negotiation -- Manages tool registration and invocation -- Supports prompt registration and execution -- Supports resource registration and retrieval -- Supports stdio & Streamable HTTP (including SSE) transports -- Supports notifications for list changes (tools, prompts, resources) - -### Supported Methods - -- `initialize` - Initializes the protocol and returns server capabilities -- `ping` - Simple health check -- `tools/list` - Lists all registered tools and their schemas -- `tools/call` - Invokes a specific tool with provided arguments -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves a specific prompt by name -- `resources/list` - Lists all registered resources and their schemas -- `resources/read` - Retrieves a specific resource by name -- `resources/templates/list` - Lists all registered resource templates and their schemas - -### Custom Methods - -The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: - -```ruby -server = MCP::Server.new(name: "my_server") - -# Define a custom method that returns a result -server.define_custom_method(method_name: "add") do |params| - params[:a] + params[:b] -end - -# Define a custom notification method (returns nil) -server.define_custom_method(method_name: "notify") do |params| - # Process notification - nil -end -``` - -**Key Features:** - -- Accepts any method name as a string -- Block receives the request parameters as a hash -- Can handle both regular methods (with responses) and notifications -- Prevents overriding existing MCP protocol methods -- Supports instrumentation callbacks for monitoring - -**Usage Example:** - -```ruby -# Client request -{ - "jsonrpc": "2.0", - "id": 1, - "method": "add", - "params": { "a": 5, "b": 3 } -} - -# Server response -{ - "jsonrpc": "2.0", - "id": 1, - "result": 8 -} -``` - -**Error Handling:** - -- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method -- Supports the same exception reporting and instrumentation as standard methods - -### Notifications - -The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. - -#### Notification Methods - -The server provides three notification methods: - -- `notify_tools_list_changed` - Send a notification when the tools list changes -- `notify_prompts_list_changed` - Send a notification when the prompts list changes -- `notify_resources_list_changed` - Send a notification when the resources list changes - -#### Notification Format - -Notifications follow the JSON-RPC 2.0 specification and use these method names: - -- `notifications/tools/list_changed` -- `notifications/prompts/list_changed` -- `notifications/resources/list_changed` - -#### Transport Support - -- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout -- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) - -#### Usage Example - -```ruby -server = MCP::Server.new(name: "my_server") -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport - -# When tools change, notify clients -server.define_tool(name: "new_tool") { |**args| { result: "ok" } } -server.notify_tools_list_changed -``` - -### Unsupported Features ( to be implemented in future versions ) - -- Log Level -- Resource subscriptions -- Completions - -### Usage - -#### Rails Controller - -When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming -[Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) transport -requests. - -You can use the `Server#handle_json` method to handle requests. - -```ruby -class ApplicationController < ActionController::Base - def index - server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, - ) - render(json: server.handle_json(request.body.read)) - end -end -``` - -#### Stdio Transport - -If you want to build a local command-line application, you can use the stdio transport: +A minimal server defines a tool and serves it over the stdio transport: ```ruby require "mcp" @@ -210,729 +76,64 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` -You can run this script and then type in requests to the server at the command line. +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: ```console -$ ruby examples/stdio_server.rb +$ ruby server.rb {"jsonrpc":"2.0","id":"1","method":"ping"} {"jsonrpc":"2.0","id":"2","method":"tools/list"} {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -### Configuration +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](https://ruby.sdk.modelcontextprotocol.io/server/transports/). -The gem can be configured using the `MCP.configure` block: +### MCP Client + +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: ```ruby -MCP.configure do |config| - config.exception_reporter = ->(exception, server_context) { - # Your exception reporting logic here - # For example with Bugsnag: - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end - } - - config.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" - } -end -``` - -or by creating an explicit configuration and passing it into the server. -This is useful for systems where an application hosts more than one MCP server but -they might require different instrumentation callbacks. - -```ruby -configuration = MCP::Configuration.new -configuration.exception_reporter = ->(exception, server_context) { - # Your exception reporting logic here - # For example with Bugsnag: - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end -} - -configuration.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" -} - -server = MCP::Server.new( - # ... all other options - configuration:, +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 ) -``` +client = MCP::Client.new(transport: stdio_transport) -### Server Context and Configuration Block Data +# Perform the MCP initialization handshake before sending any requests. +client.connect -#### `server_context` - -The `server_context` is a user-defined hash that is passed into the server instance and made available to tools, prompts, and exception/instrumentation callbacks. It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. - -**Type:** - -```ruby -server_context: { [String, Symbol] => Any } -``` - -**Example:** - -```ruby -server = MCP::Server.new( - name: "my_server", - server_context: { user_id: current_user.id, request_id: request.uuid } -) -``` - -This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks. - -#### Configuration Block Data - -##### Exception Reporter - -The exception reporter receives: - -- `exception`: The Ruby exception object that was raised -- `server_context`: The context hash provided to the server - -**Signature:** - -```ruby -exception_reporter = ->(exception, server_context) { ... } -``` - -##### Instrumentation Callback - -The instrumentation callback receives a hash with the following possible keys: - -- `method`: (String) The protocol method called (e.g., "ping", "tools/list") -- `tool_name`: (String, optional) The name of the tool called -- `prompt_name`: (String, optional) The name of the prompt called -- `resource_uri`: (String, optional) The URI of the resource called -- `error`: (String, optional) Error code if a lookup failed -- `duration`: (Float) Duration of the call in seconds - -**Type:** - -```ruby -instrumentation_callback = ->(data) { ... } -# where data is a Hash with keys as described above -``` - -**Example:** - -```ruby -config.instrumentation_callback = ->(data) { - puts "Instrumentation: #{data.inspect}" -} -``` - -### Server Protocol Version - -The server's protocol version can be overridden using the `protocol_version` keyword argument: - -```ruby -configuration = MCP::Configuration.new(protocol_version: "2024-11-05") -MCP::Server.new(name: "test_server", configuration: configuration) -``` - -This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: - -```ruby -MCP::Configuration.new(protocol_version: nil) -``` - -If an invalid `protocol_version` value is set, an `ArgumentError` is raised. - -Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. - -### Exception Reporting - -The exception reporter receives two arguments: - -- `exception`: The Ruby exception object that was raised -- `server_context`: A hash containing contextual information about where the error occurred - -The server_context hash includes: - -- For tool calls: `{ tool_name: "name", arguments: { ... } }` -- For general request handling: `{ request: { ... } }` - -When an exception occurs: - -1. The exception is reported via the configured reporter -2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }` -3. For other requests, the exception is re-raised after reporting - -If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. - -### Tools - -MCP spec includes [Tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) which provide functionality to LLM apps. - -This gem provides a `MCP::Tool` class that can be used to create tools in three ways: - -1. As a class definition: - -```ruby -class MyTool < MCP::Tool - title "My Tool" # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description "This tool performs specific functionality..." - input_schema( - properties: { - message: { type: "string" }, - }, - required: ["message"] - ) - output_schema( - properties: { - result: { type: "string" }, - success: { type: "boolean" }, - timestamp: { type: "string", format: "date-time" } - }, - required: ["result", "success", "timestamp"] - ) - annotations( - read_only_hint: true, - destructive_hint: false, - idempotent_hint: true, - open_world_hint: false, - title: "My Tool" - ) - - def self.call(message:, server_context:) - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) - end -end - -tool = MyTool -``` - -2. By using the `MCP::Tool.define` method with a block: - -```ruby -tool = MCP::Tool.define( - name: "my_tool", - title: "My Tool", # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description: "This tool performs specific functionality...", - annotations: { - read_only_hint: true, - title: "My Tool" - } -) do |args, server_context| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -3. By using the `MCP::Server#define_tool` method with a block: - -```ruby -server = MCP::Server.new -server.define_tool( - name: "my_tool", - description: "This tool performs specific functionality...", - annotations: { - title: "My Tool", - read_only_hint: true - } -) do |args, server_context| - Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -The server_context parameter is the server_context passed into the server and can be used to pass per request information, -e.g. around authentication state. - -### Tool Annotations - -Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: - -- `destructive_hint`: Indicates if the tool performs destructive operations. Defaults to true -- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false -- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true -- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false -- `title`: A human-readable title for the tool - -Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. - -> [!NOTE] -> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`. - -### Tool Output Schemas - -Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways: - -1. **Class definition with output_schema:** - -```ruby -class WeatherTool < MCP::Tool - tool_name "get_weather" - description "Get current weather for a location" - - input_schema( - properties: { - location: { type: "string" }, - units: { type: "string", enum: ["celsius", "fahrenheit"] } - }, - required: ["location"] - ) - - output_schema( - properties: { - temperature: { type: "number" }, - condition: { type: "string" }, - humidity: { type: "integer" } - }, - required: ["temperature", "condition", "humidity"] - ) - - def self.call(location:, units: "celsius", server_context:) - # Call weather API and structure the response - api_response = WeatherAPI.fetch(location, units) - weather_data = { - temperature: api_response.temp, - condition: api_response.description, - humidity: api_response.humidity_percent - } - - output_schema.validate_result(weather_data) - - MCP::Tool::Response.new([{ - type: "text", - text: weather_data.to_json - }]) - end -end -``` - -2. **Using Tool.define with output_schema:** - -```ruby -tool = MCP::Tool.define( - name: "calculate_stats", - description: "Calculate statistics for a dataset", - input_schema: { - properties: { - numbers: { type: "array", items: { type: "number" } } - }, - required: ["numbers"] - }, - output_schema: { - properties: { - mean: { type: "number" }, - median: { type: "number" }, - count: { type: "integer" } - }, - required: ["mean", "median", "count"] - } -) do |args, server_context| - # Calculate statistics and validate against schema - MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }]) -end -``` - -3. **Using OutputSchema objects:** - -```ruby -class DataTool < MCP::Tool - output_schema MCP::Tool::OutputSchema.new( - properties: { - success: { type: "boolean" }, - data: { type: "object" } - }, - required: ["success"] - ) -end -``` - -Output schema may also describe an array of objects: - -```ruby -class WeatherTool < MCP::Tool - output_schema( - type: "array", - item: { - properties: { - temperature: { type: "number" }, - condition: { type: "string" }, - humidity: { type: "integer" } - }, - required: ["temperature", "condition", "humidity"] - } - ) -end -``` - -Please note: in this case, you must provide `type: "array"`. The default type -for output schemas is `object`. - -MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) specifies that: - -- **Server Validation**: Servers MUST provide structured results that conform to the output schema -- **Client Validation**: Clients SHOULD validate structured results against the output schema -- **Better Integration**: Enables strict schema validation, type information, and improved developer experience -- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block - -The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. - -### Tool Responses with Structured Content - -Tools can return structured data alongside text content using the `structured_content` parameter. - -The structured content will be included in the JSON-RPC response as the `structuredContent` field. - -```ruby -class APITool < MCP::Tool - description "Get current weather and return structured data" - - def self.call(endpoint:, server_context:) - # Call weather API and structure the response - api_response = WeatherAPI.fetch(location, units) - weather_data = { - temperature: api_response.temp, - condition: api_response.description, - humidity: api_response.humidity_percent - } - - output_schema.validate_result(weather_data) - - MCP::Tool::Response.new( - [{ - type: "text", - text: weather_data.to_json - }], - structured_content: weather_data - ) - end -end -``` - -### Prompts - -MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. - -The `MCP::Prompt` class provides three ways to create prompts: - -1. As a class definition with metadata: - -```ruby -class MyPrompt < MCP::Prompt - prompt_name "my_prompt" # Optional - defaults to underscored class name - title "My Prompt" # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description "This prompt performs specific functionality..." - arguments [ - MCP::Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ] - meta({ version: "1.0", category: "example" }) - - class << self - def template(args, server_context:) - MCP::Prompt::Result.new( - description: "Response description", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("User message") - ), - MCP::Prompt::Message.new( - role: "assistant", - content: MCP::Content::Text.new(args["message"]) - ) - ] - ) - end - end -end - -prompt = MyPrompt -``` - -2. Using the `MCP::Prompt.define` method: - -```ruby -prompt = MCP::Prompt.define( - name: "my_prompt", - title: "My Prompt", # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description: "This prompt performs specific functionality...", - arguments: [ - MCP::Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ], - meta: { version: "1.0", category: "example" } -) do |args, server_context:| - MCP::Prompt::Result.new( - description: "Response description", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("User message") - ), - MCP::Prompt::Message.new( - role: "assistant", - content: MCP::Content::Text.new(args["message"]) - ) - ] - ) -end -``` - -3. Using the `MCP::Server#define_prompt` method: - -```ruby -server = MCP::Server.new -server.define_prompt( - name: "my_prompt", - description: "This prompt performs specific functionality...", - arguments: [ - Prompt::Argument.new( - name: "message", - title: "Message Title", - description: "Input message", - required: true - ) - ], - meta: { version: "1.0", category: "example" } -) do |args, server_context:| - Prompt::Result.new( - description: "Response description", - messages: [ - Prompt::Message.new( - role: "user", - content: Content::Text.new("User message") - ), - Prompt::Message.new( - role: "assistant", - content: Content::Text.new(args["message"]) - ) - ] - ) -end -``` - -The server_context parameter is the server_context passed into the server and can be used to pass per request information, -e.g. around authentication state or user preferences. - -### Key Components - -- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag -- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content -- `MCP::Prompt::Result` - The output of a prompt template containing description and messages -- `MCP::Content::Text` - Text content for messages - -### Usage - -Register prompts with the MCP server: - -```ruby -server = MCP::Server.new( - name: "my_server", - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, -) -``` - -The server will handle prompt listing and execution through the MCP protocol methods: - -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves and executes a specific prompt with arguments - -### Instrumentation - -The server allows registering a callback to receive information about instrumentation. -To register a handler pass a proc/lambda to as `instrumentation_callback` into the server constructor. - -```ruby -MCP.configure do |config| - config.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" - } -end -``` - -The data contains the following keys: - -- `method`: the method called, e.g. `ping`, `tools/list`, `tools/call` etc -- `tool_name`: the name of the tool called -- `prompt_name`: the name of the prompt called -- `resource_uri`: the uri of the resource called -- `error`: if looking up tools/prompts etc failed, e.g. `tool_not_found` -- `duration`: the duration of the call in seconds - -`tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered. -This is to avoid potential issues with metric cardinality - -### Resources - -MCP spec includes [Resources](https://modelcontextprotocol.io/specification/2025-06-18/server/resources). - -### Reading Resources - -The `MCP::Resource` class provides a way to register resources with the server. - -```ruby -resource = MCP::Resource.new( - uri: "https://example.com/my_resource", - name: "my-resource", - title: "My Resource", # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description: "Lorem ipsum dolor sit amet", - mime_type: "text/html", -) - -server = MCP::Server.new( - name: "my_server", - resources: [resource], -) -``` - -The server must register a handler for the `resources/read` method to retrieve a resource dynamically. - -```ruby -server.resources_read_handler do |params| - [{ - uri: params[:uri], - mimeType: "text/plain", - text: "Hello from example resource! URI: #{params[:uri]}" - }] -end -``` - -otherwise `resources/read` requests will be a no-op. - -### Resource Templates - -The `MCP::ResourceTemplate` class provides a way to register resource templates with the server. - -```ruby -resource_template = MCP::ResourceTemplate.new( - uri_template: "https://example.com/my_resource_template", - name: "my-resource-template", - title: "My Resource Template", # WARNING: This is a `Draft` and is not supported in the `Version 2025-06-18 (latest)` specification. - description: "Lorem ipsum dolor sit amet", - mime_type: "text/html", -) - -server = MCP::Server.new( - name: "my_server", - resource_templates: [resource_template], -) -``` - -## Building an MCP Client - -The `MCP::Client` class provides an interface for interacting with MCP servers. - -This class supports: - -- Tool listing via the `tools/list` method (`MCP::Client#tools`) -- Tool invocation via the `tools/call` method (`MCP::Client#call_tools`) -- Resource listing via the `resources/list` method (`MCP::Client#resources`) -- Resource reading via the `resources/read` method (`MCP::Client#read_resources`) -- Automatic JSON-RPC 2.0 message formatting -- UUID request ID generation - -Clients are initialized with a transport layer instance that handles the low-level communication mechanics. -Authorization is handled by the transport layer. - -## Transport Layer Interface - -If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: - -```ruby -class CustomTransport - # Sends a JSON-RPC request to the server and returns the raw response. - # - # @param request [Hash] A complete JSON-RPC request object. - # https://www.jsonrpc.org/specification#request_object - # @return [Hash] A hash modeling a JSON-RPC response object. - # https://www.jsonrpc.org/specification#response_object - def send_request(request:) - # Your transport-specific logic here - # - HTTP: POST to endpoint with JSON body - # - WebSocket: Send message over WebSocket - # - stdio: Write to stdout, read from stdin - # - etc. - end -end -``` - -### HTTP Transport Layer - -Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. - -You'll need to add `faraday` as a dependency in order to use the HTTP transport layer: - -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -``` - -Example usage: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") -client = MCP::Client.new(transport: http_transport) - -# List available tools +# List available tools. tools = client.tools tools.each do |tool| - puts <<~TOOL_INFORMATION - Tool: #{tool.name} - Description: #{tool.description} - Input Schema: #{tool.input_schema} - TOOL_INFORMATION + puts "Tool: #{tool.name} - #{tool.description}" end -# Call a specific tool +# Call a specific tool. response = client.call_tool( tool: tools.first, arguments: { message: "Hello, world!" } ) + +# Close the transport when done. +stdio_transport.close ``` -#### HTTP Authorization +The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transports/). -By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: +## Examples -```ruby -http_transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) +Runnable examples are available in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples), +including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). -client = MCP::Client.new(transport: http_transport) -client.tools # will make the call using Bearer auth -``` +## Documentation -You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. +- [SDK guides](https://ruby.sdk.modelcontextprotocol.io) +- [SDK API documentation](https://rubydoc.info/gems/mcp) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) -### Tool Objects +## License -The client provides a wrapper class for tools returned by the server: - -- `MCP::Client::Tool` - Represents a single tool with its metadata - -This class provides easy access to tool properties like name, description, input schema, and output schema. - -## Releases - -This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp) - -Releases are triggered by PRs to the `main` branch updating the version number in `lib/mcp/version.rb`. - -1. **Update the version number** in `lib/mcp/version.rb`, following [semver](https://semver.org/) -1. **Update CHANGELOG.md**, backfilling the changes since the last release if necessary, and adding a new section for the new version, clearing out the Unreleased section -1. **Create a PR and get approval from a maintainer** -1. **Merge your PR to the main branch** - This will automatically trigger the release workflow via GitHub Actions - -When changes are merged to the `main` branch, the GitHub Actions workflow (`.github/workflows/release.yml`) is triggered and the gem is published to RubyGems. +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..7e6a047 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,12 @@ +## Releases + +This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp) + +Releases are triggered by PRs to the `main` branch updating the version number in `lib/mcp/version.rb`. + +1. **Update the version number** in `lib/mcp/version.rb`, following [semver](https://semver.org/) +2. **Update CHANGELOG.md**, backfilling the changes since the last release if necessary, and adding a new section for the new version, clearing out the Unreleased section +3. **Create a PR and get approval from a maintainer** +4. **Merge your PR to the main branch** - This will automatically trigger the release workflow via GitHub Actions + +When changes are merged to the `main` branch, the GitHub Actions workflow (`.github/workflows/release.yml`) is triggered and the gem is published to RubyGems. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..aa58422 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,51 @@ +# Roadmap + +This roadmap outlines the MCP Ruby SDK's path toward SEP-1730 Tier 1. +It is a living document and will evolve as the SDK matures. + +## Current Status + +The SDK implements the 2025-06-18, 2025-11-25, and 2026-07-28 spec revisions, and passes all scored server +and client conformance scenarios for each revision's requirement set. +Optional protocol extensions (such as DPoP, workload identity federation, and the tasks extension) +are not yet implemented; SEP-1730 does not require extensions for any tier. + +## API Stability + +The 1.0.0 release marked the public API as stable: breaking changes ship only in major releases, +apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md), +which also defines the Semantic Versioning scheme and breaking-change policy. + +## Deprecated Features + +The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577). +These features remain supported throughout 1.x: on the legacy lifecycle they work unchanged +(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms: +roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses +(the SEP-2322 multi round-trip requests pattern) and per-request log delivery through the envelope's `logLevel`. +The replacements SEP-2577 names live outside the protocol: tool parameters or configuration for roots, +direct LLM provider APIs for sampling, and stderr or OpenTelemetry for logging. +The client emits a deprecation warning when a modern connect declares the deprecated Roots or Sampling capabilities. +Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: whether 2.0.0 removes +them depends on how future MCP spec revisions treat these features and on adoption of their replacements. + +## Conformance + +The SDK maintains a 100% conformance pass rate as new scenarios are added to the conformance suite. +Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provides modern Streamable HTTP only. + +## Documentation + +Reference documentation covers all non-experimental features with examples. + +## Tracking New Spec Revisions + +The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per +release based on feature complexity. +The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side, +so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`), +SEP-2322 multi round-trip requests, SEP-2549 cache hints, and SEP-2243 custom headers are available alongside +the legacy handshake. The few incompatible adjustments the revision required shipped in +a minor release under the spec-conformance and security exceptions described in [VERSIONING.md](VERSIONING.md). +Removing legacy-era support is reserved for a future major release. +The tasks extension (SEP-2663) remains planned ([#391](https://github.com/modelcontextprotocol/ruby-sdk/issues/391)). diff --git a/Rakefile b/Rakefile index 0d0ad08..409eef9 100644 --- a/Rakefile +++ b/Rakefile @@ -14,4 +14,90 @@ require "rubocop/rake_task" RuboCop::RakeTask.new -task default: [:test, :rubocop] +task default: [:rubocop, :test, "conformance:test"] + +namespace :conformance do + desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" + task :test do |t| + next unless npx_available?(t.name) + + require_relative "conformance/server_runner" + require_relative "conformance/client_runner" + + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"] + options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] + options[:verbose] = true if ENV["VERBOSE"] + + Conformance::ServerRunner.new(**options).run + Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run + end + + desc "List available conformance scenarios" + task :list do |t| + next unless npx_available?(t.name) + + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") + end + + desc "Start the conformance server (PORT)" + task :server do + require_relative "conformance/server" + + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + + Conformance::Server.new(**options).start + end +end + +# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance` +# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without +# running anything. This keeps the name pointing at the suite instead of at that silent no-op. +desc "Alias for conformance:test" +task conformance: "conformance:test" + +namespace :docs do + desc "Serve the documentation site locally at http://localhost:4000 (PORT)" + task :preview do + docs_dir = File.expand_path("docs", __dir__) + generate_docs_versions_data(docs_dir) + + env = { + "BUNDLE_GEMFILE" => File.join(docs_dir, "Gemfile"), + "RUBYOPT" => "-r#{File.join(docs_dir, "_preview", "taint_shim.rb")}", + } + port = ENV.fetch("PORT", "4000") + + Bundler.with_unbundled_env do + system(env, "bundle", "install", "--quiet", chdir: docs_dir, exception: true) + system(env, "bundle", "exec", "jekyll", "serve", "--port", port, chdir: docs_dir, exception: true) + rescue Interrupt + # Ctrl-C is the way to stop the preview, not an error. + end + end +end + +# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from +# the version tags at deploy time, and the preview generates the same data so the nav footer +# shows the released-gem version line. +def generate_docs_versions_data(docs_dir) + versions = %x(git tag --list).split("\n").filter_map { |tag| + tag[/\A[^0-9]*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\z/, 1] + }.sort_by { |version| + Gem::Version.new(version) + }.reverse + return if versions.empty? + + mkdir_p(File.join(docs_dir, "_data")) + File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join) +end + +def npx_available?(task_name) + return true if system("which", "npx", out: File::NULL, err: File::NULL) + + warn("Skipping #{task_name}: npx is not installed. Install Node.js to run this task: https://nodejs.org/") + false +end diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5029242 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..1b2cd88 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,56 @@ +# Versioning Policy + +The MCP Ruby SDK follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +This document describes what each version component means for users of the `mcp` gem, +what counts as a breaking change, and how breaking changes are communicated. + +## Version Scheme + +Given a version number `MAJOR.MINOR.PATCH`: + +- **MAJOR** is incremented for incompatible changes to the public API. +- **MINOR** is incremented for backwards-compatible new functionality. +- **PATCH** is incremented for backwards-compatible bug fixes. + +### Current 0.x Phase + +While the SDK is at version 0.x, the public API is not yet considered stable. +Following SemVer conventions for initial development, minor releases (0.x -> 0.y) may contain breaking changes. +All breaking changes are recorded in [CHANGELOG.md](CHANGELOG.md). + +### After 1.0.0 + +Once 1.0.0 is released, breaking changes will only ship in major releases, with the exceptions described below. + +## What Counts as a Breaking Change + +A breaking change is any change that requires users to modify their code when upgrading, including: + +- Removing or renaming a public class, module, method, or constant +- Changing a public method's signature in a way that rejects previously valid arguments +- Changing documented behavior that users can reasonably depend on + +New functionality that is purely additive (new classes, new methods, new optional keyword arguments) +is not a breaking change. + +## Exceptions: Spec Compliance, Security, and Clear Defects + +Minor releases avoid incompatible changes as much as possible. However, the SDK's primary contract is +conformance to the [MCP specification](https://modelcontextprotocol.io/specification/). +Behavior that deviates from the specification is treated as a bug, even when users may have come to rely on it. +Therefore, incompatible changes may be shipped in a minor release when they are required to: + +- Fix incorrect conformance to the MCP specification +- Address a security vulnerability +- Fix a clear defect, such as behavior that contradicts the documentation, a crash, + or data corruption, where compatibility would mean preserving the defect + +When such a change ships, the release notes and CHANGELOG entry explicitly call out the incompatibility +and describe how to migrate. + +## How Breaking Changes Are Communicated + +- [CHANGELOG.md](CHANGELOG.md) records every release in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format; + breaking changes appear under the "Changed" or "Removed" headings +- [GitHub Releases](https://github.com/modelcontextprotocol/ruby-sdk/releases) mirror the changelog for each release +- Where practical, deprecation warnings are added at least one minor release before removal diff --git a/bin/generate-gh-pages.sh b/bin/generate-gh-pages.sh new file mode 100755 index 0000000..f40d177 --- /dev/null +++ b/bin/generate-gh-pages.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set -e + +# Generates versioned documentation links and commits to gh-pages branch +# +# PURPOSE: +# This script generates a landing page with links to API documentation on +# RubyDoc.info for a specific version tag. This script is invoked by the +# publish-gh-pages job in the GitHub Actions workflow +# (.github/workflows/release.yml) when a release is published. +# +# HOW IT WORKS: +# - Creates isolated git worktrees for the specified tag and gh-pages branch +# - Copies static Jekyll template files from docs/ +# - Generates _data/versions.yml with list of versions +# - Commits changes to gh-pages (does not push automatically) +# +# WORKFLOW: +# 1. Run this script with a tag name: `generate-gh-pages.sh v1.2.3` +# 2. Script generates docs and commits to local gh-pages branch +# 3. Push gh-pages branch to deploy: `git push origin gh-pages` + +# Parse semantic version from tag name (ignoring arbitrary prefixes) +if [[ "${1}" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?)$ ]]; then + VERSION="v${BASH_REMATCH[1]}" +else + echo "Error: Must specify a tag name that contains a valid semantic version" + echo "Usage: ${0} " + echo "Examples:" + echo " ${0} 1.2.3" + echo " ${0} v2.0.0-rc.1" + exit 1 +fi + +TAG_NAME="${1}" +REPO_ROOT="$(git rev-parse --show-toplevel)" + +echo "Generating documentation for tag: ${TAG_NAME}" + +# Create temporary directories for both worktrees +WORKTREE_DIR=$(mktemp -d) +GHPAGES_WORKTREE_DIR=$(mktemp -d) + +# Set up trap to clean up both worktrees on exit +trap 'git worktree remove --force "${WORKTREE_DIR}" 2>/dev/null || true; \ + git worktree remove --force "${GHPAGES_WORKTREE_DIR}" 2>/dev/null || true' EXIT + +echo "Creating worktree for ${TAG_NAME}..." +git worktree add --quiet "${WORKTREE_DIR}" "${TAG_NAME}" + +# Check if gh-pages branch exists +if git show-ref --verify --quiet refs/heads/gh-pages; then + echo "Creating worktree for existing gh-pages branch..." + git worktree add --quiet "${GHPAGES_WORKTREE_DIR}" gh-pages +elif git ls-remote --exit-code --heads origin gh-pages > /dev/null 2>&1; then + echo "Creating worktree for gh-pages branch from remote..." + git worktree add --quiet "${GHPAGES_WORKTREE_DIR}" -b gh-pages origin/gh-pages +else + echo "Creating worktree for new orphan gh-pages branch..." + git worktree add --quiet --detach "${GHPAGES_WORKTREE_DIR}" + git -C "${GHPAGES_WORKTREE_DIR}" checkout --orphan gh-pages + git -C "${GHPAGES_WORKTREE_DIR}" rm -rf . > /dev/null 2>&1 || true +fi + +# Change to gh-pages worktree +cd "${GHPAGES_WORKTREE_DIR}" + +# Determine if this tag is the latest version +echo "Determining if ${VERSION} is the latest version..." + +# Get all existing version tags from the repository (reverse sorted, newest first) +ALL_VERSIONS=$( + git -C "${REPO_ROOT}" tag --list | \ + sed -nE 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?)$/v\1/p' | \ + sort -Vr +) + +# Get the latest version from all version tags +LATEST_VERSION=$(echo "${ALL_VERSIONS}" | head -n 1) + +if [ "${VERSION}" = "${LATEST_VERSION}" ]; then + echo "${VERSION} is the latest version" +else + echo "${VERSION} is not the latest version (latest is ${LATEST_VERSION})" +fi + +# Update custom documentation for latest version +if [ "${VERSION}" = "${LATEST_VERSION}" ]; then + echo "Updating custom documentation..." + + # Clean up old custom docs from gh-pages root + echo "Cleaning gh-pages root..." + git ls-tree --name-only HEAD | xargs -r git rm -rf + + # Copy custom docs from docs/ directory + echo "Copying custom docs from ${WORKTREE_DIR}/docs/..." + cp -r "${WORKTREE_DIR}/docs/." "${GHPAGES_WORKTREE_DIR}/" +fi + +# Generate version data for Jekyll +echo "Generating _data/versions.yml..." +mkdir -p _data +echo "${ALL_VERSIONS}" | sed 's/^v/- /' > _data/versions.yml + +# Stage all changes +git add . + +# Commit if there are changes +if git diff --staged --quiet; then + echo "No changes to commit" +else + echo "Committing documentation for ${VERSION}..." + git commit -m "Add ${VERSION} docs" + + echo "Documentation committed to gh-pages branch!" + echo "Push to remote to deploy to GitHub Pages" +fi + +echo "Done!" diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..1e2e660 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,105 @@ +# MCP Conformance Tests + +Validates the Ruby SDK's conformance to the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). + +## Prerequisites + +- Node.js (for `npx`) +- `bundle install` completed + +## Running the Tests + +### Run all scenarios + +```bash +bundle exec rake conformance:test +``` + +Runs both server and client conformance tests in sequence. Server conformance starts the +conformance server and tests it. Client conformance spawns test servers for each scenario +and invokes `conformance/client.rb` against them. Scenarios listed in `expected_failures.yml` +are allowed to fail without affecting the exit code. + +### Environment variables + +| Variable | Description | Default | +|----------------|---------------------------------------|---------| +| `PORT` | Server port (server conformance only) | `9292` | +| `SCENARIO` | Run a single scenario by name | (all) | +| `SPEC_VERSION` | Filter scenarios by spec version | (all) | +| `VERBOSE` | Show raw JSON output when set | (off) | + +```bash +# Run a single server scenario +bundle exec rake conformance:test SCENARIO=ping + +# Use a different port with verbose output +bundle exec rake conformance:test PORT=3000 VERBOSE=1 + +# Start the server on a specific port +bundle exec rake conformance:server PORT=3000 +``` + +### Start the server and test separately + +```bash +# Terminal 1: start the server +bundle exec rake conformance:server + +# Terminal 2: run all scenarios +npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp + +# Terminal 2: run a single scenario +npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp --scenario ping +``` + +Keeps the server alive between test runs, which avoids the startup overhead when iterating +on a single scenario. Stop the server with Ctrl+C when done. + +### List available scenarios + +```bash +bundle exec rake conformance:list +``` + +Prints all scenario names that can be passed to `SCENARIO`. + +## SDK Tier Report + +The [MCP SDK Tier system](https://modelcontextprotocol.io/community/sdk-tiers) requires SDK +maintainers to self-assess and report results to the SDK Working Group via +[modelcontextprotocol/modelcontextprotocol issues](https://github.com/modelcontextprotocol/modelcontextprotocol/issues). + +To generate a full tier assessment report, use the `/mcp-sdk-tier-audit` slash command from +the [modelcontextprotocol/conformance](https://github.com/modelcontextprotocol/conformance) +repository with the conformance server running: + +```bash +# Terminal 1 (this repository): start the conformance server +bundle exec rake conformance:server + +# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code +/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp +``` + +The skill evaluates conformance pass rate, issue label taxonomy, triage metrics, documentation +coverage, and policy compliance, then produces a markdown report suitable for tier advancement +submissions. + +## File Structure + +``` +conformance/ + server.rb # Conformance server (Rack + Puma, default port 9292) + server_runner.rb # Starts the server, runs npx conformance server, exits with result code + client.rb # Conformance client (invoked by npx conformance client) + client_runner.rb # Runs npx conformance client, exits with result code + expected_failures.yml # Baseline of known-failing scenarios (server and client) + README.md # This file +``` + +## Known Limitations + +Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to +fail without affecting the exit code and are tracked to catch regressions. +These are shown in the output of `bundle exec rake conformance:test`. diff --git a/conformance/client.rb b/conformance/client.rb new file mode 100644 index 0000000..b568e2e --- /dev/null +++ b/conformance/client.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +# Conformance test client for the MCP Ruby SDK. +# Invoked by the conformance runner: +# MCP_CONFORMANCE_SCENARIO= bundle exec ruby conformance/client.rb +# +# The server URL is passed as the last positional argument. +# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, +# which is set automatically by the conformance test runner. + +require "faraday" +require "json" +require_relative "../lib/mcp" + +scenario = ENV["MCP_CONFORMANCE_SCENARIO"] +server_url = ARGV.last + +unless scenario && server_url + abort("Usage: MCP_CONFORMANCE_SCENARIO= ruby conformance/client.rb ") +end + +# URL the conformance harness expects to see as the OAuth `client_id` for the `auth/basic-cimd` scenario +# when the AS advertises `client_id_metadata_document_supported`. The harness does not fetch the document, +# only matches the value, so the URL does not need to resolve. +CONFORMANCE_CIMD_URL = "https://conformance-test.local/client-metadata.json" + +# The conformance harness optionally injects scenario-specific data via +# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is +# defined by the harness, not the MCP spec, and has varied between versions: +# +# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are +# spread at the top level alongside `name`, e.g. +# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`. +# - Older: a nested `context` object: `{"name":"...","context":{...}}`. +# +# Both shapes are accepted so the client conforms to whichever harness version +# the developer has on hand. +def conformance_context + raw = ENV["MCP_CONFORMANCE_CONTEXT"] + return {} if raw.nil? || raw.empty? + + parsed = JSON.parse(raw) + return {} unless parsed.is_a?(Hash) + + if parsed["context"].is_a?(Hash) + parsed["context"] + else + parsed.reject { |key, _| key == "name" } + end +rescue JSON::ParserError + {} +end + +# Saves the pre-registered `client_id` / `client_secret` the harness injects +# via context (used by pre-registration and client_credentials scenarios). +def storage_for(context) + storage = MCP::Client::OAuth::InMemoryStorage.new + if context["client_id"] + storage.save_client_information( + "client_id" => context["client_id"], + "client_secret" => context["client_secret"], + "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end + storage +end + +# Builds a `client_credentials`-only provider (machine-to-machine, no redirect). +# The pre-registered credentials are injected by the harness via context: +# a shared secret for the basic scenario, or a PEM private key plus signing algorithm +# (default ES256, matching the TypeScript and Python conformance clients) +# for the `private_key_jwt` scenario. +def build_client_credentials_provider(context) + if context["private_key_pem"] + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + token_endpoint_auth_method: "private_key_jwt", + private_key: context["private_key_pem"], + signing_algorithm: context["signing_algorithm"] || "ES256", + ) + else + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end +end + +# Builds a SEP-990 Cross-App Access provider: the harness injects an IdP ID token plus the IdP token endpoint +# via context; the assertion provider exchanges them for an ID-JAG (RFC 8693), which the flow then presents to +# the MCP authorization server with the jwt-bearer grant. +def build_cross_app_access_provider(context) + assertion_provider = ->(audience:, resource:) do + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: context["idp_token_endpoint"], + id_token: context["idp_id_token"], + client_id: context["idp_client_id"], + audience: audience, + resource: resource, + ) + end + + MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + assertion_provider: assertion_provider, + ) +end + +# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow +# non-interactively against the conformance test's auth server. The conformance +# `/authorize` endpoint redirects synchronously to `redirect_uri` with +# `code=test-auth-code`, so we follow it manually instead of opening a browser. +def build_oauth_provider(context, scenario:) + callback_holder = {} + redirect_uri = "http://localhost:0/callback" + + redirect_handler = ->(authorization_url) do + response = Faraday.new.get(authorization_url) do |req| + req.options.params_encoder = nil + end + location = response.headers["location"] || response.headers["Location"] + abort("Authorization request did not redirect: #{response.status}.") unless location + + callback_holder[:url] = URI.parse(location) + end + + callback_handler = -> do + query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h + # The 3-element form opts into SEP-2468 / RFC 9207 `iss` validation; + # `query["iss"]` is nil when the authorization response carried no `iss`. + [query["code"], query["state"], query["iss"]] + end + + MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "ruby-sdk-conformance-client", + redirect_uris: [redirect_uri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: redirect_uri, + redirect_handler: redirect_handler, + callback_handler: callback_handler, + storage: storage_for(context), + client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil), + ) +end + +def build_provider_for(scenario, context) + if context["idp_id_token"] + build_cross_app_access_provider(context) + elsif scenario.start_with?("auth/client-credentials") + build_client_credentials_provider(context) + else + build_oauth_provider(context, scenario: scenario) + end +end + +oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil +transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) +client = MCP::Client.new(transport: transport) + +# SEP-2322: the MRTR scenario exercises the automatic driver; this elicitation handler accepts +# every embedded confirmation request, and the driver echoes `requestState` byte-exactly. +# The same registration answers a real `elicitation/create` request, so nothing here is MRTR-specific. +if scenario == "sep-2322-client-request-state" + client.on_elicitation { |_params| { action: "accept", content: { confirmed: true } } } +end +capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {} +# The conformance harness asserts on the observed protocol exchange, not on the client's exit status, +# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios +# (e.g. `auth/resource-mismatch`, `auth/scope-retry-limit`) are expected to drive the client into +# a terminal failure: the SDK correctly refuses a mismatched resource or stops after the scope step-up +# retry limit. Report such an expected failure as a one-line reason and exit cleanly so the run stays +# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace +# and a failing exit status. +begin + # The harness names the wire version per scenario run: the 2026 draft selects the stateless modern lifecycle, + # and every dated version through 2025-11-25 keeps the legacy `initialize` handshake + # (pinned rather than `:auto`, whose `server/discover` probe the legacy scenario servers do not expect). + mode = ENV["MCP_CONFORMANCE_PROTOCOL_VERSION"] == "2026-07-28" ? :modern : :legacy + connect = lambda do + client.connect( + client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, + capabilities: capabilities, + mode: mode, + ) + end + + begin + connect.call + rescue MCP::Client::RequestHandlerError + # SEP-2575 version negotiation: a server may reject the first request with `-32022` naming + # its supported versions. Retry once so the harness observes a follow-up request carrying + # a mutually supported version (this client already speaks the latest draft). + raise unless mode == :modern + + connect.call + end + + case scenario + when "initialize" + client.tools + when "json-schema-2020-12-preservation" + # SEP-1613/SEP-2106: echo the focal tool's observed inputSchema verbatim so the harness + # can diff what the client preserved. Preservation holds because `Client::Tool#input_schema` + # is the parsed response untouched. + tools = client.tools + focal = tools.find { |t| t.name == "json_schema_2020_12_tool" } + abort("Tool json_schema_2020_12_tool not found") unless focal + client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema }) + when "request-metadata" + # SEP-2575: the checks read the envelope and headers of the requests the modern connect + # and this listing already produce. + client.tools + when "sep-2322-client-request-state" + # SEP-2322: the driver resumes `test_mrtr_echo_state` automatically (fresh JSON-RPC id, + # byte-exact `requestState` echo); `test_mrtr_unrelated` must carry neither retry field; + # `test_mrtr_no_state` retries without inventing a state; `test_mrtr_no_result_type` + # has no `resultType` and therefore must be treated as complete, with no retry. + client.tools + client.call_tool(name: "test_mrtr_echo_state", arguments: {}) + client.call_tool(name: "test_mrtr_unrelated", arguments: {}) + client.call_tool(name: "test_mrtr_no_state", arguments: {}) + client.call_tool(name: "test_mrtr_no_result_type", arguments: {}) + when "http-custom-headers" + # SEP-2243: the harness names the exact tool calls (with encoding edge-case values) via context; + # listing first teaches the transport the `x-mcp-header` declarations it mirrors. + client.tools + (conformance_context["toolCalls"] || []).each do |tool_call| + client.call_tool(name: tool_call["name"], arguments: tool_call["arguments"]) + end + when "http-invalid-tool-headers" + # SEP-2243: the listing carries one valid tool among invalid `x-mcp-header` definitions; + # calling only the valid one shows the invalid definitions did not block it. + client.tools + client.call_tool(name: "valid_tool", arguments: { "message" => "hello" }) + when "http-standard-headers" + # SEP-2243: the harness inspects the `Mcp-Method` header on every POST and the `Mcp-Name` header on + # the name-carrying requests, so drive one of each standard request kind. + tools = client.tools + tool = tools.find { |t| t.name == "test_headers" } || tools.first + abort("No tool exposed by conformance server for #{scenario}") unless tool + client.call_tool(tool: tool, arguments: {}) + + resource = client.resources.first + abort("No resource exposed by conformance server for #{scenario}") unless resource + client.read_resource(uri: resource["uri"]) + + prompt = client.prompts.first + abort("No prompt exposed by conformance server for #{scenario}") unless prompt + client.get_prompt(name: prompt["name"]) + when "json-schema-ref-no-deref" + # SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing. + client.tools + when "tools_call" + tools = client.tools + add_numbers = tools.find { |t| t.name == "add_numbers" } + abort("Tool add_numbers not found") unless add_numbers + client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) + when "sse-retry" + # SEP-1699: the server closes the tools/call SSE stream right after a priming event. + # The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`, + # and receives the tool result on the resumed stream; the harness verifies the reconnect, + # its timing, and the header. + tools = client.tools + test_reconnection = tools.find { |t| t.name == "test_reconnection" } + abort("Tool test_reconnection not found") unless test_reconnection + client.call_tool(tool: test_reconnection, arguments: {}) + when "elicitation-sep1034-client-defaults" + # SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring + # a `default` and none required. The handler accepts and fills the omitted fields from those defaults. + client.on_elicitation do |params| + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) } + end + + tools = client.tools + defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" } + + abort("Tool test_client_elicitation_defaults not found") unless defaults_tool + + client.call_tool(tool: defaults_tool, arguments: {}) + when %r|\Aauth/| + # Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage) + # are observed by the conformance server during `connect` and the subsequent request below. + # Listing tools forces a second authenticated MCP request so the bearer token usage check fires. + tools = client.tools + + # `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`, + # so the client must actually invoke a tool to drive the second authorization request + # the scenario asserts on. + if scenario == "auth/scope-step-up" + tool = tools.find { |t| t.name == "test-tool" } || tools.first + abort("No tool exposed by conformance server for #{scenario}") unless tool + client.call_tool(tool: tool, arguments: {}) + end + else + abort("Unknown or unsupported scenario: #{scenario}") + end +rescue MCP::Client::OAuth::Flow::AuthorizationError, MCP::Client::RequestHandlerError, Faraday::Error => e + warn("#{scenario}: client ended in the expected terminal failure (#{e.class}: #{e.message})") + exit +end diff --git a/conformance/client_runner.rb b/conformance/client_runner.rb new file mode 100644 index 0000000..418e7f4 --- /dev/null +++ b/conformance/client_runner.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Runs `npx @modelcontextprotocol/conformance@alpha client` against the conformance client script. +require "English" + +module Conformance + class ClientRunner + def initialize(scenario: nil, spec_version: nil, verbose: false) + @scenario = scenario + @spec_version = spec_version + @verbose = verbose + end + + def run + command = build_command + puts "Command: #{command.join(" ")}\n\n" + + system(*command) + conformance_exit_code = $CHILD_STATUS.exitstatus + exit(conformance_exit_code || 1) unless conformance_exit_code == 0 + end + + private + + def build_command + expected_failures_yml = File.expand_path("expected_failures.yml", __dir__) + client_script = File.expand_path("client.rb", __dir__) + + npx_command = [ + "npx", + "--yes", + "@modelcontextprotocol/conformance@alpha", + "client", + "--command", + "bundle exec ruby #{client_script}", + ] + npx_command += if @scenario + ["--scenario", @scenario] + else + ["--suite", "all"] + end + npx_command += ["--spec-version", @spec_version] if @spec_version + npx_command += ["--verbose"] if @verbose + npx_command += ["--expected-failures", expected_failures_yml] + npx_command + end + end +end diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml new file mode 100644 index 0000000..9325842 --- /dev/null +++ b/conformance/expected_failures.yml @@ -0,0 +1,6 @@ +server: [] +client: + # Unimplemented authorization extensions. + - auth/dpop + - auth/dpop-nonce + - auth/wif-jwt-bearer diff --git a/conformance/server.rb b/conformance/server.rb new file mode 100644 index 0000000..a3f781d --- /dev/null +++ b/conformance/server.rb @@ -0,0 +1,1107 @@ +# frozen_string_literal: true + +require "rackup" +require "json" +require "securerandom" +require "set" +require "uri" +require_relative "../lib/mcp" + +module Conformance + # 1x1 red PNG pixel (matches TypeScript SDK and Python SDK) + BASE64_1X1_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + + # Minimal WAV file (matches TypeScript SDK and Python SDK) + BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" + + # SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start, + # so every round reads the client's earlier answers back through these. + module Mrtr + module_function + + # The transport parses JSON with symbolized keys, but retries may also arrive through + # `Server#handle` with string keys; tolerate both like the SDK's own readers. + def read(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + + # The `content` of an accepted `elicitation/create` response, or `nil` for a declined, + # malformed, or missing one (a `nil` return re-requests the input). + def accepted_content(response) + content = read(response, :content) + content if read(response, :action) == "accept" && content.is_a?(Hash) + end + + # The `text` of a `sampling/createMessage` response, or `nil` when malformed. + def sampled_text(response) + text = read(read(response, :content), :text) + text if text.is_a?(String) + end + + def parse_state(request_state) + return unless request_state.is_a?(String) + + parsed = JSON.parse(request_state) + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + end + end + + module Tools + class TestSimpleText < MCP::Tool + tool_name "test_simple_text" + description "A tool that returns simple text content" + + class << self + def call(**_args) + MCP::Tool::Response.new([MCP::Content::Text.new("This is a simple text response for testing.").to_h]) + end + end + end + + class TestImageContent < MCP::Tool + tool_name "test_image_content" + description "A tool that returns image content" + + class << self + def call(**_args) + MCP::Tool::Response.new([MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h]) + end + end + end + + class TestAudioContent < MCP::Tool + tool_name "test_audio_content" + description "A tool that returns audio content" + + class << self + def call(**_args) + MCP::Tool::Response.new([MCP::Content::Audio.new(BASE64_MINIMAL_WAV, "audio/wav").to_h]) + end + end + end + + class TestEmbeddedResource < MCP::Tool + tool_name "test_embedded_resource" + description "A tool that returns embedded resource content" + + class << self + def call(**_args) + text_contents = MCP::Resource::TextContents.new( + uri: "test://embedded-resource", + mime_type: "text/plain", + text: "This is an embedded resource content.", + ) + MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(text_contents).to_h]) + end + end + end + + class TestMultipleContentTypes < MCP::Tool + tool_name "test_multiple_content_types" + description "A tool that returns multiple content types" + + class << self + def call(**_args) + MCP::Tool::Response.new([ + MCP::Content::Text.new("Multiple content types test:").to_h, + MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h, + MCP::Content::EmbeddedResource.new( + MCP::Resource::TextContents.new( + uri: "test://mixed-content-resource", + mime_type: "application/json", + text: '{"test":"data","value":123}', + ), + ).to_h, + ]) + end + end + end + + class TestErrorHandling < MCP::Tool + tool_name "test_error_handling" + description "A tool that intentionally returns an error for testing" + + class << self + def call(**_args) + MCP::Tool::Response.new( + [MCP::Content::Text.new("This tool intentionally returns an error for testing").to_h], + error: true, + ) + end + end + end + + class JsonSchema202012Tool < MCP::Tool + tool_name "json_schema_2020_12_tool" + description "Tool with JSON Schema 2020-12 features" + input_schema( + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + address: { + type: "object", + properties: { + street: { type: "string" }, + city: { type: "string" }, + }, + }, + }, + properties: { + name: { type: "string" }, + address: { "$ref": "#/$defs/address" }, + }, + additionalProperties: false, + ) + + class << self + def call(**_args) + MCP::Tool::Response.new([MCP::Content::Text.new("Processed with JSON Schema 2020-12").to_h]) + end + end + end + + class TestToolWithLogging < MCP::Tool + tool_name "test_tool_with_logging" + description "A tool that sends log messages during execution" + + class << self + def call(server_context:, **_args) + server_context.notify_log_message(data: "Tool execution started", level: "info", logger: "test_logger") + sleep(0.05) # Required by the conformance test to verify clients handle interleaved notifications (same as TypeScript SDK). + server_context.notify_log_message(data: "Tool processing data", level: "info", logger: "test_logger") + sleep(0.05) # Same as above. + server_context.notify_log_message(data: "Tool execution completed", level: "info", logger: "test_logger") + MCP::Tool::Response.new([MCP::Content::Text.new("Logging complete (3 messages sent)").to_h]) + end + end + end + + class TestToolWithProgress < MCP::Tool + tool_name "test_tool_with_progress" + description "A tool that reports progress notifications" + + class << self + def call(server_context:, **_args) + server_context.report_progress(0, total: 100) + server_context.report_progress(50, total: 100) + server_context.report_progress(100, total: 100) + + MCP::Tool::Response.new([MCP::Content::Text.new("Progress complete").to_h]) + end + end + end + + class TestSampling < MCP::Tool + tool_name "test_sampling" + description "A tool that requests LLM sampling from the client" + input_schema( + properties: { prompt: { type: "string" } }, + required: ["prompt"], + ) + + class << self + def call(prompt:, server_context:) + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, + ) + model = result[:model] || "unknown" + text = result.dig(:content, :text) || "" + + MCP::Tool::Response.new([MCP::Content::Text.new("LLM response: #{text} (model: #{model})").to_h]) + end + end + end + + class TestElicitation < MCP::Tool + tool_name "test_elicitation" + description "A tool that requests user input from the client" + input_schema( + properties: { message: { type: "string" } }, + required: ["message"], + ) + + class << self + def call(server_context:, message:) + result = server_context.create_form_elicitation( + message: message, + requested_schema: { + type: "object", + properties: { + username: { type: "string", description: "User's response" }, + email: { type: "string", description: "User's email address" }, + }, + required: ["username", "email"], + }, + ) + MCP::Tool::Response.new([MCP::Content::Text.new("User response: #{result}").to_h]) + end + end + end + + class TestElicitationSep1034Defaults < MCP::Tool + tool_name "test_elicitation_sep1034_defaults" + description "A tool that tests elicitation with default values" + + class << self + def call(server_context:, **_args) + result = server_context.create_form_elicitation( + message: "Please provide your information (with defaults)", + requested_schema: { + type: "object", + properties: { + name: { type: "string", default: "John Doe" }, + age: { type: "integer", default: 30 }, + score: { type: "number", default: 95.5 }, + status: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["active", "inactive", "pending"], default: "active", + ).to_h, + verified: { type: "boolean", default: true }, + }, + }, + ) + MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h]) + end + end + end + + class TestElicitationSep1330Enums < MCP::Tool + tool_name "test_elicitation_sep1330_enums" + description "A tool that tests elicitation with enum schemas" + + class << self + def call(server_context:, **_args) + # Built with the SEP-1330 builders so the conformance run exercises the same schemas + # the SDK produces for users. + result = server_context.create_form_elicitation( + message: "Please select options", + requested_schema: { + type: "object", + properties: { + untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledSingle: MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "value1", title: "First Option" }, + { value: "value2", title: "Second Option" }, + { value: "value3", title: "Third Option" }, + ], + ).to_h, + legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled( + values: ["opt1", "opt2", "opt3"], + value_titles: ["Option One", "Option Two", "Option Three"], + ).to_h, + untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["option1", "option2", "option3"], + ).to_h, + titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select( + options: [ + { value: "value1", title: "First Choice" }, + { value: "value2", title: "Second Choice" }, + { value: "value3", title: "Third Choice" }, + ], + ).to_h, + }, + }, + ) + MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h]) + end + end + end + + class TestReconnection < MCP::Tool + tool_name "test_reconnection" + description "A tool that triggers SSE stream closure to test client reconnection behavior" + + class << self + def call(**_args) + MCP::Tool::Response.new([MCP::Content::Text.new("Reconnection test completed").to_h]) + end + end + end + + class TestMissingCapability < MCP::Tool + tool_name "test_missing_capability" + description "A tool that requires the sampling client capability (SEP-2575)" + + class << self + def call(server_context:, **_args) + server_context.require_client_capability!(:sampling) + + MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h]) + end + end + end + + class TestStreamingElicitation < MCP::Tool + tool_name "test_streaming_elicitation" + description "A tool whose response stream carries only notifications, never independent requests (SEP-2575)" + + class << self + def call(server_context:, **_args) + # No independent server-to-client request is ever written to the response stream; + # the progress notification only goes out when the request carried a progressToken. + server_context.report_progress(50, total: 100) + + MCP::Tool::Response.new([MCP::Content::Text.new("Streaming complete").to_h]) + end + end + end + + class TestInputRequiredResultElicitation < MCP::Tool + tool_name "test_input_required_result_elicitation" + description "A tool that asks for the user's name through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + + unless name.is_a?(String) + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([MCP::Content::Text.new("Hello, #{name}!").to_h]) + end + end + end + + class TestInputRequiredResultSampling < MCP::Tool + tool_name "test_input_required_result_sampling" + description "A tool that asks for an LLM completion through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + text = Mrtr.sampled_text(server_context.input_response("sample_request")) + + unless text + return MCP::Server::InputRequiredResult.new( + input_requests: { + sample_request: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }], + maxTokens: 100, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h]) + end + end + end + + class TestInputRequiredResultListRoots < MCP::Tool + tool_name "test_input_required_result_list_roots" + description "A tool that asks for the client's roots through an input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + roots = Mrtr.read(server_context.input_response("roots_request"), :roots) + + unless roots.is_a?(Array) + return MCP::Server::InputRequiredResult.new( + input_requests: { roots_request: { method: "roots/list" } }, + ) + end + + uris = roots.filter_map { |root| Mrtr.read(root, :uri) } + MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h]) + end + end + end + + class TestInputRequiredResultRequestState < MCP::Tool + tool_name "test_input_required_result_request_state" + description "A tool whose input_required result carries an opaque requestState (SEP-2322)" + + class << self + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) + + if state && state["kind"] == "request-state" && confirmed + return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h]) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + confirm: { + method: "elicitation/create", + params: { + message: "Confirm to continue", + requestedSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }), + ) + end + end + end + + class TestInputRequiredResultMultipleInputs < MCP::Tool + tool_name "test_input_required_result_multiple_inputs" + description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)" + + class << self + def call(server_context:, **_args) + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + greeting = Mrtr.sampled_text(server_context.input_response("greeting")) + roots = Mrtr.read(server_context.input_response("client_roots"), :roots) + + if name.is_a?(String) && greeting && roots.is_a?(Array) + return MCP::Tool::Response.new( + [MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h], + ) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + greeting: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }], + maxTokens: 50, + }, + }, + client_roots: { method: "roots/list" }, + }, + request_state: JSON.generate({ kind: "multiple-inputs" }), + ) + end + end + end + + class TestInputRequiredResultMultiRound < MCP::Tool + tool_name "test_input_required_result_multi_round" + description "A tool that needs two elicitation rounds before completing (SEP-2322)" + + class << self + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + + case state && state["round"] + when 1 + content = Mrtr.accepted_content(server_context.input_response("user_name")) + name = content && Mrtr.read(content, :name) + return second_round(name) if name.is_a?(String) + when 2 + content = Mrtr.accepted_content(server_context.input_response("favorite_color")) + color = content && Mrtr.read(content, :color) + if color.is_a?(String) + return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h]) + end + end + + first_round + end + + private + + def first_round + MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + request_state: JSON.generate({ round: 1 }), + ) + end + + def second_round(name) + MCP::Server::InputRequiredResult.new( + input_requests: { + favorite_color: { + method: "elicitation/create", + params: { + message: "What is your favorite color?", + requestedSchema: { + type: "object", + properties: { color: { type: "string" } }, + required: ["color"], + }, + }, + }, + }, + request_state: JSON.generate({ round: 2, name: name }), + ) + end + end + end + + class TestInputRequiredResultTamperedState < MCP::Tool + tool_name "test_input_required_result_tampered_state" + description "A tool whose sealed requestState rejects tampered echoes (SEP-2322)" + + class << self + # A tampered `requestState` never reaches this handler: the server-level unsealing rejects it with -32602 + # before dispatch, so the tool body is a plain MRTR flow. + def call(server_context:, **_args) + state = Mrtr.parse_state(server_context.request_state) + confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) + + if state && state["kind"] == "tamper-check" && confirmed + return MCP::Tool::Response.new([MCP::Content::Text.new("requestState integrity verified").to_h]) + end + + MCP::Server::InputRequiredResult.new( + input_requests: { + confirm: { + method: "elicitation/create", + params: { + message: "Confirm to continue", + requestedSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }, + }, + }, + request_state: JSON.generate({ kind: "tamper-check", nonce: SecureRandom.hex(8) }), + ) + end + end + end + + class TestInputRequiredResultCapabilities < MCP::Tool + tool_name "test_input_required_result_capabilities" + description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)" + + class << self + def call(server_context:, **_args) + capabilities = server_context.client_capabilities || {} + elicited = Mrtr.accepted_content(server_context.input_response("elicit_input")) + sampled = Mrtr.sampled_text(server_context.input_response("sample_input")) + + requests = {} + if Mrtr.read(capabilities, :elicitation) && !elicited + requests[:elicit_input] = { + method: "elicitation/create", + params: { + message: "Provide a value", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + }, + }, + } + end + if Mrtr.read(capabilities, :sampling) && !sampled + requests[:sample_input] = { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Say hello" } }], + maxTokens: 50, + }, + } + end + + if requests.empty? + MCP::Tool::Response.new( + [MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h], + ) + else + MCP::Server::InputRequiredResult.new(input_requests: requests) + end + end + end + end + + class TestLoggingTool < MCP::Tool + tool_name "test_logging_tool" + description "A tool that logs only for requests opting in via the _meta logLevel (SEP-2575)" + + class << self + def call(server_context:, **_args) + # On modern requests the notification is dropped unless `io.modelcontextprotocol/logLevel` authorized it, + # so the call itself is unconditional. + server_context.notify_log_message(data: "Log level honored", level: "info", logger: "conformance") + + MCP::Tool::Response.new([MCP::Content::Text.new("Logging evaluated").to_h]) + end + end + end + + class TestTriggerToolChange < MCP::Tool + tool_name "test_trigger_tool_change" + description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)" + + class << self + # The broadcast alone exercises the `subscriptions/listen` delivery, so no actual + # tool-list mutation is needed, matching the suite's TypeScript reference fixture. + def call(server_context:, **_args) + server_context.notify_tools_list_changed + + MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) + end + end + end + + class TestTriggerPromptChange < MCP::Tool + tool_name "test_trigger_prompt_change" + description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)" + + class << self + def call(server_context:, **_args) + server_context.notify_prompts_list_changed + + MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) + end + end + end + end + + module Prompts + class TestSimplePrompt < MCP::Prompt + prompt_name "test_simple_prompt" + description "A simple prompt for testing with no arguments" + + class << self + def template(_args, server_context: nil) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("This is a simple prompt for testing."), + ), + ], + ) + end + end + end + + class TestPromptWithArguments < MCP::Prompt + prompt_name "test_prompt_with_arguments" + description "A prompt with required arguments for testing" + arguments [ + MCP::Prompt::Argument.new(name: "arg1", description: "First test argument", required: true), + MCP::Prompt::Argument.new(name: "arg2", description: "Second test argument", required: true), + ] + + class << self + def template(args, server_context: nil) + arg1 = args.dig(:arg1) || args.dig("arg1") || "" + arg2 = args.dig(:arg2) || args.dig("arg2") || "" + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Prompt with arguments: arg1='#{arg1}', arg2='#{arg2}'"), + ), + ], + ) + end + end + end + + class TestPromptWithEmbeddedResource < MCP::Prompt + prompt_name "test_prompt_with_embedded_resource" + description "A prompt with an embedded resource for testing" + arguments [ + MCP::Prompt::Argument.new(name: "resourceUri", description: "URI of the resource to embed", required: true), + ] + + class << self + def template(args, server_context: nil) + resource_uri = args.dig(:resourceUri) || args.dig("resourceUri") || "test://example-resource" + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::EmbeddedResource.new( + MCP::Resource::TextContents.new( + uri: resource_uri, + mime_type: "text/plain", + text: "Embedded resource content for testing.", + ), + ), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please process the embedded resource above."), + ), + ], + ) + end + end + end + + class TestPromptWithImage < MCP::Prompt + prompt_name "test_prompt_with_image" + description "A prompt with image content for testing" + + class << self + def template(_args, server_context: nil) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Image.new(BASE64_1X1_PNG, "image/png"), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please analyze the image above."), + ), + ], + ) + end + end + end + + class TestInputRequiredResultPrompt < MCP::Prompt + prompt_name "test_input_required_result_prompt" + description "A prompt that asks for user context through an input_required result (SEP-2322)" + + class << self + def template(_args, server_context: nil) + content = server_context && Mrtr.accepted_content(server_context.input_response("user_context")) + context = content && Mrtr.read(content, :context) + + unless context.is_a?(String) + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_context: { + method: "elicitation/create", + params: { + message: "Provide context for the prompt", + requestedSchema: { + type: "object", + properties: { context: { type: "string" } }, + required: ["context"], + }, + }, + }, + }, + ) + end + + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Context: #{context}"), + ), + ], + ) + end + end + end + end + + class Server + DEFAULT_PORT = 9292 + + class DnsRebindingProtection + LOCALHOST_PATTERNS = /\A(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?\z/i.freeze + + def initialize(app) + @app = app + end + + def call(env) + host = env["HTTP_HOST"] || env["SERVER_NAME"] || "" + + unless localhost?(host) + return [ + 403, + { "Content-Type" => "application/json" }, + [{ error: "Forbidden: DNS rebinding protection - invalid Host header '#{host}'" }.to_json], + ] + end + + origin = env["HTTP_ORIGIN"] + if origin && !origin.empty? + begin + origin_host = URI.parse(origin).host.to_s + unless localhost?(origin_host) + return [ + 403, + { "Content-Type" => "application/json" }, + [{ error: "Forbidden: DNS rebinding protection - invalid Origin '#{origin}'" }.to_json], + ] + end + rescue URI::InvalidURIError + return [ + 403, + { "Content-Type" => "application/json" }, + [{ error: "Forbidden: invalid Origin header" }.to_json], + ] + end + end + + @app.call(env) + end + + private + + def localhost?(host) + host.empty? || host.match?(LOCALHOST_PATTERNS) + end + end + + def initialize(port: DEFAULT_PORT) + @port = port + end + + def start + server = build_server + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + configure_handlers(server) + rack_app = build_rack_app(transport) + + puts <<~MESSAGE + MCP Conformance Server starting on http://localhost:#{@port}/mcp + Use Ctrl-C to stop + MESSAGE + + Rackup::Handler.get("puma").run(rack_app, Port: @port, Host: "localhost", Silent: true) + end + + private + + def build_server + MCP::Server.new( + name: "ruby-sdk-conformance-server", + version: MCP::VERSION, + tools: [ + Tools::TestSimpleText, + Tools::TestImageContent, + Tools::TestAudioContent, + Tools::TestEmbeddedResource, + Tools::TestMultipleContentTypes, + Tools::TestErrorHandling, + Tools::JsonSchema202012Tool, + Tools::TestToolWithLogging, + Tools::TestToolWithProgress, + Tools::TestSampling, + Tools::TestElicitation, + Tools::TestElicitationSep1034Defaults, + Tools::TestElicitationSep1330Enums, + Tools::TestReconnection, + Tools::TestMissingCapability, + Tools::TestInputRequiredResultElicitation, + Tools::TestInputRequiredResultSampling, + Tools::TestInputRequiredResultListRoots, + Tools::TestInputRequiredResultRequestState, + Tools::TestInputRequiredResultMultipleInputs, + Tools::TestInputRequiredResultMultiRound, + Tools::TestInputRequiredResultTamperedState, + Tools::TestInputRequiredResultCapabilities, + Tools::TestStreamingElicitation, + Tools::TestLoggingTool, + Tools::TestTriggerToolChange, + Tools::TestTriggerPromptChange, + ], + prompts: [ + Prompts::TestSimplePrompt, + Prompts::TestPromptWithArguments, + Prompts::TestPromptWithEmbeddedResource, + Prompts::TestPromptWithImage, + Prompts::TestInputRequiredResultPrompt, + ], + resources: resources, + resource_templates: resource_templates, + # A per-boot random key is enough for conformance: every MRTR round trip completes + # within one server process, and sealing keeps the fixture's `requestState` values + # tamper-evident without any tool-level verification code. + request_state_security: MCP::Server::RequestStateSecurity.new(key: SecureRandom.bytes(32)), + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + completions: {}, + }, + ) + end + + def resources + [ + MCP::Resource.new( + uri: "test://static-text", + name: "static-text", + description: "A static text resource for testing", + mime_type: "text/plain", + ), + MCP::Resource.new( + uri: "test://static-binary", + name: "static-binary", + description: "A static binary (PNG) resource for testing", + mime_type: "image/png", + ), + MCP::Resource.new( + uri: "test://watched-resource", + name: "watched-resource", + description: "A resource for subscription testing", + mime_type: "text/plain", + ), + ] + end + + def resource_templates + [ + MCP::ResourceTemplate.new( + uri_template: "test://template/{id}/data", + name: "template-resource", + description: "A parameterized resource template for testing", + mime_type: "application/json", + ), + ] + end + + def configure_handlers(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + server.server_context = server + + configure_resources_read_handler(server) + configure_subscription_handlers(server) + configure_completion_handler(server) + end + + def configure_resources_read_handler(server) + server.resources_read_handler do |params| + uri = params[:uri].to_s + + case uri + when "test://static-text" + [ + MCP::Resource::TextContents.new( + text: "This is the content of the static text resource.", + uri: uri, + mime_type: "text/plain", + ).to_h, + ] + when "test://static-binary" + [ + MCP::Resource::BlobContents.new( + data: BASE64_1X1_PNG, + uri: uri, + mime_type: "image/png", + ).to_h, + ] + when %r{\Atest://template/(.+)/data\z} + id = Regexp.last_match(1) + content = { id: id, templateTest: true, data: "Data for ID: #{id}" }.to_json + + [ + MCP::Resource::TextContents.new( + text: content, + uri: uri, + mime_type: "application/json", + ).to_h, + ] + else + # Per SEP-2164, an unknown URI answers with -32602 carrying the URI in `error.data`; + # an empty `contents` array is forbidden. + raise MCP::Server::ResourceNotFoundError.new(uri, params) + end + end + end + + def configure_completion_handler(server) + server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value].to_s + + case ref[:type] + when "ref/prompt" + case ref[:name] + when "test_prompt_with_arguments" + candidates = case argument[:name] + when "arg1" + ["value1", "value2", "value3"] + when "arg2" + ["optionA", "optionB", "optionC"] + else + [] + end + values = candidates.select { |v| v.start_with?(value) } + { completion: { values: values, hasMore: false } } + else + { completion: { values: [], hasMore: false } } + end + else + { completion: { values: [], hasMore: false } } + end + end + end + + def configure_subscription_handlers(server) + subscribed_uris = Set.new + + server.resources_subscribe_handler do |params| + subscribed_uris.add(params[:uri].to_s) + end + + server.resources_unsubscribe_handler do |params| + subscribed_uris.delete(params[:uri].to_s) + end + end + + def build_rack_app(transport) + mcp_app = proc do |env| + request = Rack::Request.new(env) + + if request.path_info == "/health" + [200, { "Content-Type" => "application/json" }, ['{"status":"ok"}']] + elsif request.path_info == "/mcp" || request.path_info == "/" + transport.handle_request(request) + else + [404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']] + end + end + + Rack::Builder.new do + use(DnsRebindingProtection) + run(mcp_app) + end + end + end +end diff --git a/conformance/server_runner.rb b/conformance/server_runner.rb new file mode 100644 index 0000000..3decbad --- /dev/null +++ b/conformance/server_runner.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Starts the conformance server and runs `npx @modelcontextprotocol/conformance@alpha` against it. +require "English" +require "net/http" +require_relative "server" + +module Conformance + class ServerRunner + # Timeout for waiting for the Puma server to start. + SERVER_START_TIMEOUT = 20 + SERVER_POLL_INTERVAL = 0.5 + SERVER_HEALTH_CHECK_RETRIES = (SERVER_START_TIMEOUT / SERVER_POLL_INTERVAL).to_i + + def initialize(port: Server::DEFAULT_PORT, scenario: nil, spec_version: nil, verbose: false) + @port = port + @scenario = scenario + @spec_version = spec_version + @verbose = verbose + end + + def run + command = build_command + server_pid = start_server + + run_conformance(command, server_pid: server_pid) + end + + private + + def build_command + expected_failures_yml = File.expand_path("expected_failures.yml", __dir__) + + npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance@alpha", "server", "--url", "http://localhost:#{@port}/mcp"] + npx_command += ["--scenario", @scenario] if @scenario + npx_command += ["--spec-version", @spec_version] if @spec_version + npx_command += ["--verbose"] if @verbose + npx_command += ["--expected-failures", expected_failures_yml] + npx_command + end + + def start_server + puts "Starting conformance server on port #{@port}..." + + server_pid = fork do + Conformance::Server.new(port: @port).start + end + + health_url = URI("http://localhost:#{@port}/health") + ready = false + SERVER_HEALTH_CHECK_RETRIES.times do + begin + response = Net::HTTP.get_response(health_url) + if response.code == "200" + ready = true + break + end + rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::ReadTimeout + # not ready yet + end + sleep(SERVER_POLL_INTERVAL) + end + + unless ready + warn("ERROR: Conformance server did not start within #{SERVER_START_TIMEOUT} seconds") + terminate_server(server_pid) + exit(1) + end + + puts "Server ready. Running conformance tests..." + + server_pid + end + + def run_conformance(command, server_pid:) + puts "Command: #{command.join(" ")}\n\n" + + conformance_exit_code = nil + begin + system(*command) + conformance_exit_code = $CHILD_STATUS.exitstatus + ensure + terminate_server(server_pid) + end + + exit(conformance_exit_code || 1) unless conformance_exit_code == 0 + end + + def terminate_server(pid) + Process.kill("TERM", pid) + rescue Errno::ESRCH + # process already exited + ensure + begin + Process.wait(pid) + rescue Errno::ECHILD + # already reaped + end + end + end +end diff --git a/dev.yml b/dev.yml index 38c8d16..8e5138c 100644 --- a/dev.yml +++ b/dev.yml @@ -27,5 +27,4 @@ commands: style: desc: Run rubocop aliases: [rubocop, lint] - run: bin/rubocop - + run: bin/rake rubocop diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..f49a17f --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +ruby.sdk.modelcontextprotocol.io diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..eb295d0 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Dependencies for the local docs preview (`rake docs:preview`), kept out of the gem's own Gemfile: +# github-pages mirrors the GitHub Pages runtime that builds the released site +# (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs). +source "https://rubygems.org" + +gem "github-pages", group: :jekyll_plugins +gem "webrick" + +# Former default gems that the Jekyll version pinned by github-pages still requires on Ruby 4.0. +gem "base64" +gem "bigdecimal" +gem "csv" +gem "logger" diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md new file mode 100644 index 0000000..72efd01 --- /dev/null +++ b/docs/_client/authorization.md @@ -0,0 +1,405 @@ +--- +layout: default +title: Authorization +nav_order: 8 +--- + +# Authorization + +Authorization is handled by the transport layer. This page covers authenticating the HTTP transport, +from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements +(PKCE with dynamic client registration, the client credentials grant, and cross-app access). + +## HTTP Authorization + +By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: + +```ruby +http_transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) + +client = MCP::Client.new(transport: http_transport) +client.tools # will make the call using Bearer auth +``` + +You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. + +## OAuth 2.1 Authorization + +When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization), +pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: + +- Send `Authorization: Bearer ` on every request when a token is available. +- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), + perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. +- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts + as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3), + and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. + When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`, + raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright. +- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). + `ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead; + their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again. +- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), + run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. + The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. +- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata + `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise + `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. + +```ruby +require "mcp" + +provider = MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "My MCP App", + redirect_uris: ["http://localhost:3030/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:3030/callback", + redirect_handler: ->(authorization_url) { + # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` + # or a manual `puts authorization_url` in CLI tools. + }, + callback_handler: -> { + # Capture the redirect (for example, by running a small HTTP listener on + # `redirect_uri`) and return [code, state] from the query string. + }, +) + +transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + oauth: provider, +) +client = MCP::Client.new(transport: transport) +client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token +client.tools +``` + +Required keyword arguments to `Provider.new`: + +- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, + `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. + When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); + an explicit value always wins. +- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. +- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. +- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form + (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match + the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. + +Optional keyword arguments: + +- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built. + Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request). +- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard. + See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client). +- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, + which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that + issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically + (portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what + lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members + instead drops these bindings with no error. +- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document + (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). + When the authorization server advertises `client_id_metadata_document_supported: true`, + the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, + userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks + them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. + Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document + served at the URL is a separate JSON artifact from the `client_metadata` keyword above: + the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include + `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. +- `token_request_params`: Hash of String keys and values added to every token request the provider makes, + for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`. + Defaults to `nil`, which adds nothing. The authorization request is not affected. + A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity, + or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, + when the provider is built, and the accepted Hash is copied and frozen. + Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes; + the flow checks the returned value by the same rules and raises the same error before the token request is sent. + +{: .warning } +> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents, +> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it +> automatically wherever the authorization server advertises support. + +To persist credentials across restarts, supply your own storage: + +```ruby +class FileTokenStorage + def initialize(path) + @path = path + end + + def tokens + read["tokens"] + end + + def save_tokens(value) + write("tokens" => value) + end + + def client_information + read["client"] + end + + def save_client_information(value) + write("client" => value) + end + + private + + def read + File.exist?(@path) ? JSON.parse(File.read(@path)) : {} + end + + def write(updates) + File.write(@path, JSON.dump(read.merge(updates))) + end +end + +provider = MCP::Client::OAuth::Provider.new( + # ... required keywords ... + storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), +) +``` + +### Token Endpoint Errors + +When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and +the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2). +For example: + +```text +Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods +``` + +The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string +diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers. +An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token +recovery behavior is unchanged. + +Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when +truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed. +The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors. +Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging +and redaction policy before persisting them or displaying them to users. + +### Client Credentials Grant + +For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. +The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3); +a refresh token the authorization server issues anyway is used on the next `401`. + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + # token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt" + # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) + # token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`: Required. `client_secret`: Required with the secret-based methods; the grant is + for confidential clients, so a credential is mandatory. +- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`, + or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected + with `ClientCredentialsProvider::InvalidCredentialsError`. +- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string + or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` + or `"RS256"`; `client_secret` must not be set, because the private key is the credential. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. + Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`. + +### Cross-App Access (JWT Bearer) Grant + +For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. +The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG +to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. +A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again. +Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. + +`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into +an enterprise secret store or a test double without changing the transport wiring. + +```ruby +provider = MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: "my-mcp-client", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + assertion_provider: ->(audience:, resource:) { + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: "https://idp.example.com/token", + id_token: ENV.fetch("IDP_ID_TOKEN"), + client_id: "my-idp-client", + audience: audience, + resource: resource, + ) + }, + # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. +- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. + `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). + Passing both through to `IDJAGTokenExchange.request` covers the common case. +- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. + +### Customizing the OAuth HTTP Client + +The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery, +dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own, +not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server. +To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider: + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder }, +) +``` + +The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL, +and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection. +It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently; +today it runs once per authorization attempt, but that is not a promise. +A few constraints follow from the checks described below: + +- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send + to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError` + before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)` + that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off. +- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off. +- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request + and to turn a token endpoint error into `Flow::InvalidGrantError`. +- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives. +- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens, + and the access tokens in token responses; redact them before they reach a log. + +### Communication Security + +When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, +`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. + +The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through +a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, +whether the swap was triggered by +`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, +and whether the new URL is `http://` *or* `https://` to a different host or tenant. + +### Discovery URL Destinations + +The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations. +Both checks run before the request is sent, and neither is configurable. + +- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, + so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, + which does not require it. +- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be + IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). + The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`, + and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. +- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, + and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working. + +The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed, +such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one +the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly. + +On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request +goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)` +replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against +the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused. +A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself. + +The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, +measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. +Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through +`http_client_factory:` is bounded as well, so there is no way to opt out of it. + +### Reviewing the authorization request + +The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want +your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes +it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects, +so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on +MCP servers rather than on clients. + +`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so: + +```ruby +ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"] + +provider = MCP::Client::OAuth::Provider.new( + # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. + authorization_request_validator: ->(request) { + ALLOWED_ISSUERS.include?(request.authorization_server) + }, +) +``` + +The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither +the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification +can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide +(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes. + +`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized +(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one, +the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce +a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured. + +Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path, +and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising. + +The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has +any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it: + +```ruby +ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] } + +provider = MCP::Client::OAuth::Provider.new( + # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. + authorization_request_validator: ->(request) { + ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) } + }, +) +``` + +A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them. + +It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential, +or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened, +and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server +the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected. +Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it, +while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure. + +The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification +requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for, +which is what every MCP SDK does today. + +It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for. +An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to +present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is. +The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse. +Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization. diff --git a/docs/_client/cancellation.md b/docs/_client/cancellation.md new file mode 100644 index 0000000..2a9409c --- /dev/null +++ b/docs/_client/cancellation.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Cancellation +nav_order: 5 +--- + +# Cancellation + +`MCP::Client` lets the caller cancel a request it has already issued, +per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +The recommended pattern is to pass +an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call +`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to +the server, and the calling thread is woken up with `MCP::CancelledError`: + +```ruby +client = MCP::Client.new(transport: transport) +cancellation = MCP::Cancellation.new + +Thread.new do + client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) +rescue MCP::CancelledError + # cleanup +end + +# Later, from another thread: +cancellation.cancel(reason: "user pressed cancel") +``` + +All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`, +`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword. +Request ids are managed internally, so the token is the only thing a caller needs to cancel a request. + +{: .note } +> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed; +> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side +> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` +> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` +> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal +> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at +> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. + +{: .note } +> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight +> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests +> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still +> raises `MCP::CancelledError` - but the server runs the request to completion. + +## Wire-order guarantees + +`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, +so the server is guaranteed to read the request line before the cancel line. + +`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, +so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST +on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and +still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)), +and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST +happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. + +## Custom transports + +Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. +They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire +(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). +The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for +the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. + +## Server Side + +How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page. diff --git a/docs/_client/index.md b/docs/_client/index.md new file mode 100644 index 0000000..2e3077c --- /dev/null +++ b/docs/_client/index.md @@ -0,0 +1,47 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /client/ +redirect_from: + - /building-clients.html + - /building-clients/ +--- + +# Building an MCP Client + +The `MCP::Client` class provides an interface for interacting with MCP servers. + +This class supports: + +- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle + when the server serves it; see [Lifecycle](/client/lifecycle/) +- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery) +- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings; + see [Answering Server Pings](/client/ping/#answering-server-pings) +- Tool listing via the `tools/list` method (`MCP::Client#tools`) +- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) +- Resource listing via the `resources/list` method (`MCP::Client#resources`) +- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) +- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) +- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) +- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) +- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/) +- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`, + or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/) +- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/) +- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with + the `max_pages` guard; see [Pagination](/client/pagination/) +- Automatic JSON-RPC 2.0 message formatting +- UUID request ID generation + +Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics. +Authorization is handled by the transport layer; see [Authorization](/client/authorization/). + +## Tool Objects + +The client provides a wrapper class for tools returned by the server: + +- `MCP::Client::Tool` - Represents a single tool with its metadata + +This class provides easy access to tool properties like name, description, input schema, and output schema. diff --git a/docs/_client/lifecycle.md b/docs/_client/lifecycle.md new file mode 100644 index 0000000..632db50 --- /dev/null +++ b/docs/_client/lifecycle.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Lifecycle +nav_order: 3 +--- + +# Lifecycle + +Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake +on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28. +This page covers `MCP::Client#connect` and how it negotiates between the two. + +## Handshake + +Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +client.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +client.connected? # => true +client.server_info # => cached InitializeResult +``` + +`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. + +This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page. + +By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake +only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it. + +## Lifecycle Negotiation + +`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled +`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discover/) first and adopts +the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to +the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare +a `mode:` keyword always receive the classic call shape, unchanged. + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe +``` + +Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process) +and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on +the legacy lifecycle. + +Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result, +their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`, +top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`) +on modern. Code that should work against both lifecycles can use the era-independent readers instead: + +```ruby +client.protocol_version # negotiated or adopted version, either lifecycle +client.server_capabilities # capabilities Hash, either lifecycle +client.instructions # instructions text, either lifecycle +client.server_implementation # server name/version; nil when a modern server does not identify itself +``` + +Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded, +the server now serves the modern lifecycle and the automatic negotiation adopted it. +Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. + +## Explicit Discovery + +`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery +that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct +exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and +the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discover/) page +for the wire shapes. + +```ruby +result = client.discover +result.supported_versions # => ["2026-07-28"] +``` diff --git a/docs/_client/mrtr.md b/docs/_client/mrtr.md new file mode 100644 index 0000000..4a68e5f --- /dev/null +++ b/docs/_client/mrtr.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Multi Round-Trip Requests +nav_order: 4 +redirect_from: + - /client/multi-round-trip-results/ +--- + +# Multi Round-Trip Requests + +MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`, +or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map +and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. + +## Automatic Driving + +The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, +and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` +(10 by default, matching the TypeScript and Python SDKs). + +```ruby +client = MCP::Client.new(transport: transport) +client.connect(capabilities: { elicitation: { form: {} } }) + +client.on_elicitation do |params| + { action: "accept", content: { name: "Alice" } } +end + +# The input_required round trips are driven automatically; this returns the final result. +response = client.call_tool(name: "collect_name", arguments: {}) +``` + +Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds +the client declared. + +## Manual Driving + +Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; +the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords: + +```ruby +begin + client.call_tool(name: "collect_name", arguments: {}) +rescue MCP::Client::InputRequiredError => error + answers = error.input_requests.transform_values { |request| answer_for(request) } + + client.call_tool( + name: "collect_name", + arguments: {}, + input_responses: answers, + request_state: error.request_state, + ) +end +``` + +`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. +Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged. + +## Server Side + +Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment +shim that serves pre-2026 clients are documented on the server [Multi Round-Trip Requests](/server/mrtr/) page. diff --git a/docs/_client/pagination.md b/docs/_client/pagination.md new file mode 100644 index 0000000..1f89c02 --- /dev/null +++ b/docs/_client/pagination.md @@ -0,0 +1,80 @@ +--- +layout: default +title: Pagination +nav_order: 7 +--- + +# Pagination + +Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses +per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination). +Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +## Iterating Pages + +`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. +**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection. +The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor +as method accessors; a `meta` accessor also mirrors the response's `_meta` field: + +```ruby +client = MCP::Client.new(transport: transport) + +cursor = nil +loop do + page = client.list_tools(cursor: cursor) + page.tools.each { |tool| process(tool) } + cursor = page.next_cursor + break unless cursor +end +``` + +The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and +`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page. + +Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration: + +| Server `page_size` | `client.list_tools(cursor: nil)` | +|--------------------|---------------------------------------------------------------------| +| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. | +| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. | + +If your application needs the complete collection regardless of how the server is configured, either loop on +`next_cursor` as shown above, or use the whole-collection methods described below. + +## Fetching the Complete Collection + +`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate +through all pages and return a plain array of items, guaranteeing the full collection regardless +of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round +trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor` +it has already sent, and it stops after `max_pages` pages. + +```ruby +tools = client.tools # => Array of every tool on the server. +``` + +`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods +will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that +point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it +if you legitimately expect more pages than that. + +Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need +fine-grained iteration (e.g. to stream-process pages without loading everything into memory). + +## Cache Hints + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`) +and whether shared intermediaries may cache it (`cacheScope`); see +[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted. +On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: + +```ruby +page = client.list_tools +page.ttl_ms # => 60000 (nil when the server sent no hint) +page.cache_scope # => "private" +``` + +## Server Side + +Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page. diff --git a/docs/_client/ping.md b/docs/_client/ping.md new file mode 100644 index 0000000..f546afe --- /dev/null +++ b/docs/_client/ping.md @@ -0,0 +1,49 @@ +--- +layout: default +title: Ping +nav_order: 6 +--- + +# Ping + +The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping) +allows either side of the connection to verify that the peer is still responsive. + +{: .note } +> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of +> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself +> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle, +> so on a modern connection a conforming server (the Ruby SDK server included) rejects it +> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility. + +`MCP::Client` exposes `ping` to send a ping to the server: + +```ruby +client = MCP::Client.new(transport: transport) +client.ping # => {} on success +``` + +`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. +It raises `MCP::Client::ValidationError` when the response `result` is missing or +is not a Hash (matching the spec requirement that `result` be an object). +Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) +propagate as exceptions raised by the transport layer. + +## Answering Server Pings + +On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with +the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open +(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings. +Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when +the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is +dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:` +on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits. + +To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces +the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`, +as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page. + +## Server Side + +How servers answer `ping` requests and ping the client themselves is documented on +the server [Ping](/server/ping/) page. diff --git a/docs/_client/transports.md b/docs/_client/transports.md new file mode 100644 index 0000000..d69e5e9 --- /dev/null +++ b/docs/_client/transports.md @@ -0,0 +1,285 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with. +This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports, +their sessions and server-to-client request handling, and the interface a custom transport must implement. + +## Stdio Transport Layer + +Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. + +`MCP::Client::Stdio.new` accepts the following keyword arguments: + +| Parameter | Required | Description | +|---|---|---| +| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). | +| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | +| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | +| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | + +Example usage: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools. +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +# Call a specific tool. +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Close the transport when done. +stdio_transport.close +``` + +The stdio transport automatically handles: + +- Spawning the server process with `Open3.popen3` +- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) +- JSON-RPC 2.0 message framing over newline-delimited JSON +- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings) + +{: .note } +> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create` +> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer +> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route, +> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation` +> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/). + +## HTTP Transport Layer + +Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. + +You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser` +to read SSE (`text/event-stream`) responses. +Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both, +so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always +does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well. +`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to +answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode) +serving handshake-lifecycle clients, and only while no listening stream is opened: + +```ruby +gem "mcp" +gem "faraday", ">= 2.0" +gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses +``` + +Example usage: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") +client = MCP::Client.new(transport: http_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools +tools = client.tools +tools.each do |tool| + puts <<~TOOL_INFORMATION + Tool: #{tool.name} + Description: #{tool.description} + Input Schema: #{tool.input_schema} + TOOL_INFORMATION +end + +# Call a specific tool +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Call a tool with progress tracking. +response = client.call_tool( + tool: tools.first, + arguments: { count: 10 }, + progress_token: "my-progress-token" +) +``` + +The server sends `notifications/progress` during execution; the bundled transports do not currently +expose these notifications to application code, so the token's effect is visible on the server side. +See the [Progress](/server/progress/) page. + +`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server - +an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from +a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses. + +`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts, +and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread, +so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes). +The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead, +the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin +the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at. + +### Sessions + +After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: + +```ruby +http_transport.session_id # => "abc123..." +http_transport.protocol_version # => "2025-11-25" +``` + +If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. + +To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. + +```ruby +http_transport.close +``` + +These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is +no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead. + +### Server-to-Client Requests (Elicitation) + +Servers can send requests back to the client while one of the client's own requests is in flight - for example, +[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. +Register a handler and advertise the capability on `connect` to respond to them: + +```ruby +client.connect(capabilities: { elicitation: {} }) + +client.on_elicitation do |params| + { + action: "accept", + # Fill fields omitted by the user with the schema's `default` values (SEP-1034) + content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + } +end +``` + +Registering a handler opens a standalone HTTP GET SSE stream on a background thread +([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)), +since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with +a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with +`http_transport.on_server_request("method/name") { |params| ... }`. + +On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same +registered handlers instead drive the requests embedded in `input_required` results, +as documented on [Multi Round-Trip Requests](/client/mrtr/). + +### Server-to-Client Requests (Sampling) + +Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), +letting a server leverage the client's model access without its own API keys. + +{: .warning } +> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`. +> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; +> new servers should call LLM provider APIs directly. + +Register a handler and advertise the capability on `connect`: + +```ruby +client.connect(capabilities: { sampling: {} }) + +client.on_sampling do |params| + completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + { + role: "assistant", + content: { type: "text", text: completion.text }, + model: completion.model, + stopReason: "endTurn", + } +end +``` + +For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. +To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: + +```ruby +client.on_sampling do |params| + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + + generate_completion(params) +end +``` + +Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. + +### Custom Headers from Tool Parameters + +On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries +an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route +on the values without parsing bodies. The declarations are learned from `tools/list` responses: +list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values +(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`, +and a `null` or absent argument omits its header. + +Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names, +duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from +`tools/list` results on modern connections, with a warning naming the tool. +Legacy connections are unaffected: nothing is learned, mirrored, or excluded. + +### Customizing the Faraday Connection + +You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. +The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| + faraday.use MyApp::Middleware::HttpRecorder + faraday.adapter :typhoeus +end +``` + +The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through +the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client). + +{: .note } +> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside +> the SSE streaming callback of another response, re-entering the connection on the same thread. +> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter +> with persistent connections must tolerate that re-entry. + +## Custom Transports + +If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: + +```ruby +class CustomTransport + # Sends a JSON-RPC request to the server and returns the raw response. + # + # @param request [Hash] A complete JSON-RPC request object. + # https://www.jsonrpc.org/specification#request_object + # @return [Hash] A hash modeling a JSON-RPC response object. + # https://www.jsonrpc.org/specification#response_object + def send_request(request:) + # Your transport-specific logic here + # - HTTP: POST to endpoint with JSON body + # - WebSocket: Send message over WebSocket + # - stdio: Write to stdout, read from stdin + # - etc. + end +end + +client = MCP::Client.new(transport: CustomTransport.new) +``` + +Custom transports that need to support client-side cancellation have additional requirements; +see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..4f101a5 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,45 @@ +# Use package name as site title +title: "MCP Ruby SDK" +description: "The official Ruby SDK for Model Context Protocol servers and clients." +remote_theme: just-the-docs/just-the-docs +color_scheme: ruby-light + +plugins: + - jekyll-redirect-from + +# Sidebar sections: each collection renders as a plain category heading +# with its pages listed beneath it, instead of a clickable parent page. +collections: + server: + permalink: "/server/:name/" + output: true + client: + permalink: "/client/:name/" + output: true + extensions: + permalink: "/extensions/:name/" + output: true + +just_the_docs: + collections: + server: + name: Building Servers + client: + name: Building Clients + extensions: + name: Extensions + +# Search +search_enabled: true + +# Callouts +callouts: + note: + title: Note + color: blue + important: + title: Important + color: yellow + warning: + title: Warning + color: red diff --git a/docs/_extensions/capability-extensions.md b/docs/_extensions/capability-extensions.md new file mode 100644 index 0000000..ba3f0c0 --- /dev/null +++ b/docs/_extensions/capability-extensions.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Capability Extensions +nav_order: 2 +--- + +# Capability Extensions + +Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. +Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); +values are extension-defined configuration objects, with `{}` meaning "supported with no settings". + +On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_extensions("com.example/feature" => { enabled: true }) + +server = MCP::Server.new(name: "my_server", capabilities: capabilities) +``` + +The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are +readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports). + +On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result, +and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake. +Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations +with envelope-first resolution, on either lifecycle. + +On the client, pass extensions through `connect`: + +```ruby +client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) +``` diff --git a/docs/_extensions/index.md b/docs/_extensions/index.md new file mode 100644 index 0000000..5b52c0f --- /dev/null +++ b/docs/_extensions/index.md @@ -0,0 +1,17 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /extensions/ +--- + +# Extensions + +Extensions add optional functionality on top of the core MCP protocol. +Support for an extension is negotiated per connection: +clients and servers declare the extensions they speak under the `extensions` member of their capabilities, +as described on [Capability Extensions](/extensions/capability-extensions/). + +The extensions this SDK ships support for: + +- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results diff --git a/docs/_extensions/mcp-apps.md b/docs/_extensions/mcp-apps.md new file mode 100644 index 0000000..0c9c668 --- /dev/null +++ b/docs/_extensions/mcp-apps.md @@ -0,0 +1,48 @@ +--- +layout: default +title: MCP Apps +nav_order: 3 +--- + +# MCP Apps + +MCP Apps (SEP-1865) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) that lets a server ship interactive +HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention, +and `MCP::Apps` provides the vocabulary and helpers: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_resources +capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } } + +server = MCP::Server.new( + name: "weather_server", + capabilities: capabilities, + # UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type. + resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], +) + +server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] +end + +# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also +# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec). +server.define_tool( + name: "get_weather", + meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), +) do |server_context:| + # The extension is optional: always return a meaningful text result, and use + # `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content. + MCP::Apps.client_supports?(server_context.client_capabilities) # => true when the host declared the extension + MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) +end +``` + +`MCP::Apps.tool_meta` also accepts `visibility:` (an array of `"model"` / `"app"`) to restrict who sees the tool, +and merges non-destructively into caller-supplied `meta:`. + +Everything else the extension defines (the sandboxed iframe, the `ui/*` `postMessage` bridge, consent for UI-initiated actions) +is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). diff --git a/docs/_includes/footer_custom.html b/docs/_includes/footer_custom.html new file mode 100644 index 0000000..bfd1278 --- /dev/null +++ b/docs/_includes/footer_custom.html @@ -0,0 +1,48 @@ +{%- comment -%} + Previous/Next pager following the sidebar order: top-level pages by + nav_order, then the server, client, and extensions collections. +{%- endcomment -%} +{%- assign top_pages = site.html_pages | where_exp: "item", "item.nav_order" | sort: "nav_order" -%} +{%- assign server_docs = site.server | sort: "nav_order" -%} +{%- assign client_docs = site.client | sort: "nav_order" -%} +{%- assign extensions_docs = site.extensions | sort: "nav_order" -%} +{%- assign ordered_pages = top_pages | concat: server_docs | concat: client_docs | concat: extensions_docs -%} +{%- assign pager_state = "before" -%} +{%- assign prev_page = nil -%} +{%- assign next_page = nil -%} +{%- for item in ordered_pages -%} + {%- if pager_state == "current" -%} + {%- assign next_page = item -%} + {%- assign pager_state = "after" -%} + {%- elsif item.url == page.url -%} + {%- assign pager_state = "current" -%} + {%- elsif pager_state == "before" -%} + {%- assign prev_page = item -%} + {%- endif -%} +{%- endfor -%} +{%- if pager_state != "before" -%} + +{%- endif -%} diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html new file mode 100644 index 0000000..34a88e6 --- /dev/null +++ b/docs/_includes/head_custom.html @@ -0,0 +1,209 @@ + + + + + diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html new file mode 100644 index 0000000..8b2c640 --- /dev/null +++ b/docs/_includes/header_custom.html @@ -0,0 +1,8 @@ +
+ + + + +
diff --git a/docs/_includes/nav_footer_custom.html b/docs/_includes/nav_footer_custom.html new file mode 100644 index 0000000..f366cb0 --- /dev/null +++ b/docs/_includes/nav_footer_custom.html @@ -0,0 +1,14 @@ +{%- comment -%} + Replaces the theme's "This site uses Just the Docs" attribution, shown at + the bottom of the sidebar on desktop and in the page footer on mobile. + _data/versions.yml exists only on the deployed gh-pages tree, where + bin/generate-gh-pages.sh generates it; without it (local previews of + the docs/ source) only the license sentence renders. +{%- endcomment -%} +{% if site.data.versions %} +Documentation for mcp {{ site.data.versions | first }}, +licensed CC-BY-4.0. +{% else %} +Documentation licensed CC-BY-4.0. +{% endif %} +See LICENSE for the code. diff --git a/docs/_preview/taint_shim.rb b/docs/_preview/taint_shim.rb new file mode 100644 index 0000000..9d43470 --- /dev/null +++ b/docs/_preview/taint_shim.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Liquid 4.0.3 (pinned by github-pages) still calls the taint API that Ruby 3.2 removed. +# Restore it as a no-op for the local docs preview only; `rake docs:preview` loads +# this file via `RUBYOPT`, so nothing outside the preview process is affected. +class Object + def tainted? + false + end + + def taint + self + end + + def untaint + self + end +end diff --git a/docs/_sass/color_schemes/ruby-dark.scss b/docs/_sass/color_schemes/ruby-dark.scss new file mode 100644 index 0000000..5bd0ef1 --- /dev/null +++ b/docs/_sass/color_schemes/ruby-dark.scss @@ -0,0 +1,17 @@ +// Dark counterpart of ruby-light: the link color is lightened from +// the #b61d1d accent so it keeps WCAG AA contrast, and the theme's +// purple-tinted dark greys become neutral ones. +@import "./color_schemes/dark"; + +$link-color: #f0776c; +$btn-primary-color: #b61d1d; + +// The dark scheme derives $feedback-color before these overrides apply, +// so it is restated. +$body-background-color: #252525; +$sidebar-color: #252525; +$feedback-color: #1e1e1e; +$search-background-color: #202020; +$table-background-color: #202020; +$base-button-color: #202020; +$border-color: #3f3f3f; diff --git a/docs/_sass/color_schemes/ruby-light.scss b/docs/_sass/color_schemes/ruby-light.scss new file mode 100644 index 0000000..5da81c3 --- /dev/null +++ b/docs/_sass/color_schemes/ruby-light.scss @@ -0,0 +1,8 @@ +// The include imports the light scheme first, so only overrides live here. +$link-color: #990000; +$btn-primary-color: #b61d1d; +$sidebar-color: #fff; + +// The light scheme derives $feedback-color before these overrides apply, +// so it is restated (the hover shade on the white sidebar). +$feedback-color: #f2f2f2; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 0000000..7f89847 --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1,149 @@ +.docs-header-controls { + display: flex; + height: 3.75rem; + align-items: stretch; + justify-content: flex-end; + margin-left: auto; +} + +@media (min-width: 50rem) { + .docs-header-controls { + height: 100%; + padding-right: 1rem; + } +} + +.docs-icon-button.site-button { + width: 3.75rem; + justify-content: center; + padding: 0; + color: inherit; + background-color: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-decoration: none; +} + +.docs-icon { + display: block; + width: 1.25rem; + height: 1.25rem; + background-color: currentColor; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.docs-icon--github { + -webkit-mask-image: url("/assets/images/icons/github.svg"); + mask-image: url("/assets/images/icons/github.svg"); +} + +.docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-system.svg"); + mask-image: url("/assets/images/icons/theme-system.svg"); +} + +:root[data-theme-preference="light"] .docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-light.svg"); + mask-image: url("/assets/images/icons/theme-light.svg"); +} + +:root[data-theme-preference="dark"] .docs-icon--theme { + -webkit-mask-image: url("/assets/images/icons/theme-dark.svg"); + mask-image: url("/assets/images/icons/theme-dark.svg"); +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +/* + * Applied before the dark stylesheet finishes loading, so browsers that + * do not support blocking="render" avoid a light-colored first paint. + * Values match $body-background-color from the ruby-dark color scheme + * and $grey-lt-300 for the text. + */ +:root[data-theme="dark"] body { + background-color: #252525; + color: #e6e1e8; +} + +:root[data-theme="light"] { + color-scheme: light; +} + +/* + * Center the sidebar-plus-content block as one unit, splitting the leftover viewport equally + * on both sides. The theme instead widens the sidebar to absorb the whole left overflow, + * and its margin rule lives on `.side-bar + .main`, which a bare `.main` selector cannot override. + */ +$docs-content-width: 64rem; + +@media (min-width: 66.5rem) { + .side-bar { + /* Capitalized Max/Min pass through the Sass compiler as plain CSS. */ + left: Max(0px, calc((100% - #{$nav-width + $docs-content-width}) / 2)); + width: $nav-width; + } + + .side-bar + .main { + max-width: $docs-content-width; + margin-left: Max(#{$nav-width}, calc((100% - #{$nav-width + $docs-content-width}) / 2 + #{$nav-width})); + } + + /* Breathing room between the sidebar and the content. */ + .main-content-wrap { + padding-left: 3rem; + } + + .main-header { + padding-left: 1rem; + } +} + +/* Previous/Next pager in the page footer. */ +.docs-pager { + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.docs-pager-link { + display: flex; + flex-direction: column; + max-width: 48%; + text-decoration: none; +} + +.docs-pager-next { + margin-left: auto; + text-align: right; +} + +.docs-pager-direction { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $body-text-color; + opacity: 0.6; +} + +.docs-pager-title { + font-weight: 600; +} + +/* + * Category headings read as section labels, not separators: no underline, + * spaced apart from the preceding group and letter-spaced instead. + */ +.nav-category { + margin-top: 1.5rem; + letter-spacing: 0.08em; + border-bottom: none; + opacity: 0.65; +} diff --git a/docs/_sass/custom/setup.scss b/docs/_sass/custom/setup.scss new file mode 100644 index 0000000..b34691c --- /dev/null +++ b/docs/_sass/custom/setup.scss @@ -0,0 +1,2 @@ +// Widen the sidebar from the theme's 16.5rem default. +$nav-width: 19rem; diff --git a/docs/_server/cancellation.md b/docs/_server/cancellation.md new file mode 100644 index 0000000..1bc0b17 --- /dev/null +++ b/docs/_server/cancellation.md @@ -0,0 +1,137 @@ +--- +layout: default +title: Cancellation +nav_order: 13 +--- + +# Cancellation + +The MCP Ruby SDK supports server-side handling of +the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +When a client sends `notifications/cancelled` for an in-flight request, the server stops +processing cooperatively and suppresses the JSON-RPC response for that request. + +Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead, +a `MCP::Cancellation` token is threaded through [`server_context`](/server/server-context/), and long-running tools +poll it to exit early. When a tool returns after cancellation has been observed, +the server suppresses the JSON-RPC response, matching the spec. The `initialize` request +is never cancellable per the spec. + +{: .note } +> Cancellation by notification belongs to the handshake lifecycle, where the session correlates +> `notifications/cancelled` with the in-flight request it targets. Requests of the +> [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are sessionless single POST exchanges, so a separately +> POSTed cancel notification cannot reach them; a modern client abandons a request by closing +> the connection instead. + +## Handlers that Check for Cancellation + +Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, +`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`, +`resources_unsubscribe_handler`, and [`define_custom_method`](/server/custom-methods/) blocks - receives +an `MCP::ServerContext` wired to the in-flight request's cancellation token. +Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise +`MCP::CancelledError` at a safe point: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that supports cancellation" + input_schema(properties: { count: { type: "integer" } }, required: ["count"]) + + def self.call(count:, server_context:) + count.times do |i| + # Exit early if the client has sent `notifications/cancelled`. + break if server_context.cancelled? + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +Alternatively, raise at the next safe point with `raise_if_cancelled!`: + +```ruby +def self.call(count:, server_context:) + count.times do |i| + server_context.raise_if_cancelled! + + do_work(i) + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) +end +``` + +When a handler observes cancellation (either by returning early with `cancelled?` or +by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and +no JSON-RPC result is sent to the client. + +The same pattern works for other handler types: + +```ruby +# resources/read +server.resources_read_handler do |params, server_context:| + server_context.raise_if_cancelled! + # read the resource +end + +# completion/complete +server.completion_handler do |params, server_context:| + server_context.raise_if_cancelled! + # compute completions +end + +# custom method +server.define_custom_method(method_name: "custom/slow") do |params, server_context:| + server_context.raise_if_cancelled! + # do work +end + +# prompts (via Prompt subclass) +class SlowPrompt < MCP::Prompt + prompt_name "slow_prompt" + + def self.template(args, server_context:) + server_context.raise_if_cancelled! + MCP::Prompt::Result.new(messages: []) + end +end +``` + +Handlers that do not declare a `server_context:` keyword continue to work unchanged - +the opt-in detection only wraps the context when the block signature asks for it. + +## Nested Server-to-Client Requests Are Cancelled Automatically + +When a tool handler is waiting on a nested server-to-client request +(`server_context.create_sampling_message`, `create_form_elicitation`, or +`create_url_elicitation`), cancelling the parent tool call automatically raises +`MCP::CancelledError` from the nested call, so the tool does not need to wrap it +in its own `cancelled?` checks: + +```ruby +def self.call(server_context:) + result = server_context.create_sampling_message(messages: messages, max_tokens: 100) + # If the parent tools/call is cancelled while waiting above, MCP::CancelledError + # is raised here and the tool can let it propagate or clean up as needed. + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) +rescue MCP::CancelledError + # Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server + # will still suppress the JSON-RPC response per the MCP spec. + raise +end +``` + +Nested cancellation propagation is supported on `StreamableHTTPTransport` only. +`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested +`server_context.create_sampling_message` inside a tool runs to completion even if +the parent `tools/call` is cancelled. The parent tool itself still observes cancellation +via `server_context.cancelled?` between nested calls. + +## Client Side + +Cancelling a request the client has issued (the `cancellation:` keyword, wire-order guarantees, +and custom transport requirements) is documented on the client [Cancellation](/client/cancellation/) page. diff --git a/docs/_server/completion.md b/docs/_server/completion.md new file mode 100644 index 0000000..583a4bc --- /dev/null +++ b/docs/_server/completion.md @@ -0,0 +1,54 @@ +--- +layout: default +title: Completion +nav_order: 16 +redirect_from: + - /server/completions/ +--- + +# Completion + +MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), +which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. + +To enable completions, declare the `completions` capability and register a handler: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [CodeReviewPrompt], + resource_templates: [FileTemplate], + capabilities: { completions: {} }, +) + +server.completion_handler do |params| + ref = params[:ref] + argument = params[:argument] + value = argument[:value] + + case ref[:type] + when "ref/prompt" + values = case argument[:name] + when "language" + ["python", "pytorch", "pyside"].select { |v| v.start_with?(value) } + else + [] + end + { completion: { values: values, hasMore: false } } + when "ref/resource" + { completion: { values: [], hasMore: false } } + end +end +``` + +The handler receives a `params` hash with: + +- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`) +- `argument` - The argument being completed (`{ name: "...", value: "..." }`) +- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`) + +The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`. +The SDK automatically enforces the 100-item limit per the MCP specification. + +The server validates that the referenced prompt, resource, or resource template is registered before calling the handler. +Requests for unknown references return an error. diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md new file mode 100644 index 0000000..9480df8 --- /dev/null +++ b/docs/_server/configuration.md @@ -0,0 +1,206 @@ +--- +layout: default +title: Configuration +nav_order: 20 +--- + +# Configuration + +The gem can be configured using the `MCP.configure` block: + +```ruby +MCP.configure do |config| + config.exception_reporter = ->(exception, server_context) { + # Your exception reporting logic here + # For example with Bugsnag: + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, server_context) + end + } + + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") + } +end +``` + +or by creating an explicit configuration and passing it into the server. +This is useful for systems where an application hosts more than one MCP server but +they might require different configurations. + +```ruby +configuration = MCP::Configuration.new +configuration.exception_reporter = ->(exception, server_context) { + # Your exception reporting logic here + # For example with Bugsnag: + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, server_context) + end +} + +configuration.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") +} + +server = MCP::Server.new( + # ... all other options + configuration:, +) +``` + +## Exception Reporter + +The exception reporter receives two arguments: + +- `exception`: The Ruby exception object that was raised +- `server_context`: A hash containing contextual information about where the error occurred. + This is not the user-defined [`server_context`](/server/server-context/) passed to `Server.new`. + +The `server_context` hash includes: + +- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) +- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) + +**Signature:** + +```ruby +exception_reporter = ->(exception, server_context) { ... } +``` + +When an exception occurs: + +1. The exception is reported via the configured reporter +2. The client receives a generic JSON-RPC error response (for example, "Internal error calling tool " + for a tool call); the exception's own message is deliberately withheld from clients + +If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. + +## Around Request + +The `around_request` hook wraps request handling, allowing you to execute code before and after each request. +This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. + +The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: + +**Signature:** + +```ruby +around_request = ->(data, &request_handler) { request_handler.call } +``` + +**`data` availability by timing:** + +- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled +- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` +- Not available inside `around_request`: `duration` (added after `around_request` returns) + +{: .note } +> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods +> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled +> (for example, `prompt_name` is not recorded when the prompt is not found). +> `duration` is added after `around_request` returns, so it is not visible from within the hook. + +**Example:** + +```ruby +MCP.configure do |config| + config.around_request = ->(data, &request_handler) { + logger.info("Start: #{data[:method]}") + request_handler.call + logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") + } +end +``` + +### Exposing the User-Defined `server_context` + +`data` omits the user-defined `server_context` by default, because that hash is +application-supplied and may hold values a tracing backend should not receive. +Enable it when you need to tag spans with the request's subject: + +```ruby +MCP.configure do |config| + config.instrument_server_context = true + + config.around_request = ->(data, &request_handler) { + Sentry.set_user(id: data.dig(:server_context, :user_id)) + request_handler.call + } +end +``` + +`data[:server_context]` is the hash passed to `Server.new` - `nil` when the host +set none. It is not the exception reporter's context argument, which describes +where a failure occurred rather than who made the request. + +## Instrumentation Callback (soft-deprecated) + +{: .note } +> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. +> +> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: +> +> ```ruby +> # Before +> config.instrumentation_callback = ->(data) { log(data) } +> +> # After +> config.around_request = ->(data, &request_handler) do +> request_handler.call +> ensure +> log(data) +> end +> ``` +> +> Note that `data[:duration]` is not available inside `around_request`. +> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. + +The instrumentation callback is called after each request finishes, whether successfully or with an error. +It receives a hash with the following possible keys: + +- `method`: (String) The protocol method called (e.g., "ping", "tools/list") +- `tool_name`: (String, optional) The name of the tool called +- `tool_arguments`: (Hash, optional) The arguments passed to the tool +- `prompt_name`: (String, optional) The name of the prompt called +- `resource_uri`: (String, optional) The URI of the resource called +- `error`: (String, optional) Error code if a lookup failed +- `duration`: (Float) Duration of the call in seconds +- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request +- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when + `instrument_server_context` is enabled + +**Signature:** + +```ruby +instrumentation_callback = ->(data) { ... } +``` + +## Server Protocol Version + +The server's protocol version can be overridden using the `protocol_version` keyword argument: + +```ruby +configuration = MCP::Configuration.new(protocol_version: "2024-11-05") +MCP::Server.new(name: "test_server", configuration: configuration) +``` + +If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default. + +This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: + +```ruby +MCP::Configuration.new(protocol_version: nil) +``` + +If an invalid `protocol_version` value is set, an `ArgumentError` is raised. + +The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model, +`2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`; +a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs. +Clients reach `2026-07-28` through [`server/discover`](/server/discover/) and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed. + +Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md new file mode 100644 index 0000000..85dc87a --- /dev/null +++ b/docs/_server/custom-methods.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Custom Methods +nav_order: 21 +--- + +# Custom Methods + +The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Define a custom method that returns a result +server.define_custom_method(method_name: "add") do |params| + params[:a] + params[:b] +end + +# Define a custom notification method (returns nil) +server.define_custom_method(method_name: "notify") do |params| + # Process notification + nil +end +``` + +**Key Features:** + +- Accepts any method name as a string +- Block receives the request parameters as a hash +- Can handle both regular methods (with responses) and notifications +- Prevents overriding existing MCP protocol methods +- Supports instrumentation callbacks for monitoring +- Blocks may opt in to a [`server_context:`](/server/server-context/) keyword like the built-in handlers + (see [Cancellation](/server/cancellation/) for an example) + +**Usage Example:** + +The wire exchange for the custom `add` method defined above. The client sends: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "add", + "params": { "a": 5, "b": 3 } +} +``` + +The server responds: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": 8 +} +``` + +**Error Handling:** + +- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method +- Supports the same [exception reporting and instrumentation](/server/configuration/) as standard methods diff --git a/docs/_server/discover.md b/docs/_server/discover.md new file mode 100644 index 0000000..20f8bd6 --- /dev/null +++ b/docs/_server/discover.md @@ -0,0 +1,63 @@ +--- +layout: default +title: Discovery +nav_order: 3 +redirect_from: + - /server/discovery/ +--- + +# Discovery + +`server/discover` is the sessionless capability discovery method of MCP 2026-07-28 (SEP-2575). +It responds before `initialize` and without an `Mcp-Session-Id`, so a client learns what a server +offers in a single exchange, without creating a session. + +## The Discovery Result + +The result carries the modern `supportedVersions`, `capabilities`, and `instructions`, together with +the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional +`io.modelcontextprotocol/serverInfo` stamp in the result `_meta`. + +The request takes no parameters: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "server/discover" } +``` + +The server answers with the full discovery result: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "supportedVersions": ["2026-07-28"], + "capabilities": { "tools": { "listChanged": true } }, + "instructions": "Use the tools of this server as a last resort", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "my_server", "version": "1.0.0" } }, + "ttlMs": 0, + "cacheScope": "private", + "resultType": "complete" + } +} +``` + +## The Stateless Modern Lifecycle + +Beyond answering `server/discover`, the server serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope +(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, +and the Streamable HTTP transport serves them on a sessionless single-exchange path. +Calling `server/discover` first is not required: the envelope alone selects the modern lifecycle for a request, +while a client performing the classic `initialize` handshake is served on the handshake lifecycle - +the lifecycle is a per-request property, not a server-wide mode. + +The bundled transports serve `server/discover` and the per-request `_meta` envelope alongside the `initialize` handshake +with no configuration needed. The `protocol_version` pin scopes the handshake only +and does not affect the modern lifecycle; see [Configuration](/server/configuration/) for details. + +## Client Side + +On the client, `MCP::Client#connect` negotiates the lifecycle automatically by default +(probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips +the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` +exposes the raw discovery result. See the client-side [Lifecycle](/client/lifecycle/) page for details. diff --git a/docs/_server/elicitation.md b/docs/_server/elicitation.md new file mode 100644 index 0000000..4eea52b --- /dev/null +++ b/docs/_server/elicitation.md @@ -0,0 +1,231 @@ +--- +layout: default +title: Elicitation +nav_order: 9 +--- + +# Elicitation + +The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/latest/client/elicitation), +which allows servers to request additional information from users through the client during tool execution. + +Elicitation is a **server-to-client request**. The server sends a request and blocks until the user responds via the client. + +{: .note } +> Unlike [roots](/server/roots/) and [sampling](/server/sampling/), elicitation carries no SEP-2577 deprecation +> and remains fully available. On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28), which forbids server-initiated requests, +> an `elicitation/create` request is embedded in an `input_required` result instead; +> see [Multi Round-Trip Requests](/server/mrtr/). + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +## Capabilities + +Clients must declare the `elicitation` capability during initialization. The server checks this before sending any elicitation request +and raises a `RuntimeError` if the client does not support it. + +For URL mode support, the client must also declare `elicitation.url` capability. + +## Using Elicitation in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_form_elicitation` on it: + +```ruby +server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your name", + requested_schema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }]) +end +``` + +## Form Mode + +Form mode collects structured data from the user directly through the MCP client: + +```ruby +server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:| + result = server_context.create_form_elicitation( + message: "Please provide your contact information", + requested_schema: { + type: "object", + properties: { + name: { type: "string", description: "Your full name" }, + email: { type: "string", format: "email", description: "Your email address" }, + }, + required: ["name", "email"], + }, + ) + + text = case result[:action] + when "accept" + "Hello, #{result[:content][:name]} (#{result[:content][:email]})" + when "decline" + "User declined" + when "cancel" + "User cancelled" + end + + MCP::Tool::Response.new([{ type: "text", text: text }]) +end +``` + +The `requested_schema` must be a flat object schema: a top-level `type: "object"` whose `properties` are limited to +primitive types (`string`, `number`, `integer`, `boolean`). Nested objects and arrays are not allowed, which keeps +the schema simple enough for clients to render as a form. Per the MCP specification, the client validates +the user's input against this schema before returning it, so the `content` of an `accept` response matches the requested shape. + +## Default Values and Enums + +Properties may declare a `default` value (SEP-1034), which clients use to pre-fill the form. +String properties may declare `enum` values, optionally with human-readable `enumNames` (SEP-1330), which clients render as a choice list: + +```ruby +server.define_tool(name: "configure_deploy", description: "Configure a deployment") do |server_context:| + result = server_context.create_form_elicitation( + message: "Configure the deployment", + requested_schema: { + type: "object", + properties: { + replicas: { type: "integer", default: 3 }, + verbose: { type: "boolean", default: false }, + environment: { + type: "string", + enum: ["dev", "staging", "prod"], + enumNames: ["Development", "Staging", "Production"], + default: "dev", + }, + }, + required: ["environment"], + }, + ) + + MCP::Tool::Response.new([{ type: "text", text: "Deploying to #{result[:content][:environment]}" }]) +end +``` + +## Enum Schemas + +For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per +[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) instead of building +the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select, +plus the legacy `enumNames` form retained for backward compatibility: + +```ruby +size_schema = MCP::Elicitation::EnumSchema.titled_single_select( + options: [ + { value: "s", title: "Small" }, + { value: "m", title: "Medium" }, + { value: "l", title: "Large" }, + ], + default: "m", +) + +tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select( + values: ["urgent", "billing", "feedback"], +) + +result = server_context.create_form_elicitation( + message: "Tell us about your order", + requested_schema: { + type: "object", + properties: { + size: size_schema.to_h, + tags: tags_schema.to_h, + }, + required: ["size"], + }, +) +``` + +The available builders are `untitled_single_select`, `titled_single_select`, `untitled_multi_select`, `titled_multi_select`, +and `legacy_titled`. Each accepts optional `default:`, `title:`, and `description:`. + +The same builders produce the `requestedSchema` of an `elicitation/create` request embedded in a SEP-2322 `input_required` result, +which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle: + +```ruby +MCP::Server::InputRequiredResult.new( + input_requests: { + "size" => { + method: "elicitation/create", + params: { + message: "Pick a size", + requestedSchema: { + type: "object", + properties: { size: size_schema.to_h }, + required: ["size"], + }, + }, + }, + }, +) +``` + +## URL Mode + +URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: + +```ruby +server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:| + elicitation_id = SecureRandom.uuid + + result = server_context.create_url_elicitation( + message: "Please authorize access to your GitHub account", + url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}", + elicitation_id: elicitation_id, + ) + + server_context.notify_elicitation_complete(elicitation_id: elicitation_id) + + MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }]) +end +``` + +## URLElicitationRequiredError + +When a tool cannot proceed until an out-of-band elicitation is completed, raise `MCP::Server::URLElicitationRequiredError`. +This returns a JSON-RPC error with code `-32042` to the client: + +```ruby +server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:| + raise MCP::Server::URLElicitationRequiredError.new([ + { + mode: "url", + elicitationId: SecureRandom.uuid, + url: "https://example.com/oauth/authorize", + message: "GitHub authorization is required.", + }, + ]) +end +``` + +## Timeouts + +Every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely. +`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells +the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`, +which is the knob to reach for when a prompt legitimately waits on a person: + +```ruby +server_context.create_form_elicitation( + message: "Approve this deployment?", + requested_schema: { type: "object", properties: { approved: { type: "boolean" } } }, + timeout: 3600, # This one waits up to an hour. +) +``` + +`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering +surfaces as end-of-file rather than as a wait that never ends. + +The same timeout applies to [roots](/server/roots/) and [sampling](/server/sampling/) requests. diff --git a/docs/_server/index.md b/docs/_server/index.md new file mode 100644 index 0000000..5d938f1 --- /dev/null +++ b/docs/_server/index.md @@ -0,0 +1,53 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /server/ +redirect_from: + - /building-servers.html + - /building-servers/ +--- + +# Building an MCP Server + +The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. +It implements the Model Context Protocol specification, handling model context requests and responses. + +## Key Features + +- Implements JSON-RPC 2.0 message handling +- Supports protocol initialization and capability negotiation +- Manages tool registration and invocation +- Supports prompt registration and execution +- Supports resource registration and retrieval +- Supports stdio and Streamable HTTP (including SSE) transports +- Supports notifications for list changes (tools, prompts, resources) +- Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28) +- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28) +- Supports cursor-based pagination for list operations +- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) + +## Supported Methods + +- `initialize` - Initializes the protocol and returns server capabilities +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the server's capabilities + before `initialize` and without an `Mcp-Session-Id`, and anchors the stateless modern lifecycle; see [Discovery](/server/discover/) +- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET + listening stream; see [Subscriptions](/server/subscriptions/) +- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask + the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/) +- `ping` - Simple health check +- `logging/setLevel` - Configures the minimum log level for the server (deprecated as of 2026-07-28) +- `tools/list` - Lists all registered tools and their schemas +- `tools/call` - Invokes a specific tool with provided arguments +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves a specific prompt by name +- `resources/list` - Lists all registered resources and their schemas +- `resources/read` - Retrieves a specific resource by name +- `resources/templates/list` - Lists all registered resource templates and their schemas +- `resources/subscribe` - Subscribes to updates for a specific resource +- `resources/unsubscribe` - Unsubscribes from updates for a specific resource +- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `roots/list` - Requests filesystem roots from the client (server-to-client; deprecated as of 2026-07-28) +- `sampling/createMessage` - Requests LLM completion from the client (server-to-client; deprecated as of 2026-07-28) +- `elicitation/create` - Requests user input from the client (server-to-client) diff --git a/docs/_server/logging.md b/docs/_server/logging.md new file mode 100644 index 0000000..b861628 --- /dev/null +++ b/docs/_server/logging.md @@ -0,0 +1,90 @@ +--- +layout: default +title: Logging +nav_order: 17 +--- + +# Logging + +The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +The `notifications/message` notification is used for structured logging between client and server. + +{: .warning } +> MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Use stderr or OpenTelemetry for new servers. + +{: .note } +> On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), where `logging/setLevel` does not exist, the level +> comes per request from the `io.modelcontextprotocol/logLevel` `_meta` member: it authorizes +> `notifications/message` for that request only, delivered on the request's own response stream. +> A request without the member (or with an unrecognized level) receives no log messages. + +## Log Levels + +The SDK supports 8 log levels with increasing severity: + +- `debug` - Detailed debugging information +- `info` - General informational messages +- `notice` - Normal but significant events +- `warning` - Warning conditions +- `error` - Error conditions +- `critical` - Critical conditions +- `alert` - Action must be taken immediately +- `emergency` - System is unusable + +## How Logging Works + +1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level +2. **Server Filtering**: The server only sends log messages at the configured level or higher severity +3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client + +For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. + +For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +**Usage Example:** + +The client first configures the level with a `logging/setLevel` request: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": { "level": "info" } } +``` + +The server then emits messages at each severity; only those at or above the configured level are delivered: + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) + +server.notify_log_message( + data: { message: "Application started successfully" }, + level: "info" +) + +server.notify_log_message( + data: { message: "Configuration file not found, using defaults" }, + level: "warning" +) + +server.notify_log_message( + data: { + error: "Database connection failed", + details: { host: "localhost", port: 5432 } + }, + level: "error", + logger: "DatabaseLogger" # Optional logger name +) +``` + +**Key Features:** + +- Server has capability `logging` to send log messages +- Messages are only sent if a transport is configured +- Messages are filtered based on the client's configured log level +- If the log level hasn't been set by the client, no messages will be sent + +## Transport Support + +- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout +- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) diff --git a/docs/_server/mrtr.md b/docs/_server/mrtr.md new file mode 100644 index 0000000..3113bdf --- /dev/null +++ b/docs/_server/mrtr.md @@ -0,0 +1,87 @@ +--- +layout: default +title: Multi Round-Trip Requests +nav_order: 10 +redirect_from: + - /server/multi-round-trip-results/ +--- + +# Multi Round-Trip Requests + +The [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) forbids server-initiated requests. Instead, per SEP-2322, a `tools/call`, `prompts/get`, or `resources/read` handler that +opts in to [`server_context:`](/server/server-context/) may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for +additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes): + +```ruby +class GreetingTool < MCP::Tool + description "Greets the user by name" + + def self.call(server_context:, **_args) + response = server_context.input_response("user_name") + + unless response + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{response.dig(:content, :name)}!" }]) + end +end +``` + +## Deterministic Replay + +On the retried request the handler re-runs from the start and reads the answers via +`server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` +(deterministic replay; the server holds no memory between rounds). The server returns `-32021` +when an embedded request needs a client capability the request did not declare. + +## Securing `requestState` + +The echoed `requestState` arrives as client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via +`Server.new(request_state_security:)` to have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, +all transparently to handlers. Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses +the wire exactly as the handler wrote it and protecting it is the handler author's responsibility. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: [GreetingTool], + request_state_security: MCP::Server::RequestStateSecurity.new(key: ENV.fetch("MCP_REQUEST_STATE_KEY")), +) +``` + +## `resultType` Stamping + +SEP-2322 also makes `resultType` a required member of every result a 2026-07-28 server returns. The server stamps `resultType: "complete"` +on all results of requests carrying the modern `_meta` envelope (and on `server/discover` results), while results that already carry +a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it. Legacy results stay unstamped, and clients treat an absent +`resultType` as `"complete"` per the spec. + +## Legacy Clients + +Handlers written in the 2026 style serve pre-2026 clients too: when a `tools/call`, `prompts/get`, or `resources/read` handler returns +an `InputRequiredResult` on the legacy wire, the server fulfills it in place of the client's driver. Each `inputRequests` entry is sent +as the equivalent real server-to-client request (`elicitation/create`, `sampling/createMessage`, `roots/list`), associated with +the originating request per SEP-2260; the answers are collected under the same keys, and the handler re-runs with +`server_context.input_responses` populated and the raw `requestState` echoed, the same deterministic replay contract +the modern client driver follows. The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; +`MCP::Server.new(input_required_legacy_shim: false)` restores the strict rejection of `input_required` results on legacy requests. + +## Client Side + +`call_tool`, `get_prompt`, and `read_resource` drive `input_required` results automatically once the matching handlers are registered; +see the client [Multi Round-Trip Requests](/client/mrtr/) page. diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md new file mode 100644 index 0000000..7cf9460 --- /dev/null +++ b/docs/_server/notifications.md @@ -0,0 +1,68 @@ +--- +layout: default +title: Notifications +nav_order: 11 +--- + +# Notifications + +The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. + +## Notification Methods + +The server provides the following notification methods: + +- `notify_tools_list_changed` - Send a notification when the tools list changes +- `notify_prompts_list_changed` - Send a notification when the prompts list changes +- `notify_resources_list_changed` - Send a notification when the resources list changes +- `notify_log_message` - Send a structured logging notification message (see [Logging](/server/logging/)) + +## Session Scoping + +When using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows: + +- **`report_progress`** and **`notify_log_message`** called via [`server_context`](/server/server-context/) inside a tool handler are automatically sent only to the requesting client. +No extra configuration is needed. +- **`notify_tools_list_changed`**, **`notify_prompts_list_changed`**, and **`notify_resources_list_changed`** are always broadcast to all connected clients, +as they represent server-wide state changes. These should be called on the `server` instance directly. + +On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; +see [Subscriptions](/server/subscriptions/). + +## Notification Format + +Notifications follow the JSON-RPC 2.0 specification and use these method names: + +- `notifications/tools/list_changed` +- `notifications/prompts/list_changed` +- `notifications/resources/list_changed` +- `notifications/cancelled` (see [Cancellation](/server/cancellation/)) +- `notifications/progress` (see [Progress](/server/progress/)) +- `notifications/message` (see [Logging](/server/logging/)) + +## Broadcasting a List Change + +Call the matching `notify_*` method after changing a server-wide list: + +```ruby +server = MCP::Server.new(name: "my_server") + +# Default Streamable HTTP - session oriented +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +# When tools change, notify clients +server.define_tool(name: "new_tool") { |**args| MCP::Tool::Response.new([{ type: "text", text: "ok" }]) } +server.notify_tools_list_changed + +# When prompts change, notify clients +server.define_prompt(name: "new_prompt") do |args, server_context:| + MCP::Prompt::Result.new(messages: []) +end +server.notify_prompts_list_changed + +# When resources change, notify clients +server.define_resource(uri: "resource://new", name: "new_resource", mime_type: "text/plain") do + [MCP::Resource::TextContents.new(uri: "resource://new", mime_type: "text/plain", text: "contents")] +end +server.notify_resources_list_changed +``` diff --git a/docs/_server/pagination.md b/docs/_server/pagination.md new file mode 100644 index 0000000..c721f18 --- /dev/null +++ b/docs/_server/pagination.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Pagination +nav_order: 18 +--- + +# Pagination + +The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination) +for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, +treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`, +including lists produced by a custom `resources_list_handler`. + +## Enabling Pagination + +Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), +list responses contain all items in a single response, preserving the pre-pagination behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + page_size: 50, +) +``` + +When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { "name": "example_tool" } + ], + "nextCursor": "50" + } +} +``` + +Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. + +## List Result Caching + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; +`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). + +Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "private"`, +the scope that keeps a potentially user-dependent result out of shared caches). +When neither is set, responses are serialized exactly as before. +The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern `_meta` envelope +the server always emits them, filling unset values with the same defaults; legacy protocol versions keep the opt-in behavior. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: tools, + ttl_ms: 60_000, # results stay fresh for one minute + cache_scope: "private", # only the requesting client may cache them +) +``` + +A `resources_read_handler` can override the hints per result by returning a full result hash instead of bare contents: + +```ruby +server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } +end +``` + +## Client Side + +Iterating pages, fetching whole collections with the `max_pages` guard, and reading the cache hints +on the result structs are documented on the client [Pagination](/client/pagination/) page. diff --git a/docs/_server/ping.md b/docs/_server/ping.md new file mode 100644 index 0000000..d8c6627 --- /dev/null +++ b/docs/_server/ping.md @@ -0,0 +1,52 @@ +--- +layout: default +title: Ping +nav_order: 15 +--- + +# Ping + +The MCP Ruby SDK supports +the [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), +which allows either side of the connection to verify that the peer is still responsive. +A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. + +{: .note } +> `ping` belongs to the handshake lifecycle: MCP 2026-07-28 removes the method altogether (SEP-2575), +> since requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are single POST exchanges whose connection +> itself signals liveness, leaving nothing to probe between requests. The server answers `ping` on +> the handshake lifecycle only - a modern request naming it is rejected with `-32601` Method not found - +> and calling `ping` on a `server_context` while serving a modern request raises an error. The long-lived +> [`subscriptions/listen`](/server/subscriptions/) stream is kept alive by SSE keepalive +> frames instead. + +Servers respond to incoming `ping` requests automatically - no setup is required. +Any `MCP::Server` instance replies with an empty result. + +Servers can also send `ping` requests to the client via `ServerSession#ping`. +`ping` is exempt from the SEP-2260 association requirement, so it may also be sent outside a handler. +Inside a tool handler that receives [`server_context:`](/server/server-context/), call `ping` on it: + +```ruby +class HealthCheckTool < MCP::Tool + description "Verifies the client is still responsive" + + def self.call(server_context:) + server_context.ping # => {} on success + + MCP::Tool::Response.new([{ type: "text", text: "client is alive" }]) + end +end +``` + +`#ping` raises `MCP::Server::ValidationError` when the client returns a `result` +that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error) +propagate as exceptions raised by the transport layer. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Client Side + +Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page. +The Ruby client answers the pings sent with `ServerSession#ping` automatically; +see [Answering Server Pings](/client/ping/#answering-server-pings). diff --git a/docs/_server/progress.md b/docs/_server/progress.md new file mode 100644 index 0000000..f82645b --- /dev/null +++ b/docs/_server/progress.md @@ -0,0 +1,64 @@ +--- +layout: default +title: Progress +nav_order: 14 +--- + +# Progress + +The MCP Ruby SDK supports progress tracking for long-running tool operations, +following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). + +## How Progress Works + +1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool +2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution +3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress + +## Reporting Progress from Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `report_progress` on it. +The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: + +```ruby +class LongRunningTool < MCP::Tool + description "A tool that reports progress during execution" + input_schema( + properties: { + count: { type: "integer" }, + }, + required: ["count"] + ) + + def self.call(count:, server_context:) + count.times do |i| + # Do work here. + server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}") + end + + MCP::Tool::Response.new([{ type: "text", text: "Done" }]) + end +end +``` + +The `server_context.report_progress` method accepts: + +- `progress` (required) - current progress value (numeric) +- `total:` (optional) - total expected value, so clients can display a percentage +- `message:` (optional) - human-readable status message + +`report_progress` is a no-op when the request carried no `progressToken`, and both numeric and +string tokens are supported. + +{: .note } +> On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), progress notifications emitted during a request ride +> the request's own SSE response stream. The bundled transport buffers them and flushes after +> the handler returns, so they preserve order but arrive together with the final response rather +> than in real time. + +## Client Side + +Requesting progress is the client's side of the contract: pass `progress_token:` to `MCP::Client#call_tool` +and the token is sent as `_meta.progressToken`, as shown on the client [Transports](/client/transports/) page. +The bundled client transports do not currently expose a callback for observing the incoming `notifications/progress` messages; +the token's effect is visible on the server side only. diff --git a/docs/_server/prompts.md b/docs/_server/prompts.md new file mode 100644 index 0000000..e8f5a48 --- /dev/null +++ b/docs/_server/prompts.md @@ -0,0 +1,194 @@ +--- +layout: default +title: Prompts +nav_order: 5 +--- + +# Prompts + +MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. + +## Defining Prompts + +The `MCP::Prompt` class provides three ways to create prompts. + +### 1. As a class definition with metadata + +Subclass `MCP::Prompt` and declare the metadata with class-level helpers; `template` builds the result: + +```ruby +class MyPrompt < MCP::Prompt + prompt_name "my_prompt" # Optional - defaults to underscored class name + title "My Prompt" + description "This prompt performs specific functionality..." + arguments [ + MCP::Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ] + meta({ version: "1.0", category: "example" }) + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + description: "Response description", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("User message") + ), + MCP::Prompt::Message.new( + role: "assistant", + content: MCP::Content::Text.new(args["message"]) + ) + ] + ) + end + end +end + +prompt = MyPrompt +``` + +### 2. Using the `MCP::Prompt.define` method + +`MCP::Prompt.define` builds a prompt from keyword arguments, with the block as its template: + +```ruby +prompt = MCP::Prompt.define( + name: "my_prompt", + title: "My Prompt", + description: "This prompt performs specific functionality...", + arguments: [ + MCP::Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ], + meta: { version: "1.0", category: "example" } +) do |args, server_context:| + MCP::Prompt::Result.new( + description: "Response description", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("User message") + ), + MCP::Prompt::Message.new( + role: "assistant", + content: MCP::Content::Text.new(args["message"]) + ) + ] + ) +end +``` + +### 3. Using the `MCP::Server#define_prompt` method + +`MCP::Server#define_prompt` registers the prompt directly on a server instance: + +```ruby +server = MCP::Server.new +server.define_prompt( + name: "my_prompt", + description: "This prompt performs specific functionality...", + arguments: [ + Prompt::Argument.new( + name: "message", + title: "Message Title", + description: "Input message", + required: true + ) + ], + meta: { version: "1.0", category: "example" } +) do |args, server_context:| + Prompt::Result.new( + description: "Response description", + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User message") + ), + Prompt::Message.new( + role: "assistant", + content: Content::Text.new(args["message"]) + ) + ] + ) +end +``` + +The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, +e.g. around authentication state or user preferences. + +## Key Components + +- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag +- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content +- `MCP::Prompt::Result` - The output of a prompt template containing description and messages +- `MCP::Content::Text` - Text content for messages + +## Registering Prompts + +Register prompts with the MCP server: + +```ruby +server = MCP::Server.new( + name: "my_server", + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, +) +``` + +The server will handle prompt listing and execution through the MCP protocol methods: + +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves and executes a specific prompt with arguments + +## Prompts with Image and Embedded Resource Content + +Prompt messages are not limited to text. The same `MCP::Content` types used in [tool responses](/server/tools/) can be used as message content, +letting a prompt template include images or inline resource contents. Unlike tool responses, the content object is passed directly rather than as a hash; +`MCP::Prompt::Message` serializes it when the prompt result is returned: + +```ruby +class CodeReviewPrompt < MCP::Prompt + prompt_name "code_review" + description "Review a source file with an accompanying diagram" + arguments [ + MCP::Prompt::Argument.new(name: "file_uri", description: "URI of the file to review", required: true), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::EmbeddedResource.new( + MCP::Resource::TextContents.new( + uri: args["file_uri"], + mime_type: "text/x-ruby", + text: read_source(args["file_uri"]), + ), + ), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Image.new(architecture_diagram_base64, "image/png"), + ), + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("Please review the code above, using the diagram for context."), + ), + ], + ) + end + end +end +``` diff --git a/docs/_server/resources.md b/docs/_server/resources.md new file mode 100644 index 0000000..f53548a --- /dev/null +++ b/docs/_server/resources.md @@ -0,0 +1,319 @@ +--- +layout: default +title: Resources +nav_order: 6 +--- + +# Resources + +MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Defining Resources + +Like [tools](/server/tools/) and [prompts](/server/prompts/), resources can be defined in three ways. + +### 1. As a class definition + +Define a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: + +```ruby +class MyResource < MCP::Resource + uri "https://example.com/my_resource" + resource_name "my-resource" + title "My Resource" + description "Lorem ipsum dolor sit amet" + mime_type "text/html" + + class << self + def contents + [MCP::Resource::TextContents.new( + uri: uri, + mime_type: mime_type, + text: "Hello from example resource!" + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resources: [MyResource], +) +``` + +`resources/read` requests are routed automatically: when the requested URI matches a registered +class-based resource, its `contents` method is called. `contents` may return an array of +`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. +Like tools, `contents` can opt in to a [`server_context:`](/server/server-context/) keyword argument to receive per-request context. + +When class-based resources or resource templates are registered and a `resources/read` request +does not match any of them, the server responds with the standard JSON-RPC Invalid Params error +(`-32602`) carrying the requested URI in the error `data` member, per SEP-2164. + +### 2. Using the `MCP::Resource.define` method + +The block implements `contents`: + +```ruby +resource = MCP::Resource.define( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")] +end +``` + +### 3. Using the `MCP::Server#define_resource` method + +`MCP::Server#define_resource` registers the resource directly on a server instance: + +```ruby +server = MCP::Server.new(name: "my_server") +server.define_resource( + uri: "https://example.com/my_resource", + name: "my-resource", + mime_type: "text/html", +) do + [MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")] +end +``` + +Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, +in which case the server only lists them: + +```ruby +resource = MCP::Resource.new( + uri: "https://example.com/my_resource", + name: "my-resource", + title: "My Resource", + description: "Lorem ipsum dolor sit amet", + mime_type: "text/html", +) + +server = MCP::Server.new( + name: "my_server", + resources: [resource], +) +``` + +With plain data resources, the server must register a handler for the `resources/read` method to +retrieve a resource dynamically. + +```ruby +server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: "text/plain", + text: "Hello from example resource! URI: #{params[:uri]}" + }] +end +``` + +otherwise `resources/read` requests will be a no-op. Note that a `resources_read_handler` fully replaces +the default `resources/read` handling, including the automatic routing to class-based resources described above. + +To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, +so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array +and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. +A block that declares `server_context:` receives it: + +```ruby +server.resources_list_handler do |params, server_context:| + server_context[:authenticated] ? real_resources : demo_resources +end +``` + +The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset +into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. + +For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. +Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) +carrying the requested URI in the error `data` member: + +```ruby +server.resources_read_handler do |params| + resource = lookup(params[:uri]) + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource + + [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }] +end +``` + +## Reading Binary Resources + +For binary resources, respond with a base64-encoded `blob` field instead of `text`. +The `MCP::Resource::TextContents` and `MCP::Resource::BlobContents` classes build the two contents shapes defined by the spec: + +```ruby +server.resources_read_handler do |params| + case params[:uri] + when "file:///logo.png" + [ + MCP::Resource::BlobContents.new( + uri: params[:uri], + mime_type: "image/png", + data: Base64.strict_encode64(File.binread("logo.png")), + ).to_h, + ] + else + [ + MCP::Resource::TextContents.new( + uri: params[:uri], + mime_type: "text/plain", + text: "Hello from example resource!", + ).to_h, + ] + end +end +``` + +## Resource Templates + +Resource templates follow the same pattern. Class-based templates declare a `uri_template` and +receive the variables extracted from the requested URI as keyword arguments to `contents`: + +```ruby +class UserProfileTemplate < MCP::ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user-profile" + title "User Profile" + description "Profile data for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: mime_type, + text: { id: user_id }.to_json + )] + end + end +end + +server = MCP::Server.new( + name: "my_server", + resource_templates: [UserProfileTemplate], +) +``` + +A `resources/read` request for `users://42/profile` calls `UserProfileTemplate.contents(user_id: "42")`. +An exact match against a registered resource takes precedence over template matching. +`contents` can also opt in to a `server_context:` keyword argument. + +URI template matching supports simple [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: + +- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. +- A variable matches one or more characters excluding `/`. +- Extracted values are not percent-decoded. + +The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, +mirroring the resource variants: + +```ruby +server.define_resource_template( + uri_template: "users://{user_id}/profile", + name: "user-profile", + mime_type: "application/json", +) do |user_id:| + [MCP::Resource::TextContents.new( + uri: "users://#{user_id}/profile", + mime_type: "application/json", + text: { id: user_id }.to_json + )] +end +``` + +Resource templates can also be registered as plain data objects with `MCP::ResourceTemplate.new`, +in which case reads must be served by a `resources_read_handler`: + +```ruby +resource_template = MCP::ResourceTemplate.new( + uri_template: "https://example.com/my_resource_template", + name: "my-resource-template", + title: "My Resource Template", + description: "Lorem ipsum dolor sit amet", + mime_type: "text/html", +) + +server = MCP::Server.new( + name: "my_server", + resource_templates: [resource_template], +) +``` + +Registered templates are listed through the `resources/templates/list` protocol method. +To serve reads for URIs that match a template, extract the variable parts of the URI in your `resources_read_handler`: + +```ruby +resource_template = MCP::ResourceTemplate.new( + uri_template: "file:///items/{item_id}", + name: "item", + mime_type: "application/json", +) + +server = MCP::Server.new(name: "my_server", resource_templates: [resource_template]) + +server.resources_read_handler do |params| + if (match = params[:uri].match(%r{\Afile:///items/(?[^/]+)\z})) + [{ + uri: params[:uri], + mimeType: "application/json", + text: { id: match[:item_id] }.to_json, + }] + else + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) + end +end +``` + +## Resource Subscriptions + +Resource subscriptions allow clients to monitor specific resources for changes. +When a subscribed resource is updated, the server sends a notification to the client. + +The SDK does not track subscription state internally. +Server developers register handlers and manage their own subscription state. +Three methods are provided: + +- `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests +- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests +- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client + +```ruby +subscribed_uris = Set.new + +server = MCP::Server.new( + name: "my_server", + resources: [my_resource], + capabilities: { resources: { subscribe: true } }, +) + +server.resources_subscribe_handler do |params| + subscribed_uris.add(params[:uri].to_s) +end + +server.resources_unsubscribe_handler do |params| + subscribed_uris.delete(params[:uri].to_s) +end + +server.define_tool(name: "update_resource") do |server_context:, **args| + if subscribed_uris.include?("test://my-resource") + server_context.notify_resources_updated(uri: "test://my-resource") + end + MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h]) +end +``` + +The `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows +alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it +returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` +rather than returning it at the top level, which interoperating clients reject: + +```ruby +server.resources_subscribe_handler do |params| + id = subscriptions.create(params[:uri].to_s) + { _meta: { "myapp.example/subscriptionId" => id } } +end +``` diff --git a/docs/_server/roots.md b/docs/_server/roots.md new file mode 100644 index 0000000..a3341c6 --- /dev/null +++ b/docs/_server/roots.md @@ -0,0 +1,82 @@ +--- +layout: default +title: Roots +nav_order: 7 +--- + +# Roots + +The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. +Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available. + +{: .warning } +> MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Prefer tool parameters, resource URIs, server configuration, or environment +> variables for new servers. A client declaring the `roots` capability on a modern connection emits a deprecation warning. + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Like [sampling](/server/sampling/), roots listing is initiated by the server +- **Client Capability**: Clients must declare `roots` capability during initialization +- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change + +## Using Roots in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `list_roots` on it. +The request is automatically routed to the correct client session: + +```ruby +class FileSearchTool < MCP::Tool + description "Search files within the client's project roots" + input_schema( + properties: { + query: { type: "string" } + }, + required: ["query"] + ) + + def self.call(query:, server_context:) + roots = server_context.list_roots + root_uris = roots[:roots].map { |root| root[:uri] } + + MCP::Tool::Response.new([{ + type: "text", + text: "Searching in roots: #{root_uris.join(", ")}" + }]) + end +end +``` + +Result contains an array of root objects: + +```ruby +{ + roots: [ + { uri: "file:///home/user/projects/myproject", name: "My Project" }, + { uri: "file:///home/user/repos/backend", name: "Backend Repository" } + ] +} +``` + +## Handling Root Changes + +Register a callback to be notified when the client's roots change: + +```ruby +server.roots_list_changed_handler do + puts "Client's roots have changed, tools will see updated roots on next call." +end +``` + +## Error Handling + +- Raises `RuntimeError` if client does not support `roots` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/sampling.md b/docs/_server/sampling.md new file mode 100644 index 0000000..af37df0 --- /dev/null +++ b/docs/_server/sampling.md @@ -0,0 +1,87 @@ +--- +layout: default +title: Sampling +nav_order: 8 +--- + +# Sampling + +The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method. +This enables servers to leverage the client's LLM capabilities without needing direct access to AI models. + +{: .warning } +> MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. New servers should call LLM provider APIs directly. +> A client declaring the `sampling` capability on a modern connection emits a deprecation warning. + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server +- **Client Capability**: Clients must declare `sampling` capability during initialization +- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability +- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs + +## Using Sampling in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_sampling_message` on it. +The request is automatically routed to the correct client session: + +```ruby +class SummarizeTool < MCP::Tool + description "Summarize text using LLM" + input_schema( + properties: { + text: { type: "string" } + }, + required: ["text"] + ) + + def self.call(text:, server_context:) + result = server_context.create_sampling_message( + messages: [ + { role: "user", content: { type: "text", text: "Please summarize: #{text}" } } + ], + max_tokens: 500 + ) + + MCP::Tool::Response.new([{ + type: "text", + text: result[:content][:text] + }]) + end +end + +server = MCP::Server.new(name: "my_server", tools: [SummarizeTool]) +``` + +## Parameters + +Required: + +- `messages:` (Array) - Array of message objects with `role` and `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +## Error Handling + +- Raises `RuntimeError` if client does not support `sampling` capability +- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/server-context.md b/docs/_server/server-context.md new file mode 100644 index 0000000..f45f576 --- /dev/null +++ b/docs/_server/server-context.md @@ -0,0 +1,111 @@ +--- +layout: default +title: Server Context +nav_order: 19 +--- + +# Server Context + +The `server_context` is a user-defined hash that is passed into the server instance and made available to [tool](/server/tools/) and [prompt](/server/prompts/) calls. +It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. + +**Type:** + +```ruby +server_context: { [String, Symbol] => Any } +``` + +**Example:** + +```ruby +server = MCP::Server.new( + name: "my_server", + server_context: { user_id: current_user.id, request_id: request.uuid } +) +``` + +This hash is then passed as the `server_context` keyword argument to tool and prompt calls. +Note that the exception reporter does not receive this user-defined hash, and instrumentation +callbacks omit it unless you opt in with `instrument_server_context`. +See the [Configuration](/server/configuration/) page for the arguments they receive. + +## Request-specific `_meta` Parameter + +The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/latest/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. + +{: .note } +> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). +> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it +> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. + +**Access Pattern:** + +When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: + +```ruby +class MyTool < MCP::Tool + def self.call(message:, server_context:) + # Access provider-specific metadata + session_id = server_context.dig(:_meta, :session_id) + request_id = server_context.dig(:_meta, :request_id) + + # Access server's original context + user_id = server_context.dig(:user_id) + + MCP::Tool::Response.new([{ + type: "text", + text: "Processing for user #{user_id} in session #{session_id}" + }]) + end +end +``` + +**Client Request Example:** + +A `tools/call` request carrying `_meta`, as read by the tool above: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "my_tool", + "arguments": { "message": "Hello" }, + "_meta": { + "session_id": "abc123", + "request_id": "req_456" + } + } +} +``` + +### Distributed Tracing + +Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating +[W3C Trace Context](https://www.w3.org/TR/trace-context/) across MCP requests. The SDK guarantees these keys pass through +incoming request `_meta` untouched, and exposes their names as constants on `MCP::TraceContext` (`TRACEPARENT_META_KEY`, +`TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`, and `META_KEYS`). The SDK does not depend on OpenTelemetry; bridge the values +to your tracing system yourself: + +```ruby +class TracedTool < MCP::Tool + def self.call(message:, server_context:) + traceparent = server_context.dig(:_meta, :traceparent) + # Hand traceparent/tracestate/baggage to your tracing library + # (e.g. the opentelemetry-ruby gems) to continue the caller's trace. + + MCP::Tool::Response.new([{ type: "text", text: "ok" }]) + end +end +``` + +On the client side, every request method (`call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`, and the `list_*` methods) +accepts a `meta:` keyword to inject these keys into the outgoing request, so trace context can flow on every request: + +```ruby +meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" } + +client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta) +client.read_resource(uri: "file:///report.txt", meta: meta) +``` diff --git a/docs/_server/subscriptions.md b/docs/_server/subscriptions.md new file mode 100644 index 0000000..22116aa --- /dev/null +++ b/docs/_server/subscriptions.md @@ -0,0 +1,83 @@ +--- +layout: default +title: Subscriptions +nav_order: 12 +redirect_from: + - /server/notification-subscriptions/ +--- + +# Subscriptions + +`subscriptions/listen` is the long-lived notification subscription stream of MCP 2026-07-28 (SEP-2575), +replacing the legacy HTTP GET listening stream. + +## Subscribing + +The client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), +the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, +and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. +The honored subset follows the capabilities the server declares (`listChanged` and `subscribe` flags). + +The client opens the stream with a `subscriptions/listen` request: + +```json +{ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "my_client", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +``` + +The first SSE event on the stream is the acknowledgement: + +```json +{ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { "io.modelcontextprotocol/subscriptionId": "listen-1" } + } +} +``` + +## Delivery Semantics + +A stream delivers only the changes that happen after its acknowledgement; it is not a replay log. +Nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed. +Clients fetch the current state after subscribing and treat each notification as a cue to refetch, +rather than as a payload. + +## Transport Support + +The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. +A host that cannot hold an SSE response open (such as the [Rails controller pattern](/server/transports/#rails-controller), +which buffers the body) declines the method with `serve_subscriptions_listen: false`, answering `-32601` and dropping +the `listChanged`/`subscribe` flags from discovery. + +## Limits and Keepalives + +Concurrent streams are capped by `max_listen_subscriptions:` (default 1000; pass `nil` to remove the cap), and each stream receives an SSE keepalive +comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` +when an upstream proxy already keeps the stream alive. + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + max_listen_subscriptions: 500, + listen_keepalive_interval: 30, +) +``` + +A stream stays open until the client closes the connection; on graceful shutdown via `transport.close`, +each open stream receives its `subscriptions/listen` response before closing. + +See [Notifications](/server/notifications/) for the notification types themselves and their session scoping. diff --git a/docs/_server/tools.md b/docs/_server/tools.md new file mode 100644 index 0000000..0ebf6fe --- /dev/null +++ b/docs/_server/tools.md @@ -0,0 +1,419 @@ +--- +layout: default +title: Tools +nav_order: 4 +--- + +# Tools + +MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. + +## Defining Tools + +This gem provides a `MCP::Tool` class that can be used to create tools in three ways. + +### 1. As a class definition + +Subclass `MCP::Tool` and declare the metadata with class-level helpers; `call` implements the tool: + +```ruby +class MyTool < MCP::Tool + title "My Tool" + description "This tool performs specific functionality..." + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + output_schema( + properties: { + result: { type: "string" }, + success: { type: "boolean" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["result", "success", "timestamp"] + ) + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + title: "My Tool" + ) + + def self.call(message:, server_context:) + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) + end +end + +tool = MyTool +``` + +### 2. Using the `MCP::Tool.define` method + +`MCP::Tool.define` builds a tool from keyword arguments, with the block as its implementation: + +```ruby +tool = MCP::Tool.define( + name: "my_tool", + title: "My Tool", + description: "This tool performs specific functionality...", + annotations: { + read_only_hint: true, + title: "My Tool" + } +) do |args, server_context:| + MCP::Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +### 3. Using the `MCP::Server#define_tool` method + +`MCP::Server#define_tool` registers the tool directly on a server instance: + +```ruby +server = MCP::Server.new +server.define_tool( + name: "my_tool", + description: "This tool performs specific functionality...", + annotations: { + title: "My Tool", + read_only_hint: true + } +) do |args, server_context:| + Tool::Response.new([{ type: "text", text: "OK" }]) +end +``` + +The [`server_context`](/server/server-context/) parameter is the `server_context` passed into the server and can be used to pass per request information, +e.g. around authentication state. + +## Tool argument keys + +Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects +and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, +so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. + +This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), +and nested objects must be read with symbol keys: + +```ruby +class ExampleTool < MCP::Tool + description "Echoes a nested argument" + input_schema( + properties: { + message: { type: "string" }, + payload: { + type: "object", + properties: { + subject: { type: "string" }, + } + } + }, + required: ["message"] + ) + + def self.call(message:, payload: nil, server_context:) + subject = payload && payload[:subject] # symbol key, not payload["subject"] + MCP::Tool::Response.new([{ + type: "text", + text: "Message: #{message}; subject: #{subject}" + }]) + end +end +``` + +Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: +Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. + +Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys +that a transport never delivers, so string-key access can pass tests yet fail against a real client. +Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: + +```ruby +delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) +MyTool.call(**delivered, server_context: nil) +``` + +## Tool Annotations + +Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: + +- `destructive_hint`: Indicates if the tool performs destructive operations. Defaults to true +- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false +- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true +- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false +- `title`: A human-readable title for the tool + +Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. + +{: .note } +> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`. + +## Tool Output Schemas + +Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways. + +### 1. Class definition with `output_schema` + +Declare the schema with the `output_schema` class helper, alongside `input_schema`: + +```ruby +class WeatherTool < MCP::Tool + tool_name "get_weather" + description "Get current weather for a location" + + input_schema( + properties: { + location: { type: "string" }, + units: { type: "string", enum: ["celsius", "fahrenheit"] } + }, + required: ["location"] + ) + + output_schema( + properties: { + temperature: { type: "number" }, + condition: { type: "string" }, + humidity: { type: "integer" } + }, + required: ["temperature", "condition", "humidity"] + ) + + def self.call(location:, units: "celsius", server_context:) + # Call weather API and structure the response + api_response = WeatherAPI.fetch(location, units) + weather_data = { + temperature: api_response.temp, + condition: api_response.description, + humidity: api_response.humidity_percent + } + + output_schema.validate_result(weather_data) + + MCP::Tool::Response.new([{ + type: "text", + text: weather_data.to_json + }]) + end +end +``` + +### 2. Using `Tool.define` with `output_schema` + +Pass the schema as the `output_schema:` keyword argument: + +```ruby +tool = MCP::Tool.define( + name: "calculate_stats", + description: "Calculate statistics for a dataset", + input_schema: { + properties: { + numbers: { type: "array", items: { type: "number" } } + }, + required: ["numbers"] + }, + output_schema: { + properties: { + mean: { type: "number" }, + median: { type: "number" }, + count: { type: "integer" } + }, + required: ["mean", "median", "count"] + } +) do |args, server_context:| + # Calculate statistics and validate against schema + MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }]) +end +``` + +### 3. Using `OutputSchema` objects + +Construct an `MCP::Tool::OutputSchema` object explicitly: + +```ruby +class DataTool < MCP::Tool + output_schema MCP::Tool::OutputSchema.new( + properties: { + success: { type: "boolean" }, + data: { type: "object" } + }, + required: ["success"] + ) +end +``` + +Output schema may also describe an array of objects: + +```ruby +class WeatherTool < MCP::Tool + output_schema( + type: "array", + items: { + properties: { + temperature: { type: "number" }, + condition: { type: "string" }, + humidity: { type: "integer" } + }, + required: ["temperature", "condition", "humidity"] + } + ) +end +``` + +Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`, +applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`). + +Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root +(`{ type: "string" }`) or a root-level composition: + +```ruby +class FlexibleTool < MCP::Tool + output_schema( + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "number" } } + ] + ) +end +``` + +Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it +(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to +all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are +capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects; +violations raise `ArgumentError` at construction time. + +MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: + +- **Server Validation**: Servers MUST provide structured results that conform to the output schema +- **Client Validation**: Clients SHOULD validate structured results against the output schema +- **Better Integration**: Enables strict schema validation, type information, and improved developer experience +- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block + +The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients. + +By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. +To validate successful tool responses, enable `validate_tool_call_results` on the server [configuration](/server/configuration/): + +```ruby +configuration = MCP::Configuration.new(validate_tool_call_results: true) +server = MCP::Server.new( + name: "example_server", + tools: [WeatherTool], + configuration: configuration +) +``` + +When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. +Error responses are not validated against the output schema. + +## Tool Responses with Structured Content + +Tools can return structured data alongside text content using the `structured_content` parameter. + +The structured content will be included in the JSON-RPC response as the `structuredContent` field. + +Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array) +without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients +that only read `content` still receive the data. + +```ruby +class WeatherTool < MCP::Tool + description "Get current weather and return structured data" + + def self.call(location:, units: "celsius", server_context:) + # Call weather API and structure the response + api_response = WeatherAPI.fetch(location, units) + weather_data = { + temperature: api_response.temp, + condition: api_response.description, + humidity: api_response.humidity_percent + } + + output_schema.validate_result(weather_data) + + MCP::Tool::Response.new( + [{ + type: "text", + text: weather_data.to_json + }], + structured_content: weather_data + ) + end +end +``` + +## Tool Responses with Errors + +Tools can return error information alongside text content using the `error` parameter. + +The error will be included in the JSON-RPC response as the `isError` field. + +```ruby +class WeatherTool < MCP::Tool + description "Get current weather and return structured data" + + def self.call(server_context:) + # Do something here + content = {} + + MCP::Tool::Response.new( + [{ + type: "text", + text: content.to_json + }], + structured_content: content, + error: true + ) + end +end +``` + +## Tool Responses with Image, Audio, and Embedded Resources + +Tool responses are not limited to text. The `MCP::Content` module provides `Image`, `Audio`, and `EmbeddedResource` content types, +which serialize to the `image`, `audio`, and `resource` content blocks defined by the MCP spec. Image and audio data is passed as +a base64-encoded string together with its MIME type: + +```ruby +class ChartTool < MCP::Tool + description "Render a chart as a PNG image" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Text.new("Here is the rendered chart:").to_h, + MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h, + ]) + end +end + +class SpeechTool < MCP::Tool + description "Synthesize speech audio" + + def self.call(server_context:) + MCP::Tool::Response.new([ + MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h, + ]) + end +end +``` + +An [embedded resource](/server/resources/) wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: + +```ruby +class ReportTool < MCP::Tool + description "Return a report as an embedded resource" + + def self.call(server_context:) + contents = MCP::Resource::TextContents.new( + uri: "report://monthly", + mime_type: "application/json", + text: { total: 42 }.to_json, + ) + + MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h]) + end +end +``` diff --git a/docs/_server/transports.md b/docs/_server/transports.md new file mode 100644 index 0000000..009c506 --- /dev/null +++ b/docs/_server/transports.md @@ -0,0 +1,288 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +The server ships two transports: [stdio](#stdio-transport) for command-line and desktop integrations, +and [Streamable HTTP](#streamable-http-transport) for web deployments. +This page covers starting each transport, mounting the HTTP transport in Rack and Rails applications, +and its deployment, session, and security settings. + +## Stdio Transport + +If you want to build a local command-line application, you can use the stdio transport: + +```ruby +require "mcp" + +# Create a simple tool +class ExampleTool < MCP::Tool + description "A simple example tool that echoes back its arguments" + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + + class << self + def call(message:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +# Set up the server +server = MCP::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +# Create and start the transport +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open +``` + +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + +You can run this script and then type in requests to the server at the command line. + +```console +$ ruby examples/stdio_server.rb +{"jsonrpc":"2.0","id":"1","method":"ping"} +{"jsonrpc":"2.0","id":"2","method":"tools/list"} +{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} +``` + +## Streamable HTTP Transport + +`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. +The following examples show two common integration styles in Rails. + +{: .important } +> On the legacy handshake lifecycle, `MCP::Server::Transports::StreamableHTTPTransport` stores session and +> SSE stream state in memory, so it must run in a single process. Use a single-process server (e.g., Puma with +> `workers 0`). Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work +> with any configuration, with two caveats: a [`subscriptions/listen`](/server/subscriptions/) stream +> is held in memory by the process that accepted it, so only notifications emitted in that process reach it, +> and the [multi round-trip](/server/mrtr/) `requestState` crosses workers only when they +> share a `RequestStateSecurity` key. + +{: .important } +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). +> The check runs before any lifecycle dispatch, so it protects requests of +> the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) as well. + +### Rails (mount) + +`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: + +```ruby +# config/routes.rb +server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], +) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches +`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), +`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per +the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http), +so no additional route configuration is needed. + +A complete runnable application using this approach is available in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). + +### Rails (controller) + +While the mount approach creates a single server at boot time, the controller approach creates a new server per request. +This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). + +`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): + +```ruby +class McpController < ActionController::API + def create + server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], + server_context: { user_id: current_user.id }, + ) + # Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set, + # and since `render` buffers the whole body, `subscriptions/listen` streams are declined. + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + serve_subscriptions_listen: false, + ) + status, headers, body = transport.handle_request(request) + + render(json: body.first, status: status, headers: headers) + end +end +``` + +{: .important } +> The controller pattern builds a fresh transport per request and `render` buffers the whole body, +> so it cannot serve the open SSE stream that [`subscriptions/listen`](/server/subscriptions/) needs: +> without `serve_subscriptions_listen: false`, that request returns a streaming `Proc` body +> that `body.first` cannot render, and a modern client's notification stream fails with a 500. +> With the flag, the method answers 404 with JSON-RPC `-32601`, and `server/discover` stops +> advertising the `listChanged`/`subscribe` capability flags, so well-behaved clients do not ask. +> To actually serve listen streams, use the [mount approach](#rails-mount). + +### Stateless Mode + +You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions. +This mode allows for easy multi-node deployment. +Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`): + +```ruby +# Stateless Streamable HTTP - session-less +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) +``` + +In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required, +handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server), +and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped +(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. + +{: .note } +> This transport option is distinct from the sessionless [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28. +> The option governs how handshake-lifecycle clients are served; modern requests carry their own `_meta` envelope +> and are served as single exchanges whether or not `stateless: true` is set. + +### JSON Response Mode + +You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`. +Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`: + +```ruby +# JSON response mode +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true) +``` + +In JSON response mode, the POST response is a single JSON object, so server-to-client messages +that need to arrive during request processing are not supported: +request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests +(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error. +Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and +broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream. +This mode is suitable for simple tool servers that do not need server-initiated requests. + +{: .note } +> Like [stateless mode](#stateless-mode), this option applies to handshake-lifecycle clients only. +> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:` +> and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client. + +### Session Limits + +By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted: +they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent +session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it +(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute, +so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots +and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session). + +```ruby +# Tune the limits +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000) + +# Opt out of expiry and/or the cap (not recommended on internet-facing deployments) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil) +``` + +Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. The same holds +for requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), which never create a session; +their long-lived [`subscriptions/listen`](/server/subscriptions/) streams are bounded separately +by `max_listen_subscriptions:`. + +### Session Ownership + +`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session +existence and idle timeout only. It does not bind a session to a user, because the transport never receives +an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, +so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). + +The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` +on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, +so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return +rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded +when the session was created: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, +) +``` + +Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), +it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only +when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. +Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. + +Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, +so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them +(the per-request `Origin` validation of the DNS rebinding protection above still applies); +on that path, authorization is enforced per request by the deploying application. + +### Request Size Limits + +`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory +with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, +and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON +string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only +if you exchange unusually large payloads: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) +``` + +Unlike the deployment options above, these bounds apply to both lifecycles: +requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits. diff --git a/docs/assets/css/just-the-docs-dark.scss b/docs/assets/css/just-the-docs-dark.scss new file mode 100644 index 0000000..20226a0 --- /dev/null +++ b/docs/assets/css/just-the-docs-dark.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="ruby-dark" %} diff --git a/docs/assets/css/just-the-docs-light.scss b/docs/assets/css/just-the-docs-light.scss new file mode 100644 index 0000000..c2a0250 --- /dev/null +++ b/docs/assets/css/just-the-docs-light.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="ruby-light" %} diff --git a/docs/assets/images/icons/github.svg b/docs/assets/images/icons/github.svg new file mode 100644 index 0000000..75db234 --- /dev/null +++ b/docs/assets/images/icons/github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/docs/assets/images/icons/theme-dark.svg b/docs/assets/images/icons/theme-dark.svg new file mode 100644 index 0000000..0571e5c --- /dev/null +++ b/docs/assets/images/icons/theme-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/images/icons/theme-light.svg b/docs/assets/images/icons/theme-light.svg new file mode 100644 index 0000000..e31928f --- /dev/null +++ b/docs/assets/images/icons/theme-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/assets/images/icons/theme-system.svg b/docs/assets/images/icons/theme-system.svg new file mode 100644 index 0000000..77dd0d4 --- /dev/null +++ b/docs/assets/images/icons/theme-system.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..819f4c1 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,48 @@ +--- +layout: default +title: Examples +nav_order: 3 +permalink: /examples/ +--- + +# Examples + +Runnable examples live in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples) in the repository. + +## Standalone Scripts + +- [`stdio_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_server.rb) - a stdio server for desktop applications and command-line tools +- [`stdio_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_client.rb) - a client that spawns the stdio server as a subprocess and exercises its tools, prompts, and resources +- [`http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_server.rb) - a Rack-based Streamable HTTP server with session management and SSE support +- [`http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_client.rb) - a client driving the HTTP server through all MCP protocol methods +- [`streamable_http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rb) - an SSE-focused server with tools that trigger notifications and progress updates +- [`streamable_http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_client.rb) - an interactive, menu-driven client for testing the SSE stream + +Each script is standalone and run from the repository root: + +```console +$ ruby examples/stdio_server.rb +``` + +See [`examples/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/README.md) for per-example usage and the requests each one demonstrates. + +## Rails Application + +[`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails) is a complete, minimal +Rails application serving an MCP server over the Streamable HTTP transport. The server and transport are built once +at boot and mounted at `/mcp` in `config/routes.rb`; tools live in `app/tools/`, and a text resource is served through +a `resources_read_handler`. + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is then available at `http://localhost:9292/mcp`. See +[`examples/rails/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/rails/README.md) +for a step-by-step cURL walkthrough of the session handshake and tool calls. + +The mount pattern this application uses is explained in [Rails (mount)](/server/transports/#rails-mount) +on the Transports page, alongside [Rails (controller)](/server/transports/#rails-controller), an alternative +that builds a server per request so tools and configuration can vary by request. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..a280d7f --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ee275fc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,131 @@ +--- +layout: default +title: Introduction +nav_order: 1 +--- + +# MCP Ruby SDK + +The official Ruby SDK for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), implementing both server and client functionality for JSON-RPC 2.0 based communication between LLM applications and context providers. + +## Features + +- Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination + +## Quick Start + +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. + +### MCP Server + +A minimal server defines a tool and serves it over the stdio transport: + +```ruby +require "mcp" + +# Create a simple tool +class ExampleTool < MCP::Tool + description "A simple example tool that echoes back its arguments" + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) + + class << self + def call(message:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello from example tool! Message: #{message}", + }]) + end + end +end + +# Set up the server +server = MCP::Server.new( + name: "example_server", + tools: [ExampleTool], +) + +# Create and start the transport +transport = MCP::Server::Transports::StdioTransport.new(server) +transport.open +``` + +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: + +```console +$ ruby server.rb +{"jsonrpc":"2.0","id":"1","method":"ping"} +{"jsonrpc":"2.0","id":"2","method":"tools/list"} +{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} +``` + +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](/server/transports/). + +### MCP Client + +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# Perform the MCP initialization handshake before sending any requests. +client.connect + +# List available tools. +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" +end + +# Call a specific tool. +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } +) + +# Close the transport when done. +stdio_transport.close +``` + +The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](/client/transports/). + +For comprehensive documentation, see: + +- [Installation](/installation/) - installing the gem and optional feature dependencies +- [Examples](/examples/) - runnable example scripts and a complete Rails application +- [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation +- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip requests, notifications, protocol utilities, and configuration +- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip requests, and OAuth 2.1 authorization +- [Extensions](/extensions/) - capability extensions and MCP Apps + +## API Documentation + +Full API reference is hosted on [RubyDoc.info](https://rubydoc.info/gems/mcp). Select a version to view: + + + +## License + +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..91aaee1 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,53 @@ +--- +layout: default +title: Installation +nav_order: 2 +permalink: /installation/ +redirect_from: + - /installation.html +--- + +# Installation + +Install the gem and any optional dependencies for the features you use. + +## Requirements + +Ruby 2.7.0 or later. + +## Installing the Gem + +Add this line to your application's Gemfile: + +```ruby +gem "mcp" +``` + +And then execute: + +```console +$ bundle install +``` + +Or install it yourself as: + +```console +$ gem install mcp +``` + +You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: + +```ruby +gem "faraday", ">= 2.0" +``` + +Reading SSE (`text/event-stream`) responses needs `event_stream_parser` as well. +Whether a response is JSON or SSE is the server's choice, made for each response, and this SDK's own server picks SSE by default, +so a client written for arbitrary servers needs both gems: + +```ruby +gem "faraday", ">= 2.0" +gem "event_stream_parser", ">= 1.0" +``` + +The [Transports](/client/transports/#http-transport-layer) page describes the one setup where `faraday` alone is enough. diff --git a/docs/latest/index.html b/docs/latest/index.html new file mode 100644 index 0000000..8665374 --- /dev/null +++ b/docs/latest/index.html @@ -0,0 +1,19 @@ +--- +# Empty Jekyll front matter to enable Liquid templating (see {{ ... }} below) +--- + + + + + + Redirecting to latest documentation... + + + + +

Redirecting to latest documentation...

+ + + diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000..54e5e53 --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Protocol Versions +nav_order: 4 +permalink: /protocol-versions/ +--- + +# Protocol Versions + +The SDK supports the following MCP protocol versions: + +| Version | Era | Notes | +|--------------|-----------|-------------------------------------------------------------------------------------------------| +| `2026-07-28` | Modern | Stateless lifecycle: no handshake, version carried on every request (SEP-2575) | +| `2025-11-25` | Handshake | The default handshake version; adds URL mode elicitation, enum schemas, and sampling tools | +| `2025-06-18` | Handshake | Adds elicitation, structured tool output, and OAuth resource servers; removes JSON-RPC batching | +| `2025-03-26` | Handshake | Adds Streamable HTTP, OAuth 2.1 authorization, tool annotations, and audio content | +| `2024-11-05` | Handshake | Initial protocol revision | + +## The Era Model + +Per the SEP-2575 era model, an era is a property of the protocol version itself: + +- **The modern version** (`2026-07-28`) has no handshake at all: clients discover the server through + [`server/discover`](/server/discover/), and every request carries its version in the `_meta` envelope, + validated per request. +- **Handshake versions** (`2025-11-25` and earlier) establish a session through the `initialize` handshake. + The server offers `2025-11-25` by default, and the version can be pinned with `MCP::Configuration.new(protocol_version:)`; + see [Server Protocol Version](/server/configuration/#server-protocol-version). + +Pinning the server's handshake version: + +```ruby +configuration = MCP::Configuration.new(protocol_version: "2025-06-18") +MCP::Server.new(name: "my_server", configuration: configuration) +``` + +The handshake never negotiates a modern version: a client asking `initialize` for `2026-07-28` is counter-offered +the latest handshake version, matching the TypeScript and Python SDKs. The bundled transports serve both eras +side by side with no configuration needed. + +## Client Negotiation + +`MCP::Client#connect` negotiates the lifecycle automatically by default: it probes `server/discover` and adopts +the modern lifecycle when the server serves it, falling back to the `initialize` handshake otherwise. +`connect(mode: :modern)`, `connect(mode: :legacy)`, and an explicit `protocol_version:` pin select a lifecycle +directly; see [Lifecycle](/client/lifecycle/). + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # pin the handshake version, no probe +``` + +## Deprecations + +The `2026-07-28` revision deprecates [Roots](/server/roots/), [Sampling](/server/sampling/), and +[Logging](/server/logging/) per SEP-2577; all three remain fully supported on the handshake versions. + +Check the [MCP specification](https://modelcontextprotocol.io/specification/versioning) to understand what each +protocol version includes. diff --git a/examples/README.md b/examples/README.md index 17de63b..af1828a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ This directory contains examples of how to use the Model Context Protocol (MCP) ## Available Examples -### 1. STDIO Server (`stdio_server.rb`) +### 1. stdio Server (`stdio_server.rb`) A simple server that communicates over standard input/output. This is useful for desktop applications and command-line tools. @@ -15,7 +15,26 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":0,"method":"tools/list"} ``` -### 2. HTTP Server (`http_server.rb`) +### 2. stdio Client (`stdio_client.rb`) + +A client that connects to the stdio server using the `MCP::Client::Stdio` transport. +This demonstrates how to use the SDK's built-in client classes to interact with a server subprocess. + +**Usage:** + +```console +$ ruby examples/stdio_client.rb +``` + +The client will automatically launch `stdio_server.rb` as a subprocess and demonstrate: + +- Performing the MCP initialization handshake via `client.connect` +- Listing and calling tools +- Listing prompts +- Listing and reading resources +- Transport cleanup on exit + +### 3. HTTP Server (`http_server.rb`) A standalone HTTP server built with Rack that implements the MCP Streamable HTTP transport protocol. This demonstrates how to create a web-based MCP server with session management and Server-Sent Events (SSE) support. @@ -36,12 +55,12 @@ $ ruby examples/http_server.rb The server will start on `http://localhost:9292` and provide: - **Tools**: - - `ExampleTool` - adds two numbers + - `example_tool` - adds two numbers - `echo` - echoes back messages -- **Prompts**: `ExamplePrompt` - echoes back arguments as a prompt +- **Prompts**: `example_prompt` - echoes back arguments as a prompt - **Resources**: `test_resource` - returns example content -### 3. HTTP Client Example (`http_client.rb`) +### 4. HTTP Client Example (`http_client.rb`) A client that demonstrates how to interact with the HTTP server using all MCP protocol methods. @@ -67,7 +86,7 @@ The client will demonstrate: - Listing and reading resources - Session cleanup -### 4. Streamable HTTP Server (`streamable_http_server.rb`) +### 5. Streamable HTTP Server (`streamable_http_server.rb`) A specialized HTTP server designed to test and demonstrate Server-Sent Events (SSE) functionality in the MCP protocol. @@ -79,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Available Tools:** -- `NotificationTool` - Send custom SSE notifications with optional delays +- `notification_tool` - Sends progress notifications over SSE, with optional delays - `echo` - Simple echo tool for basic testing **Usage:** @@ -90,7 +109,7 @@ $ ruby examples/streamable_http_server.rb The server will start on `http://localhost:9393` and provide detailed instructions for testing SSE functionality. -### 5. Streamable HTTP Client (`streamable_http_client.rb`) +### 6. Streamable HTTP Client (`streamable_http_client.rb`) An interactive client that connects to the SSE stream and provides a menu-driven interface for testing SSE functionality. @@ -105,14 +124,15 @@ An interactive client that connects to the SSE stream and provides a menu-driven 1. Start the SSE test server in one terminal: - ```console - $ ruby examples/streamable_http_server.rb - ``` +```console +$ ruby examples/streamable_http_server.rb +``` 2. Run the SSE test client in another terminal: - ```console - $ ruby examples/streamable_http_client.rb - ``` + +```console +$ ruby examples/streamable_http_client.rb +``` The client will: @@ -121,6 +141,74 @@ The client will: - Provide an interactive menu to trigger notifications - Display all received SSE events in real-time +### 7. Rails Server (`rails/`) + +A minimal Rails application that mounts `StreamableHTTPTransport` in its routes, following the "Rails (mount)" pattern from the top-level README. +It demonstrates class-based tools in `app/tools/` and a resource with a read handler. + +**Usage:** + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. + +### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) + +A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests. + +**Features:** + +- `server/discover` capability discovery before (or instead of) a handshake +- The per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers, stamped by the SDK automatically +- `resultType` stamping and SEP-2549 cache hints (`ttlMs`, `cacheScope`) on results +- A multi round-trip `deploy` tool (SEP-2322), resumed automatically by the client's elicitation handler +- The removal of legacy-only methods such as `ping` + +**Usage:** + +1. Start the server in one terminal: + +```console +$ ruby examples/modern_http_server.rb +``` + +2. Run the client in another terminal: + +```console +$ ruby examples/modern_http_client.rb +``` + +The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header. + +### Testing with MCP Inspector + +[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers. + +1. Start the server: + +```console +$ ruby examples/streamable_http_server.rb +``` + +2. Start Inspector in another terminal: + +```console +$ npx @modelcontextprotocol/inspector +``` + +3. Open `http://localhost:6274` in a browser: + +- Set Transport Type to "Streamable HTTP" +- Set URL to `http://localhost:9393` +- Disable the Authorization header toggle (the example server does not require authentication) +- Click "Connect" + +Once connected, you can list tools, call them, and see SSE notifications in the Inspector UI. + ### Testing SSE with cURL You can also test SSE functionality manually using cURL: @@ -129,24 +217,78 @@ You can also test SSE functionality manually using cURL: ```console SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') ``` -2. Connect to SSE stream (in one terminal): +2. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal): ```console curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393 ``` -3. Trigger notifications (in another terminal): +3. Call the notification tool. The `notifications/progress` events (requested via the `progressToken` in `_meta`) and the final response arrive as SSE events on the POST response itself: ```console -# Send immediate notification curl -i http://localhost:9393 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: $SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}' ``` +### Testing the modern lifecycle with cURL + +The modern lifecycle (2026-07-28, SEP-2575) is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. Start `examples/modern_http_server.rb` and walk it manually: + +1. Probe capabilities with `server/discover`: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' +``` + +2. List tools (the `MCP-Protocol-Version` header selects the era, `Mcp-Method` mirrors the method, and `params._meta` carries the SEP-2575 envelope): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/list" \ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +3. Call a tool (name-bearing methods additionally mirror the name in the `Mcp-Name` header): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: greet" \ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +4. Observe a multi round-trip result (SEP-2322), then resume it by echoing the `requestState` back together with the answer. Declaring the `elicitation` capability in the envelope is required before the server may embed elicitation requests: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' + +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"inputResponses":{"environment":{"action":"accept","content":{"environment":"staging"}}},"requestState":"{\"app\":\"storefront\"}","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' +``` + +The same server still accepts the legacy flow from the previous sections, routed by the `MCP-Protocol-Version` header. + ## Streamable HTTP Transport Details ### Protocol Flow @@ -177,13 +319,15 @@ Initialize a session: ```console curl -i http://localhost:9292 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' ``` List tools (using the session ID from initialization): ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/list","id":2}' ``` @@ -192,6 +336,7 @@ Call a tool: ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"ExampleTool","arguments":{"a":5,"b":3}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}' ``` diff --git a/examples/http_client.rb b/examples/http_client.rb index 3efbff9..b61b563 100644 --- a/examples/http_client.rb +++ b/examples/http_client.rb @@ -4,7 +4,7 @@ require "net/http" require "json" require "uri" -# Simple HTTP client example for interacting with the MCP HTTP server +# Simple HTTP client example for interacting with the MCP HTTP server. class MCPHTTPClient def initialize(base_url = "http://localhost:9292") @base_url = base_url @@ -30,19 +30,51 @@ class MCPHTTPClient response = http.request(request) - # Store session ID if provided + # Store session ID if provided. if response["Mcp-Session-Id"] @session_id = response["Mcp-Session-Id"] puts "Session ID: #{@session_id}" end - JSON.parse(response.body) + parse_response_body(response) + end + + # In the transport's default SSE mode, POST responses on an established session arrive as + # a Server-Sent Events stream whose `data:` line carries the JSON-RPC response; unwrap it + # before parsing. + def parse_response_body(response) + body = response.body + + if response["Content-Type"]&.start_with?("text/event-stream") + data_lines = body.lines.select { |line| line.start_with?("data:") } + body = data_lines.map { |line| line.sub(/\Adata:\s*/, "") }.join + end + + JSON.parse(body) + end + + def send_notification(method, params = nil) + uri = URI(@base_url) + http = Net::HTTP.new(uri.host, uri.port) + + request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path) + request["Content-Type"] = "application/json" + request["Mcp-Session-Id"] = @session_id if @session_id + + # Notifications carry no `id` and receive no JSON-RPC response body. + request.body = { + jsonrpc: "2.0", + method: method, + params: params, + }.compact.to_json + + http.request(request) end def initialize_session puts "=== Initializing session ===" result = send_request("initialize", { - protocolVersion: "2024-11-05", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "example_client", @@ -54,6 +86,14 @@ class MCPHTTPClient result end + def notify_initialized + puts "=== Sending notifications/initialized ===" + response = send_notification("notifications/initialized") + puts "Response status: #{response.code} #{response.message}" + + response + end + def ping puts "=== Sending ping ===" result = send_request("ping") @@ -137,8 +177,7 @@ class MCPHTTPClient end end -# Main script -if __FILE__ == $PROGRAM_NAME +def main puts <<~MESSAGE MCP HTTP Client Example Make sure the HTTP server is running (ruby examples/http_server.rb) @@ -148,37 +187,42 @@ if __FILE__ == $PROGRAM_NAME client = MCPHTTPClient.new begin - # Initialize session + # Initialize session. client.initialize_session - # Test ping + # Complete the handshake. + client.notify_initialized + + # Test ping. client.ping - # List available tools + # List available tools. client.list_tools - # Call the example_tool (note: snake_case name) + # Call the example_tool (note: snake_case name). client.call_tool("example_tool", { a: 5, b: 3 }) - # Call the echo tool + # Call the echo tool. client.call_tool("echo", { message: "Hello from client!" }) - # List prompts + # List prompts. client.list_prompts - # Get a prompt (note: snake_case name) + # Get a prompt (note: snake_case name). client.get_prompt("example_prompt", { message: "This is a test message" }) - # List resources + # List resources. client.list_resources - # Read a resource - client.read_resource("test_resource") + # Read a resource (the URI registered by http_server.rb). + client.read_resource("https://test_resource.invalid") rescue => e puts "Error: #{e.message}" puts e.backtrace ensure - # Clean up session + # Clean up session. client.close_session end end + +main diff --git a/examples/http_server.rb b/examples/http_server.rb index a8e2cf5..33d8d80 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -2,7 +2,7 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "mcp" -require "rack" +require "rack/cors" require "rackup" require "json" require "logger" @@ -56,6 +56,7 @@ end # Set up the server server = MCP::Server.new( name: "example_http_server", + version: "1.0.0", tools: [ExampleTool], prompts: [ExamplePrompt], resources: [ @@ -94,62 +95,74 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport -# Create a logger for MCP-specific logging -mcp_logger = Logger.new($stdout) -mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| - "[MCP] #{msg}\n" -end - -# Create a Rack application with logging -app = proc do |env| - request = Rack::Request.new(env) - - # Log MCP-specific details for POST requests - if request.post? - body = request.body.read - request.body.rewind - begin - parsed_body = JSON.parse(body) - mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") - mcp_logger.debug("Request body: #{JSON.pretty_generate(parsed_body)}") - rescue JSON::ParserError - mcp_logger.warn("Request body (raw): #{body}") - end +# Rack middleware for MCP-specific request/response logging. +class McpRequestLogger + def initialize(app) + @app = app + @logger = Logger.new($stdout) + @logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" } end - # Handle the request - response = transport.handle_request(request) + def call(env) + if env["REQUEST_METHOD"] == "POST" + body = env["rack.input"].read + env["rack.input"].rewind - # Log the MCP response details - _, _, body = response - if body.is_a?(Array) && !body.empty? && body.first - begin - parsed_response = JSON.parse(body.first) - if parsed_response["error"] - mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") - else - mcp_logger.info("Response: #{parsed_response["result"] ? "success" : "empty"} (id: #{parsed_response["id"]})") + begin + parsed = JSON.parse(body) + + @logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})") + @logger.debug("Request body: #{JSON.pretty_generate(parsed)}") + rescue JSON::ParserError + @logger.warn("Request body (raw): #{body}") end - mcp_logger.debug("Response body: #{JSON.pretty_generate(parsed_response)}") - rescue JSON::ParserError - mcp_logger.warn("Response body (raw): #{body}") + end + + status, headers, response_body = @app.call(env) + + if response_body.is_a?(Array) && !response_body.empty? && response_body.first + begin + parsed = JSON.parse(response_body.first) + + if parsed["error"] + @logger.error("Response error: #{parsed["error"]["message"]}") + else + @logger.info("Response: #{parsed["result"] ? "success" : "empty"} (id: #{parsed["id"]})") + end + @logger.debug("Response body: #{JSON.pretty_generate(parsed)}") + rescue JSON::ParserError + @logger.warn("Response body (raw): #{response_body}") + end + end + + [status, headers, response_body] + end +end + +# Build the Rack application with middleware. +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. +rack_app = Rack::Builder.new do + # Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector) + # WARNING: origins("*") allows all origins. Restrict this in production. + use(Rack::Cors) do + allow do + origins("*") + resource( + "*", + headers: :any, + methods: [:get, :post, :delete, :options], + expose: ["Mcp-Session-Id"], + ) end end - response -end - -# Wrap the app with Rack middleware -rack_app = Rack::Builder.new do # Use CommonLogger for standard HTTP request logging use(Rack::CommonLogger, Logger.new($stdout)) - - # Add other useful middleware use(Rack::ShowExceptions) + use(McpRequestLogger) - run(app) + run(transport) end # Start the server @@ -157,7 +170,9 @@ puts <<~MESSAGE Starting MCP HTTP server on http://localhost:9292 Use POST requests to initialize and send JSON-RPC commands Example initialization: - curl -i http://localhost:9292 --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + curl -i http://localhost:9292 \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' The server will return a session ID in the Mcp-Session-Id header. Use this session ID for subsequent requests. diff --git a/examples/modern_http_client.rb b/examples/modern_http_client.rb new file mode 100644 index 0000000..ca2cd1f --- /dev/null +++ b/examples/modern_http_client.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "json" + +# Client example for the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake +# and per-session state with sessionless, self-contained requests. +class MCPModernHTTPClient + def initialize(base_url = "http://localhost:9494") + @transport = MCP::Client::HTTP.new(url: base_url) + @client = MCP::Client.new(transport: @transport) + end + + # `server/discover` (SEP-2575) is sessionless capability discovery that works before, or instead of, `connect`. + def discover + puts "=== Discovering server capabilities (server/discover) ===" + result = @client.discover + puts <<~MESSAGE + Supported versions: #{result.supported_versions.inspect} + Server: #{result.server_info} + Cache hints: ttlMs=#{result.ttl_ms}, cacheScope=#{result.cache_scope} (SEP-2549) + Instructions: #{result.instructions} + MESSAGE + + result + end + + # The handler answers `elicitation/create` requests embedded in SEP-2322 `input_required` results; once registered, + # `call_tool` resumes those results automatically. + # Register it before `connect` so the connection never needs a server-to-client route. + def register_elicitation_handler + @client.on_elicitation do |params| + puts " Server asked: #{params["message"]}" + + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]) } + end + end + + # `mode: :modern` requires the modern lifecycle and fails on legacy-only servers; + # the default `:auto` probes `server/discover` first and falls back to the legacy `initialize` handshake. + # The `elicitation` capability must be declared before the server may embed elicitation requests. + def connect + puts "=== Adopting the modern lifecycle ===" + result = @client.connect( + client_info: { name: "modern-http-client", version: "1.0" }, + capabilities: { elicitation: {} }, + mode: :modern, + ) + puts <<~MESSAGE + Modern connection: #{@transport.modern?} + Protocol version: #{@client.protocol_version} + MESSAGE + + result + end + + # Modern requests are self-contained: the SDK stamps the `_meta` envelope (protocol version, + # client info, capabilities) and the Mcp-Method and Mcp-Name headers on every request automatically. + def list_tools + puts "=== Listing tools ===" + page = @client.list_tools + page.tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + puts "Cache hints: ttlMs=#{page.ttl_ms}, cacheScope=#{page.cache_scope}" + + page + end + + def call_tool(name, arguments) + puts "=== Calling tool: #{name} ===" + response = @client.call_tool(name: name, arguments: arguments) + puts <<~MESSAGE + resultType: #{response.dig("result", "resultType")} + Response: #{response.dig("result", "content", 0, "text")} + MESSAGE + + response + end + + # The modern lifecycle removed `initialize`, `ping`, `logging/setLevel`, and resource subscriptions (SEP-2575); + # calling one is an error. + def ping + puts "=== Calling removed method: ping ===" + @client.ping + rescue MCP::Client::RequestHandlerError => e + puts "ping was removed by the modern lifecycle (SEP-2575): #{e.message}" + end + + def close + # No session exists on a modern connection; this only clears local state. + @transport.close + end +end + +puts <<~MESSAGE + MCP Modern Lifecycle Client (2026-07-28) + Make sure the server is running (ruby examples/modern_http_server.rb) + #{"=" * 60} +MESSAGE + +client = MCPModernHTTPClient.new + +begin + # Probe the server before adopting a lifecycle. + client.discover + + # Answer the elicitation requests the server embeds in its results. + client.register_elicitation_handler + + # Adopt the modern lifecycle. + client.connect + + # List available tools. + client.list_tools + + # Call a plain single round-trip tool. + client.call_tool("greet", { name: "MCP" }) + + # Call a multi round-trip tool (SEP-2322): the driver fulfills the embedded elicitation via the handler registered above + # and re-issues the call with `inputResponses` plus the echoed `requestState`. Without a handler, `call_tool` raises + # `MCP::Client::InputRequiredError` for manual driving via the `input_responses:` / `request_state:` keywords. + client.call_tool("deploy", { app: "storefront" }) + + # Call a method the modern lifecycle removed. + client.ping +rescue => e + puts <<~MESSAGE + Error: #{e.message} + #{e.backtrace.join("\n")} + MESSAGE +ensure + client.close +end diff --git a/examples/modern_http_server.rb b/examples/modern_http_server.rb new file mode 100644 index 0000000..b21c35d --- /dev/null +++ b/examples/modern_http_server.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "rackup" +require "json" +require "logger" + +# Example server for the 2026-07-28 modern lifecycle (SEP-2575). +# +# The modern lifecycle is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. +# Clients probe the server with `server/discover` and then send self-contained requests that carry +# a `_meta` envelope (protocol version, client info, capabilities) plus `MCP-Protocol-Version` / `Mcp-Method` headers. +# +# The same server still accepts the legacy `initialize` flow: the transport routes each request to +# the legacy or modern lifecycle by its `MCP-Protocol-Version` header, so no constructor flag is needed. +server = MCP::Server.new( + name: "modern_lifecycle_server", + version: "1.0.0", + instructions: "Example server demonstrating the 2026-07-28 modern lifecycle.", + # SEP-2549 cache hints, advertised in `server/discover` and stamped on cacheable results. + # The defaults are 0 / "private"; non-default values are used here so they are visible + # in the example output. + ttl_ms: 60_000, + cache_scope: "public", +) + +server.define_tool( + name: "greet", + description: "Greets a name; a plain single round-trip tool", + input_schema: { properties: { name: { type: "string" } }, required: ["name"] }, +) do |name:| + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }]) +end + +# A multi round-trip tool (SEP-2322). The first round returns an `input_required` result asking +# the client to answer an embedded `elicitation/create` request; the client re-issues the call with +# `inputResponses` plus the echoed opaque `requestState`, and the handler re-runs from the start +# and reads the answer via `server_context`. +# Over a legacy connection the default `input_required_legacy_shim` fulfills the same result through +# a real server-to-client elicitation request instead. +server.define_tool( + name: "deploy", + description: "Deploys an app; asks which environment via a multi round-trip elicitation (SEP-2322)", + input_schema: { properties: { app: { type: "string" } }, required: ["app"] }, +) do |app:, server_context:| + answer = server_context.input_response("environment") + + if answer.nil? + MCP::Server::InputRequiredResult.new( + input_requests: { + "environment" => { + method: "elicitation/create", + params: { + message: "Which environment should #{app} deploy to?", + requestedSchema: { + type: "object", + properties: { + environment: { type: "string", title: "Environment", default: "staging" }, + }, + required: ["environment"], + }, + }, + }, + }, + request_state: JSON.generate(app: app), + ) + else + # Transports parse JSON with `symbolize_names: true`, so answers normally arrive symbol-keyed; + # the string fallback covers custom transports. + action = answer[:action] || answer["action"] + + if action == "accept" + environment = answer.dig(:content, :environment) || answer.dig("content", "environment") + MCP::Tool::Response.new([{ type: "text", text: "Deployed #{app} to #{environment}" }]) + else + MCP::Tool::Response.new([{ type: "text", text: "Deployment of #{app} was cancelled (#{action})" }]) + end + end +end + +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. +# See http_server.rb for a CORS setup for browser-based clients. +rack_app = Rack::Builder.new do + use(Rack::CommonLogger, Logger.new($stdout)) + use(Rack::ShowExceptions) + + run(transport) +end + +puts <<~MESSAGE + === MCP Modern Lifecycle Server (2026-07-28) === + + Starting server on http://localhost:9494 + + Run the SDK client against it: + ruby examples/modern_http_client.rb + + Or walk the modern lifecycle with cURL: + + 1. Probe capabilities (sessionless, works without any protocol headers): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' + + 2. Modern tools/list (the MCP-Protocol-Version header selects the era, the + Mcp-Method header mirrors the method, and params._meta carries the + SEP-2575 envelope): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \\ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + 3. Modern tools/call (name-bearing methods additionally mirror the name in + the Mcp-Name header; no Mcp-Session-Id is ever sent): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: greet" \\ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + Press Ctrl+C to stop the server +MESSAGE + +Rackup::Handler.get("puma").run(rack_app, Port: 9494, Host: "localhost") diff --git a/examples/rails/.gitignore b/examples/rails/.gitignore new file mode 100644 index 0000000..4910c63 --- /dev/null +++ b/examples/rails/.gitignore @@ -0,0 +1,3 @@ +/Gemfile.lock +/log/ +/tmp/ diff --git a/examples/rails/Gemfile b/examples/rails/Gemfile new file mode 100644 index 0000000..a1494d8 --- /dev/null +++ b/examples/rails/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "mcp", path: "../.." +gem "puma" +gem "rails", "~> 8.0" diff --git a/examples/rails/README.md b/examples/rails/README.md new file mode 100644 index 0000000..eaee531 --- /dev/null +++ b/examples/rails/README.md @@ -0,0 +1,116 @@ +# Rails Example MCP Server + +A minimal Rails application that serves an MCP server over the Streamable HTTP transport, +following the "Rails (mount)" pattern from the [top-level README](../../README.md). + +The application provides: + +- `add_tool` tool (`app/tools/add_tool.rb`) - adds two numbers, demonstrates `input_schema` +- `greeting_tool` tool (`app/tools/greeting_tool.rb`) - greets a name, demonstrates `annotations` and `server_context` +- `example://rails/readme` resource - a text resource served via `resources_read_handler` + +The MCP server and transport are built once at boot in `config/routes.rb`, where the transport is mounted at `/mcp`. + +## Requirements + +- Ruby >= 3.2 (required by Rails 8; the `mcp` gem itself supports older Rubies) +- curl >= 7.82 (for the `--json` flag used below) + +## Running + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is now available at `http://localhost:9292/mcp`. + +## Testing with cURL + +POST requests must include an `Accept` header that allows both `application/json` and `text/event-stream`, +per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` lines. + +1. Initialize a session and capture the session ID: + +```console +SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ + | grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') +``` + +2. Complete the handshake (expect `202 Accepted`): + +```console +curl -i http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","method":"notifications/initialized"}' +``` + +3. List and call tools: + +```console +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_tool","arguments":{"a":5,"b":3}}}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"greeting_tool","arguments":{"name":"Rails"}}}' +``` + +4. List and read the resource: + +```console +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":5,"method":"resources/list"}' + +curl http://localhost:9292/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + --json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"example://rails/readme"}}' +``` + +5. Optionally, open the standalone SSE stream for server-to-client messages + (in another terminal): + +```console +curl -N http://localhost:9292/mcp \ + -H "Accept: text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" +``` + +6. End the session: + +```console +curl -i -X DELETE http://localhost:9292/mcp -H "Mcp-Session-Id: $SESSION_ID" +``` + +## Testing with MCP Inspector + +Start [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) with `npx @modelcontextprotocol/inspector`, +set Transport Type to "Streamable HTTP", and connect to `http://localhost:9292/mcp`. + +## Notes + +- `StreamableHTTPTransport` keeps session and SSE state in memory, so it must run in a single process. + Puma runs in single mode (`workers 0`) by default; do not enable clustered mode for this app. + When running multiple instances behind a load balancer, use sticky sessions keyed on the `Mcp-Session-Id` header, + or pass `stateless: true` to the transport. +- The server is built once at boot in `config/routes.rb`, so code reloading is disabled + (`config.enable_reloading = false` in `config/application.rb`). + Restart the server after changing tool or resource code. Note that tool classes cannot be referenced from + `config/initializers` because Zeitwerk has not set up autoloading at that point; routes load late enough that they can. +- For a per-request server with request-specific tools or context, see the "Rails (controller)" section in the top-level README, + which uses `stateless: true` and `transport.handle_request(request)` inside a controller action. diff --git a/examples/rails/app/tools/add_tool.rb b/examples/rails/app/tools/add_tool.rb new file mode 100644 index 0000000..b3bf506 --- /dev/null +++ b/examples/rails/app/tools/add_tool.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddTool < MCP::Tool + title "Add Tool" + description "A simple example tool that adds two numbers" + input_schema( + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + ) + + class << self + def call(a:, b:) + MCP::Tool::Response.new([{ + type: "text", + text: "The sum of #{a} and #{b} is #{a + b}", + }]) + end + end +end diff --git a/examples/rails/app/tools/greeting_tool.rb b/examples/rails/app/tools/greeting_tool.rb new file mode 100644 index 0000000..00c8c3f --- /dev/null +++ b/examples/rails/app/tools/greeting_tool.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class GreetingTool < MCP::Tool + title "Greeting Tool" + description "Greets the given name and reports which app served the request" + input_schema( + properties: { + name: { type: "string" }, + }, + required: ["name"], + ) + annotations( + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + read_only_hint: true, + ) + + class << self + def call(name:, server_context:) + MCP::Tool::Response.new([{ + type: "text", + text: "Hello, #{name}! (served by #{server_context[:app_name]})", + }]) + end + end +end diff --git a/examples/rails/config.ru b/examples/rails/config.ru new file mode 100644 index 0000000..14f9aab --- /dev/null +++ b/examples/rails/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +run(Rails.application) diff --git a/examples/rails/config/application.rb b/examples/rails/config/application.rb new file mode 100644 index 0000000..a61ce76 --- /dev/null +++ b/examples/rails/config/application.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" + +require "rails" +require "action_controller/railtie" + +require "mcp" + +module McpRailsExample + class Application < Rails::Application + config.load_defaults(8.0) + + # This example has no views, assets, or database. + config.api_only = true + + # The MCP server is built once at boot (config/routes.rb) and holds references to the tool classes in app/tools, + # so code reloading is disabled; restart the server after changing tool or resource code. + # A reloading app would need `config.to_prepare` and a way to swap the transport's server instead. + config.enable_reloading = false + config.eager_load = true + + config.logger = ActiveSupport::Logger.new($stdout) + end +end diff --git a/examples/rails/config/environment.rb b/examples/rails/config/environment.rb new file mode 100644 index 0000000..e8173e0 --- /dev/null +++ b/examples/rails/config/environment.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "application" + +Rails.application.initialize! diff --git a/examples/rails/config/routes.rb b/examples/rails/config/routes.rb new file mode 100644 index 0000000..76f3c27 --- /dev/null +++ b/examples/rails/config/routes.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# The MCP server and its Streamable HTTP transport are built here, once at boot, +# following the "Rails (mount)" pattern from the top-level README. +# Routes are loaded after the application initializes, so the tool classes in app/tools +# are available at this point (they are not yet available in config/initializers, +# where Zeitwerk has not set up autoloading). +# +# The transport keeps session and SSE state in memory, so run a single server process +# (e.g., Puma with `workers 0`, which is the rackup default). +# See the top-level README for multi-instance and stateless deployment notes. +server = MCP::Server.new( + name: "rails_example_server", + title: "Rails Example Server", + version: "1.0.0", + tools: [AddTool, GreetingTool], + resources: [ + MCP::Resource.new( + uri: "example://rails/readme", + name: "readme", + title: "Example README", + description: "Describes this Rails example MCP server", + mime_type: "text/plain", + ), + ], + server_context: { app_name: "mcp_rails_example" }, +) + +server.resources_read_handler do |params| + [ + { + uri: params[:uri], + mimeType: "text/plain", + text: "This resource is served by the Rails example MCP server (Rails #{Rails.version}).", + }, + ] +end + +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + # `StreamableHTTPTransport` is a Rack app, so it can be mounted directly. + # `mount` directs all HTTP methods on /mcp to the transport, which internally dispatches POST (JSON-RPC messages), + # GET (SSE stream), and DELETE (session termination) per the MCP Streamable HTTP transport spec. + mount(transport => "/mcp") +end diff --git a/examples/stdio_client.rb b/examples/stdio_client.rb new file mode 100644 index 0000000..60b2067 --- /dev/null +++ b/examples/stdio_client.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "json" + +# Simple stdio client example that connects to the stdio_server.rb example +# Usage: ruby examples/stdio_client.rb + +server_script = File.expand_path("stdio_server.rb", __dir__) + +transport = MCP::Client::Stdio.new(command: "ruby", args: [server_script]) +client = MCP::Client.new(transport: transport) + +begin + # Perform the MCP initialization handshake before sending any requests. + client.connect + + # List available tools + puts "=== Listing tools ===" + tools = client.tools + tools.each do |tool| + puts " Tool: #{tool.name} - #{tool.description}" + end + + # Call the example_tool (adds two numbers) + puts "\n=== Calling tool: example_tool ===" + tool = tools.find { |t| t.name == "example_tool" } + response = client.call_tool(tool: tool, arguments: { a: 5, b: 3 }) + puts " Response: #{JSON.pretty_generate(response.dig("result", "content"))}" + + # Call the echo tool + puts "\n=== Calling tool: echo ===" + tool = tools.find { |t| t.name == "echo" } + response = client.call_tool(tool: tool, arguments: { message: "Hello from stdio client!" }) + puts " Response: #{JSON.pretty_generate(response.dig("result", "content"))}" + + # List prompts + puts "\n=== Listing prompts ===" + prompts = client.prompts + prompts.each do |prompt| + puts " Prompt: #{prompt["name"]} - #{prompt["description"]}" + end + + # List resources + puts "\n=== Listing resources ===" + resources = client.resources + resources.each do |resource| + puts " Resource: #{resource["name"]} (#{resource["uri"]})" + end + + # Read a resource + puts "\n=== Reading resource: https://test_resource.invalid ===" + contents = client.read_resource(uri: "https://test_resource.invalid") + puts " Response: #{JSON.pretty_generate(contents)}" +rescue => e + puts "Error: #{e.message}" + puts e.backtrace.first(5).join("\n") +ensure + transport.close +end diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index b3a7477..ab7aeed 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -1,49 +1,28 @@ # frozen_string_literal: true +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" require "net/http" require "uri" require "json" require "logger" +require "event_stream_parser" -# Logger for client operations -logger = Logger.new($stdout) -logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" +SERVER_URL = "http://localhost:9393" + +def create_logger + logger = Logger.new($stdout) + logger.formatter = proc do |severity, datetime, _progname, msg| + "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" + end + logger end -# Server configuration -SERVER_URL = "http://localhost:9393/mcp" -PROTOCOL_VERSION = "2024-11-05" - -# Helper method to make JSON-RPC requests -def make_request(session_id, method, params = {}, id = nil) - uri = URI(SERVER_URL) - http = Net::HTTP.new(uri.host, uri.port) - - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/json" - request["Mcp-Session-Id"] = session_id if session_id - - body = { - jsonrpc: "2.0", - method: method, - params: params, - id: id || SecureRandom.uuid, - } - - request.body = body.to_json - response = http.request(request) - - { - status: response.code, - headers: response.to_hash, - body: JSON.parse(response.body), - } -rescue => e - { error: e.message } -end - -# Connect to SSE stream +# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler +# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with +# raw `Net::HTTP` to show the SSE wire format. The GET stream carries server-initiated messages +# that are not tied to a client request; request-scoped notifications such as progress arrive on +# the POST's own SSE response stream (see `stream_tool_call` below). def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -59,17 +38,13 @@ def connect_sse(session_id, logger) if response.code == "200" logger.info("SSE stream connected successfully") + parser = EventStreamParser::Parser.new response.read_body do |chunk| - chunk.split("\n").each do |line| - if line.start_with?("data: ") - data = line[6..-1] - begin - logger.info("SSE data: #{data}") - rescue JSON::ParserError - logger.debug("Non-JSON SSE data: #{data}") - end - elsif line.start_with?(": ") - logger.debug("SSE keepalive received: #{line}") + parser.feed(chunk) do |type, data, _id| + if type.empty? + logger.info("SSE event: #{data}") + else + logger.info("SSE event (#{type}): #{data}") end end end @@ -79,125 +54,172 @@ def connect_sse(session_id, logger) end end rescue Interrupt - logger.info("SSE connection interrupted by user") + logger.info("SSE connection interrupted") rescue => e logger.error("SSE connection error: #{e.message}") end -# Main client flow -def main - logger = Logger.new($stdout) - logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" +def print_response(response) + if response.nil? + puts "Response accepted; watch the SSE stream for the server response." + else + puts "Response: #{JSON.pretty_generate(response)}" end +end - puts "=== MCP SSE Test Client ===" +# Calls a tool with a raw streaming POST so each SSE event on the response stream is printed as +# it arrives: the `notifications/progress` events requested via `_meta.progressToken`, +# then the final JSON-RPC response. `MCP::Client#call_tool` reads the same stream but consumes +# the progress notifications internally, so the raw request makes them visible here. +def stream_tool_call(session_id, request_id, name:, arguments:, logger:) + uri = URI(SERVER_URL) - # Step 1: Initialize session - logger.info("Initializing session...") - - init_response = make_request( - nil, - "initialize", - { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: "sse-test-client", - version: "1.0", - }, + body = { + jsonrpc: "2.0", + id: request_id, + method: "tools/call", + params: { + name: name, + arguments: arguments, + _meta: { progressToken: "streamable-http-client" }, }, - "init-1", - ) + } - if init_response[:error] - logger.error("Failed to initialize: #{init_response[:error]}") - exit(1) - end + Net::HTTP.start(uri.host, uri.port) do |http| + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Accept"] = "application/json, text/event-stream" + request["Mcp-Session-Id"] = session_id + request.body = JSON.generate(body) - session_id = init_response[:headers]["mcp-session-id"]&.first - - if session_id.nil? - logger.error("No session ID received") - exit(1) - end - - logger.info("Session initialized: #{session_id}") - logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}") - - # Step 2: Start SSE connection in a separate thread - sse_thread = Thread.new { connect_sse(session_id, logger) } - - # Give SSE time to connect - sleep(1) - - # Step 3: Interactive menu - loop do - puts <<~MESSAGE.chomp - - === Available Actions === - 1. Send custom notification - 2. Test echo - 3. List tools - 0. Exit - - Choose an action:#{" "} - MESSAGE - - choice = gets.chomp - - case choice - when "1" - print("Enter notification message: ") - message = gets.chomp - print("Enter delay in seconds (0 for immediate): ") - delay = gets.chomp.to_f - - response = make_request( - session_id, - "tools/call", - { - name: "notification_tool", - arguments: { - message: message, - delay: delay, - }, - }, - ) - if response[:body]["accepted"] - logger.info("Notification sent successfully") - else - logger.error("Error: #{response[:body]["error"]}") + http.request(request) do |response| + parser = EventStreamParser::Parser.new + response.read_body do |chunk| + parser.feed(chunk) do |_type, data, _id| + logger.info("SSE event: #{data}") + end end - when "2" - print("Enter message to echo: ") - message = gets.chomp - make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } }) - when "3" - make_request(session_id, "tools/list") - when "0" - logger.info("Exiting...") - break - else - puts "Invalid choice" end end - - # Clean up - sse_thread.kill if sse_thread.alive? - - # Close session - logger.info("Closing session...") - make_request(session_id, "close") - logger.info("Session closed") -rescue Interrupt - logger.info("Client interrupted by user") -rescue => e - logger.error("Client error: #{e.message}") - logger.error(e.backtrace.join("\n")) end -# Run the client -if __FILE__ == $PROGRAM_NAME - main +def main + logger = create_logger + + puts <<~MESSAGE + MCP Streamable HTTP Client + Make sure the server is running (ruby examples/streamable_http_server.rb) + #{"=" * 60} + MESSAGE + + http_transport = MCP::Client::HTTP.new(url: SERVER_URL) + client = MCP::Client.new(transport: http_transport) + sse_thread = nil + raw_request_id = 0 + + begin + puts "=== Initializing session ===" + + # The default `mode: :auto` adopts the sessionless modern lifecycle (2026-07-28) + # when the server supports it. This example demonstrates the legacy session's SSE stream, + # so pin the legacy `initialize` handshake. + server_info = client.connect( + client_info: { name: "streamable-http-client", version: "1.0" }, + mode: :legacy, + ) + + puts <<~MESSAGE + ID: #{http_transport.session_id} + Version: #{http_transport.protocol_version} + Server: #{server_info["serverInfo"]} + MESSAGE + + unless http_transport.session_id + logger.error("No session ID received; this example requires a stateful Streamable HTTP session.") + return + end + + puts "=== Listing tools ===" + tools = client.tools + tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + + echo_tool = tools.find { |tool| tool.name == "echo" } + notification_tool = tools.find { |tool| tool.name == "notification_tool" } + + sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) } + sleep(1) + + loop do + puts <<~MENU.chomp + + === Available Actions === + 1. Send notification (triggers SSE event) + 2. Echo message + 3. Show cached tools + 0. Exit + + Choose an action:#{" "} + MENU + + case gets.chomp + when "1" + if notification_tool + print("Enter notification message: ") + message = gets.chomp + print("Enter delay in seconds (0 for immediate): ") + delay = gets.chomp.to_f + + puts "=== Calling tool: notification_tool (raw streaming POST) ===" + raw_request_id += 1 + stream_tool_call( + http_transport.session_id, + "raw-#{raw_request_id}", + name: notification_tool.name, + arguments: { message: message, delay: delay }, + logger: logger, + ) + else + puts "notification_tool not available" + end + when "2" + if echo_tool + print("Enter message to echo: ") + message = gets.chomp + + puts "=== Calling tool: echo ===" + response = client.call_tool(tool: echo_tool, arguments: { message: message }) + print_response(response) + else + puts "echo tool not available" + end + when "3" + puts "=== Cached tools ===" + tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + when "0" + logger.info("Exiting...") + break + else + puts "Invalid choice" + end + end + rescue MCP::Client::SessionExpiredError => e + logger.error("Session expired: #{e.message}") + rescue MCP::Client::RequestHandlerError => e + logger.error("Request error: #{e.message}") + rescue Interrupt + logger.info("Client interrupted") + rescue => e + logger.error("Error: #{e.message}") + logger.error(e.backtrace.first(5).join("\n")) + ensure + sse_thread.kill if sse_thread&.alive? + + if http_transport.connected? + puts "=== Closing session ===" + http_transport.close + puts "Session closed" + end + end end + +main diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index 11cc2e5..f122296 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -2,7 +2,7 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "mcp" -require "rack" +require "rack/cors" require "rackup" require "json" require "logger" @@ -13,14 +13,16 @@ sse_logger.formatter = proc do |severity, datetime, _progname, msg| "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" end -# Tool that returns a response that will be sent via SSE if a stream is active +# Tool that emits `notifications/progress` events, delivered over the SSE stream. +# The client must request them by sending a `progressToken` in the request `_meta`. class NotificationTool < MCP::Tool tool_name "notification_tool" - description "Returns a notification message that will be sent via SSE if stream is active" + description "Sends the message back as progress notifications via SSE, then returns a summary" input_schema( properties: { message: { type: "string", description: "Message to send via SSE" }, - delay: { type: "number", description: "Delay in seconds before returning (optional)" }, + steps: { type: "number", description: "Number of progress notifications to send (default: 3)" }, + delay: { type: "number", description: "Delay in seconds between notifications (optional)" }, }, required: ["message"], ) @@ -28,14 +30,19 @@ class NotificationTool < MCP::Tool class << self attr_accessor :logger - def call(message:, delay: 0) - sleep(delay) if delay > 0 + def call(message:, server_context:, steps: 3, delay: 0) + steps = steps.to_i.clamp(1, 10) - logger&.info("Returning notification message: #{message}") + steps.times do |i| + sleep(delay) if delay > 0 + + logger&.info("Reporting progress #{i + 1}/#{steps}: #{message}") + server_context.report_progress(i + 1, total: steps, message: message) + end MCP::Tool::Response.new([{ type: "text", - text: "Notification: #{message} (timestamp: #{Time.now.iso8601})", + text: "Sent #{steps} progress notifications for: #{message}", }]) end end @@ -44,6 +51,7 @@ end # Create the server server = MCP::Server.new( name: "sse_test_server", + version: "1.0.0", tools: [NotificationTool], prompts: [], resources: [], @@ -63,75 +71,83 @@ end # Create the Streamable HTTP transport transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) -server.transport = transport -# Create a logger for MCP request/response logging -mcp_logger = Logger.new($stdout) -mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg| - "[MCP] #{msg}\n" -end +# Rack middleware for MCP request/response and SSE logging. +class McpSseLogger + def initialize(app) + @app = app -# Create the Rack application -app = proc do |env| - request = Rack::Request.new(env) + @mcp_logger = Logger.new($stdout) + @mcp_logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" } - # Log request details - if request.post? - body = request.body.read - request.body.rewind - begin - parsed_body = JSON.parse(body) - mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})") - - # Log SSE-specific setup - if parsed_body["method"] == "initialize" - sse_logger.info("New client initializing session") - end - rescue JSON::ParserError - mcp_logger.warn("Invalid JSON in request") - end - elsif request.get? - session_id = request.env["HTTP_MCP_SESSION_ID"] || - Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"] - sse_logger.info("SSE connection request for session: #{session_id}") + @sse_logger = Logger.new($stdout) + @sse_logger.formatter = proc { |severity, datetime, _progname, msg| "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" } end - # Handle the request - response = transport.handle_request(request) + def call(env) + if env["REQUEST_METHOD"] == "POST" + body = env["rack.input"].read + env["rack.input"].rewind - # Log response details - status, headers, body = response - if body.is_a?(Array) && !body.empty? && request.post? - begin - parsed_response = JSON.parse(body.first) - if parsed_response["error"] - mcp_logger.error("Response error: #{parsed_response["error"]["message"]}") - elsif parsed_response["accepted"] - # Response was sent via SSE - sse_logger.info("Response sent via SSE stream") - else - mcp_logger.info("Response: success (id: #{parsed_response["id"]})") + begin + parsed = JSON.parse(body) - # Log session ID for initialization - if headers["Mcp-Session-Id"] - sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") + @mcp_logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})") + @sse_logger.info("New client initializing session") if parsed["method"] == "initialize" + rescue JSON::ParserError + @mcp_logger.warn("Invalid JSON in request") + end + elsif env["REQUEST_METHOD"] == "GET" + session_id = env["HTTP_MCP_SESSION_ID"] || Rack::Utils.parse_query(env["QUERY_STRING"])["sessionId"] + + @sse_logger.info("SSE connection request for session: #{session_id}") + end + + status, headers, response_body = @app.call(env) + + if response_body.is_a?(Array) && !response_body.empty? && env["REQUEST_METHOD"] == "POST" + begin + parsed = JSON.parse(response_body.first) + + if parsed["error"] + @mcp_logger.error("Response error: #{parsed["error"]["message"]}") + else + @mcp_logger.info("Response: success (id: #{parsed["id"]})") + @sse_logger.info("Session created: #{headers["mcp-session-id"]}") if headers["mcp-session-id"] end + rescue JSON::ParserError + @mcp_logger.warn("Invalid JSON in response") end - rescue JSON::ParserError - mcp_logger.warn("Invalid JSON in response") + elsif env["REQUEST_METHOD"] == "GET" && status == 200 + @sse_logger.info("SSE stream established") end - elsif request.get? && status == 200 - sse_logger.info("SSE stream established") - end - response + [status, headers, response_body] + end end -# Build the Rack application with middleware +# Build the Rack application with middleware. +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. rack_app = Rack::Builder.new do + # Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector) + # WARNING: origins("*") allows all origins. Restrict this in production. + use(Rack::Cors) do + allow do + origins("*") + resource( + "*", + headers: :any, + methods: [:get, :post, :delete, :options], + expose: ["Mcp-Session-Id"], + ) + end + end + use(Rack::CommonLogger, Logger.new($stdout)) use(Rack::ShowExceptions) - run(app) + use(McpSseLogger) + + run(transport) end # Print usage instructions @@ -141,29 +157,34 @@ puts <<~MESSAGE Starting server on http://localhost:9393 Available Tools: - 1. NotificationTool - Returns messages that are sent via SSE when stream is active" + 1. notification_tool - Sends the message back as progress notifications via SSE 2. echo - Simple echo tool Testing SSE: 1. Initialize session: curl -i http://localhost:9393 \\ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' - 2. Connect SSE stream (use the session ID from step 1):" + 2. Connect SSE stream (use the session ID from step 1): curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393 3. In another terminal, test tools (responses will be sent via SSE if stream is active): Echo tool: curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ + -H "Accept: application/json, text/event-stream" \\ --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}' - Notification tool (with 2 second delay): + Notification tool (progress notifications require a progressToken in _meta): curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!","delay":1},"_meta":{"progressToken":"curl-progress"}}}' - Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {"accepted": true} + Note: Each POST is answered with an SSE stream of its own; request-scoped notifications + (such as the progress events above) appear on it before the final response. + The standalone GET stream carries server-initiated messages that are not tied to a request. Press Ctrl+C to stop the server MESSAGE diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb new file mode 100644 index 0000000..6cbce1c --- /dev/null +++ b/lib/json_rpc_handler.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "json" + +module JsonRpcHandler + class Version + V1_0 = "1.0" + V2_0 = "2.0" + end + + class ErrorCode + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + PARSE_ERROR = -32700 + end + + DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/.freeze + + # Sentinel return value from a handler. When a handler returns this, + # `process_request` emits no JSON-RPC response for the request, + # matching the notification-style semantics (id is ignored). + NO_RESPONSE = Object.new.freeze + + extend self + + def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder) + if request.is_a?(Array) + return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_REQUEST, + message: "Invalid Request", + data: "Request is an empty array", + }) if request.empty? + + # Handle batch requests + responses = request.map { |req| process_request(req, id_validation_pattern: id_validation_pattern, &method_finder) }.compact + + # A single item is hoisted out of the array + return responses.first if responses.one? + + # An empty array yields nil + responses if responses.any? + elsif request.is_a?(Hash) + # Handle single request + process_request(request, id_validation_pattern: id_validation_pattern, &method_finder) + else + error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_REQUEST, + message: "Invalid Request", + data: "Request must be an array or a hash", + }) + end + end + + def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder) + begin + request = JSON.parse(request_json, symbolize_names: true) + response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder) + rescue JSON::ParserError + response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::PARSE_ERROR, + message: "Parse error", + data: "Invalid JSON", + }) + rescue StandardError + # Last-resort guard so an unexpected handling error returns a JSON-RPC error + # instead of escaping to the transport loop. + response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INTERNAL_ERROR, + message: "Internal error", + }) + end + + response&.to_json + end + + def process_request(request, id_validation_pattern:, &method_finder) + # A batch element can be any JSON value; a non-object element cannot carry an id or a method, + # so reject it before the `request[:id]` access below. + unless request.is_a?(Hash) + return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_REQUEST, + message: "Invalid Request", + data: "Request must be an object", + }) + end + + id = request[:id] + + error = if !valid_version?(request[:jsonrpc]) + "JSON-RPC version must be 2.0" + elsif !valid_id?(id, id_validation_pattern) + "Request ID must match validation pattern, or be an integer or null" + elsif !valid_method_name?(request[:method]) + 'Method name must be a string and not start with "rpc."' + end + + # Per JSON-RPC 2.0 (Response object, `id`), the error response must carry + # the same id as the request when the id could be detected; null is only + # for requests whose id could not be determined. `error_response` nils out + # ids that fail validation, and the `:unknown_id` sentinel keeps a response + # (with a null id) being emitted when the request carried no id at all. + return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_REQUEST, + message: "Invalid Request", + data: error, + }) if error + + method_name = request[:method] + params = request[:params] + + unless valid_params?(params) + return error_response(id: id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INVALID_PARAMS, + message: "Invalid params", + data: "Method parameters must be an array or an object or null", + }) + end + + begin + method = method_finder.call(method_name, id) + + if method.nil? + return error_response(id: id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::METHOD_NOT_FOUND, + message: "Method not found", + data: method_name, + }) + end + + result = method.call(params) + return if result.equal?(NO_RESPONSE) + + success_response(id: id, result: result) + rescue MCP::Server::RequestHandlerError => e + handle_request_error(e, id, id_validation_pattern) + rescue StandardError + # The exception message is deliberately not echoed into `data`: it can carry + # internals that must not reach untrusted clients (CWE-209). + error_response(id: id, id_validation_pattern: id_validation_pattern, error: { + code: ErrorCode::INTERNAL_ERROR, + message: "Internal error", + }) + end + end + + def handle_request_error(error, id, id_validation_pattern) + if error.respond_to?(:error_code) && error.error_code + code = error.error_code + message = error.message + else + error_type = error.respond_to?(:error_type) ? error.error_type : nil + + code, message = case error_type + when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"] + when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"] + when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"] + when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"] + else [ErrorCode::INTERNAL_ERROR, "Internal error"] + end + end + + data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message + + error_response(id: id, id_validation_pattern: id_validation_pattern, error: { + code: code, + message: message, + data: data, + }) + end + + def valid_version?(version) + version == Version::V2_0 + end + + def valid_id?(id, pattern = nil) + return true if id.nil? || id.is_a?(Integer) + return false unless id.is_a?(String) + + pattern ? id.match?(pattern) : true + end + + def valid_method_name?(method) + method.is_a?(String) && !method.start_with?("rpc.") + end + + def valid_params?(params) + params.nil? || params.is_a?(Array) || params.is_a?(Hash) + end + + def success_response(id:, result:) + { + jsonrpc: Version::V2_0, + id: id, + result: result, + } unless id.nil? + end + + def error_response(id:, id_validation_pattern:, error:) + { + jsonrpc: Version::V2_0, + id: valid_id?(id, id_validation_pattern) ? id : nil, + error: error.compact, + } unless id.nil? + end +end diff --git a/lib/mcp.rb b/lib/mcp.rb index f3e7788..2213f23 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -1,33 +1,32 @@ # frozen_string_literal: true +require_relative "json_rpc_handler" require_relative "mcp/configuration" -require_relative "mcp/content" -require_relative "mcp/instrumentation" -require_relative "mcp/methods" -require_relative "mcp/prompt" -require_relative "mcp/prompt/argument" -require_relative "mcp/prompt/message" -require_relative "mcp/prompt/result" -require_relative "mcp/resource" -require_relative "mcp/resource/contents" -require_relative "mcp/resource/embedded" -require_relative "mcp/resource_template" -require_relative "mcp/server" -require_relative "mcp/server/transports/streamable_http_transport" -require_relative "mcp/server/transports/stdio_transport" +require_relative "mcp/protocol_deprecations" require_relative "mcp/string_utils" -require_relative "mcp/tool" -require_relative "mcp/tool/input_schema" -require_relative "mcp/tool/output_schema" -require_relative "mcp/tool/response" -require_relative "mcp/tool/annotations" require_relative "mcp/transport" require_relative "mcp/version" -require_relative "mcp/client" -require_relative "mcp/client/http" -require_relative "mcp/client/tool" module MCP + autoload :Annotations, "mcp/annotations" + autoload :Apps, "mcp/apps" + autoload :Cancellation, "mcp/cancellation" + autoload :CancelledError, "mcp/cancelled_error" + autoload :Client, "mcp/client" + autoload :Content, "mcp/content" + autoload :Elicitation, "mcp/elicitation" + autoload :ErrorCodes, "mcp/error_codes" + autoload :Icon, "mcp/icon" + autoload :Prompt, "mcp/prompt" + autoload :RequestEnvelope, "mcp/request_envelope" + autoload :Resource, "mcp/resource" + autoload :ResourceTemplate, "mcp/resource_template" + autoload :ResultType, "mcp/result_type" + autoload :Server, "mcp/server" + autoload :ServerSession, "mcp/server_session" + autoload :Tool, "mcp/tool" + autoload :TraceContext, "mcp/trace_context" + class << self def configure yield(configuration) @@ -37,13 +36,4 @@ module MCP @configuration ||= Configuration.new end end - - class Annotations - attr_reader :audience, :priority - - def initialize(audience: nil, priority: nil) - @audience = audience - @priority = priority - end - end end diff --git a/lib/mcp/annotations.rb b/lib/mcp/annotations.rb new file mode 100644 index 0000000..f8562f2 --- /dev/null +++ b/lib/mcp/annotations.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module MCP + class Annotations + SUPPORTED_AUDIENCES = ["user", "assistant"].freeze + + attr_reader :audience, :priority, :last_modified + + def initialize(audience: nil, priority: nil, last_modified: nil) + if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) }) + raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".' + end + raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1) + + @audience = audience + @priority = priority + @last_modified = last_modified + end + + def to_h + { + audience: audience, + priority: priority, + lastModified: last_modified, + }.compact + end + end +end diff --git a/lib/mcp/apps.rb b/lib/mcp/apps.rb new file mode 100644 index 0000000..de09192 --- /dev/null +++ b/lib/mcp/apps.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module MCP + # Server-side helpers for the MCP Apps extension (SEP-1865, Extensions Track, Final): + # interactive user interfaces delivered as `ui://` HTML template resources that hosts + # render for tool results. The extension is negotiated per SEP-2133 through + # `capabilities.extensions` on both sides; a server declares {EXTENSION_ID}, registers + # UI templates as ordinary resources, and links tools to templates via `_meta`. + # + # Everything else defined by the extension (the sandboxed iframe, the `ui/*` + # postMessage bridge methods, consent for UI-initiated calls) is HOST responsibility: + # a server only ever receives ordinary `resources/read` and `tools/call` requests. + # + # Because the extension is optional, a UI-enabled tool MUST still return a meaningful + # text-only result for clients that did not declare the capability; + # use {Apps.client_supports?} to branch. + # + # @example Declaring an Apps-enabled server + # capabilities = MCP::Server::Capabilities.new + # capabilities.support_tools + # capabilities.support_resources + # capabilities.support_extensions(MCP::Apps.capability) + # server = MCP::Server.new( + # name: "weather_server", + # capabilities: capabilities, + # resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], + # ) + # server.define_tool( + # name: "get_weather", + # meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), + # ) { |server_context:| + # ... + # } + # + # https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx + module Apps + # Reverse-DNS extension identifier (note: `/ui`, not `/apps`), shared wire vocabulary with + # the reference `@modelcontextprotocol/ext-apps` package and the Python SDK's `Apps` extension. + EXTENSION_ID = "io.modelcontextprotocol/ui" + # UI template resources MUST use this parameterized MIME type. + RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" + # UI template resource URIs MUST use this scheme. + URI_SCHEME = "ui://" + # Legacy flat `_meta` key linking a tool to its template; the canonical shape is + # the nested `_meta.ui.resourceUri`. {Apps.tool_meta} can emit both. + RESOURCE_URI_META_KEY = "ui/resourceUri" + + extend self + + # The `capabilities.extensions` fragment advertising Apps support. Pass to + # `MCP::Server::Capabilities.new(extensions: ...)` or `support_extensions(...)`, + # or merge into a client's `connect(capabilities: { extensions: ... })`. + def capability(mime_types: [RESOURCE_MIME_TYPE]) + { EXTENSION_ID => { mimeTypes: mime_types } } + end + + # Builds an `MCP::Resource` for a UI template, enforcing the spec's MUSTs: + # a `ui://` URI and the `text/html;profile=mcp-app` MIME type by default. + def ui_resource(uri:, name:, mime_type: RESOURCE_MIME_TYPE, **rest) + unless uri.is_a?(String) && uri.start_with?(URI_SCHEME) + raise ArgumentError, "MCP Apps template URIs must start with #{URI_SCHEME.inspect} (got #{uri.inspect})" + end + + Resource.new(uri: uri, name: name, mime_type: mime_type, **rest) + end + + # Builds the tool `_meta` linking a tool to its UI template, merged non-destructively into + # caller-supplied `meta`. `visibility` restricts who sees the tool (an array of `"model"` / `"app"`). + # `legacy: true` also writes the flat `"ui/resourceUri"` alias, matching the reference server helper + # that keeps both key shapes in sync for older hosts. + def tool_meta(resource_uri:, visibility: nil, meta: nil, legacy: false) + unless resource_uri.is_a?(String) && resource_uri.start_with?(URI_SCHEME) + raise ArgumentError, "resource_uri must start with #{URI_SCHEME.inspect} (got #{resource_uri.inspect})" + end + + ui_entry = { resourceUri: resource_uri } + ui_entry[:visibility] = visibility if visibility + + merged = (meta || {}).merge(ui: ui_entry) + merged[RESOURCE_URI_META_KEY.to_sym] = resource_uri if legacy + merged + end + + # Whether the client declared Apps support for `mime_type` in its `capabilities.extensions` + # (symbol or string keys). UI-enabled tools use this to fall back to a text-only result for + # clients without the extension. + def client_supports?(client_capabilities, mime_type: RESOURCE_MIME_TYPE) + extensions = read_key(client_capabilities, :extensions) + declaration = read_key(extensions, EXTENSION_ID) + return false unless declaration.is_a?(Hash) + + mime_types = read_key(declaration, :mimeTypes) + + # A declaration without mimeTypes advertises the extension without narrowing. + return true if mime_types.nil? + + mime_types.is_a?(Array) && mime_types.include?(mime_type) + end + + private + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + end +end diff --git a/lib/mcp/cancellation.rb b/lib/mcp/cancellation.rb new file mode 100644 index 0000000..b702ae2 --- /dev/null +++ b/lib/mcp/cancellation.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "cancelled_error" + +module MCP + class Cancellation + attr_reader :reason, :request_id + + def initialize(request_id: nil) + @request_id = request_id + @reason = nil + @cancelled = false + @callbacks = [] + @mutex = Mutex.new + end + + def cancelled? + @mutex.synchronize { @cancelled } + end + + def cancel(reason: nil) + callbacks = @mutex.synchronize do + return false if @cancelled + + @cancelled = true + @reason = reason + @callbacks.tap { @callbacks = [] } + end + + callbacks.each do |callback| + callback.call(reason) + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" }) + end + + true + end + + # Registers a callback invoked synchronously on the first `cancel` call. + # If already cancelled, fires immediately. + # + # Returns the block itself as a handle that can be passed to `off_cancel` + # to deregister it (e.g. when a nested request completes normally and the + # hook should not fire on a later parent cancellation). + def on_cancel(&block) + fire_now = false + @mutex.synchronize do + if @cancelled + fire_now = true + else + @callbacks << block + end + end + + block.call(@reason) if fire_now + block + end + + # Removes a previously-registered `on_cancel` callback. Returns `true` + # if the callback was still pending (i.e. had not yet fired), `false` + # otherwise. Safe to call with `nil`. + def off_cancel(handle) + return false unless handle + + @mutex.synchronize { !@callbacks.delete(handle).nil? } + end + + def raise_if_cancelled! + raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled? + end + end +end diff --git a/lib/mcp/cancelled_error.rb b/lib/mcp/cancelled_error.rb new file mode 100644 index 0000000..767e6f9 --- /dev/null +++ b/lib/mcp/cancelled_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MCP + class CancelledError < StandardError + attr_reader :request_id, :reason + + def initialize(message = "Request was cancelled", request_id: nil, reason: nil) + super(message) + @request_id = request_id + @reason = reason + end + end +end diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 90095d3..7c9d8b9 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,105 +1,31 @@ # frozen_string_literal: true +require_relative "client/elicitation" +require_relative "client/mcp_param_headers" +require_relative "client/modern_envelope" +require_relative "client/oauth" +require_relative "client/stdio" +require_relative "client/http" +require_relative "client/paginated_result" +require_relative "client/tool" +require_relative "result_type" + module MCP class Client - # Initializes a new MCP::Client instance. - # - # @param transport [Object] The transport object to use for communication with the server. - # The transport should be a duck type that responds to `send_request`. See the README for more details. - # - # @example - # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") - # client = MCP::Client.new(transport: transport) - def initialize(transport:) - @transport = transport - end + # Upper bound on the number of pages the all-pages methods (`tools`, `resources`, + # `resource_templates`, `prompts`) will walk. The cursor guard in `fetch_all_pages` only + # stops a server that repeats or cycles cursors; one that returns a fresh `nextCursor` on + # every response would otherwise be followed indefinitely, growing the retained pages with it. + MAX_PAGES = 1_000 - # The user may want to access additional transport-specific methods/attributes - # So keeping it public - attr_reader :transport + class ServerError < StandardError + attr_reader :code, :data - # Returns the list of tools available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available tools. - # - # @example - # tools = client.tools - # tools.each do |tool| - # puts tool.name - # end - def tools - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "tools/list", - }) - - response.dig("result", "tools")&.map do |tool| - Tool.new( - name: tool["name"], - description: tool["description"], - input_schema: tool["inputSchema"], - ) - end || [] - end - - # Returns the list of resources available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available resources. - def resources - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/list", - }) - - response.dig("result", "resources") || [] - end - - # Calls a tool via the transport layer and returns the full response from the server. - # - # @param tool [MCP::Client::Tool] The tool to be called. - # @param arguments [Object, nil] The arguments to pass to the tool. - # @return [Hash] The full JSON-RPC response from the transport. - # - # @example - # tool = client.tools.first - # response = client.call_tool(tool: tool, arguments: { foo: "bar" }) - # structured_content = response.dig("result", "structuredContent") - # - # @note - # The exact requirements for `arguments` are determined by the transport layer in use. - # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - def call_tool(tool:, arguments: nil) - transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "tools/call", - params: { name: tool.name, arguments: arguments }, - }) - end - - # Reads a resource from the server by URI and returns the contents. - # - # @param uri [String] The URI of the resource to read. - # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:) - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/read", - params: { uri: uri }, - }) - - response.dig("result", "contents") || [] - end - - private - - def request_id - SecureRandom.uuid + def initialize(message, code:, data: nil) + super(message) + @code = code + @data = data + end end class RequestHandlerError < StandardError @@ -112,5 +38,976 @@ module MCP @original_error = original_error end end + + # Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`) + # to answer the request with a specific JSON-RPC error code instead of the default internal error. + # Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example, + # the sampling spec answers a rejected request with code `-1`. + # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling + class ServerRequestError < StandardError + attr_reader :code + + def initialize(message, code:) + super(message) + @code = code + end + end + + # Raised when a server response fails client-side validation, e.g., a success response + # whose `result` field is missing or has the wrong type. This is distinct from a + # server-returned JSON-RPC error, which is raised as `ServerError`. + class ValidationError < StandardError; end + + # Raised when an all-pages method reaches `max_pages` while the server is still offering + # another cursor. Use the single-page `list_*` methods to walk such a collection with + # a policy of your own. + class PaginationLimitError < StandardError; end + + # Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of + # a final result. The result is not an error on the wire: it asks the client to fulfill the server's + # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with + # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue + # the original request with `inputResponses` plus the echoed opaque `requestState`. + # With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs + # automatically; this error surfaces when no matching handler exists (manual driving via + # `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + class InputRequiredError < StandardError + attr_reader :input_requests, :request_state, :result + + def initialize(message, input_requests:, request_state: nil, result: nil) + super(message) + @input_requests = input_requests + @request_state = request_state + @result = result + end + end + + # Raised when the server responds 404 to a request containing a session ID, + # indicating the session has expired. Inherits from `RequestHandlerError` for + # backward compatibility with callers that rescue the generic error. Per spec, + # clients MUST start a new session with a fresh `initialize` request in response. + class SessionExpiredError < RequestHandlerError + def initialize(message, request, original_error: nil) + super(message, request, error_type: :not_found, original_error: original_error) + end + end + + # The server's `DiscoverResult` (MCP 2026-07-28, SEP-2575): the modern protocol versions it serves, + # its capabilities and identity, optional instructions, and the REQUIRED `ttlMs`/`cacheScope` cache hints. + DiscoverResult = Struct.new( + :supported_versions, + :capabilities, + :server_info, + :instructions, + :ttl_ms, + :cache_scope, + keyword_init: true, + ) + + # Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and + # Python SDK defaults. Every leg counts, including `requestState`-only retries. + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10 + + # Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to + # a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms). + STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05 + STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25 + + # Initializes a new MCP::Client instance. + # + # @param transport [Object] The transport object to use for communication with the server. + # The transport should be a duck type that responds to `send_request`. See the README for more details. + # @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds. + # @param max_pages [Integer] Maximum number of pages the all-pages methods ({#tools}, {#resources}, + # {#resource_templates}, {#prompts}) will walk before raising {MCP::Client::PaginationLimitError}. + # + # Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`, + # `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers + # (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving, + # exactly as before. + # + # @example + # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") + # client = MCP::Client.new(transport: transport) + def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, max_pages: MAX_PAGES) + # `nil` or a non-positive value would make the pagination unbounded and silently + # disable the protection, so reject it up front. + unless max_pages.is_a?(Integer) && max_pages > 0 + raise ArgumentError, "max_pages must be a positive Integer" + end + + @transport = transport + @max_pages = max_pages + # Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways + # the server can ask for input: a real server-to-client request, and an embedded request inside + # a SEP-2322 `input_required` result. + @input_required_handlers = {} + @input_required_max_rounds = input_required_max_rounds + end + + # The user may want to access additional transport-specific methods/attributes + # So keeping it public + attr_reader :transport + + # The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575): + # after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption + # it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`). + # Code that must work against both eras should prefer the era-independent readers {#protocol_version}, {#server_capabilities}, + # {#instructions}, and {#server_implementation}; this raw form remains the window to everything they do not cover + # (`supportedVersions`, cache hints, `_meta`, extension data). Returns `nil` before `connect`, after `close`, + # or when the transport does not expose a cached handshake result. + def server_info + transport.server_info if transport.respond_to?(:server_info) + end + + # The protocol version in use on this connection, independent of its era: + # the version negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). + # Returns `nil` before `connect`. + def protocol_version + return transport.protocol_version if transport.respond_to?(:protocol_version) + + server_info&.dig("protocolVersion") + end + + # The server's capabilities Hash, present in both eras. Returns `nil` before `connect`. + def server_capabilities + server_info&.dig("capabilities") + end + + # The server's instructions text, present in both eras when provided. + def instructions + server_info&.dig("instructions") + end + + # The server's identity (`name`/`version`), independent of where the era puts it: top-level `serverInfo` + # on legacy results, the optional `_meta` `io.modelcontextprotocol/serverInfo` stamp on modern results. + # Returns `nil` when a modern server does not identify itself. + def server_implementation + server_info&.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || server_info&.dig("serverInfo") + end + + # Performs the MCP `initialize` handshake by delegating to the transport + # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's + # `InitializeResult`. + # + # When the transport does not respond to `:connect`, this is a no-op and + # returns `nil`. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # @param protocol_version [String, nil] Protocol version to offer. + # @param capabilities [Hash] Capabilities advertised by the client. May include + # an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers, + # e.g. `{ extensions: { "com.example/feature" => {} } }`. + # @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose + # `connect` declares `mode:` (the bundled `MCP::Client::HTTP` and `MCP::Client::Stdio`) + # negotiate with `:auto`: probe `server/discover` first and fall back to the legacy handshake + # when the server does not serve a mutually supported modern version. Transports without `mode:` + # keep receiving the historical legacy call shape. `:legacy` forces the `initialize` handshake + # exactly as before; `:modern` requires the modern lifecycle and fails without a mutual modern version. + # Passing an explicit `protocol_version` from a legacy generation (e.g. `"2025-11-25"`) pins + # the legacy handshake without a probe, so an explicitly requested version is never overridden + # by the default negotiation. + # @return [Hash, nil] The server's `InitializeResult` (legacy) or `DiscoverResult` (modern), + # or `nil` when the transport does not expose an explicit handshake. + # Prefer the era-independent readers over inspecting this Hash directly. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: nil) + return unless transport.respond_to?(:connect) + + effective_mode = resolve_connect_mode(mode, protocol_version) + + if effective_mode == :legacy + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + ) + else + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + mode: effective_mode, + ) + end + end + + # Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery + # that works before (or instead of) `connect`. + # + # @param meta [Hash, nil] Additional `_meta` entries to send with the request. + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [MCP::Client::DiscoverResult] + # @raise [ServerError] If the server returns a JSON-RPC error. + # @raise [ValidationError] If the response `result` is missing or not a Hash. + def discover(meta: nil, cancellation: nil) + response = request(method: Methods::SERVER_DISCOVER, meta: meta, cancellation: cancellation) + result = response.is_a?(Hash) ? response["result"] : nil + raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) + + DiscoverResult.new( + supported_versions: result["supportedVersions"], + capabilities: result["capabilities"], + # The finalized spec (PR #3002) stamps the server identity into the result `_meta`; + # the top-level fallback tolerates servers built against the frozen SEP text. + server_info: result.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || result["serverInfo"], + instructions: result["instructions"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) + end + + # Returns true once `connect` has completed the handshake on the underlying + # transport. Transports that do not expose connection state are assumed + # connected and return `true`. + def connected? + return transport.connected? if transport.respond_to?(:connected?) + + true + end + + # Returns a single page of tools from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional token; cancelling it sends + # `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call. + # @return [MCP::Client::ListToolsResult] Result with `tools` (Array) + # and `next_cursor` (String or nil). + # + # @example Iterate all pages + # cursor = nil + # loop do + # page = client.list_tools(cursor: cursor) + # page.tools.each { |tool| puts tool.name } + # cursor = page.next_cursor + # break unless cursor + # end + def list_tools(cursor: nil, meta: nil, cancellation: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation) + result = response["result"] || {} + + tools = (result["tools"] || []).filter_map do |tool| + next if exclude_invalid_x_mcp_header?(tool) + + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"], + output_schema: tool["outputSchema"], + annotations: tool["annotations"], + ) + end + + ListToolsResult.new( + tools: tools, + next_cursor: result["nextCursor"], + meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) + end + + # Returns every tool available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_tools} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. + # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # Cancelling it aborts whichever page is currently in flight; pages already returned are kept, + # but the call raises `MCP::CancelledError` instead of returning the partial set. + # @return [Array] An array of available tools. + # @raise [MCP::Client::PaginationLimitError] If the server offers more than `max_pages` pages. + # + # @example + # tools = client.tools + # tools.each do |tool| + # puts tool.name + # end + def tools(cancellation: nil) + # TODO: consider renaming to `list_all_tools`. + fetch_all_pages { |cursor| list_tools(cursor: cursor, cancellation: cancellation) }.flat_map(&:tools) + end + + # Returns a single page of resources from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array) + # and `next_cursor` (String or nil). + def list_resources(cursor: nil, meta: nil, cancellation: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation) + result = response["result"] || {} + + ListResourcesResult.new( + resources: result["resources"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) + end + + # Returns every resource available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resources} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. + # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). + # @return [Array] An array of available resources. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. + def resources(cancellation: nil) + # TODO: consider renaming to `list_all_resources`. + fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources) + end + + # Returns a single page of resource templates from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates` + # (Array) and `next_cursor` (String or nil). + def list_resource_templates(cursor: nil, meta: nil, cancellation: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation) + result = response["result"] || {} + + ListResourceTemplatesResult.new( + resource_templates: result["resourceTemplates"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) + end + + # Returns every resource template available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_resource_templates} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. + # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). + # @return [Array] An array of available resource templates. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. + def resource_templates(cancellation: nil) + # TODO: consider renaming to `list_all_resource_templates`. + fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates) + end + + # Returns a single page of prompts from the server. + # + # @param cursor [String, nil] Cursor from a previous page response. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array) + # and `next_cursor` (String or nil). + def list_prompts(cursor: nil, meta: nil, cancellation: nil) + params = cursor ? { cursor: cursor } : nil + response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation) + result = response["result"] || {} + + ListPromptsResult.new( + prompts: result["prompts"] || [], + next_cursor: result["nextCursor"], + meta: result["_meta"], + ttl_ms: result["ttlMs"], + cache_scope: result["cacheScope"], + ) + end + + # Returns every prompt available on the server. Iterates through all pages automatically + # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting. + # Use {#list_prompts} when you need fine-grained cursor control. + # + # Each call will make a new request - the result is not cached. + # + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). + # @return [Array] An array of available prompts. + # @raise [MCP::Client::PaginationLimitError] See {#tools}. + def prompts(cancellation: nil) + # TODO: consider renaming to `list_all_prompts`. + fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts) + end + + # Calls a tool via the transport layer and returns the full response from the server. + # + # @param name [String] The name of the tool to call. + # @param tool [MCP::Client::Tool] The tool to be called. + # @param arguments [Object, nil] The arguments to pass to the tool. + # @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. the W3C Trace Context keys reserved by SEP-414 + # (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`). + # `progress_token` takes precedence over a `progressToken` entry in `meta`. + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. Cancelling it from another thread + # sends `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call. + # @return [Hash] The full JSON-RPC response from the transport. + # + # @example Call by name + # response = client.call_tool(name: "my_tool", arguments: { foo: "bar" }) + # content = response.dig("result", "content") + # + # @example Call with a tool object + # tool = client.tools.first + # response = client.call_tool(tool: tool, arguments: { foo: "bar" }) + # structured_content = response.dig("result", "structuredContent") + # + # @example Cancellable call + # cancellation = MCP::Cancellation.new + # Thread.new do + # client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation) + # rescue MCP::CancelledError + # # cleanup + # end + # cancellation.cancel(reason: "user pressed cancel") + # + # @note + # The exact requirements for `arguments` are determined by the transport layer in use. + # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. + # @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`, + # keyed identically (manual retry legs). + # @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly. + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + tool_name = name || tool&.name + raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name + + params = { name: tool_name, arguments: arguments } + meta_entries = meta ? meta.dup : {} + if progress_token + meta_entries.delete("progressToken") + meta_entries[:progressToken] = progress_token + end + params[:_meta] = meta_entries unless meta_entries.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + drive_input_required(method: "tools/call", params: params, cancellation: cancellation) + end + + # Reads a resource from the server by URI and returns the contents. + # + # @param uri [String] The URI of the resource to read. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [Array] An array of resource contents (text or blob). + def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { uri: uri } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation) + + response.dig("result", "contents") || [] + end + + # Gets a prompt from the server by name and returns its details. + # + # @param name [String] The name of the prompt to get. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [Hash] A hash containing the prompt details. + def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + params = { name: name } + params = params.merge(_meta: meta) if meta && !meta.empty? + params[:inputResponses] = input_responses if input_responses + params[:requestState] = request_state if request_state + + response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation) + + response.fetch("result", {}) + end + + # Requests completion suggestions from the server for a prompt argument or resource template URI. + # + # @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }` + # or `{ type: "ref/resource", uri: "file:///{path}" }`. + # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`. + # @param context [Hash, nil] Optional context with previously resolved arguments. + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`. + def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil) + params = { ref: ref, argument: argument } + params[:context] = context if context + + response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation) + + response.dig("result", "completion") || { "values" => [], "hasMore" => false } + end + + # Registers a handler for `elicitation/create` requests the server sends while one of + # this client's requests is in flight. The handler receives the request `params` + # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash: + # `{ action: "accept" | "decline" | "cancel", content: { ... } }`. + # + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it + # forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322, + # a server MUST NOT embed input requests of a kind the client has not declared. + # + # @example Accept with schema defaults applied (SEP-1034) + # client.on_elicitation do |params| + # { + # action: "accept", + # content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + # } + # end + # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation + def on_elicitation(&handler) + register_input_handler(Methods::ELICITATION_CREATE, &handler) + end + + # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight. + # The handler receives the request `params` (`messages`, `maxTokens`, optionally `systemPrompt`, `modelPreferences`, `tools`, + # `toolChoice`, ...; string keys) and must return a `CreateMessageResult`-shaped Hash: + # `{ role: "assistant", content: { type: "text", text: "..." }, model: "...", stopReason: "..." }`. + # + # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response + # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`. + # + # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that + # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322 + # `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to + # `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of + # a kind the client has not declared. + # + # @example Forward the request to an LLM and return its completion + # + # client.on_sampling do |params| + # raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + # + # completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + # { + # role: "assistant", + # content: { type: "text", text: completion.text }, + # model: completion.model, + # stopReason: "endTurn", + # } + # end + # + # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling + # + # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window; + # new servers should call LLM provider APIs directly. + def on_sampling(&handler) + register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler) + end + + # Registers a handler for `roots/list`, answering both a server-to-client request on transports that + # support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler + # receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash: + # `{ roots: [{ uri: "file:///project", name: "Project" }] }`. + # + # @example + # client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } } + # + # @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Register this handler only to interoperate with servers that still ask for roots. + # + # https://modelcontextprotocol.io/specification/2025-11-25/client/roots + def on_roots(&handler) + register_input_handler(Methods::ROOTS_LIST, &handler) + end + + # Sends a `ping` request to the server to verify the connection is alive. + # Per the MCP spec, the server responds with an empty result. + # + # @param meta [Hash, nil] Additional `_meta` entries to send with the request, + # e.g. SEP-414 trace context (see {MCP::TraceContext}). + # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. + # @return [Hash] An empty hash on success. + # @raise [ServerError] If the server returns a JSON-RPC error. + # @raise [ValidationError] If the response `result` is missing or not a Hash. + # + # @example + # client.ping # => {} + # + # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping + def ping(meta: nil, cancellation: nil) + result = request(method: Methods::PING, meta: meta, cancellation: cancellation)["result"] + raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) + + result + end + + private + + # Records a handler for one of the three kinds of input a server can ask this client for, and wires it to + # the transport when the transport can carry server-to-client requests. One registration serves both routes: + # the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322 + # `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client + # requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio, + # and every modern-lifecycle connection); only the wire route is skipped there. + def register_input_handler(method, &handler) + @input_required_handlers[method] = handler + transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request) + + handler + end + + # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate + # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition + # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. + # Legacy connections, and transports without a lifecycle notion, list everything as before. + def exclude_invalid_x_mcp_header?(tool) + return false unless transport.respond_to?(:modern?) && transport.modern? + + scan = McpParamHeaders.scan(tool["inputSchema"]) + return false if scan[:valid] + + warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}") + true + end + + # Resolves the effective SEP-2575 lifecycle mode for `connect`: + # + # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe, + # so the default negotiation can never override a version the caller asked for. + # - Absent an explicit mode, transports declaring `mode:` negotiate with `:auto`; other transports keep + # the historical legacy call shape. + # - An explicit `:modern`/`:auto` on a transport without `mode:` raises, rather than silently downgrading to + # a lifecycle the caller did not ask for. + def resolve_connect_mode(mode, protocol_version) + unless [nil, :legacy, :modern, :auto].include?(mode) + raise ArgumentError, "mode must be :legacy, :modern, or :auto" + end + + return :legacy if mode == :legacy + return :legacy if mode.nil? && protocol_version && !Configuration.modern_protocol_version?(protocol_version) + + if transport_connect_accepts_mode? + mode || :auto + elsif mode.nil? + :legacy + else + raise ArgumentError, "transport does not support mode: #{mode.inspect}" + end + end + + # `mode:` is forwarded only when the transport's `connect` declares it as a keyword. + # A bare `**kwargs` deliberately does not count, so wrappers and test doubles that + # absorb arbitrary keywords keep the historical legacy call shape. Objects that + # dispatch `connect` through `method_missing` (e.g. mocks) may not support `method`, + # which reads as not declaring `mode:`. + def transport_connect_accepts_mode? + connect_method = begin + transport.method(:connect) + rescue NameError + return false + end + + connect_method.parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :mode } + end + + # Walks every page of a list endpoint, following `next_cursor`, and returns + # the page results. The `seen` set guards against a server that repeats or + # cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every + # time, so the loop always terminates. + def fetch_all_pages + pages = [] + seen = Set.new + cursor = nil + + loop do + page = yield(cursor) + pages << page + next_cursor = page.next_cursor + break if next_cursor.nil? || seen.include?(next_cursor) + + if pages.size >= @max_pages + raise PaginationLimitError, "Server returned more than #{@max_pages} pages; pass a larger `max_pages:` to " \ + "`MCP::Client.new` if this is expected." + end + + seen << next_cursor + cursor = next_cursor + end + + pages + end + + # Merges caller-supplied `meta` entries into the request params as `_meta`, + # without mutating the caller's hashes. Per SEP-414, `_meta` carries + # request-specific metadata such as W3C trace context (`traceparent`, + # `tracestate`, `baggage`); see {MCP::TraceContext}. + def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true) + params = (params || {}).merge(_meta: meta) if meta && !meta.empty? + + request_body = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: generate_request_id, + method: method, + } + request_body[:params] = params if params + + response = if cancellation + dispatch_with_cancellation(request_body, cancellation) + else + transport.send_request(request: request_body) + end + + # Guard with `is_a?(Hash)` because custom transports may return non-Hash values. + if response.is_a?(Hash) && response.key?("error") + error = response["error"] + raise ServerError.new(error["message"], code: error["code"], data: error["data"]) + end + + raise_on_input_required(response) if raise_on_input_required + + response + end + + # Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`. + # With no configured handlers this degrades to the plain request (an `input_required` result raises + # `InputRequiredError` for manual driving). Otherwise each `inputRequests` entry is fulfilled by + # the matching handler and the ORIGINAL request is re-issued with `inputResponses` under + # the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg. + # A `requestState`-only result (load shedding) retries after an exponential backoff. + # Every leg counts against `input_required_max_rounds`. + def drive_input_required(method:, params:, cancellation:) + response = request( + method: method, + params: params, + cancellation: cancellation, + raise_on_input_required: @input_required_handlers.empty?, + ) + return response unless input_required?(response) + + original_params = params.dup + original_params.delete(:inputResponses) + original_params.delete(:requestState) + rounds = 0 + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + + loop do + result = response["result"] + rounds += 1 + if rounds > @input_required_max_rounds + raise InputRequiredError.new( + "Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).", + input_requests: result["inputRequests"] || {}, + request_state: result["requestState"], + result: result, + ) + end + + input_requests = result["inputRequests"] || {} + responses = nil + if input_requests.empty? + sleep(backoff) + backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min + else + backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS + responses = fulfill_input_requests(input_requests, result) + end + + retry_params = original_params.dup + retry_params[:inputResponses] = responses if responses + retry_params[:requestState] = result["requestState"] if result["requestState"] + + response = request( + method: method, + params: retry_params, + cancellation: cancellation, + raise_on_input_required: false, + ) + return response unless input_required?(response) + end + end + + # Dispatches every embedded request to its configured handler and collects + # the answers under the same keys. A kind without a handler falls back to + # the manual path by raising `InputRequiredError` with the full result. + def fulfill_input_requests(input_requests, result) + input_requests.each_with_object({}) do |(key, entry), responses| + entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil + handler = @input_required_handlers[entry_method] + unless handler + raise InputRequiredError.new( + "Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \ + "is configured; inspect `input_requests` to respond manually (SEP-2322).", + input_requests: input_requests, + request_state: result["requestState"], + result: result, + ) + end + + responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil) + end + end + + def input_required?(response) + result = response.is_a?(Hash) ? response["result"] : nil + result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED + end + + # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result. + # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them. + def raise_on_input_required(response) + return unless input_required?(response) + + result = response["result"] + raise InputRequiredError.new( + "Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \ + "`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.", + input_requests: result["inputRequests"] || {}, + request_state: result["requestState"], + result: result, + ) + end + + # Generates a fresh JSON-RPC request id for an outgoing request. + # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through + # an `MCP::Cancellation` token instead. + def generate_request_id + SecureRandom.uuid + end + + # Sends `request_body` while watching `cancellation`. The actual blocking `transport.send_request` runs on + # a worker thread; the calling thread waits on a Queue that is woken either by the response or by a cancel signal + # (whichever arrives first - matching the server-side `StreamableHTTPTransport#cancel_pending_request` race contract). + # + # When a cancel wins the race, the calling thread raises `MCP::CancelledError` immediately and the `notifications/cancelled` + # dispatch runs fire-and-forget on its own thread. We deliberately do not wait for that dispatch here: the calling thread + # must not be blocked by a slow or stalled transport write on the cancel path. + # The worker thread is also not force-killed; it stays blocked on the underlying I/O until the server actually responds + # (or the transport closes). This is the same trade-off the server-side `StreamableHTTPTransport#send_request` accepts and + # is noted in the README's Cancellation section. + def dispatch_with_cancellation(request_body, cancellation) + unless transport.respond_to?(:send_notification) + raise NoMethodError, "Cancellation support requires a transport that responds to `send_notification(notification:)` " \ + "so `notifications/cancelled` can be delivered to the peer. The bundled `MCP::Client::Stdio` and `MCP::Client::HTTP` transports " \ + "implement this interface; custom transports must add it before passing `cancellation:` to a request method." + end + + cancellation.raise_if_cancelled! + + request_id = request_body[:id] + queue = Queue.new + + # First-writer-wins gate. Whichever side (worker or on_cancel) flips `completed` first owns the queue's single slot; the loser bails. + # This closes the late-cancel window between the worker pushing `:response` and the main thread completing `dispatch_with_cancellation`, + # where a callback firing in that gap would otherwise emit a stray `notifications/cancelled` for a request that already succeeded. + completion_mutex = Mutex.new + completed = false + sent_mutex = Mutex.new + sent_cond = ConditionVariable.new + request_sent = false + signal_sent = lambda do + sent_mutex.synchronize do + unless request_sent + request_sent = true + sent_cond.broadcast + end + end + end + + Thread.new do + Thread.current.report_on_exception = false + begin + result = transport.send_request(request: request_body, &signal_sent) + completion_mutex.synchronize do + next if completed + + completed = true + queue.push([:response, result]) + end + rescue StandardError => e + completion_mutex.synchronize do + next if completed + + completed = true + queue.push([:error, e]) + end + ensure + # Unblock any waiting cancel-dispatch thread on completion (or error) + # so it does not stall when the transport ignored the block. + signal_sent.call + end + end + + cancel_hook = cancellation.on_cancel do |reason| + should_dispatch = completion_mutex.synchronize do + next false if completed + + completed = true + + # Wake the waiting thread first, then dispatch the `notifications/cancelled` send on a separate thread. + # The wake-first ordering matters because the cancellation callback can run on the worker thread itself + # (e.g. a tool that triggers cancel from within `transport.send_request`), and a synchronous `send_notification` + # here would deadlock when the worker holds a transport-level mutex. + queue.push([:cancelled, reason]) + true + end + + next unless should_dispatch + + Thread.new do + Thread.current.report_on_exception = false + # Wait for the worker's send-boundary signal before issuing `notifications/cancelled`. Bundled transports raise + # the signal via `&on_sent` from inside `send_request`; custom transports that ignore the block still raise it + # via the worker's `ensure -> signal_sent.call`, so the loop is bounded by worker termination rather than by wall-clock time. + # The previous fixed-duration fallback could release this thread before the worker reached its send-boundary at all, + # allowing the cancel to be issued without any prior request commitment - which the spec only covers under + # the receiver's MAY-ignore-unknown-id clause and is therefore avoided here. + sent_mutex.synchronize do + sent_cond.wait(sent_mutex) until request_sent + end + cancel(request_id: request_id, reason: reason) + rescue StandardError + # Swallow notification-send failures: the calling thread has already been woken with `:cancelled` above and + # is on its way to raising `MCP::CancelledError`. + end + end + + tag, payload = queue.pop + + case tag + when :response + payload + when :error + raise payload + when :cancelled + raise MCP::CancelledError.new(request_id: request_id, reason: payload) + end + ensure + cancellation&.off_cancel(cancel_hook) if cancel_hook + end + + # Sends `notifications/cancelled` to the server for an in-flight request. + # Per spec, this is fire-and-forget: the server is expected to stop processing and suppress its response, + # with no acknowledgement returned. Driven internally by {#dispatch_with_cancellation} when a request's + # `cancellation` token fires. + def cancel(request_id:, reason: nil) + params = { requestId: request_id } + params[:reason] = reason if reason + + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: Methods::NOTIFICATIONS_CANCELLED, + params: params, + } + + transport.send_notification(notification: notification) + nil + end end end diff --git a/lib/mcp/client/elicitation.rb b/lib/mcp/client/elicitation.rb new file mode 100644 index 0000000..52df933 --- /dev/null +++ b/lib/mcp/client/elicitation.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module MCP + class Client + # Client-side helpers for the `elicitation/create` server-to-client request. + # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation + module Elicitation + # Fills fields omitted from `content` with the `default` values declared in + # the elicitation request's `requestedSchema` properties, per SEP-1034. + # Provided values are never overwritten, and properties without a `default` + # are left out so the server can apply its own handling. + # Mirrors the TypeScript SDK's `applyElicitationDefaults`; the Python SDK + # applies defaults in the elicitation callback the same way this helper + # is intended to be used. + # + # @param requested_schema [Hash] The `requestedSchema` from the `elicitation/create` + # request params (string or symbol keys). + # @param content [Hash] Values already collected from the user. + # @return [Hash] `content` (string keys) with defaults filled in. + # + # @example Accept an elicitation request with all defaults + # transport.on_server_request("elicitation/create") do |params| + # { + # action: "accept", + # content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]), + # } + # end + class << self + def apply_defaults(requested_schema, content = {}) + filled = content.to_h.transform_keys(&:to_s) + properties = requested_schema["properties"] || requested_schema[:properties] + return filled unless properties.is_a?(Hash) + + properties.each do |name, property_schema| + name = name.to_s + next if filled.key?(name) + next unless property_schema.is_a?(Hash) + + if property_schema.key?("default") + filled[name] = property_schema["default"] + elsif property_schema.key?(:default) + filled[name] = property_schema[:default] + end + end + + filled + end + end + end + end +end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 7b065a8..6969b07 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,81 +1,1040 @@ # frozen_string_literal: true +require "securerandom" +require_relative "../../json_rpc_handler" +require_relative "../configuration" +require_relative "../methods" +require_relative "../protocol_deprecations" +require_relative "../version" +require_relative "mcp_param_headers" +require_relative "modern_envelope" + module MCP class Client class HTTP - attr_reader :url + ACCEPT_HEADER = "application/json, text/event-stream" + SSE_ACCEPT_HEADER = "text/event-stream" + SESSION_ID_HEADER = "Mcp-Session-Id" + PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" + METHOD_HEADER = "Mcp-Method" + NAME_HEADER = "Mcp-Name" + LAST_EVENT_ID_HEADER = "Last-Event-ID" - def initialize(url:, headers: {}) - @url = url - @headers = headers + # SEP-1699 reconnection tuning: the SSE `retry:` field from the server takes precedence; + # this default applies when the server sent none. Both values match the Python SDK + # (`DEFAULT_RECONNECTION_DELAY_MS`, `MAX_RECONNECTION_ATTEMPTS`); the TypeScript SDK uses + # an exponential backoff that the `retry:` field likewise overrides. + DEFAULT_RECONNECTION_DELAY_MS = 1000 + MAX_RECONNECTION_ATTEMPTS = 2 + + # Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success + # and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop. + # Waiting longer than the server asked for is explicitly allowed: the `retry` field the spec points at is + # the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time + # of the event source. Optionally, wait some more." Waiting *less* is what the spec's MUST rules out, + # and nothing here ever does that. + # + # https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time + MIN_RECONNECTION_DELAY_MS = 100 + + # Budget in seconds for `await_response_after_disconnect`: it gates every wait between reconnection attempts, + # and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread, + # so without a deadline a server answering with a large `retry:` parks a thread of the embedding application for + # as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, + # not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops + # reconnecting instead, the same kind of decision the attempt cap already makes, so the server's `retry:` is + # always honored in full or not acted on at all. + # + # Matches `SSE_LISTENER_READ_TIMEOUT`, this client's other "how long to wait on a quiet SSE stream" value. + # `listen_for_server_requests` has no such deadline: it runs on a thread this client owns and is meant + # to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application. + MAX_RECONNECTION_WAIT = 300 + + # How long the standalone GET listening stream may stay idle before the read times out + # and the connection is counted as a failure and retried. Matches the Python SDK's + # `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout + # (60 seconds for Net::HTTP) would recycle quiet streams too eagerly. + SSE_LISTENER_READ_TIMEOUT = 300 + + # Upper bound in bytes on a single JSON-RPC message from the server - an SSE event or + # a JSON response body - buffered in memory while reading a response. Without a bound, + # a server that never terminates an SSE event (or never ends a JSON body) grows + # the buffer indefinitely. Matches the 4 MiB default of `MCP::Client::Stdio::MAX_LINE_BYTES` + # and the server transports' request cap. + MAX_MESSAGE_BYTES = 4 * 1024 * 1024 + + # Upper bound on the tools whose `x-mcp-header` declarations are retained for + # `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing + # (the spec's guidance to send without custom headers), so a server rotating tool names across + # `tools/list` responses cannot grow the registry without bound. + MAX_MCP_PARAM_TOOLS = 1000 + + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor + # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host + # is trivially observed and stolen. + class InsecureURLError < ArgumentError; end + + # Faraday request middleware that compares the outgoing request URL + # against the URL snapshotted at `MCP::Client::HTTP#initialize` time. + # Registered after the user's customizer so it sees `env.url` *after* + # any custom middleware has had a chance to rewrite it - closing + # the `Faraday env.url = URI("https://attacker...")` bypass that a plain + # `client.url_prefix` check would miss. The comparison includes + # the query string, so a middleware that rewrites `env.url.query` to + # a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise + # the audience-binding check on the OAuth side could be bypassed at + # the send step. + class OAuthURLGuard + def initialize(app, expected_url:) + @app = app + @expected_url = expected_url + end + + def call(env) + effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s) + unless effective == @expected_url + # Surface the *canonicalized* form (no userinfo, no fragment) so + # credentials like `user:pass@` cannot leak into logs, stack + # traces, or exception reporters. + raise InsecureURLError, + "Effective request URL #{effective.inspect} does not match the URL " \ + "validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token." + end + @app.call(env) + end end + # Internal control-flow signal raised inside the streaming callback to stop reading + # an SSE stream once the awaited JSON-RPC response has arrived; servers may hold + # a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on. + class StreamAbort < StandardError; end + private_constant :StreamAbort + + # Raised inside the streaming callback when the server sends more bytes than + # `max_message_bytes` without completing a message. Translated into + # a `RequestHandlerError` (or a listener stop) where the request context is known. + class MessageTooLargeError < StandardError; end + private_constant :MessageTooLargeError + + # Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams: + # the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`), + # and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path. + class SSEStream + attr_reader :buffer, :response, :last_event_id, :retry_ms + attr_accessor :abortable + + def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil) + @abortable = abortable + @max_message_bytes = max_message_bytes + @on_request = on_request + @buffer = +"" + @parser = nil + @response = nil + @last_event_id = nil + @retry_ms = nil + end + + # Whether the server sent a SEP-1699 priming event (any event carrying an id), + # which marks the stream as resumable after a graceful close. + def primed? + !@last_event_id.nil? + end + + # Faraday `on_data` streaming callback. SSE chunks are parsed incrementally; + # anything else (JSON bodies) accumulates in `buffer`. Both paths are bounded + # by `max_message_bytes`, checked after the awaited response had its chance to + # abort the stream so an over-limit tail cannot mask a response that already arrived. + def on_data + proc do |chunk, _received_bytes, env| + if event_stream?(env) + feed(chunk) + raise StreamAbort if @abortable && @response + + if parser_buffered_bytes > @max_message_bytes + raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing" + end + else + @buffer << chunk + + if @buffer.bytesize > @max_message_bytes + raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes" + end + end + end + end + + # A fresh parser for a new HTTP connection (reconnection GET), + # so a partial line from the previous stream cannot corrupt the next one. + def reset_parser! + @parser = nil + end + + # Parses an SSE body that was delivered outside the streaming callback: + # Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer` + # (consumed here so they are not parsed twice), and adapters without `on_data` support + # yield the whole body only via `fallback_body` (the Faraday `response.body`). + def ingest_pending!(fallback_body) + text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1) + feed(text) if text.is_a?(String) && !text.empty? + end + + private + + def event_stream?(env) + headers = env&.response_headers + content_type = headers && (headers["content-type"] || headers["Content-Type"]) + !!content_type&.include?("text/event-stream") + end + + def feed(chunk) + parser.feed(chunk) do |_type, data, id, reconnection_time| + @last_event_id = id if id && !id.empty? + @retry_ms = reconnection_time if reconnection_time + next if data.nil? || data.empty? + + begin + parsed = JSON.parse(data) + rescue JSON::ParserError + next + end + + next unless parsed.is_a?(Hash) + + if parsed.key?("result") || parsed.key?("error") + @response ||= parsed + elsif parsed["method"] && parsed.key?("id") + # A server-to-client request (e.g. `elicitation/create`) delivered + # on the stream while the original request is still pending. + @on_request&.call(parsed) + end + end + end + + # Bytes the parser has buffered for a not-yet-dispatched event: the partial line + # and the accumulated `data:` field values (plus the small event type and last event id buffers). + # External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters) + # from consumed-and-retained ones, so the parser's own String buffers are measured instead; + # summing every String instance variable keeps the measurement valid if the parser renames or adds buffers. + # A parser rewrite that buffered elsewhere would make this return 0 and quietly lift the cap - + # the regression tests feeding an over-limit event guard against that. + def parser_buffered_bytes + parser.instance_variables.sum do |name| + value = parser.instance_variable_get(name) + value.is_a?(String) ? value.bytesize : 0 + end + end + + def parser + @parser ||= begin + require "event_stream_parser" + EventStreamParser::Parser.new + rescue LoadError + raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \ + "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \ + "See https://rubygems.org/gems/event_stream_parser for more details." + end + end + end + private_constant :SSEStream + + attr_reader :url, :session_id, :protocol_version, :server_info, :oauth + + def initialize( + url:, + headers: {}, + oauth: nil, + max_message_bytes: MAX_MESSAGE_BYTES, + max_reconnection_wait: MAX_RECONNECTION_WAIT, + &block + ) + # `nil` or a non-positive value would make the buffering unbounded and silently + # disable the protection, so reject it up front. + unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0 + raise ArgumentError, "max_message_bytes must be a positive Integer" + end + + unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0 + raise ArgumentError, "max_reconnection_wait must be a positive number" + end + + if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) + # Mask credentials (userinfo) and query parameters before quoting the URL in the error message + # so they cannot leak into logs. + safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url) + raise InsecureURLError, + "MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \ + "sending bearer tokens over plain http to a remote host would leak them on the wire." + end + + @url = url + @headers = headers + @faraday_customizer = block + @oauth = oauth + @max_message_bytes = max_message_bytes + @max_reconnection_wait = max_reconnection_wait + # Snapshot the canonical URL at construction time. This single value + # serves two related roles, both of which need to see the query string: + # + # - As the RFC 8707 `resource` claim sent on the authorization and + # token requests (and as the base for PRM discovery URLs) - + # matching the TS / Python SDKs' `resourceUrlFromServerUrl` / + # `resource_url_from_server_url` so multi-tenant servers that scope + # by `?tenant=...` round-trip correctly. + # - As the comparison value for the URL guard middleware. Comparing + # query strings as well as origin + path is required so a Faraday + # middleware that rewrites `env.url.query` to a different tenant + # cannot send the bearer token to the wrong audience while + # the resource binding on the OAuth side stays correct. + # + # Saved only when `oauth:` is set so non-OAuth transports keep their + # existing behavior. + @oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil + @session_id = nil + @protocol_version = nil + @server_info = nil + @connected = false + @server_request_handlers = {} + @listener_thread = nil + @modern_client_info = nil + @modern_capabilities = nil + @mcp_param_declarations = {} + end + + # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. + # The handler receives the request's `params` (a Hash with string keys, possibly empty) and its return value is + # sent back to the server as the JSON-RPC `result`. + # The handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code. + # Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error. + # Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests + # that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients, + # which start listening after the `initialize` handshake. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server + def on_server_request(method, &handler) + raise ArgumentError, "A handler block is required" unless handler + + @server_request_handlers[method.to_s] = handler + start_listening if connected? + end + + # Performs the MCP `initialize` handshake: sends an `initialize` request + # followed by the required `notifications/initialized` notification. + # The server's `InitializeResult` (protocol version, capabilities, server + # info, instructions) is cached on the transport and returned. + # + # Idempotent: a second call returns the cached `InitializeResult` without + # contacting the server. After `close`, state is cleared and `connect` + # will handshake again. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). + # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. + # @return [Hash] The server's `InitializeResult`. + # @raise [RequestHandlerError] If the server responds with a JSON-RPC error + # or a malformed result. + # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs + # the handshake below, `:modern` skips it and probes `server/discover`, + # and `:auto` probes `server/discover` first, falling back to the legacy handshake + # when the server does not serve a mutually supported modern version. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) + return @server_info if connected? + + # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + + case mode + when :legacy + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :modern + connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :auto + connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + else + raise ArgumentError, "mode must be :legacy, :modern, or :auto" + end + end + + # Whether the transport operates in the stateless modern lifecycle (SEP-2575): + # no handshake was performed and every request carries the `_meta` envelope. + def modern? + !@modern_client_info.nil? + end + + # Returns true once `connect` has completed the full handshake + # (`initialize` response received and `notifications/initialized` sent), + # or once the modern lifecycle was adopted via `server/discover`. + # Returns false before the first handshake and after `close`. + def connected? + @connected + end + + # Sends a JSON-RPC request and returns the parsed response body. + # After a successful `initialize` handshake, the session ID and protocol version returned by + # the server are captured and automatically included on subsequent requests. + # + # If a block is given, it is invoked just before Faraday's `post` is called. + # Faraday's synchronous `post` does not expose a post-write / pre-response hook, + # so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens + # inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release + # the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST + # that may overlap with the original request on the network. The spec covers this: + # the sender has issued the request and still believes it in-progress, and receivers MAY ignore + # a cancellation referring to an unknown request id when the cancel POST happens to arrive first. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation def send_request(request:) + # Modern requests (never notifications, whose `_meta` has no envelope) carry + # the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers` + # matches it by construction. + if modern? && (request[:id] || request["id"]) + request = ModernEnvelope.stamp( + request, + protocol_version: @protocol_version, + client_info: @modern_client_info, + capabilities: @modern_capabilities, + ) + end + method = request[:method] || request["method"] params = request[:params] || request["params"] + oauth_retried = false + step_up_retried = false - client.post("", request).body - rescue Faraday::BadRequestError => e + begin + # The response is consumed incrementally so that an SSE stream the server holds open + # (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF + # so the response object (and its `Mcp-Session-Id` header) is always available for capture. + stream = SSEStream.new( + abortable: method.to_s != MCP::Methods::INITIALIZE, + max_message_bytes: @max_message_bytes, + on_request: ->(message) { dispatch_server_request(message) }, + ) + + yield if block_given? + + response = begin + client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req| + req.options.on_data = stream.on_data + end + rescue StreamAbort + nil + end + + body = resolve_response_body(stream, response, method, params) + + capture_session_info(method, response, body) if response + capture_mcp_param_declarations(method, params, body) + + body + rescue MessageTooLargeError => e + raise RequestHandlerError.new( + e.message, + { method: method, params: params }, + error_type: :internal_error, + ) + rescue Faraday::BadRequestError => e + raise RequestHandlerError.new( + "The #{method} request is invalid", + { method: method, params: params }, + error_type: :bad_request, + original_error: e, + ) + rescue Faraday::UnauthorizedError => e + # Run the OAuth flow at most once per `send_request` invocation. + # The `oauth_retried` flag lives outside the `begin` so it survives `retry`, + # ensuring a server returning 401 indefinitely raises rather than loops. + if @oauth && !oauth_retried + oauth_retried = true + run_oauth_flow!(unauthorized_error: e) + retry + end + + raise RequestHandlerError.new( + "You are unauthorized to make #{method} requests", + { method: method, params: params }, + error_type: :unauthorized, + original_error: e, + ) + rescue Faraday::ForbiddenError => e + # OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in + # the Bearer challenge means the existing access token is valid + # but lacks scopes the server now requires for this operation. + # Re-run the full authorization flow with the escalated scope from + # the challenge and retry once. A plain 403 without the challenge is + # surfaced unchanged. + if @oauth && !step_up_retried && insufficient_scope_challenge?(e) + step_up_retried = true + run_step_up_flow!(forbidden_error: e) + + retry + end + + raise RequestHandlerError.new( + "You are forbidden to make #{method} requests", + { method: method, params: params }, + error_type: :forbidden, + original_error: e, + ) + rescue Faraday::ResourceNotFound => e + # Per spec, 404 is the session-expired signal only when the request + # actually carried an `Mcp-Session-Id`. A 404 without a session attached + # (e.g. wrong URL or a stateless server) surfaces as a generic not-found. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + if @session_id + clear_session + raise SessionExpiredError.new( + "The #{method} request is not found", + { method: method, params: params }, + original_error: e, + ) + else + raise RequestHandlerError.new( + "The #{method} request is not found", + { method: method, params: params }, + error_type: :not_found, + original_error: e, + ) + end + rescue Faraday::UnprocessableEntityError => e + raise RequestHandlerError.new( + "The #{method} request is unprocessable", + { method: method, params: params }, + error_type: :unprocessable_entity, + original_error: e, + ) + rescue Faraday::Error => e + raise RequestHandlerError.new( + "Internal error handling #{method} request", + { method: method, params: params }, + error_type: :internal_error, + original_error: e, + ) + end + end + + # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver + # `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted + # per the Streamable HTTP spec. + def send_notification(notification:) + method = notification[:method] || notification["method"] + + client.post("", notification, session_headers) + nil + rescue Faraday::Error => e raise RequestHandlerError.new( - "The #{method} request is invalid", - { method:, params: }, - error_type: :bad_request, - original_error: e, - ) - rescue Faraday::UnauthorizedError => e - raise RequestHandlerError.new( - "You are unauthorized to make #{method} requests", - { method:, params: }, - error_type: :unauthorized, - original_error: e, - ) - rescue Faraday::ForbiddenError => e - raise RequestHandlerError.new( - "You are forbidden to make #{method} requests", - { method:, params: }, - error_type: :forbidden, - original_error: e, - ) - rescue Faraday::ResourceNotFound => e - raise RequestHandlerError.new( - "The #{method} request is not found", - { method:, params: }, - error_type: :not_found, - original_error: e, - ) - rescue Faraday::UnprocessableEntityError => e - raise RequestHandlerError.new( - "The #{method} request is unprocessable", - { method:, params: }, - error_type: :unprocessable_entity, - original_error: e, - ) - rescue Faraday::Error => e # Catch-all - raise RequestHandlerError.new( - "Internal error handling #{method} request", - { method:, params: }, + "Failed to send #{method} notification", + { method: method }, error_type: :internal_error, original_error: e, ) end + # Terminates the session by sending an HTTP DELETE to the MCP endpoint + # with the current `Mcp-Session-Id` header, and clears locally tracked + # session state afterward. No-op when no session has been established. + # + # Per spec, the server MAY respond with HTTP 405 Method Not Allowed when + # it does not support client-initiated termination, and returns 404 for + # a session it has already terminated. Both mean the session is gone — + # the desired end state. Other errors surface to the caller; local + # session state is cleared either way. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + def close + unless @session_id + clear_session + return + end + + begin + client.delete("", nil, session_headers) + rescue Faraday::ClientError => e + raise unless [404, 405].include?(e.response&.dig(:status)) + ensure + clear_session + end + end + private attr_reader :headers + def connect_legacy(client_info:, protocol_version:, capabilities:) + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION + + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + }) + + if response.is_a?(Hash) && response.key?("error") + clear_session + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response.is_a?(Hash) && response["result"].is_a?(Hash) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + negotiated_protocol_version = @server_info["protocolVersion"] + # A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake + # settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + }) + rescue StandardError + clear_session + raise + end + + @connected = true + start_listening if @server_request_handlers.any? + @server_info + end + + # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. + # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. + def connect_modern(client_info:, protocol_version:, capabilities:) + version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION + unless MCP::Configuration.modern_protocol_version?(version) + raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" + end + + enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities) + + begin + result = probe_discover + rescue StandardError + leave_modern_mode + raise + end + + supported = result["supportedVersions"] + unless supported.is_a?(Array) && supported.include?(version) + leave_modern_mode + raise RequestHandlerError.new( + "Server discovery failed: no mutually supported modern protocol version " \ + "(server supports #{supported.inspect})", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + + @server_info = result + @connected = true + @server_info + end + + # Probes `server/discover` and adopts the modern lifecycle when the server serves + # a mutually supported modern version; otherwise falls back to the legacy handshake. + # The fallback intentionally covers a successful discovery without a mutual modern + # version as well: during the 2026-07-28 rollout a server may answer discovery while + # only serving legacy versions. + def connect_auto(client_info:, protocol_version:, capabilities:) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) + rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + end + + def enter_modern_mode(protocol_version:, client_info:, capabilities:) + @modern_client_info = client_info + @modern_capabilities = capabilities || {} + # `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header, + # which the modern lifecycle requires to match the `_meta`-carried version. + @protocol_version = protocol_version + end + + def leave_modern_mode + @modern_client_info = nil + @modern_capabilities = nil + @protocol_version = nil + end + + def probe_discover + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::SERVER_DISCOVER, + }) + + if response.is_a?(Hash) && response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server discovery failed: #{error["message"]}", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result = response.is_a?(Hash) ? response["result"] : nil + unless result.is_a?(Hash) + raise RequestHandlerError.new( + "Server discovery failed: missing result in response", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result + end + def client require_faraday! @client ||= Faraday.new(url) do |faraday| faraday.request(:json) - faraday.response(:json) + # The client parses response bodies itself (`resolve_response_body`), so streamed and buffered responses + # take the same path; Faraday's json response middleware is deliberately left out. faraday.response(:raise_error) + faraday.headers["Accept"] = ACCEPT_HEADER headers.each do |key, value| faraday.headers[key] = value end + + @faraday_customizer&.call(faraday) + + # Register the URL identity guard *after* the user's customizer + # so it sits closest to the adapter in the request stack. That way + # the guard sees `env.url` after any customizer-added middleware has had + # a chance to rewrite it, closing the `env.url = URI("https://...")` + # bypass that a `Faraday::Connection#url_prefix` check cannot detect. + faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url end end + # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one) + # and `MCP-Protocol-Version` on all requests after `initialize`. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header + def session_headers + request_headers = {} + request_headers[SESSION_ID_HEADER] = @session_id if @session_id + request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version + if @oauth && (token = @oauth.access_token) + request_headers["Authorization"] = "Bearer #{token}" + end + request_headers + end + + # Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries + # can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name` + # (tools, prompts) or, when absent, `params.uri` (resources). + # + # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata + def request_metadata_headers(method, params) + return {} unless method + + metadata_headers = { METHOD_HEADER => method } + if params.is_a?(Hash) + name = params[:name] || params["name"] + name = params[:uri] || params["uri"] unless name.is_a?(String) + metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String) + + if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]]) + arguments = params[:arguments] || params["arguments"] + + metadata_headers.merge!(McpParamHeaders.build(declarations, arguments)) + end + end + + metadata_headers + end + + # Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises, + # so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243). + # The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs, + # so legacy connections learn nothing. Only a valid, non-empty declaration set is kept: + # an invalid tool definition mirrors nothing, + # following the spec's guidance to send without custom headers when no reliable declarations are available. + def capture_mcp_param_declarations(method, params, body) + return unless modern? + return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash) + + tools = body.dig("result", "tools") + return unless tools.is_a?(Array) + + # An uncursored request answered without `nextCursor` is the complete tool universe, + # so knowledge about unlisted tools is stale; the registry is rebuilt from this listing, + # the same pruning the Python SDK applies to complete listings. + cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"]) + complete = !cursor && body.dig("result", "nextCursor").nil? + registry = complete ? {} : @mcp_param_declarations + + tools.each do |tool| + next unless tool.is_a?(Hash) + + name = tool["name"] + next unless name.is_a?(String) + + scan = McpParamHeaders.scan(tool["inputSchema"]) + if scan[:valid] && !scan[:declarations].empty? + next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS + + registry[name] = scan[:declarations] + else + registry.delete(name) + end + end + + @mcp_param_declarations = registry if complete + end + + # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF, + # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as + # `=?base64??=`. Safe ASCII values are sent unchanged. + def encode_header_value(value) + return value if safe_header_value?(value) + + "=?base64?#{[value].pack("m0")}?=" + end + + def safe_header_value?(value) + value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } && + (value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) && + !(value.start_with?("=?base64?") && value.end_with?("?=")) + end + + # Drives the OAuth orchestrator on a 401 from the MCP endpoint. + # The `WWW-Authenticate` header (when present) supplies the `resource_metadata` + # URL and an optional `scope` challenge per RFC 9728 Section 5.1. + # + # If the provider already holds a refresh token, we try to exchange it + # for a fresh access token first; only when that fails (e.g., the refresh token was revoked) + # do we fall back to the full Authorization Code + PKCE + DCR flow. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling + def run_oauth_flow!(unauthorized_error:) + params = parse_www_authenticate_from_error(unauthorized_error) + flow = MCP::Client::OAuth::Flow.new(provider: @oauth) + return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"]) + + run_full_authorization_flow!(flow: flow, params: params) + end + + # Drives a full Authorization Code + PKCE flow without first attempting + # to refresh the access token. Used for the MCP scope-selection-strategy + # step-up path: the provider already holds a valid access token, + # but the server returned a 403 with + # `WWW-Authenticate: ... error="insufficient_scope", scope="..."` + # per RFC 6750 Section 3.1. Refreshing the existing token would re-issue + # the same scope set the server already rejected, so the SDK must run + # a fresh authorization request. The request asks for the union of + # the currently granted scope and the newly demanded scope; otherwise + # the caller would lose previously held scopes and trigger another step-up + # on the next operation that needs them. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy + def run_step_up_flow!(forbidden_error:) + params = parse_www_authenticate_from_error(forbidden_error) + flow = MCP::Client::OAuth::Flow.new(provider: @oauth) + params = params.merge("scope" => escalated_step_up_scope(params["scope"])) + + run_full_authorization_flow!(flow: flow, params: params) + end + + # Returns the space-separated union of the currently granted scope (read + # from the stored token response per RFC 6749 Section 5.1) and the scope + # demanded by the step-up challenge. Duplicates are collapsed; order + # follows first appearance so existing scopes precede the newly added + # ones. Returns `nil` when neither side carries a scope so + # `build_authorization_url` omits the `scope` parameter entirely. + def escalated_step_up_scope(challenge_scope) + tokens = @oauth.tokens + granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil + scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq + + scopes.empty? ? nil : scopes.join(" ") + end + + # True when the response on `forbidden_error` carries a Bearer challenge + # with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP + # scope-selection-strategy section. A 403 without that signal is not a + # step-up challenge and must not trigger re-authorization. + def insufficient_scope_challenge?(forbidden_error) + parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope" + end + + def parse_www_authenticate_from_error(error) + response = error.response || {} + response_headers = response[:headers] || {} + header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"] + MCP::Client::OAuth::Discovery.parse_www_authenticate(header) + end + + def run_full_authorization_flow!(flow:, params:) + # Use the URL snapshotted at `initialize` time so a post-construction + # mutation of `@url` cannot redirect PRM/AS discovery and the authorize + # URL to an attacker-controlled host. + flow.run!( + server_url: @oauth_server_url, + resource_metadata_url: params["resource_metadata"], + scope: params["scope"], + ) + end + + # Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy + # on success and falsy on either "no refresh token available" or "refresh attempt failed" + # (in which case the caller should run the full interactive flow). + def attempt_refresh(flow:, resource_metadata_url:) + return false unless refresh_token_available? + + # Use the snapshotted URL for the same reason as `run_oauth_flow!` above: + # post-construction `@url` mutation must not redirect token-refresh + # discovery to an attacker-controlled host. Use the query-bearing form + # so the refresh request's RFC 8707 `resource` claim matches + # the original authorization request. + flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url) + true + rescue MCP::Client::OAuth::Flow::InvalidGrantError + # The refresh token has been revoked or expired by the AS. Wipe it so + # the full interactive flow runs fresh on the retry. + @oauth.clear_tokens! + false + rescue MCP::Client::OAuth::Flow::AuthorizationError + # Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh + # token in place; the next attempt may succeed and we avoid forcing + # the user through an interactive reauth for a recoverable error. + false + end + + def refresh_token_available? + tokens = @oauth.tokens + return false unless tokens.is_a?(Hash) + + !(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty? + end + + def capture_session_info(method, response, body) + return unless method.to_s == Methods::INITIALIZE + + # Faraday normalizes header names to lowercase. + session_id = response.headers[SESSION_ID_HEADER.downcase] + @session_id ||= session_id unless session_id.to_s.empty? + @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil + end + + def clear_session + stop_listening + @session_id = nil + @protocol_version = nil + @server_info = nil + @connected = false + @modern_client_info = nil + @modern_capabilities = nil + end + + # Opens the standalone GET SSE listening stream on a background thread so server-to-client requests + # can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation). + # Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established. + def start_listening + return if @listener_thread&.alive? + + @listener_thread = Thread.new { listen_for_server_requests } + end + + def stop_listening + @listener_thread&.kill + @listener_thread = nil + end + + # Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and + # then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics), + # while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener + # once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count. + # A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream. + def listen_for_server_requests + stream = SSEStream.new( + abortable: false, + max_message_bytes: @max_message_bytes, + on_request: ->(message) { dispatch_server_request(message) }, + ) + consecutive_failures = 0 + + loop do + begin + client.get("") do |request| + request.headers.update(session_headers) + request.headers["Accept"] = SSE_ACCEPT_HEADER + request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id + request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT + request.options.on_data = stream.on_data + end + + consecutive_failures = 0 + rescue MessageTooLargeError + # Reconnecting would let the server stream the same over-limit event again, so stop listening instead of retrying. + break + rescue Faraday::Error => e + break if e.response&.dig(:status) == 405 + + consecutive_failures += 1 + + break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS + end + + stream.reset_parser! + + sleep(reconnection_delay_seconds(stream)) + end + end + + # The reconnection delay in seconds: the server's `retry:` value when it sent one and + # the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server + # asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all; + # the SSE parser drops it, so those arrive here as the default rather than as something to guard. + def reconnection_delay_seconds(stream) + delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS + + [delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0 + end + + # Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant + # still asks the adapter for a timeout rather than for "no timeout". + def remaining_reconnection_budget(deadline) + [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max + end + def require_faraday! require "faraday" rescue LoadError @@ -83,6 +1042,192 @@ module MCP "Add it to your Gemfile: gem 'faraday', '>= 2.0'" \ "See https://rubygems.org/gems/faraday for more details." end + + # Determines the logical JSON-RPC body of the exchange after the POST stream finished + # (or was aborted because the response already arrived). + def resolve_response_body(stream, response, method, params) + return stream.response if stream.response + + # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + return if response.status == 202 + + content_type = response.headers["Content-Type"] + + if content_type&.include?("text/event-stream") + # Nothing was parsed during streaming: either the adapter does not support `on_data` + # (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data` + # (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body. + unless stream.primed? + stream.ingest_pending!(response.body) + return stream.response if stream.response + end + + # SEP-1699: a graceful close after a priming event (an event with an id) but before + # the response means "reconnect via GET to resume". + return await_response_after_disconnect(stream, method, params) if stream.primed? + + raise RequestHandlerError.new( + "No valid JSON-RPC response found in SSE stream", + { method: method, params: params }, + error_type: :parse_error, + ) + elsif content_type&.include?("application/json") + return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty? + + # Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware + # added by the Faraday customizer may have parsed it already. + response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body + else + raise RequestHandlerError.new( + "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.", + { method: method, params: params }, + error_type: :unsupported_media_type, + ) + end + end + + # Answers a server-to-client request received on an SSE stream by invoking the handler registered via + # `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with + # 202 Accepted. Requests without a registered handler are answered with a "method not found" error. + # A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code + # (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error". + # In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler + # failures into JSON-RPC error responses. + def dispatch_server_request(message) + handler = @server_request_handlers[message["method"]] + response = if handler + begin + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + result: handler.call(message["params"] || {}), + } + rescue ServerRequestError => e + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { code: e.code, message: e.message }, + } + rescue StandardError => e + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal error handling #{message["method"]} request: #{e.message}", + }, + } + end + elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric)) + # The ping receiver "MUST respond promptly with an empty response" (MCP ping utility), + # so an unhandled ping is answered with the empty result rather than Method not found. + # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at + # schema validation, so a ping carrying one falls through to Method not found instead. + # The default lives here instead of in `@server_request_handlers`, whose `any?` opens + # the GET listening stream on connect: a built-in pong must not change listener behavior. + # A handler registered via `on_server_request("ping")` still wins through the branch above. + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + result: {}, + } + else + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + error: { + code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found: #{message["method"]}", + }, + } + end + + send_client_response(response) + end + + def send_client_response(response) + client.post("", response, session_headers) + end + + def parse_json_buffer(buffer, method, params) + return if buffer.empty? + + JSON.parse(buffer) + rescue JSON::ParserError => e + raise RequestHandlerError.new( + "Failed to parse JSON response: #{e.message}", + { method: method, params: params }, + error_type: :parse_error, + ) + end + + # SEP-1699 resumability: the server closed the SSE stream after a priming event + # without delivering the response. Treat the graceful close like a network failure: + # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with + # a GET carrying `Last-Event-ID` so the server can replay the pending response on + # the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport` + # reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap). + # + # This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as + # by their count: the deadline gates each wait, and what is left of it becomes the read timeout of + # the resumed stream. A delay that would run past the deadline is never shortened: the client stops + # reconnecting instead, so the server's `retry:` is honored in full or not acted on at all. + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 + def await_response_after_disconnect(stream, method, params) + stream.abortable = true + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait + gave_up_waiting = false + + MAX_RECONNECTION_ATTEMPTS.times do + delay = reconnection_delay_seconds(stream) + if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline + gave_up_waiting = true + break + end + + sleep(delay) + stream.reset_parser! + + # Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to + # whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way; + # without this, a caller-supplied adapter with no default read timeout would let a server hold + # the connection open past the budget by simply sending nothing. + read_timeout = remaining_reconnection_budget(deadline) + + reconnect_response = begin + client.get("") do |req| + req.headers.update(session_headers) + req.headers["Accept"] = SSE_ACCEPT_HEADER + req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id + req.options.read_timeout = read_timeout + req.options.on_data = stream.on_data + end + rescue StreamAbort + # The awaited response arrived on the reconnected stream. + nil + end + + # Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`. + if reconnect_response && stream.response.nil? + stream.ingest_pending!(reconnect_response.body) + end + + return stream.response if stream.response + end + + reason = if gave_up_waiting + "the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget" + else + "#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts" + end + + raise RequestHandlerError.new( + "Server closed the SSE stream without a response for #{method} after #{reason}", + { method: method, params: params }, + error_type: :internal_error, + ) + end end end end diff --git a/lib/mcp/client/mcp_param_headers.rb b/lib/mcp/client/mcp_param_headers.rb new file mode 100644 index 0000000..4aa4d54 --- /dev/null +++ b/lib/mcp/client/mcp_param_headers.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +module MCP + class Client + # The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for + # `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers, + # with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values. + # Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`) + # lives with the transport. + # + # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters + module McpParamHeaders + # The fixed prefix every custom-parameter header carries. + HEADER_PREFIX = "Mcp-Param-" + + # The schema-extension property name a tool's `inputSchema` carries. + X_MCP_HEADER_KEY = "x-mcp-header" + + # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces, + # control characters (including CR/LF), and the HTTP delimiters. + RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze + + # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because + # the published conformance referee annotates `type: "number"` parameters and expects them + # mirrored; the TypeScript SDK makes the same accommodation. + PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze + + # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from + # the `properties`-only chain. An `x-mcp-header` under any of these invalidates + # the tool definition rather than being silently ignored. + NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + "items", + "prefixItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "patternProperties", + "dependentSchemas", + "oneOf", + "anyOf", + "allOf", + "not", + "if", + "then", + "else", + "$defs", + "definitions", + ].freeze + + # Keywords whose value maps names to subschemas rather than being one subschema or a list of them. + OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze + + # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored; + # the TypeScript SDK refuses unsafe integers the same way. + MAX_SAFE_INTEGER = (2**53) - 1 + + BASE64_SENTINEL_PREFIX = "=?base64?" + BASE64_SENTINEL_SUFFIX = "?=" + + class << self + # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint + # the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed + # declaring properties, and static reachability through a chain of `properties` keys only. + # Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`, + # or `{ valid: false, reason: "..." }` on the first violation. + def scan(input_schema) + declarations = [] + fault = visit(input_schema, [], true, declarations, {}) + + fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations } + end + + # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and + # the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows); + # a non-primitive or non-representable value is omitted rather than emitted malformed. + def build(declarations, arguments) + declarations.each_with_object({}) do |declaration, headers| + value = value_at_path(arguments, declaration[:path]) + next if value.nil? + + string_value = primitive_to_string(value) + next unless string_value + + encoded = begin + encode_value(string_value) + rescue EncodingError + # A string that cannot be represented as UTF-8 (e.g. binary data) has no header + # representation; omit it like the other non-representable values. + next + end + + headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded + end + end + + # Converts a primitive argument to its header string per the spec's type-conversion rules: + # strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become + # their decimal string. `nil` means "not representable: do not emit a header". + def primitive_to_string(value) + case value + when String + value + when true + "true" + when false + "false" + when Integer + value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil + when Float + return unless value.finite? + + # JSON has one number type: an integral float serializes without the fractional part, + # matching the `String(42.0)` the JavaScript reference emits. + value == value.truncate ? value.truncate.to_s : value.to_s + end + end + + # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value + # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`. + def encode_value(value) + return value unless needs_base64?(value) + + "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}" + end + + private + + def visit(node, path, reachable, declarations, seen_lower) + return unless node.is_a?(Hash) + + if key?(node, X_MCP_HEADER_KEY) + fault = validate_declaration(node, path, reachable, declarations, seen_lower) + + return fault if fault + end + + properties = read(node, "properties") + if properties.is_a?(Hash) + properties.each do |key, child| + fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower) + + return fault if fault + end + end + + # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass + # through with `reachable: false`, so an annotation under any of them is reported. + # `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope. + NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword| + next unless (sub = read(node, keyword)) + + branches = if sub.is_a?(Array) + sub + elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword) + sub.values + else + [sub] + end + + branches.each do |branch| + fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower) + + return fault if fault + end + end + + nil + end + + def validate_declaration(node, path, reachable, declarations, seen_lower) + if !reachable || path.empty? + return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys" + + end + + annotation = read(node, X_MCP_HEADER_KEY) + + unless annotation.is_a?(String) && !annotation.empty? + return "#{path_name(path)}: x-mcp-header MUST be a non-empty string" + end + + unless RFC9110_TOKEN.match?(annotation) + return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token" + end + + type = read(node, "type") + unless type.is_a?(String) && PERMITTED_TYPES.include?(type) + return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \ + "(got `#{type.inspect}`)" + end + + lower = annotation.downcase + prior = seen_lower[lower] + if prior + return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)" + end + + seen_lower[lower] = annotation + declarations << { path: path, header_name: annotation, type: type } + nil + end + + # A value cannot ride as a plain ASCII field value when it is empty, already shaped like + # the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing + # would strip, or contains a byte outside visible ASCII plus interior tab. + def needs_base64?(value) + return true if value.empty? + return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX) + return true if value != value.strip + + value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) } + end + + def value_at_path(root, path) + path.reduce(root) do |node, key| + break unless node.is_a?(Hash) + + read(node, key) + end + end + + # Schemas and arguments arrive with string keys off the wire but may carry symbol keys + # when constructed in Ruby; read both forms like the SDK's other readers. + def read(hash, key) + value = hash[key] + + value.nil? ? hash[key.to_sym] : value + end + + def key?(hash, key) + hash.key?(key) || hash.key?(key.to_sym) + end + + def path_name(path) + path.empty? ? "" : path.join(".") + end + end + end + end +end diff --git a/lib/mcp/client/modern_envelope.rb b/lib/mcp/client/modern_envelope.rb new file mode 100644 index 0000000..b9aed32 --- /dev/null +++ b/lib/mcp/client/modern_envelope.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../request_envelope" + +module MCP + class Client + # Stamps the SEP-2575 per-request `_meta` envelope onto outgoing modern requests. + # The reserved key names are shared with the server-side `MCP::RequestEnvelope`. + # Reserved keys always win over caller-supplied `_meta` entries because they are + # wire vocabulary the SDK is responsible for; every other entry is preserved. + module ModernEnvelope + extend self + + # Returns a copy of `request` with `params._meta` carrying the modern triple. + # Neither `request` nor its nested hashes are mutated. + def stamp(request, protocol_version:, client_info:, capabilities:) + params_key = request.key?("params") ? "params" : :params + params = request[params_key] || {} + meta_key = params.key?("_meta") ? "_meta" : :_meta + meta = params[meta_key] || {} + + stamped_meta = meta.merge( + RequestEnvelope::PROTOCOL_VERSION_META_KEY.to_sym => protocol_version, + RequestEnvelope::CLIENT_INFO_META_KEY.to_sym => client_info, + RequestEnvelope::CLIENT_CAPABILITIES_META_KEY.to_sym => capabilities, + ) + + request.merge(params_key => params.merge(meta_key => stamped_meta)) + end + end + end +end diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb new file mode 100644 index 0000000..ecacc88 --- /dev/null +++ b/lib/mcp/client/oauth.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "oauth/authorization_request" +require_relative "oauth/bounded_body" +require_relative "oauth/discovery" +require_relative "oauth/flow" +require_relative "oauth/in_memory_storage" +require_relative "oauth/pkce" +require_relative "oauth/storage_backed_provider" +require_relative "oauth/jwt_client_assertion" +require_relative "oauth/provider" +require_relative "oauth/client_credentials_provider" +require_relative "oauth/id_jag_token_exchange" +require_relative "oauth/cross_app_access_provider" + +module MCP + class Client + # OAuth client support for the MCP Authorization spec (PRM discovery, + # Authorization Server metadata discovery, Dynamic Client Registration, + # OAuth 2.1 Authorization Code + PKCE, the client_credentials grant, + # and the SEP-990 Enterprise Managed Authorization jwt-bearer grant). + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + module OAuth + end + end +end diff --git a/lib/mcp/client/oauth/authorization_request.rb b/lib/mcp/client/oauth/authorization_request.rb new file mode 100644 index 0000000..57462f5 --- /dev/null +++ b/lib/mcp/client/oauth/authorization_request.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to, + # and with which scopes. + # + # Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it + # without changing the shape every implementer wrote. + # + # Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not, + # so the representation can change later. + AuthorizationRequest = Struct.new( + # `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata. + :authorization_server, + # The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`. + :scopes, + # The MCP server URL this client was configured with. + :server_url, + # The RFC 8707 `resource` the token is being requested for. + :resource, + keyword_init: true, + ) + end + end +end diff --git a/lib/mcp/client/oauth/bounded_body.rb b/lib/mcp/client/oauth/bounded_body.rb new file mode 100644 index 0000000..f3b5efa --- /dev/null +++ b/lib/mcp/client/oauth/bounded_body.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Bounds an OAuth response body while it arrives, rather than after it has been buffered. + # Discovery documents, registration responses, and token responses are all small by definition, + # so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of + # `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`. + class BoundedBody + MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + + # Raised while the body is read. Each caller translates it into its own error type, + # so this never reaches an embedder. + class TooLargeError < StandardError; end + + # What the OAuth code reads from a response. The Faraday response itself is not passed on, + # so a later caller cannot reach the unbounded `response.body` by accident. + Response = Struct.new(:status, :body) + + def initialize(max_bytes: MAX_RESPONSE_BYTES) + @max_bytes = max_bytes + @buffer = +"" + end + + # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates + # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body + # that expands past the cap is refused partway through the expansion rather than after it. That holds only while + # the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`. + def on_data + proc do |chunk, _received_bytes, _env| + @buffer << chunk + + raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes + end + end + + # The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver + # the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then, + # but refusing them still keeps an over-cap document out of `JSON.parse`. + def response_for(response) + Response.new(response.status, bounded_body(response)) + end + + private + + def bounded_body(response) + return @buffer unless @buffer.empty? + + body = response.body + body = body.is_a?(String) ? body : body.to_s + raise TooLargeError, too_large_message if body.bytesize > @max_bytes + + body + end + + def too_large_message + # Not "the authorization server": protected resource metadata comes from the MCP server's own origin, + # so this message covers endpoints on both sides of the flow. + "Response body from the OAuth endpoint exceeds #{@max_bytes} bytes" + end + end + + private_constant :BoundedBody + end + end +end diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb new file mode 100644 index 0000000..4e1e1cf --- /dev/null +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # OAuth client configuration for the OAuth 2.1 `client_credentials` grant + # (machine-to-machine, no user and no browser redirect). Handed to + # `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`. + # The interactive Authorization Code flow lives in `Provider`; + # this class exists so a credentials-only client never has to supply + # the redirect arguments that grant has no use for, mirroring the dedicated + # `ClientCredentialsProvider` in the TypeScript SDK and + # `ClientCredentialsOAuthProvider` in the Python SDK. + # + # Required keyword arguments: + # + # - `client_id` - String identifying the pre-registered confidential client. + # - `client_secret` - String shared secret for the `client_secret_basic` / + # `client_secret_post` methods. The `client_credentials` grant is for + # confidential clients, so a credential is mandatory; with + # `private_key_jwt` the credential is the `private_key` instead. + # + # Optional keyword arguments: + # + # - `token_endpoint_auth_method` - `"client_secret_basic"` (default), + # `"client_secret_post"`, or `"private_key_jwt"` (RFC 7523 JWT client + # assertion, per the `io.modelcontextprotocol/oauth-client-credentials` + # extension / SEP-1046). `"none"` is rejected: an unauthenticated + # `client_credentials` request is meaningless. + # - `private_key` - PEM string (or `OpenSSL::PKey::PKey`) used to sign + # the JWT client assertion. Required with `private_key_jwt`. + # The key is held on the provider and never written to `storage`. + # - `signing_algorithm` - `"ES256"` or `"RS256"`. Required with + # `private_key_jwt`; there is no default, so a mismatch with + # the server's `token_endpoint_auth_signing_alg_values_supported` fails + # loudly at construction instead of as a 401 (the TypeScript and Python SDKs + # also take the algorithm as an explicit option). + # - `scope` - String of space-separated scopes to request when the server's + # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, + # and `save_client_information(info)`. Defaults to an `InMemoryStorage`. + # The `client_id` / `client_secret` are written into it so the token exchange reads + # them through the same path as a pre-registered authorization-code client. + # - `token_request_params` - Hash of String keys and values added to every + # token request this provider makes, for parameters the authorization + # server requires beyond the grant itself (Auth0's `audience`, for example). + # A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`. + # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for + # its own requests; see `StorageBackedProvider#http_client_customizer`. + class ClientCredentialsProvider + include StorageBackedProvider + + # Raised when the credentials required for the `client_credentials` grant are + # missing or the requested client authentication method cannot carry them. + class InvalidCredentialsError < ArgumentError; end + + SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze + + attr_reader :scope, :storage + + def initialize( + client_id:, + client_secret: nil, + token_endpoint_auth_method: "client_secret_basic", + private_key: nil, + signing_algorithm: nil, + scope: nil, + storage: nil, + authorization_request_validator: nil, + token_request_params: nil, + http_client_customizer: nil + ) + if blank?(client_id) + raise InvalidCredentialsError, "client_id is required for the client_credentials grant." + end + + unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method) + raise InvalidCredentialsError, + "token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \ + "client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \ + "client_credentials request is not allowed." + end + + client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method } + + if token_endpoint_auth_method == "private_key_jwt" + validate_private_key_jwt_arguments!( + client_secret: client_secret, + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + + # Fail fast on an unparseable key or a key/algorithm mismatch by + # signing a throwaway assertion now rather than at token time. + JWTClientAssertion.generate( + client_id: client_id, + audience: "urn:mcp:credential-validation", + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + else + if blank?(client_secret) + raise InvalidCredentialsError, "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + end + + client_information["client_secret"] = client_secret + end + + http_client_customizer = validated_http_client_customizer(http_client_customizer) + + @client_id = client_id + @private_key = private_key + @signing_algorithm = signing_algorithm + @scope = scope + @storage = storage || InMemoryStorage.new + @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer + @storage.save_client_information(client_information) + end + + # See `Provider#authorization_flow`. + def authorization_flow + :client_credentials + end + + # Returns a freshly signed RFC 7523 JWT client assertion for the `private_key_jwt` method. + # `audience` is the authorization server's issuer identifier. Called by `Flow#post_to_token_endpoint`. + def client_assertion(audience:) + JWTClientAssertion.generate( + client_id: @client_id, + audience: audience, + private_key: @private_key, + signing_algorithm: @signing_algorithm, + ) + end + + private + + def validate_private_key_jwt_arguments!(client_secret:, private_key:, signing_algorithm:) + unless client_secret.nil? + raise InvalidCredentialsError, "client_secret must not be set with private_key_jwt; the private key is the credential." + end + + if private_key.nil? + raise InvalidCredentialsError, "private_key is required for the client_credentials grant with private_key_jwt." + end + + return unless blank?(signing_algorithm) + + raise InvalidCredentialsError, "signing_algorithm is required with private_key_jwt (one of #{JWTClientAssertion::SUPPORTED_ALGORITHMS.inspect})." + end + + def blank?(value) + value.nil? || (value.is_a?(String) && value.strip.empty?) + end + end + end + end +end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb new file mode 100644 index 0000000..44b5ffd --- /dev/null +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"): + # the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and + # presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with + # `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`. + # + # Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come + # from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double. + # + # Required keyword arguments: + # + # - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server. + # - `client_secret` - String shared secret for `client_secret_basic`. + # - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to + # present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL; + # pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token. + # + # Optional keyword arguments: + # + # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and + # the Protected Resource Metadata do not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. + # Defaults to an `InMemoryStorage`. + # - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters + # the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises + # `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen. + # See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests; + # see `StorageBackedProvider#http_client_customizer`. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + class CrossAppAccessProvider + include StorageBackedProvider + + # Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs. + class InvalidConfigurationError < ArgumentError; end + + attr_reader :scope, :storage + + def initialize( + client_id:, + client_secret:, + assertion_provider:, + scope: nil, + storage: nil, + authorization_request_validator: nil, + token_request_params: nil, + http_client_customizer: nil + ) + if blank?(client_id) + raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." + end + + if blank?(client_secret) + raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic." + end + + unless assertion_provider.respond_to?(:call) + raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." + end + + http_client_customizer = validated_http_client_customizer(http_client_customizer) + + @assertion_provider = assertion_provider + @scope = scope + @storage = storage || InMemoryStorage.new + @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer + @storage.save_client_information( + "client_id" => client_id, + "client_secret" => client_secret, + "token_endpoint_auth_method" => "client_secret_basic", + ) + end + + # See `Provider#authorization_flow`. + def authorization_flow + :jwt_bearer + end + + # Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience + # and resource resolved during discovery. + def jwt_bearer_assertion(audience:, resource:) + @assertion_provider.call(audience: audience, resource: resource) + end + + private + + def blank?(value) + value.nil? || (value.is_a?(String) && value.strip.empty?) + end + end + end + end +end diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb new file mode 100644 index 0000000..a8ad3be --- /dev/null +++ b/lib/mcp/client/oauth/discovery.rb @@ -0,0 +1,625 @@ +# frozen_string_literal: true + +require "ipaddr" +require "uri" + +module MCP + class Client + module OAuth + # Stateless helpers that map MCP-authorization spec URLs and headers into something + # the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on. + # The module bundles six concerns that share no state but are closely related to + # the spec's "Discovery" and "Communication Security" sections: + # + # - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls + # the Bearer challenge parameters (`resource_metadata`, `scope`, `error`, + # ...) out of a header that may carry multiple challenges per RFC 7235 + # and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6. + # - **Discovery URL builders** (`protected_resource_metadata_urls`, + # `authorization_server_metadata_urls`): list the candidate well-known + # URLs to probe when no explicit metadata URL is supplied, + # in the priority order required by RFC 9728 and RFC 8414. + # - **Communication Security check** (`secure_url?`): enforces "HTTPS only" + # for every OAuth-facing URL, with the loopback carve-out described in + # `secure_url?`'s comment. + # - **Destination checks** (`same_origin?`, `private_network_host?`): + # answer *where* a URL points, which the scheme alone does not. `Flow` uses + # them to refuse server-supplied discovery URLs aimed at hosts the MCP server + # has no business steering the client toward. + # - **URL canonicalization** (`canonicalize_url`): normalizes scheme, + # host, port, path, percent-encoded dot segments, and fragments + # so two URLs that *refer to the same resource* compare as equal, + # and drops userinfo so credentials never reach the RFC 8707 `resource` claim + # or any error message. + # - **Resource coverage** (`resource_covers?`): decides whether a PRM `resource` URI + # is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits + # "under" the resource per RFC 8707 audience semantics. + # + # Every entry point is a class method so it can be called from initializers and + # from any thread without synchronization. + module Discovery + # Matches a single `key=value` pair inside an HTTP auth-scheme challenge. + # `value` is either a quoted string (which can contain commas and spaces) + # or a bare token, per RFC 7235. + WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze + + class << self + # Parses a `WWW-Authenticate` header and returns the parameters of + # the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`, + # `scope`, `error`). Returns `{}` when no Bearer challenge is present. + # Handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`) + # by extracting only the Bearer parameters. + # + # - https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1 + # - https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1 + def parse_www_authenticate(header) + return {} unless header + + # Locate the Bearer challenge: at the start of the header or after a comma. + bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i) + return {} unless bearer + + # Walk key=value pairs starting where Bearer's parameters begin. + # The loop stops at the first token that is not a key=value pair, + # which marks the next challenge (e.g. `, DPoP algs="..."`). + cursor = bearer.end(0) + params = {} + while cursor < header.length + prefix = header[cursor..] + prefix = prefix.sub(/\A\s*,?\s*/, "") + break if prefix.empty? + + match = prefix.match(WWW_AUTH_PARAM_PATTERN) + break unless match + + params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3] + cursor = header.length - prefix.length + match.end(0) + end + params + end + + # Returns the candidate Protected Resource Metadata URLs to probe, in priority order. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements + def protected_resource_metadata_urls(server_url:, resource_metadata_url: nil) + urls = [] + urls << resource_metadata_url if resource_metadata_url + + uri = URI.parse(server_url) + path = uri.path == "/" ? "" : uri.path.to_s + base = base_url(uri) + + urls << "#{base}/.well-known/oauth-protected-resource#{path}" + urls << "#{base}/.well-known/oauth-protected-resource" + urls.uniq + end + + # Returns the candidate Authorization Server metadata URLs to probe, in priority order. + # + # Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix + # registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own. + # The OAuth candidates below therefore use only that default suffix + # (plus the `openid-configuration` suffix from OpenID Connect Discovery), + # both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component + # and in the root form for issuers without one. + # + # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351 + # - https://www.rfc-editor.org/rfc/rfc8414#section-3.1 + def authorization_server_metadata_urls(issuer_url) + uri = URI.parse(issuer_url) + path = uri.path == "/" ? "" : uri.path.to_s + base = base_url(uri) + + if path.empty? + ["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"] + else + [ + "#{base}/.well-known/oauth-authorization-server#{path}", + "#{base}/.well-known/openid-configuration#{path}", + "#{base}#{path}/.well-known/openid-configuration", + ] + end + end + + # Returns a canonical form of `url` suitable for comparing two URIs + # that are meant to identify the same protected resource: lowercased scheme/host, + # default port stripped, fragment removed, percent-encoded dot octets normalized + # to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per + # RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away. + # + # Userinfo is *dropped*. The MCP authorization spec sends the canonicalized URL + # on the wire as the RFC 8707 `resource` claim and surfaces it in error messages; + # both paths would leak `user:pass@` credentials to the authorization server and + # to log destinations if we preserved them. The MCP server URI does not legitimately + # carry userinfo, so dropping it is also a no-op for normal traffic. + # + # Decoding `%2e`/`%2E` *before* dot-segment resolution is what prevents + # an attacker-supplied URL like `https://srv.example.com/api/%2e%2e/mcp` from sneaking + # past the PRM `resource` check in `resource_covers?`. + def canonicalize_url(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + uri.scheme = uri.scheme.downcase if uri.scheme + uri.host = uri.host.downcase if uri.host + uri.port = nil if uri.port == uri.default_port + + path = uri.path.to_s.gsub(/%2[eE]/, ".") + uri.path = remove_dot_segments(path) + uri.path = "" if uri.path == "/" + + uri.query = normalize_query(uri.query) + + uri.to_s + end + + # Returns true when `url` is safe to use for OAuth communication per + # the MCP authorization spec's "Communication Security" requirement: + # `https` is always allowed, `http` is permitted only when the host is + # a loopback address (`localhost`, `127.0.0.0/8`, or `::1`). + # + # The loopback exception applies uniformly to every OAuth-related URL + # the SDK consumes (PRM URL, AS metadata URL, `authorization_servers` + # entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, + # the `redirect_uri`, and the MCP transport URL when `oauth:` is set). + # A strict reading of OAuth 2.1 reserves the loopback carve-out for + # `redirect_uri` only (per RFC 8252), but neither the Python nor + # the TypeScript MCP SDK enforces HTTPS on those endpoints either - + # and the official MCP conformance test suite drives its fixtures + # over `http://localhost` auth servers, so enforcing HTTPS for everything + # except `redirect_uri` would break local development out of the box and + # regress 16 conformance scenarios. Operators who run in production are + # expected to deploy real HTTPS endpoints; this helper does not enforce + # that at the SDK boundary. + # + # Rejects URLs that fail to parse, lack a host, or whose `http://` host is + # something like `127.attacker.com` or `foo.localhost`, + # which would otherwise pass a naive `start_with?("127.")` check. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security + def secure_url?(url) + return false if url.nil? || url.to_s.empty? + + uri = URI.parse(url.to_s) + return false if uri.host.nil? || uri.host.empty? + + scheme = uri.scheme&.downcase + return true if scheme == "https" + return loopback_host?(uri.host) if scheme == "http" + + false + rescue URI::InvalidURIError + false + end + + # Returns true when `url` and `other` share an origin: same scheme, same host, + # and same port. Scheme and host compare case-insensitively, and an implicit default port compares + # equal to the same port written out, because `URI::HTTP#port` already resolves to the default. + # + # Anything that fails to parse or carries no host returns false, so a caller using this as + # a security gate refuses rather than admits on malformed input. + def same_origin?(url, other) + uri = URI.parse(url.to_s) + other_uri = URI.parse(other.to_s) + return false if uri.host.nil? || uri.host.empty? + return false if other_uri.host.nil? || other_uri.host.empty? + + uri.scheme&.downcase == other_uri.scheme&.downcase && + uri.host.downcase == other_uri.host.downcase && + uri.port == other_uri.port + rescue URI::InvalidURIError + false + end + + # Returns true when `host` is an IP literal that is not reachable from the public internet, + # or the `localhost` name. The ranges are the ones RFC 9728 Section 7.7 and the MCP security best practices name + # as SSRF targets, so `169.254.0.0/16` covers the `169.254.169.254` cloud metadata address that motivates the check. + # + # Hostnames are deliberately NOT resolved. A lookup here would be a second network request driven by + # the same untrusted input, and the address it returned could differ from the one the HTTP client connects to + # a moment later, so the check would read as a guarantee it cannot make. That leaves names like `vault.corp.internal` + # out of reach of this predicate; it is one layer of an SSRF defense, not the whole of one. + def private_network_host?(host) + return false if host.nil? || host.empty? + + normalized = host.downcase + return true if normalized == "localhost" + + literal = normalized.delete_prefix("[").delete_suffix("]") + address = parse_ip_address(literal) + return numeric_ipv4_spelling?(literal) unless address + + # `::ffff:169.254.169.254` addresses the same interface as `169.254.169.254`, + # so compare the IPv4 form rather than letting the mapped spelling through. + address = address.native if address.ipv6? && address.ipv4_mapped? + + ranges = address.ipv4? ? PRIVATE_IPV4_RANGES : PRIVATE_IPV6_RANGES + ranges.any? { |range| range.include?(address) } + end + + # Returns true when `url` satisfies the structural requirements for + # a Client ID Metadata Document URL per the MCP 2025-11-25 + # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`. + # + # Spec-required: + # + # - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply: + # the document URL is sent verbatim to the authorization server as the OAuth `client_id` + # and travels off-loopback) + # - host MUST be present + # - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource, + # not the origin + # - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo + # would leak credentials into every `client_id` log line + # - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with + # the same effective path do not produce different `client_id` strings + # + # SDK policy (stricter than the draft): + # + # - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include, + # but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct + # `client_id` values for the same logical document. + def client_id_metadata_document_url?(url) + return false if url.nil? || url.to_s.empty? + + uri = URI.parse(url.to_s) + return false unless uri.scheme&.downcase == "https" + return false if uri.host.nil? || uri.host.empty? + return false unless uri.fragment.nil? + return false unless uri.query.nil? + return false if uri.respond_to?(:user) && (uri.user || uri.password) + + path = uri.path.to_s + return false if path.empty? || path == "/" + + decoded = path.gsub(/%2[eE]/, ".") + segments = decoded.split("/", -1) + return false if segments.any? { |segment| segment == "." || segment == ".." } + + true + rescue URI::InvalidURIError + false + end + + # Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`. + # Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration + # so the authorization server can apply the matching redirect URI policy. + # + # Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1) + # or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3). + # Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837 + # - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + def infer_application_type(redirect_uris) + uris = Array(redirect_uris) + return "web" if uris.empty? + + uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web" + end + + # Like `canonicalize_url` but also strips query string, fragment, and + # userinfo. This variant is used for identity comparison against + # the request URL Faraday actually sends, which differs from the value + # the caller passed in two ways: `Faraday::Connection#url_prefix` + # drops query parameters, and Faraday hoists `user:pass@` out of + # the URL into an `Authorization: Basic` header before the request goes + # out. Including userinfo here would (a) raise a false-positive + # `InsecureURLError` on any legitimate URL with credentials in + # the authority, and (b) leak `user:pass` through the resulting error + # message - both of which would defeat the bearer-token-protection + # purpose of the identity check. + def canonicalize_origin_and_path(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + uri.query = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + uri.scheme = uri.scheme.downcase if uri.scheme + uri.host = uri.host.downcase if uri.host + uri.port = nil if uri.port == uri.default_port + + path = uri.path.to_s.gsub(/%2[eE]/, ".") + uri.path = remove_dot_segments(path) + uri.path = "" if uri.path == "/" + + uri.to_s + end + + # Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting + # a URL next to the request that failed, where it must still match the server's access log; `URI#to_s` + # still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path` + # it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters + # for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry + # the very credentials being dropped. + def redact_url(url) + uri = URI.parse(url.to_s) + + uri.fragment = nil + uri.query = nil + # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), + # so clear the components individually. + if uri.respond_to?(:user) && (uri.user || uri.password) + uri.user = nil + uri.password = nil + end + + uri.to_s + rescue URI::Error + "[unparseable URL]" + end + + # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL): + # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM + # also advertises a query string, the server's query MUST be identical to it (otherwise + # a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim` + # and let the attacker mint a different tenant's token for the same origin + path). + # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over + # the origin + path prefix and covers any server query. + # + # An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard: + # it represents the URI literally `<...>?`, which is distinct from "no query at all" + # and from any non-empty query, so it must match exactly. + # + # Both arguments must already be canonicalized. + def resource_covers?(prm:, server:) + prm_uri = URI.parse(prm) + server_uri = URI.parse(server) + return false unless prm_uri.scheme == server_uri.scheme && + prm_uri.host == server_uri.host && + prm_uri.port == server_uri.port + + prm_path = prm_uri.path.to_s + server_path = server_uri.path.to_s + prm_path = "" if prm_path == "/" + server_path = "" if server_path == "/" + path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/") + return false unless path_covers + + prm_query = prm_uri.query + return true if prm_query.nil? + + prm_query == server_uri.query + end + + private + + # Unescapes a `quoted-string` value's `quoted-pair` octets per + # RFC 7230 Section 3.2.6 (referenced from RFC 7235): `\` becomes ``. + # https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 + def unescape_quoted_pair(value) + value.gsub(/\\(.)/, '\1') + end + + # Recognizes the IPv4 loopback range (`127.0.0.0/8`), IPv6 loopback + # (`::1`, optionally bracketed by `URI.parse`), and the `localhost` + # hostname (matched exactly so that hostnames like `foo.localhost` or + # `127.attacker.com` are not treated as loopback). + IPV4_LOOPBACK_RANGE = IPAddr.new("127.0.0.0/8") + IPV6_LOOPBACK = IPAddr.new("::1") + private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK + + # Backing ranges for `private_network_host?`. + # + # `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT + # space; neither is a destination a legitimate authorization server publishes, + # and both are reachable enough from a client's network position to be worth + # refusing. `::/96` covers the unspecified address, IPv6 loopback, and + # the deprecated IPv4-compatible form (`::10.0.0.1`) in one range. + PRIVATE_IPV4_RANGES = [ + IPAddr.new("0.0.0.0/8"), + IPAddr.new("10.0.0.0/8"), + IPAddr.new("100.64.0.0/10"), + IPAddr.new("127.0.0.0/8"), + IPAddr.new("169.254.0.0/16"), + IPAddr.new("172.16.0.0/12"), + IPAddr.new("192.168.0.0/16"), + ].freeze + PRIVATE_IPV6_RANGES = [ + IPAddr.new("::/96"), + IPAddr.new("fc00::/7"), + IPAddr.new("fe80::/10"), + ].freeze + private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES + + def loopback_host?(host) + return false if host.nil? || host.empty? + + normalized = host.downcase + return true if normalized == "localhost" + + ip_candidate = normalized.delete_prefix("[").delete_suffix("]") + address = parse_ip_address(ip_candidate) + return false unless address + + return IPV4_LOOPBACK_RANGE.include?(address) if address.ipv4? + return address == IPV6_LOOPBACK if address.ipv6? + + false + end + + def parse_ip_address(candidate) + IPAddr.new(candidate) + rescue IPAddr::Error + nil + end + + # Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`), + # octal (`0177`), or decimal (`127`). + INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze + private_constant :INET_ATON_PART + + # Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts + # and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or + # decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and + # `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, + # so a check that understood only the canonical form would refuse `169.254.169.254` + # while waving through every other spelling of it. + # + # Such a host is refused outright rather than decoded and range-checked, because its + # value is resolver-dependent: a leading zero reads as octal on one platform and decimal + # on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. + # Deciding here which one it meant would leave whichever reading was not taken as a way + # through. No authorization server is legitimately addressed in this notation, so + # refusing the grammar entirely costs nothing and leaves no spelling to miss. + def numeric_ipv4_spelling?(host) + parts = host.split(".", -1) + return false if parts.empty? || parts.length > 4 + + parts.all? { |part| INET_ATON_PART.match?(part) } + end + + # A redirect URI counts as native when it uses a custom non-http(s) scheme + # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is + # a loopback address. A URI without a scheme or one that fails to parse is not native. + def native_redirect_uri?(url) + uri = URI.parse(url.to_s) + scheme = uri.scheme&.downcase + return false if scheme.nil? + return loopback_host?(uri.host) if ["http", "https"].include?(scheme) + + true + rescue URI::InvalidURIError + false + end + + def base_url(uri) + port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : "" + "#{uri.scheme}://#{uri.host}#{port_part}" + end + + # Normalizes a URL query string so two URLs that are equivalent in + # OAuth identity terms compare as equal. This is required because + # Faraday transparently rewrites `env.url` before sending a request: + # + # - Parameters get sorted by name (`?b=2&a=1` -> `?a=1&b=2`). + # - Percent-encoded hex is uppercased (`?x=%2f` -> `?x=%2F`). + # - A trailing `?` with no parameters is dropped (`?` -> no query). + # - Same-name keys are collapsed so only the last value survives + # (`?a=1&a=2` -> `?a=2`). + # - Empty-name pairs and blank `&&` separators are dropped + # (`?=v` -> no query, `?&&a=1&&` -> `?a=1`). + # - Value-less keys (`?tenant`) and empty-value keys (`?tenant=`) + # are kept distinct -- Faraday preserves the `=` exactly as + # the caller passed it. `URI.decode_www_form` / `encode_www_form` + # would collapse both to `?tenant=`, so this function does + # the parsing by hand on `&`-separated segments. + # + # Without applying the same transformation to our snapshotted URL, + # the request-time URL guard would false-positive on every URL that + # falls under one of the rules above. + # + # Returns `nil` when the resulting query is empty + # (matching Faraday's drop-empty-query behavior). + def normalize_query(query) + return if query.nil? || query.empty? + + # Each segment becomes `[decoded_name, has_equals?, decoded_value_or_nil]`. + # `has_equals?` is what lets us preserve the `?key` vs `?key=` + # distinction that Faraday respects. + parsed = query.split("&").filter_map do |segment| + next if segment.empty? + + name_raw, separator, value_raw = segment.partition("=") + name = URI.decode_www_form_component(name_raw) + next if name.empty? + + has_equals = !separator.empty? + value = has_equals ? URI.decode_www_form_component(value_raw) : nil + [name, has_equals, value] + end + + # Keys ending in `[]` (`tenant[]`, `roles[]`) are Faraday's array + # notation: the encoder preserves every occurrence in input order + # instead of collapsing them. All other keys (`tenant`, `a[b]`, + # plain scalars) are collapsed with last-write-wins semantics. + # Separating the two avoids a false negative where a hijacked + # middleware drops an entry from a `?tenant[]=victim&tenant[]=...` + # URL and slips past the guard, and a false positive on + # a legitimate `?roles[]=a&roles[]=b` URL. + array_segments = [] + scalar_segments = {} + parsed.each do |name, has_equals, value| + if name.end_with?("[]") + array_segments << [name, has_equals, value] + else + scalar_segments[name] = [has_equals, value] + end + end + + scalar_entries = scalar_segments.map { |name, (has_equals, value)| [name, has_equals, value] } + combined = scalar_entries + array_segments + return if combined.empty? + + # Stable sort by name: array entries that share a name keep their + # original order, while scalar names are alphabetized to match + # Faraday's deterministic encoding order. + combined.each_with_index + .sort_by { |(name, _, _), index| [name, index] } + .map do |(name, has_equals, value), _index| + encoded_name = URI.encode_www_form_component(name) + if has_equals + "#{encoded_name}=#{URI.encode_www_form_component(value)}" + else + encoded_name + end + end.join("&") + end + + # Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input + # buffer one segment at a time, popping the previous output segment + # whenever a `..` is encountered, so that `/api/../mcp` collapses to + # `/mcp` and `/foo/./bar` collapses to `/foo/bar`. + # https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4 + def remove_dot_segments(path) + return path if path.nil? || path.empty? + + input = path.dup + output = +"" + until input.empty? + if input.start_with?("../") + input = input[3..] + elsif input.start_with?("./") + input = input[2..] + elsif input.start_with?("/./") + input = "/#{input[3..]}" + elsif input == "/." + input = "/" + elsif input.start_with?("/../") + input = "/#{input[4..]}" + output = remove_last_segment(output) + elsif input == "/.." + input = "/" + output = remove_last_segment(output) + elsif input == "." || input == ".." + input = "" + else + segment = input.match(%r{\A/?[^/]*})[0] + output << segment + input = input[segment.length..] + end + end + output + end + + def remove_last_segment(output) + idx = output.rindex("/") + return +"" if idx.nil? + + output[0...idx] + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb new file mode 100644 index 0000000..58c234d --- /dev/null +++ b/lib/mcp/client/oauth/flow.rb @@ -0,0 +1,1457 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" +require "uri" + +module MCP + class Client + module OAuth + # Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow. + # Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is + # `Provider`; this class consumes a Provider plus signal data extracted from + # the failing response (resource_metadata URL, scope challenge). + class Flow + TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 + TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512 + METADATA_DIAGNOSTIC_MAX_LENGTH = 128 + METADATA_URL_MAX_LENGTH = 2048 + + # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`, + # so a provider naming one of these is refused rather than left believing its value was sent. + RESERVED_TOKEN_REQUEST_PARAMS = [ + "grant_type", + "client_id", + "client_secret", + "client_assertion", + "client_assertion_type", + "scope", + "resource", + "code", + "code_verifier", + "redirect_uri", + "refresh_token", + "assertion", + ].freeze + + class AuthorizationError < StandardError + attr_reader :http_status, :error, :error_description + + def initialize(message = nil, http_status: nil, error: nil, error_description: nil) + super(message) + @http_status = http_status + @error = error + @error_description = error_description + end + end + + # Raised specifically when the token endpoint rejects a grant with + # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to + # distinguish "the stored refresh token is dead, discard it" from + # transient failures (network, 5xx, other RFC 6749 error codes) that + # should leave the refresh token intact. + class InvalidGrantError < AuthorizationError; end + + # Raised when `authorization_request_validator` returns a falsy value. Separate from its parent + # so that a caller can tell a refusal by its own policy from a network, discovery, + # or authorization server metadata failure by rescuing a class rather than by matching the message text. + class AuthorizationRefusedError < AuthorizationError; end + + # Raised by metadata discovery when every candidate URL answered that nothing usable is published there + # (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object). + # The only discovery failure that may select the legacy 2025-03-26 path. + class MetadataNotPublishedError < AuthorizationError; end + + # Raised by metadata discovery when the answer says nothing about what is published: the request failed to + # reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move + # the flow to a different authorization server because of a transient failure, so it is surfaced instead, + # as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both. + class MetadataUnreachableError < AuthorizationError; end + + # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity, + # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake, + # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on + # a failed refresh as a reason to run the interactive flow. + class InvalidTokenRequestParamsError < ArgumentError; end + + # Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer` + # would send a request to an origin other than the one the flow validated, or has dropped the record of + # the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake, + # and deliberately outside `AuthorizationError`, which discovery treats as "nothing published" + # and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow. + class DestinationMismatchError < ArgumentError; end + + # Faraday middleware registered on the connection `build_http_client` assembles before the customizer + # is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly + # as the flow requested it, which it records on the request environment for `RequestedOriginGuard`. + # The guard covers what happens to a request after that record; middleware inserted ahead of it with + # `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard. + # The record lives on the environment, not in `env.request.context`: that slot belongs to the application, + # which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen + # on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after + # a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with + # the redirected URL. + class RequestedURLStamp + KEY = :mcp_oauth_requested_url + + def initialize(app) + @app = app + end + + def call(env) + env[KEY] ||= env.url.to_s + @app.call(env) + end + end + + # Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added + # middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked + # for is refused before it reaches the adapter, since every destination check ran against the URL as + # written; a same-origin change stays with the server those checks admitted. The record survives + # the `env.dup` that redirect-following middleware performs, and a request that arrives without it is + # refused as well, so a middleware that rebuilds the environment fails closed rather than open. + # The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows + # a redirect itself only within the origin; this flow follows none. + class RequestedOriginGuard + def initialize(app) + @app = app + end + + def call(env) + requested = env[RequestedURLStamp::KEY] + unless requested + raise DestinationMismatchError, <<~MESSAGE + Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \ + the URL the flow asked for; middleware that rebuilds the request environment is refused. + MESSAGE + end + + unless Discovery.same_origin?(env.url.to_s, requested) + raise DestinationMismatchError, <<~MESSAGE + Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \ + #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \ + middleware that follows redirects or rewrites URLs is refused. + MESSAGE + end + + @app.call(env) + end + end + private_constant :RequestedURLStamp, :RequestedOriginGuard + + class << self + # Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can. + # Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`, + # so the same problem reads the same wherever it surfaces. + def token_request_params_problem(params) + return "must be a Hash (got #{params.class})." unless params.is_a?(Hash) + + # Two equal keys are two entries here, which would be sent twice from a provider method + # or silently collapse into one when the constructors copy the Hash. + return "must not compare keys by identity." if params.compare_by_identity? + + params.each do |key, value| + return "keys must be Strings (got #{key.class})." unless key.is_a?(String) + return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String) + return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key) + end + + nil + end + + # Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`, + # then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`), + # then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request + # after the stamp recorded it. Every request on the connection passes through both, so a caller using + # it directly is held to the same origin rule. + # + # Deliberately built without redirect-following middleware. Every destination check in this class runs + # against the URL as written, before the request goes out, so a connection that transparently followed + # a `3xx` would let a server reach a host the checks just refused. The guard turns following at + # the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must + # not enable it. + # + # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes + # the response only while the caller has not claimed that header; assigning it turns `decode_content` off, + # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it + # after the check. + def build_http_client(customizer = nil) + require "faraday" + + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + faraday.use(RequestedURLStamp) + customizer&.call(faraday) + faraday.use(RequestedOriginGuard) + end + end + end + + def initialize(provider:, http_client_factory: nil) + @provider = provider + @http_client_factory = http_client_factory || -> { default_http_client } + end + + # Runs the full discovery, registration, authorization, and token exchange flow. + # On success, persists tokens via the provider and returns `:authorized`. + def run!(server_url:, resource_metadata_url: nil, scope: nil) + # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very + # first thing we contact in the OAuth flow, so it has to clear the same + # Communication Security bar as the OAuth endpoints downstream, and it has to + # point back at the server that issued the challenge. + if resource_metadata_url + ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + ensure_same_origin!( + resource_metadata_url, + label: "WWW-Authenticate resource_metadata URL", + server_url: server_url, + ) + end + + prm, authorization_server = locate_authorization_server( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + + # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on + # both the authorization and token requests. When PRM advertises a `resource`, + # it MUST identify the same MCP server we are talking to; otherwise we are + # being redirected to credentials minted for a different audience. + resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) + + as_metadata = authorization_server_metadata( + authorization_server: authorization_server, + legacy: prm.nil?, + server_url: server_url, + ) + + case provider_authorization_flow + when :client_credentials + return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) + when :jwt_bearer + return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) + end + + ensure_pkce_supported!(as_metadata) + + effective_scope = resolve_scope(scope: scope, prm: prm) + effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) + + # Asked before registering, not after: a refusal must not leave this client registered at an authorization server + # the embedding application has just rejected. + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) + + client_info = ensure_client_registered(as_metadata: as_metadata) + + pkce = PKCE.generate + state = SecureRandom.urlsafe_base64(32) + + authorization_url = build_authorization_url( + as_metadata: as_metadata, + client_id: client_info_required_value(client_info, "client_id"), + scope: effective_scope, + state: state, + code_challenge: pkce[:code_challenge], + resource: resource, + ) + + @provider.redirect_handler.call(authorization_url) + callback_result = Array(@provider.callback_handler.call) + code, returned_state, returned_iss = callback_result + raise AuthorizationError, "Authorization callback did not return an authorization code." unless code + + unless states_match?(returned_state, state) + raise AuthorizationError, "OAuth state mismatch (CSRF protection)." + end + + validate_authorization_response_issuer!( + as_metadata: as_metadata, + iss: returned_iss, + iss_provided: callback_result.length >= 3, + ) + + tokens = exchange_authorization_code( + as_metadata: as_metadata, + client_info: client_info, + code: code, + code_verifier: pkce[:code_verifier], + resource: resource, + ) + + save_tokens_issued_by(tokens, as_metadata: as_metadata) + :authorized + end + + # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists + # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is + # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, + # and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3). + # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:) + client_info = client_credentials_client_info + ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) + + form = { "grant_type" => "client_credentials" } + effective_scope = resolve_scope(scope: scope, prm: prm) + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) + form["scope"] = effective_scope if effective_scope + form["resource"] = resource if resource + + tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + save_tokens_issued_by(tokens, as_metadata: as_metadata) + + :authorized + end + + # Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored + # `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code + # flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`). + # The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback + # to dynamic registration. + def client_credentials_client_info + info = @provider.client_information + unless info.is_a?(Hash) && client_info_required_value(info, "client_id") + raise AuthorizationError, + "Cannot run the client_credentials grant: the provider has no stored `client_id`." + end + + info + end + + # Per SEP-2352, static machine-to-machine credentials are bound to their authorization + # server the same way registered ones are: when the stored `client_information` records + # an `issuer` that differs from the current authorization server, surface an error + # instead of silently sending another server's credentials (the spec's "SHOULD surface + # an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here). + # Re-registration is not an option for the `client_credentials` grant - the credentials + # are pre-registered, not DCR results - so the operator must update the stored credentials. + # Credentials without a recorded issuer keep working unchanged. + def ensure_client_credentials_issuer!(client_info, as_metadata:) + stored_issuer = client_info_required_value(client_info, "issuer") + return if stored_issuer.nil? + return if stored_issuer == as_metadata["issuer"] + + raise AuthorizationError, + "Stored client credentials are bound to a different authorization server " \ + "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \ + "refusing to send them to the current authorization server (SEP-2352)." + end + + # Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension: + # the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`), + # which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery + # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request. + # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated. + # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:) + client_info = @provider.client_information + unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") + raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." + end + + # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant: + # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once + # the host has refused it. + effective_scope = resolve_scope(scope: scope, prm: prm) + authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) + + assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource) + if assertion.nil? || assertion.to_s.empty? + raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion." + end + + form = { + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" => assertion, + } + form["scope"] = effective_scope if effective_scope + form["resource"] = resource if resource + + tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + save_tokens_issued_by(tokens, as_metadata: as_metadata) + :authorized + end + + # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6). + # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security + # checks before talking to it. + # + # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, + # when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer, + # or when the token endpoint refuses the refresh request. + # https://www.rfc-editor.org/rfc/rfc6749#section-6 + def refresh!(server_url:, resource_metadata_url: nil) + refresh_token = read_token("refresh_token") + raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token + + ensure_refresh_token_issuer_recorded! + + stored_client_info = @provider.client_information + have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id") + + # A CIMD-configured provider stores no `client_information` on purpose + # (the CIMD URL is re-resolved against the live AS metadata on every flow). + # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable. + have_cimd_url = !provider_client_id_metadata_document_url.nil? + + unless have_stored_client_info || have_cimd_url + raise AuthorizationError, "Cannot refresh: no client_information in provider storage." + end + + if resource_metadata_url + ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") + ensure_same_origin!( + resource_metadata_url, + label: "WWW-Authenticate resource_metadata URL", + server_url: server_url, + ) + end + + prm, authorization_server = locate_authorization_server( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + + resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) + + as_metadata = authorization_server_metadata( + authorization_server: authorization_server, + legacy: prm.nil?, + server_url: server_url, + ) + + ensure_token_issuer!(as_metadata: as_metadata) + + client_info = if have_stored_client_info + # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, + # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. + ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata) + stored_client_info + elsif as_metadata["client_id_metadata_document_supported"] == true + { "client_id" => provider_client_id_metadata_document_url } + else + raise AuthorizationError, + "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ + "`client_id_metadata_document_supported: true`." + end + + new_tokens = exchange_refresh_token( + as_metadata: as_metadata, + client_info: client_info, + refresh_token: refresh_token, + resource: resource, + ) + + save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata) + :refreshed + end + + private + + def read_token(key) + tokens = @provider.tokens + return unless tokens.is_a?(Hash) + + value = tokens[key] || tokens[key.to_sym] + value.to_s.empty? ? nil : value + end + + # Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in + # which case the previous one stays valid. Preserve it explicitly so + # downstream refresh attempts still work. + def preserve_refresh_token(new_tokens, previous_refresh_token) + return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token] + + new_tokens.merge("refresh_token" => previous_refresh_token) + end + + def fetch_protected_resource_metadata(server_url:, resource_metadata_url:) + urls = Discovery.protected_resource_metadata_urls( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + fetch_metadata_json(urls, label: "protected resource metadata") + end + + # Locates the authorization server for `server_url` and returns `[prm, authorization_server]`. + # + # Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in + # `authorization_servers`. + # + # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil + # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs. + # Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`, + # a redirect that was not followed, or a body that is not a JSON object) selects the legacy path. + # A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes, + # so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`), + # as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors. + # A body over the response cap is refused outright and never reaches the fallback either. + # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery + def locate_authorization_server(server_url:, resource_metadata_url:) + prm = begin + fetch_protected_resource_metadata( + server_url: server_url, + resource_metadata_url: resource_metadata_url, + ) + rescue MetadataNotPublishedError + nil + end + + if prm + authorization_server = first_authorization_server(prm) + ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") + ensure_routable_destination!( + authorization_server, + label: "PRM `authorization_servers` entry", + server_url: server_url, + ) + [prm, authorization_server] + else + authorization_base = server_origin!(server_url) + ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)") + [nil, authorization_base] + end + end + + # Fetches and validates the authorization server's RFC 8414 metadata. + # + # The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths. + # On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as + # the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs + # accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer` + # would otherwise become the identity tokens and client information are bound to, assertions are minted for, + # and the validator is shown, so a server could claim another authorization server and unlock the credentials + # bound to it. + # When even the metadata document is absent, the legacy spec's default endpoints are used. + def authorization_server_metadata(authorization_server:, legacy:, server_url:) + metadata = if legacy + fetched = begin + fetch_authorization_server_metadata(issuer_url: authorization_server) + rescue AuthorizationError + nil + end + + if fetched + ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"]) + fetched + else + default_legacy_metadata(authorization_server) + end + else + fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched| + ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"]) + end + end + + ensure_secure_endpoints!(metadata, server_url: server_url) + metadata + end + + # The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths + # relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata + # to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too). + def default_legacy_metadata(authorization_base) + { + "issuer" => authorization_base, + "authorization_endpoint" => "#{authorization_base}/authorize", + "token_endpoint" => "#{authorization_base}/token", + "registration_endpoint" => "#{authorization_base}/register", + "code_challenge_methods_supported" => ["S256"], + } + end + + # Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM. + def server_origin!(server_url) + uri = URI.parse(server_url.to_s) + unless uri.is_a?(URI::HTTP) && uri.host + raise AuthorizationError, + "Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}." + end + + port_part = uri.port == uri.default_port ? "" : ":#{uri.port}" + "#{uri.scheme}://#{uri.host}#{port_part}" + rescue URI::InvalidURIError => e + raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}." + end + + def fetch_authorization_server_metadata(issuer_url:) + urls = Discovery.authorization_server_metadata_urls(issuer_url) + fetch_metadata_json(urls, label: "authorization server metadata") + end + + # The legacy authorization base is an origin, which a document may render as `https://host/`; + # both name the same server, and nothing else does. + def ensure_legacy_issuer_matches!(expected:, returned:) + return if returned == "#{expected}/" + + ensure_issuer_matches!(expected: expected, returned: returned) + end + + # Reads `authorization_servers` from a PRM document and returns + # the first entry, raising `AuthorizationError` for any of the malformed + # shapes a non-compliant server could emit (missing field, non-Array + # value, empty Array, non-String first entry). Centralizing this lets + # both the full flow and the refresh flow share the same defensive + # parse instead of each one duplicating a Hash-and-Array check. + def first_authorization_server(prm) + authorization_servers = prm["authorization_servers"] + unless authorization_servers.is_a?(Array) + raise AuthorizationError, + "Protected resource metadata `authorization_servers` is not an array " \ + "(got #{authorization_servers.class})." + end + + if authorization_servers.empty? + raise AuthorizationError, "Protected resource metadata has no authorization_servers." + end + + first = authorization_servers.first + unless first.is_a?(String) && !first.empty? + raise AuthorizationError, + "Protected resource metadata `authorization_servers[0]` is not a non-empty string." + end + + first + end + + # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object; + # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still + # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path, + # or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing + # about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises + # `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that + # was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`. + # A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`, + # before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment + # and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against + # a server's access log, and with exception text bounded, since the message lands in every log destination + # the error passes through. + def fetch_metadata_json(urls, label:) + failures = [] + inconclusive = false + urls.each do |url| + response = begin + http_get(url) + rescue Faraday::Error => e + detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) + failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}" + inconclusive = true + next + end + + unless response.status >= 200 && response.status < 300 + failures << "GET #{reported_url(url)} returned #{response.status}" + inconclusive = true if response.status >= 500 || response.status == 429 + next + end + + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name + failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}" + next + end + + # Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...). + # The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that + # is not a Hash would raise `TypeError` / `NoMethodError` downstream. + unless parsed.is_a?(Hash) + failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})" + next + end + + return parsed + end + + message = "Failed to fetch #{label}: #{failures.join("; ")}." + + if inconclusive + raise MetadataUnreachableError, message + else + raise MetadataNotPublishedError, message + end + end + + def ensure_pkce_supported!(as_metadata) + methods = as_metadata["code_challenge_methods_supported"] + return if methods.is_a?(Array) && methods.include?("S256") + + raise AuthorizationError, + "Authorization server does not advertise S256 PKCE support; refusing to proceed." + end + + # Per the MCP authorization spec's Communication Security requirement, + # OAuth endpoints MUST use HTTPS unless the host is a loopback address. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security + def ensure_secure_url!(url, label:) + return if Discovery.secure_url?(url) + + raise AuthorizationError, + "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)." + end + + # Requires a URL the *server* chose to sit on the origin the *caller* chose. + # + # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is + # published on that server's own origin. Without this check a `WWW-Authenticate` challenge + # can aim the first request of the flow at any host the client can route to: the URL arrives + # from the network, it is fetched before the user approves anything, and the `resource` check that + # runs afterwards cannot un-send the request. + # + # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than + # the specification. It is enforced unconditionally because no known deployment publishes its PRM + # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see + # internal hosts that are named rather than addressed. + # https://www.rfc-editor.org/rfc/rfc9728#section-7.7 + def ensure_same_origin!(url, label:, server_url:) + return if Discovery.same_origin?(url, server_url) + + raise AuthorizationError, + "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \ + "#{sanitized_url(server_url).inspect}; refusing to fetch it." + end + + # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address, + # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take. + # + # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address, + # the whole flow is already inside that network and the authorization server legitimately lives there too. + # That covers `http://localhost` development, the conformance harness (which runs the MCP server and + # the authorization server on two loopback ports), and deployments that never leave a corporate network. + # Only a server reachable on the public internet is barred from steering the client inward. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + # Hands the embedding application the authorization server and the scopes that are about to be requested, + # and abandons the flow when it refuses them. + # + # Both values are chosen by the MCP server: it names its own authorization server in Protected + # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge. + # Neither the specification nor any MCP SDK binds that choice to the server's own identity, + # and validating that a token was issued for the intended audience is a responsibility the specification + # places on MCP servers rather than on clients. + # A host that knows which providers its user deals with can apply that knowledge here. + # + # The scopes are passed on unchanged whatever the host decides, because the specification requires + # a client to treat the challenged scopes as authoritative for the operation; the choice offered is + # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before. + # + # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered + # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead: + # an authorization server that has changed since the tokens were issued sends the flow back through here, + # where the host sees the new one and decides again. + def authorize_request!(as_metadata:, scope:, server_url:, resource:) + return unless @provider.respond_to?(:authorization_request_validator) + return unless (validator = @provider.authorization_request_validator) + + request = AuthorizationRequest.new( + authorization_server: as_metadata["issuer"], + scopes: scope.to_s.split, + server_url: server_url, + resource: resource, + ).freeze + + return if validator.call(request) + + raise AuthorizationRefusedError, <<~MESSAGE + The authorization request was refused by `authorization_request_validator` \ + (authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}). + MESSAGE + end + + def ensure_routable_destination!(url, label:, server_url:) + return unless private_network_url?(url) + return if private_network_url?(server_url) + + raise AuthorizationError, + "#{label} #{sanitized_url(url).inspect} points into a private network range, " \ + "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it." + end + + def ensure_secure_endpoints!(as_metadata, server_url:) + ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key| + endpoint = as_metadata[key] + next unless endpoint + + ensure_secure_url!(endpoint, label: "Authorization server #{key}") + ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url) + end + end + + # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse, + # so the rescue here is a backstop rather than a leniency. + def private_network_url?(url) + Discovery.private_network_host?(URI.parse(url.to_s).host) + rescue URI::InvalidURIError + false + end + + # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes + # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in + # every log destination the error passes through. + def sanitized_url(url) + Discovery.canonicalize_origin_and_path(url) + rescue URI::Error + url.to_s + end + + # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be + # identical (literal byte-for-byte equality, no normalization) to + # the issuer URL the client used to discover that document. This guards + # against a CDN/relay returning the metadata of a *different* + # authorization server than the one PRM advertised, and against + # ambiguities like trailing `/`, fragments, or case differences that + # could mask a confused-deputy attempt. + # https://www.rfc-editor.org/rfc/rfc8414#section-3.3 + def ensure_issuer_matches!(expected:, returned:) + unless returned + raise AuthorizationError, "Authorization server metadata is missing the `issuer` field." + end + + return if expected.to_s == returned.to_s + + raise AuthorizationError, + "Authorization server metadata `issuer` does not match the discovery URL " \ + "(expected #{expected.inspect}, got #{returned.inspect})." + end + + def ensure_client_registered(as_metadata:) + existing = stored_client_information_for(issuer: as_metadata["issuer"]) + return existing if existing + + # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`, + # if the authorization server advertises Client ID Metadata Document support and the provider has + # a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration. + # + # The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in. + # A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support, + # otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against + # a server that has not actually adopted it. + # + # The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support + # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise + # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata + # and the current `provider.client_id_metadata_document_url`. + cimd_url = provider_client_id_metadata_document_url + if cimd_url && as_metadata["client_id_metadata_document_supported"] == true + return { "client_id" => cimd_url } + end + + registration_endpoint = as_metadata["registration_endpoint"] + unless registration_endpoint + raise AuthorizationError, + "Authorization server has no registration_endpoint and no pre-registered client information was provided." + end + + response = begin + http_post_json(registration_endpoint, registration_client_metadata) + rescue Faraday::Error => e + raise AuthorizationError, + "Dynamic client registration failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + raise AuthorizationError, "Dynamic client registration failed with status #{response.status}." + end + + info = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, + "Failed to parse dynamic client registration response: #{e.message}." + end + + unless info.is_a?(Hash) && client_info_required_value(info, "client_id") + raise AuthorizationError, + "Dynamic client registration response is missing `client_id`." + end + + # Per SEP-2352, persisted client credentials are keyed by the issuer identifier of + # the authorization server that minted them, so a later flow can detect an AS change and + # re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field; + # the SDK adds it to the opaque persisted hash. + @provider.save_client_information(info.merge("issuer" => as_metadata["issuer"])) + + info + end + + # Returns the client metadata to submit on Dynamic Client Registration. + # Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type` + # so the authorization server can apply the matching redirect URI policy. + # When the user did not set one explicitly, infer `"native"` vs `"web"` from + # the registered `redirect_uris`; an explicit value always wins. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837 + def registration_client_metadata + metadata = @provider.client_metadata + return metadata if metadata[:application_type] || metadata["application_type"] + + redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"] + metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris)) + end + + # Returns the stored `client_information` when it may be used against the authorization server + # identified by `issuer`, applying SEP-2352's authorization server binding rules: + # + # - Credentials persisted with an `"issuer"` binding MUST NOT be reused against + # a different authorization server. When the AS changed, the stale registration and its tokens + # are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so + # the flow re-registers. + # - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version, + # or user-supplied pre-registered credentials) are bound to the current issuer on first use. + # - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused + # and re-bound instead of discarded. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352 + def stored_client_information_for(issuer:) + existing = @provider.client_information + return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id") + + stored_issuer = client_info_required_value(existing, "issuer") + return existing if stored_issuer == issuer + return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil? + + client_id = client_info_required_value(existing, "client_id") + return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id) + + @provider.save_client_information(nil) + @provider.clear_tokens! + nil + end + + def rebind_client_information(info, issuer:) + rebound = info.merge("issuer" => issuer) + @provider.save_client_information(rebound) + rebound + end + + # Per SEP-2352, stored client credentials are bound to the authorization server that issued them; + # refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues + # the error and falls back to the full flow, which discards the stale registration and re-registers. + # Credentials without an `"issuer"` binding predate this check and are allowed through; + # CIMD `client_id`s are portable. + # Records which authorization server minted these tokens, alongside the tokens themselves. + # SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that + # SEP leaves unbound. + # Stored in the token hash so it travels through any `storage` a caller supplied, without widening + # the storage interface that custom implementations have to satisfy. + def save_tokens_issued_by(tokens, as_metadata:) + issuer = as_metadata["issuer"] + tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer + + @provider.save_tokens(tokens) + end + + # Refuses to present a refresh token to an authorization server other than the one that issued it. + # `refresh!` rediscovers the authorization server from Protected Resource Metadata every time, + # so the server named for a session can differ from the one the stored tokens came from. + # Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is, + # including a Client ID Metadata Document URL, which is portable across authorization servers precisely + # so that re-registration is unnecessary. + # + # Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats + # client information without one; the binding takes effect from their next authorization. + # The caller answers a refusal by running the full authorization flow, which is where the embedding application + # is asked about the new authorization server. + def ensure_token_issuer!(as_metadata:) + stored_issuer = read_token("issuer") + return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"] + + raise AuthorizationError, <<~MESSAGE + Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \ + current #{as_metadata["issuer"].inspect}); re-authorization is required. + MESSAGE + end + + # `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`). + # The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new, + # and a refresh asks no validator, so a token without an `issuer` would be presented to whatever + # authorization server discovery names now. Refusing it sends the transport back through the grant, + # which does ask. + def ensure_refresh_token_issuer_recorded! + return if provider_authorization_flow == :authorization_code + return unless read_token("issuer").nil? + + raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required." + end + + def ensure_refreshable_client_information!(client_info, as_metadata:) + stored_issuer = client_info_required_value(client_info, "issuer") + return if stored_issuer.nil? + return if stored_issuer == as_metadata["issuer"] + + client_id = client_info_required_value(client_info, "client_id") + return if Discovery.client_id_metadata_document_url?(client_id) + + raise AuthorizationError, + "Cannot refresh: stored client credentials were issued by a different authorization server " \ + "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \ + "re-registration is required (SEP-2352)." + end + + # Reads `key` from a `client_information` hash that may use either string or + # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built + # `{ client_id:, client_secret: }` and have both work. + def client_info_value(info, key) + info[key] || info[key.to_sym] + end + + # Same as `client_info_value` but treats blank strings (`""` or only + # whitespace) as absent. Used for fields where empty values are never + # meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`) + # and would otherwise let a misbehaving AS or hand-built + # `client_information` short-circuit the "is the client registered?" + # check, or send a literal `client_secret: " "` to the token endpoint. + def client_info_required_value(info, key) + value = client_info_value(info, key) + return if value.nil? + return if value.is_a?(String) && value.strip.empty? + + value + end + + # Returns the canonical RFC 8707 `resource` URI to send on authorization and + # token requests. When PRM advertises `resource`, that value is + # the authorization server's idea of the resource identifier and is preferred. + # When PRM omits it, the canonicalized MCP server URL is used. + # + # Either way, we validate that PRM's `resource` covers the MCP server URL + # the client is actually talking to (same origin, with PRM's path as a prefix of + # the server URL's path) to prevent a malicious or misconfigured PRM from + # redirecting credentials to a different audience. + def canonical_resource(server_url:, prm_resource:) + server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL") + return server_canonical unless prm_resource + + prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`") + unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical) + raise AuthorizationError, + "Protected resource metadata `resource` does not match the MCP server URL " \ + "(server=#{server_canonical}, prm=#{prm_canonical})." + end + + prm_canonical + end + + # Wraps `Discovery.canonicalize_url` so that any URI parsing failure + # caused by malformed input from the server (`PRM.resource`, AS metadata + # endpoints, ...) surfaces as `AuthorizationError` instead of leaking + # a raw `URI::InvalidURIError` / `ArgumentError`. + def safe_canonicalize_url(url, label:) + Discovery.canonicalize_url(url) + rescue URI::InvalidURIError, ArgumentError => e + raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}." + end + + # Validates the RFC 9207 `iss` authorization response parameter against the issuer of the authorization server + # the flow is talking to, per SEP-2468 (mix-up attack mitigation in multi-IdP setups): + # + # - When the callback supplied a non-empty `iss`, it MUST equal the AS metadata `issuer` exactly (simple string comparison, + # no normalization, per RFC 9207 Section 2.4); on mismatch the flow aborts before the authorization code is ever sent to + # a token endpoint. + # - When the callback returned a 3-element `[code, state, iss]` whose `iss` is nil (asserting it inspected the authorization response + # and found no `iss`) and the AS metadata advertises `authorization_response_iss_parameter_supported: true`, the missing parameter + # is treated as an attack per RFC 9207 Section 2.4 and the flow aborts. + # - A legacy 2-element `[code, state]` callback skips the check: the caller never looked for `iss`, so its absence carries no signal. + # + # `as_metadata["issuer"]` has already been byte-compared against the discovery URL by `ensure_issuer_matches!`, + # so it is the RFC 9207 anchor value. `iss` is not a secret; a plain `==` suffices. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468 + # - https://www.rfc-editor.org/rfc/rfc9207 + def validate_authorization_response_issuer!(as_metadata:, iss:, iss_provided:) + expected = as_metadata["issuer"] + + if iss && !iss.to_s.empty? + return if iss.to_s == expected.to_s + + raise AuthorizationError, + "Authorization response `iss` does not match the authorization server issuer " \ + "(expected #{expected.inspect}, got #{iss.inspect}) (RFC 9207); " \ + "refusing to exchange the authorization code." + end + + return unless iss_provided + return unless as_metadata["authorization_response_iss_parameter_supported"] == true + + raise AuthorizationError, + "Authorization server advertises `authorization_response_iss_parameter_supported` but " \ + "the authorization response carried no `iss` parameter (RFC 9207 Section 2.4); " \ + "refusing to exchange the authorization code." + end + + # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery + # of the expected value. + # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7 + # (the project's minimum supported version). + # The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time + # does not leak the position of the first mismatching byte. + def states_match?(returned, expected) + returned = returned.to_s + return false unless returned.bytesize == expected.bytesize + + result = 0 + returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b } + result.zero? + end + + # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution + # prefers the `WWW-Authenticate` challenge first, then `scopes_supported` + # from the Protected Resource Metadata, and falls back to a provider-supplied + # scope only if both are absent. The provider-supplied scope must not pre-empt + # a server-advertised one. + def resolve_scope(scope:, prm:) + return scope if scope && !scope.empty? + + # `prm` is nil on the legacy path, where nothing advertises scopes. + supported = prm && prm["scopes_supported"] + return supported.join(" ") if supported.is_a?(Array) && !supported.empty? + + return @provider.scope if @provider.scope && !@provider.scope.empty? + + nil + end + + # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves: + # + # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing + # `refresh_token` in its registered `grant_types`) MAY request `offline_access` + # when the authorization server advertises it in metadata `scopes_supported`. + # When the server advertises it and the client opted in, add it if absent. + # + # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`, + # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge, + # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT + # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge, + # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into + # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's + # own request consistent with the AS's advertisement. + # + # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207 + def normalize_offline_access_scope(scope, as_metadata:) + scopes = scope.to_s.split + + if server_supports_offline_access?(as_metadata) + scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access") + else + scopes.delete("offline_access") + end + + scopes.empty? ? nil : scopes.join(" ") + end + + def server_supports_offline_access?(as_metadata) + supported = as_metadata["scopes_supported"] + + supported.is_a?(Array) && supported.include?("offline_access") + end + + def wants_refresh_token? + metadata = @provider.client_metadata + grant_types = metadata[:grant_types] || metadata["grant_types"] + + Array(grant_types).include?("refresh_token") + end + + # The OAuth flow the provider drives. Dispatching on the provider's + # declared flow keeps `Flow` from second-guessing intent by parsing + # `client_metadata[:grant_types]` (which is protocol metadata for the + # authorization server, not an SDK control signal). A provider that + # predates this method is treated as the interactive authorization-code + # flow it was the only option for. + def provider_authorization_flow + return :authorization_code unless @provider.respond_to?(:authorization_flow) + + @provider.authorization_flow + end + + # Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them; + # Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method, + # or one returning `nil`, adds nothing. + # A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do. + def provider_token_request_params + return {} unless @provider.respond_to?(:token_request_params) + + params = @provider.token_request_params + return {} if params.nil? + + problem = self.class.token_request_params_problem(params) + raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem + + params + end + + # The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader + # (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves + # every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`. + def provider_client_id_metadata_document_url + return unless @provider.respond_to?(:client_id_metadata_document_url) + + @provider.client_id_metadata_document_url + end + + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) + authorization_endpoint = as_metadata["authorization_endpoint"] + unless authorization_endpoint + raise AuthorizationError, + "Authorization server metadata is missing `authorization_endpoint`." + end + + uri = begin + URI.parse(authorization_endpoint) + rescue URI::InvalidURIError => e + raise AuthorizationError, + "Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}." + end + + params = URI.decode_www_form(uri.query.to_s) + params << ["response_type", "code"] + params << ["client_id", client_id] + params << ["redirect_uri", @provider.redirect_uri] + params << ["code_challenge", code_challenge] + params << ["code_challenge_method", "S256"] + params << ["state", state] + params << ["scope", scope] if scope + params << ["resource", resource] if resource + uri.query = URI.encode_www_form(params) + uri + end + + def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:) + form = { + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => @provider.redirect_uri, + "code_verifier" => code_verifier, + } + form["resource"] = resource if resource + + post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + end + + def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:) + form = { + "grant_type" => "refresh_token", + "refresh_token" => refresh_token, + } + form["resource"] = resource if resource + + post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) + end + + # Submits a form-encoded token request using the authentication method + # stored in `client_information`. The method determines whether client + # credentials belong in the form body, a Basic header, or a JWT assertion. + # A provider's `token_request_params` go underneath the flow's own parameters, + # which therefore win, and are refused before the request is sent when they name + # a reserved parameter or are not a Hash of Strings. + def post_to_token_endpoint(as_metadata:, client_info:, form:) + client_id = client_info_required_value(client_info, "client_id") + unless client_id + raise AuthorizationError, + "Cannot post to token endpoint: client_information is missing `client_id`." + end + + client_secret = client_info_required_value(client_info, "client_secret") + token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") + form = provider_token_request_params.merge(form) + + # Apply one client authentication method per request (RFC 6749 Section 2.3). + headers = {} + form = case token_endpoint_auth_method + when "private_key_jwt" + # The assertion identifies the client through its `iss` and `sub` + # claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2). + # Use the issuer already checked by `ensure_issuer_matches!` as the audience. + unless @provider.respond_to?(:client_assertion) + raise AuthorizationError, + "token_endpoint_auth_method is private_key_jwt but the provider does not " \ + "implement `client_assertion(audience:)`." + end + + form.merge( + "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, + "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), + ) + when "client_secret_post" + # Send the client ID and available secret in the form body. + body = form.merge("client_id" => client_id) + body["client_secret"] = client_secret if client_secret + body + else + if client_secret && token_endpoint_auth_method != "none" + # Basic is also the fallback when a secret is present but no method + # is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1); + # omit it because some servers treat it alongside Basic as a second + # authentication method and reject the request with `invalid_request`. + headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret) + form + else + # With `none` or no secret, identify the client using `client_id`. + # This is required for unauthenticated authorization-code exchanges + # (RFC 6749 Section 3.2.1). + form.merge("client_id" => client_id) + end + end + + token_endpoint = as_metadata["token_endpoint"] + unless token_endpoint + raise AuthorizationError, + "Authorization server metadata is missing `token_endpoint`." + end + + response = begin + http_post_form(token_endpoint, form, headers: headers) + rescue Faraday::Error => e + raise AuthorizationError, + "Token request to #{token_endpoint} failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + raise token_endpoint_error(response) + end + + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}." + end + + # Token responses MUST be a JSON object per RFC 6749 §5.1. Anything + # else (`null`, `[]`, a bare string) would otherwise be persisted + # as `provider.tokens` and raise raw `NoMethodError` / `TypeError` + # the next time `provider.access_token` is read. + unless parsed.is_a?(Hash) + raise AuthorizationError, + "Token endpoint response is not a JSON object (got #{parsed.class})." + end + + parsed + end + + # Surface only RFC 6749 §5.2 diagnostic fields, never the raw response, + # which may contain tokens or other credentials. Classify the original + # code so sanitization cannot turn malformed input into invalid_grant. + def token_endpoint_error(response) + message = "Token endpoint returned status #{response.status}." + error_class = AuthorizationError + parsed = JSON.parse(response_body_string(response)) + parsed = {} unless parsed.is_a?(Hash) + + error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError + error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) + description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) + message += " #{[error, description].compact.join(": ")}" if error || description + + error_class.new(message, http_status: response.status, error: error, error_description: description) + rescue StandardError + # Diagnostics must not mask the endpoint failure or change refresh recovery. + error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status) + end + + def bounded_diagnostic(value, limit:) + return unless value.is_a?(String) + + # RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields. + # Replace other characters to keep text received off the network on one log line. + value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip + return if value.empty? + + value.length > limit ? "#{value[0, limit - 3]}..." : value + end + + # A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot + # grow the message without limit. + def reported_url(url) + bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH) + end + + # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be + # `application/x-www-form-urlencoded` encoded before they are joined with + # `:` and base64-encoded for the `Authorization: Basic` header. This is + # what prevents credentials containing `:` or other special characters + # from being mis-parsed by the authorization server. + # https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1 + def basic_auth_credentials(client_id, client_secret) + encoded_id = URI.encode_www_form_component(client_id) + encoded_secret = URI.encode_www_form_component(client_secret) + Base64.strict_encode64("#{encoded_id}:#{encoded_secret}") + end + + def http_get(url) + bounded_request do |on_data| + http_client.get(url) do |req| + req.options.on_data = on_data + end + end + end + + def http_post_json(url, body) + bounded_request do |on_data| + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/json" + req.headers["Accept"] = "application/json" + req.options.on_data = on_data + req.body = JSON.generate(body) + end + end + end + + def http_post_form(url, form, headers: {}) + bounded_request do |on_data| + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + + headers.each do |key, value| + req.headers[key] = value + end + + req.options.on_data = on_data + req.body = URI.encode_www_form(form) + end + end + end + + # Issues a request with the response body bounded as it arrives, and returns the status paired with + # that body. An over-cap response is refused rather than truncated: a partial discovery or token document + # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either, + # since the same server would serve the same body. + def bounded_request + bounded = BoundedBody.new + + response = yield(bounded.on_data) + + bounded.response_for(response) + rescue BoundedBody::TooLargeError => e + raise AuthorizationError, "#{e.message}." + end + + def http_client + @http_client ||= @http_client_factory.call + end + + # A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and + # `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on + # `build_http_client`; `bounded_request` caps its responses all the same. + def default_http_client + self.class.build_http_client(provider_http_client_customizer) + end + + # `nil` for a provider that predates the hook or leaves it unset. + def provider_http_client_customizer + return unless @provider.respond_to?(:http_client_customizer) + + @provider.http_client_customizer + end + + def response_body_string(response) + body = response.body + body.is_a?(String) ? body : body.to_s + end + end + end + end +end diff --git a/lib/mcp/client/oauth/id_jag_token_exchange.rb b/lib/mcp/client/oauth/id_jag_token_exchange.rb new file mode 100644 index 0000000..ea771a9 --- /dev/null +++ b/lib/mcp/client/oauth/id_jag_token_exchange.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "json" +require "uri" + +module MCP + class Client + module OAuth + # RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into + # an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990). + # The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with + # the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`). + # Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 + # - https://www.rfc-editor.org/rfc/rfc8693 + module IDJAGTokenExchange + # Raised when the identity provider's token exchange fails or returns something other than an ID-JAG. + class ExchangeError < StandardError; end + + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" + ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag" + + class << self + # Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string. + # + # @param token_endpoint [String] The identity provider's token endpoint. + # @param id_token [String] The IdP-issued ID token (the subject token). + # @param client_id [String] The client's identifier at the IdP. + # @param audience [String] The MCP authorization server's issuer identifier. + # @param resource [String] The canonical MCP server URL (RFC 8707). + # @param http_client [Object, nil] Faraday-compatible client; built lazily by default. + def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil) + http_client ||= default_http_client + + bounded = BoundedBody.new + + response = begin + raw_response = http_client.post(token_endpoint) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + req.options.on_data = bounded.on_data + req.body = URI.encode_www_form( + "grant_type" => GRANT_TYPE, + "subject_token" => id_token, + "subject_token_type" => ID_TOKEN_TYPE, + "requested_token_type" => ID_JAG_TOKEN_TYPE, + "audience" => audience, + "resource" => resource, + "client_id" => client_id, + ) + end + + bounded.response_for(raw_response) + rescue BoundedBody::TooLargeError => e + raise ExchangeError, "#{e.message}." + rescue Faraday::Error => e + raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}." + end + + if response.status < 200 || response.status >= 300 + raise ExchangeError, "Identity provider token exchange returned status #{response.status}." + end + + parse_id_jag(response) + end + + private + + def parse_id_jag(response) + body = response.body.is_a?(String) ? response.body : response.body.to_s + parsed = begin + JSON.parse(body) + rescue JSON::ParserError => e + raise ExchangeError, "Failed to parse token exchange response: #{e.message}." + end + + unless parsed.is_a?(Hash) + raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})." + end + + issued_token_type = parsed["issued_token_type"] + unless issued_token_type == ID_JAG_TOKEN_TYPE + raise ExchangeError, + "Token exchange did not issue an ID-JAG " \ + "(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})." + end + + assertion = parsed["access_token"] + if assertion.nil? || assertion.to_s.empty? + raise ExchangeError, "Token exchange response is missing `access_token`." + end + + assertion + end + + # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`: + # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap + # onto compressed bytes. + def default_http_client + require "faraday" + + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + end + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/in_memory_storage.rb b/lib/mcp/client/oauth/in_memory_storage.rb new file mode 100644 index 0000000..23edad9 --- /dev/null +++ b/lib/mcp/client/oauth/in_memory_storage.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Default reference implementation of the storage contract that + # `Provider` uses to persist OAuth state. Holds the two pieces of data + # the flow saves and reads on every request: + # + # - `tokens`: the hash returned by the token endpoint + # (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.). + # - `client_information`: the hash returned by Dynamic Client Registration + # or supplied as pre-registered credentials + # (`client_id`, optional `client_secret`, optional + # `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member + # binding the credentials to the authorization server that issued them (SEP-2352); + # custom storages should treat the hash as opaque and persist it as-is. + # + # This class keeps everything in process memory, so the credentials live + # only for the lifetime of the Ruby process. Applications that need + # persistence across restarts should supply a custom object responding to + # the same four-method contract (`tokens`, `save_tokens(t)`, + # `client_information`, `save_client_information(info)`) and pass it via + # `Provider.new(storage: ...)`. The shape mirrors Python SDK's + # `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls + # the same responsibilities into a single object. + class InMemoryStorage + attr_accessor :tokens, :client_information + + def initialize + @tokens = nil + @client_information = nil + end + + def save_tokens(tokens) + @tokens = tokens + end + + def save_client_information(info) + @client_information = info + end + end + end + end +end diff --git a/lib/mcp/client/oauth/jwt_client_assertion.rb b/lib/mcp/client/oauth/jwt_client_assertion.rb new file mode 100644 index 0000000..5311a05 --- /dev/null +++ b/lib/mcp/client/oauth/jwt_client_assertion.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" + +module MCP + class Client + module OAuth + # Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt` + # client authentication method used by the `client_credentials` grant + # (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046). + # + # The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency + # (the TypeScript and Python SDKs use jose and PyJWT for the same assertion). + # Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id, + # `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`, + # and a unique `jti`. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046 + # - https://www.rfc-editor.org/rfc/rfc7523#section-2.2 + module JWTClientAssertion + # Raised when `signing_algorithm` is not supported. + class UnsupportedAlgorithmError < ArgumentError; end + + # Raised when the private key cannot be parsed or does not match + # the requested signing algorithm (e.g. an RSA key with ES256). + class InvalidKeyError < ArgumentError; end + + # RFC 7523 Section 2.2 `client_assertion_type` value. + ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + # Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds` + # and the Python SDK's `lifetime_seconds` default. + DEFAULT_LIFETIME = 300 + + # ES256 produces a raw `r || s` JWS signature of two 32-byte integers. + ES256_COMPONENT_BYTES = 32 + private_constant :ES256_COMPONENT_BYTES + + SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze + + class << self + # Returns a signed compact-serialization JWT (`header.payload.signature`). + # + # @param client_id [String] The pre-registered OAuth client identifier. + # @param audience [String] The authorization server's issuer identifier. + # @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or + # traditional encoding) or an already-parsed key. + # @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or + # `"RS256"` (RSA key). + # @param lifetime [Integer] Seconds until the `exp` claim expires. + def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME) + unless SUPPORTED_ALGORITHMS.include?(signing_algorithm) + raise UnsupportedAlgorithmError, + "signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})." + end + + key = parse_key(private_key) + validate_key!(key, signing_algorithm) + + now = Time.now.to_i + header = { alg: signing_algorithm, typ: "JWT" } + claims = { + iss: client_id, + sub: client_id, + aud: audience, + exp: now + lifetime, + iat: now, + jti: SecureRandom.uuid, + } + + signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}" + "#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}" + end + + private + + def parse_key(private_key) + return private_key if private_key.is_a?(OpenSSL::PKey::PKey) + + OpenSSL::PKey.read(private_key.to_s) + rescue OpenSSL::PKey::PKeyError => e + raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}." + end + + def validate_key!(key, signing_algorithm) + case signing_algorithm + when "ES256" + unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1" + raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve." + end + when "RS256" + unless key.is_a?(OpenSSL::PKey::RSA) + raise InvalidKeyError, "RS256 requires an RSA private key." + end + end + + return if key.private? + + raise InvalidKeyError, "private_key must contain the private component to sign assertions." + end + + def sign(key, signing_algorithm, signing_input) + der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) + return der unless signing_algorithm == "ES256" + + ecdsa_der_to_raw(der) + end + + # `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`, + # while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte + # `r || s` concatenation with each integer left-padded to 32 bytes. + def ecdsa_der_to_raw(der) + OpenSSL::ASN1.decode(der).value.map do |integer| + integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0") + end.join.then { |hex| [hex].pack("H*") } + end + + def base64url(data) + Base64.urlsafe_encode64(data, padding: false) + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/pkce.rb b/lib/mcp/client/oauth/pkce.rb new file mode 100644 index 0000000..63df4ff --- /dev/null +++ b/lib/mcp/client/oauth/pkce.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "base64" +require "digest" +require "securerandom" + +module MCP + class Client + module OAuth + # Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to + # bind an OAuth 2.1 authorization request to the same client that later + # redeems the resulting authorization code. Without PKCE, an attacker + # who steals an authorization code in transit (e.g. through a logged redirect URI) + # can exchange it for an access token; with PKCE, the attacker would also need + # the per-request `code_verifier`, which is never sent to the browser or any intermediary. + # + # MCP authorization mandates the `S256` method, so this module always returns + # that method and refuses to expose `plain` as an option. + # + # The module is stateless: callers ask for a fresh pair with `generate` for + # every authorization request and discard the result once the token endpoint has accepted it. + # + # - https://datatracker.ietf.org/doc/html/rfc7636 + # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection + module PKCE + class << self + # Generates a PKCE pair (code_verifier and S256 code_challenge). + def generate + verifier = SecureRandom.urlsafe_base64(64) + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + + { code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" } + end + end + end + end + end +end diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb new file mode 100644 index 0000000..803b5b9 --- /dev/null +++ b/lib/mcp/client/oauth/provider.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow, + # handed to `MCP::Client::HTTP` via the `oauth:` keyword. + # Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider + # in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant, + # use `ClientCredentialsProvider` instead. + # + # Required keyword arguments: + # - `client_metadata` - Hash sent to the authorization server's Dynamic Client + # Registration endpoint. Must include at minimum `redirect_uris`, + # `grant_types`, `response_types`, and `token_endpoint_auth_method`. + # When `application_type` is omitted, the SDK infers `"native"` or `"web"` + # from `redirect_uris` per SEP-837 before registering; an explicit value + # always wins. + # - `redirect_uri` - String: the redirect URI used for the authorization + # request. Must be one of `redirect_uris` in `client_metadata`. + # - `redirect_handler` - Callable invoked with the fully-built authorization + # URL (a `URI`). Implementations typically open the user's browser. + # - `callback_handler` - Callable invoked after `redirect_handler`. Returns + # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code, + # `state` is the `state` parameter received on the redirect URI, and `iss` is + # the RFC 9207 `iss` parameter (or nil when the authorization response carried none). + # Returning the 3-element form opts into SEP-2468 issuer validation: a present `iss` + # must match the authorization server's issuer, and a nil `iss` is + # rejected when the AS advertises `authorization_response_iss_parameter_supported`. + # The 2-element form skips the check for backward compatibility. + # + # Optional keyword arguments: + # - `scope` - String of space-separated scopes to request when the server's + # `WWW-Authenticate` does not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, + # `client_information`, and `save_client_information(info)`. Defaults to + # an `InMemoryStorage`. Persisted `client_information` is stamped with + # an `"issuer"` member binding it to the authorization server that + # issued it (SEP-2352); when the authorization server changes, the SDK discards + # the stale registration and tokens and re-registers. + # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document + # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification). + # When the authorization server advertises `client_id_metadata_document_supported: true`, + # the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + # Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments. + # The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include, + # but different encodings of the same query would yield different `client_id` strings for the same document). + # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword: + # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set + # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. + # - `token_request_params` - Hash of String keys and values added to every token request this provider makes + # (the authorization code exchange and refresh), for parameters the authorization server requires beyond + # the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` + # raises `Flow::InvalidTokenRequestParamsError`. + # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. + # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for + # its own requests; see `StorageBackedProvider#http_client_customizer`. + class Provider + include StorageBackedProvider + + # Raised when `Provider#initialize` is called with a `redirect_uri` that + # is neither HTTPS nor a loopback `http://` URL, per the MCP + # authorization spec's Communication Security requirement. + class InsecureRedirectURIError < ArgumentError; end + + # Raised when the `redirect_uri` argument is not listed in + # `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering + # the URI with the authorization server but then sending a different + # one with the authorization request would be rejected by the AS at + # runtime; failing at construction surfaces the bug earlier. + class UnregisteredRedirectURIError < ArgumentError; end + + # Raised when `client_id_metadata_document_url` is provided but does not meet + # the structural requirements for a Client ID Metadata Document URL: + # HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments. + # The CIMD URL is sent to the authorization server as the OAuth `client_id`, + # so the same Communication Security guarantee that protects the redirect URI + # applies and the value must unambiguously identify the document. + class InvalidClientIDMetadataDocumentURLError < ArgumentError; end + + attr_reader :client_metadata, + :redirect_uri, + :scope, + :storage, + :redirect_handler, + :callback_handler, + :client_id_metadata_document_url + + def initialize( + client_metadata:, + redirect_uri:, + redirect_handler:, + callback_handler:, + scope: nil, + storage: nil, + client_id_metadata_document_url: nil, + authorization_request_validator: nil, + token_request_params: nil, + http_client_customizer: nil + ) + unless Discovery.secure_url?(redirect_uri) + raise InsecureRedirectURIError, + "redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \ + "(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement." + end + + registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"]) + unless registered.include?(redirect_uri) + raise UnregisteredRedirectURIError, + "redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \ + "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request." + end + + if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url) + raise InvalidClientIDMetadataDocumentURLError, + "client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \ + "with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \ + "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." + end + + http_client_customizer = validated_http_client_customizer(http_client_customizer) + + @client_metadata = client_metadata + @redirect_uri = redirect_uri + @redirect_handler = redirect_handler + @callback_handler = callback_handler + @scope = scope + @storage = storage || InMemoryStorage.new + @client_id_metadata_document_url = client_id_metadata_document_url + @authorization_request_validator = authorization_request_validator + @token_request_params = frozen_token_request_params(token_request_params) + @http_client_customizer = http_client_customizer + end + + # Identifies the OAuth flow this provider drives. + # `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`, + # which is protocol metadata for the authorization server, not an SDK control signal. + def authorization_flow + :authorization_code + end + end + end + end +end diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb new file mode 100644 index 0000000..087f223 --- /dev/null +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Shared token/credential persistence for the OAuth provider classes + # (`Provider` for the authorization-code flow, `ClientCredentialsProvider` + # for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow). + # The grants differ in how they authenticate, but all read and write the same two pieces of state + # through a `storage` object: the token response and the client information. This module supplies + # that delegation so the `Flow` orchestrator can treat any provider uniformly. + # + # Including classes must set `@storage` to an object responding to `tokens`, + # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` + # (see `InMemoryStorage`). + module StorageBackedProvider + # Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`. + # An MCP server names its own authorization server and the scopes to ask for, so this is where + # an embedding application sees both before the request is made and can decline to proceed. + # `nil` (the default) authorizes whatever the server asked for, which is the behavior every + # MCP SDK has today. + attr_reader :authorization_request_validator + + # Optional Hash of String keys and values added to every token request the provider makes, for parameters + # the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them; + # Auth0's `audience` is the usual one). `nil` (the default) adds nothing. + # Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value + # with `Flow::InvalidTokenRequestParamsError`. + attr_reader :token_request_params + + # Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests, + # after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can + # add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default) + # keeps the default connection. The transport's own connection and customizer block never serve these requests: + # they are bound to the MCP server URL and carry headers meant for that server. + attr_reader :http_client_customizer + + def access_token + tokens&.dig("access_token") || tokens&.dig(:access_token) + end + + def tokens + @storage.tokens + end + + def save_tokens(tokens) + @storage.save_tokens(tokens) + end + + def client_information + @storage.client_information + end + + def save_client_information(info) + @storage.save_client_information(info) + end + + def clear_tokens! + @storage.save_tokens(nil) + end + + private + + # Constructors call this before writing to `storage`, so a rejected provider leaves it untouched. + # The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key, + # or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly + # because `Hash` copies and freezes only keys whose class is exactly `String`. + def frozen_token_request_params(params) + return if params.nil? + + problem = Flow.token_request_params_problem(params) + raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem + + params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze + end + + # Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed + # in place of a callable fails at construction rather than at the first `401`. + def validated_http_client_customizer(customizer) + return customizer if customizer.nil? || customizer.respond_to?(:call) + + raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})." + end + end + end + end +end diff --git a/lib/mcp/client/paginated_result.rb b/lib/mcp/client/paginated_result.rb new file mode 100644 index 0000000..eb06ee6 --- /dev/null +++ b/lib/mcp/client/paginated_result.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module MCP + class Client + # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. + # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination, + # an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549 + # cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and + # `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields. + ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true) + end +end diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb new file mode 100644 index 0000000..0fbb96e --- /dev/null +++ b/lib/mcp/client/stdio.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +require "json" +require "open3" +require "securerandom" +require "timeout" +require_relative "../../json_rpc_handler" +require_relative "../configuration" +require_relative "../methods" +require_relative "../protocol_deprecations" +require_relative "../version" +require_relative "modern_envelope" + +module MCP + class Client + class Stdio + # Seconds to wait for the server process to exit before sending SIGTERM. + # Matches the Python and TypeScript SDKs' shutdown timeout: + # https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/stdio/__init__.py#L48 + # https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/stdio.ts#L221 + CLOSE_TIMEOUT = 2 + STDERR_READ_SIZE = 4096 + + # Default upper bound on a single newline-delimited frame read from the + # server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a + # newline arrives, so a spawned server that never emits one can grow a single + # String until the host process is OOM-killed. 4 MiB is large enough for any + # realistic JSON-RPC frame, including base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + + # Seconds the `server/discover` probe may wait when no `read_timeout` was configured. + # A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one + # that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever. + # Matches the C# SDK's `DiscoverProbeTimeout` default. + DEFAULT_DISCOVER_PROBE_TIMEOUT = 5 + + attr_reader :command, :args, :env, :server_info + + def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + + @command = command + @args = args + @env = env + @read_timeout = read_timeout + @max_line_bytes = max_line_bytes + @stdin = nil + @stdout = nil + @stderr = nil + @wait_thread = nil + @stderr_thread = nil + @started = false + @initialized = false + @server_info = nil + @modern_protocol_version = nil + @modern_client_info = nil + @modern_capabilities = nil + # Serializes writes to `@stdin` so a request line and a notification line emitted from + # different threads (e.g. cancellation) cannot interleave on the wire. + @write_mutex = Mutex.new + end + + # Performs the MCP `initialize` handshake: sends an `initialize` request + # followed by the required `notifications/initialized` notification. The + # server's `InitializeResult` (protocol version, capabilities, server + # info, instructions) is cached on the transport and returned. + # + # Idempotent: a second call returns the cached `InitializeResult` without + # contacting the server. After `close`, state is cleared and `connect` + # will handshake again. Spawns the subprocess via `start` if it has not + # been started yet. + # + # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. + # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). + # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. + # @return [Hash] The server's `InitializeResult`. + # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, + # a malformed result, or an unsupported protocol version. + # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs + # the handshake below, `:modern` skips it and probes `server/discover`, + # and `:auto` probes `server/discover` first, falling back to the legacy handshake + # when the server does not serve a mutually supported modern version. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) + return @server_info if connected? + + # Validated before `start` so a pure argument error never spawns the server process. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + + start unless @started + + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + + case mode + when :legacy + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :modern + connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + when :auto + connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + else + raise ArgumentError, "mode must be :legacy, :modern, or :auto" + end + end + + # Whether the transport operates in the stateless modern lifecycle (SEP-2575): + # no handshake was performed and every request carries the `_meta` envelope. + def modern? + !@modern_client_info.nil? + end + + # The protocol version in use on this connection, independent of its era: + # negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). + # Returns `nil` before `connect` and after `close`. + def protocol_version + @modern_protocol_version || (@server_info && @server_info["protocolVersion"]) + end + + # Returns true once `connect` has completed the handshake or adopted the modern lifecycle. + # Returns false before the handshake and after `close`. + def connected? + @initialized || modern? + end + + # Transports may yield once the request line has been written to `@stdin`. + # `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled` + # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`, + # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request. + def send_request(request:) + method = request[:method] || request["method"] + if method == MCP::Methods::SERVER_DISCOVER + # `server/discover` (SEP-2575) is sessionless capability discovery that + # works before (or instead of) `connect`. + start unless @started + elsif !connected? + raise "MCP::Client#connect must be called before sending requests." + end + + request = stamp_modern(request) + + @write_mutex.synchronize do + write_message(request) + yield if block_given? + end + read_response(request) + end + + # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver + # `notifications/cancelled` for an in-flight request. + def send_notification(notification:) + start unless @started + connect unless connected? + + @write_mutex.synchronize { write_message(notification) } + nil + end + + def start + raise "MCP::Client::Stdio already started" if @started + + spawn_env = @env || {} + @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args) + @stdout.set_encoding("UTF-8") + @stdin.set_encoding("UTF-8") + + # Drain stderr in the background to prevent the pipe buffer from filling up, + # which would cause the server process to block and deadlock. + @stderr_thread = Thread.new do + loop do + @stderr.readpartial(STDERR_READ_SIZE) + end + rescue IOError + nil + end + + @started = true + rescue Errno::ENOENT, Errno::EACCES, Errno::ENOEXEC => e + raise RequestHandlerError.new( + "Failed to spawn server process: #{e.message}", + {}, + error_type: :internal_error, + original_error: e, + ) + end + + def close + return unless @started + + @stdin.close + @stdout.close + @stderr.close + + begin + Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value } + rescue Timeout::Error + begin + Process.kill("TERM", @wait_thread.pid) + Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value } + rescue Timeout::Error + begin + Process.kill("KILL", @wait_thread.pid) + rescue Errno::ESRCH + nil + end + rescue Errno::ESRCH + nil + end + end + + @stderr_thread.join(CLOSE_TIMEOUT) + @started = false + @initialized = false + @server_info = nil + leave_modern_mode + end + + private + + def connect_legacy(client_info:, protocol_version:, capabilities:) + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION + + init_request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + } + + write_message(init_request) + response = read_response(init_request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + unless response["result"].is_a?(Hash) + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # Per spec, if the client does not support the server's returned protocol version, + # the client SHOULD disconnect. A modern version is rejected along with unknown ones: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising + # so a retry starts without a stale `server_info`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation + @server_info = nil + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + } + write_message(notification) + rescue StandardError + @server_info = nil + raise + end + + @initialized = true + @server_info + end + + # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. + # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. + def connect_modern(client_info:, protocol_version:, capabilities:) + version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION + unless MCP::Configuration.modern_protocol_version?(version) + raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" + end + + @modern_protocol_version = version + @modern_client_info = client_info + @modern_capabilities = capabilities || {} + + begin + result = with_probe_read_timeout { probe_discover } + rescue StandardError + leave_modern_mode + raise + end + + supported = result["supportedVersions"] + unless supported.is_a?(Array) && supported.include?(version) + leave_modern_mode + raise RequestHandlerError.new( + "Server discovery failed: no mutually supported modern protocol version " \ + "(server supports #{supported.inspect})", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + + @server_info = result + @server_info + end + + # Probes `server/discover` and adopts the modern lifecycle when the server serves + # a mutually supported modern version; otherwise falls back to the legacy handshake. + # The fallback intentionally covers a successful discovery without a mutual modern + # version as well: during the 2026-07-28 rollout a server may answer discovery while + # only serving legacy versions. + def connect_auto(client_info:, protocol_version:, capabilities:) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) + rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) + end + + def leave_modern_mode + @modern_protocol_version = nil + @modern_client_info = nil + @modern_capabilities = nil + end + + # Bounds the probe read when the caller configured no `read_timeout`, and only for the probe: + # regular requests keep the unbounded default so long-running tools are unaffected. + # The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence + # and falls back on. + def with_probe_read_timeout + return yield if @read_timeout + + @read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT + begin + yield + ensure + @read_timeout = nil + end + end + + def probe_discover + request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::SERVER_DISCOVER, + } + + @write_mutex.synchronize { write_message(stamp_modern(request)) } + response = read_response(request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server discovery failed: #{error["message"]}", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result = response["result"] + unless result.is_a?(Hash) + raise RequestHandlerError.new( + "Server discovery failed: missing result in response", + { method: MCP::Methods::SERVER_DISCOVER }, + error_type: :internal_error, + ) + end + + result + end + + # Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple. + def stamp_modern(request) + return request unless modern? && (request[:id] || request["id"]) + + ModernEnvelope.stamp( + request, + protocol_version: @modern_protocol_version, + client_info: @modern_client_info, + capabilities: @modern_capabilities, + ) + end + + def write_message(message) + ensure_running! + json = JSON.generate(message) + @stdin.puts(json) + @stdin.flush + rescue IOError, Errno::EPIPE => e + raise RequestHandlerError.new( + "Failed to write to server process", + {}, + error_type: :internal_error, + original_error: e, + ) + end + + def read_response(request) + request_id = request[:id] || request["id"] + method = request[:method] || request["method"] + params = request[:params] || request["params"] + + loop do + ensure_running! + wait_for_readable!(method, params) if @read_timeout + line = read_line(method, params) + raise_connection_error!(method, params) if line.nil? + + parsed = JSON.parse(line.strip) + + # A frame carrying `method` is a request or notification, never the awaited response, + # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id + # may legitimately collide with the awaited one. Pings are answered; + # other server-to-client requests over stdio stay unsupported as documented, + # and notifications carry no id. + if parsed.is_a?(Hash) && parsed.key?("method") + # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at + # schema validation, so a ping carrying one is skipped rather than echoed back. + answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) + next + end + + # A JSON-RPC message is an object; skip a non-object frame (array or scalar) + # the same way as a frame without an id. + next unless parsed.is_a?(Hash) && parsed.key?("id") + + return parsed if parsed["id"] == request_id + end + rescue JSON::ParserError => e + raise RequestHandlerError.new( + "Failed to parse server response", + { method: method, params: params }, + error_type: :internal_error, + original_error: e, + ) + end + + # The ping receiver "MUST respond promptly with an empty response". Answered inline from + # the read loop, so a keepalive cannot stall unanswered while a response is awaited; + # between requests nothing reads stdout, and a queued ping is answered on the next read. + def answer_ping(parsed) + @write_mutex.synchronize do + write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} }) + end + rescue RequestHandlerError + # Best effort: a pong cannot be delivered over a broken stdin, and the failure must not + # surface as an error of the unrelated request whose response the loop is reading. + end + + def json_rpc_id?(id) + id.is_a?(String) || id.is_a?(Numeric) + end + + def ensure_running! + return if @wait_thread.alive? + + raise RequestHandlerError.new( + "Server process has exited", + {}, + error_type: :internal_error, + ) + end + + def wait_for_readable!(method, params) + ready = @stdout.wait_readable(@read_timeout) + return if ready + + raise RequestHandlerError.new( + "Timed out waiting for server response", + { method: method, params: params }, + error_type: :internal_error, + ) + end + + # Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`. + # Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit + # is reached before a newline arrives, which signals a server streaming an unbounded frame. + # A short final frame without a trailing newline (EOF) is still returned, since its length + # stays under the limit. + def read_line(method, params) + line = @stdout.gets("\n", @max_line_bytes) + return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + + # The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and + # cannot be resumed. Close before raising so a later `send_request` fails cleanly instead + # of parsing a truncated frame. + begin + close + rescue StandardError + nil + end + + raise RequestHandlerError.new( + "Server response frame exceeds #{@max_line_bytes} bytes without a newline", + { method: method, params: params }, + error_type: :internal_error, + ) + end + + def raise_connection_error!(method, params) + raise RequestHandlerError.new( + "Server process closed stdout unexpectedly", + { method: method, params: params }, + error_type: :internal_error, + ) + end + end + end +end diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index b348d4d..bfefda8 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -3,13 +3,14 @@ module MCP class Client class Tool - attr_reader :name, :description, :input_schema, :output_schema + attr_reader :name, :description, :input_schema, :output_schema, :annotations - def initialize(name:, description:, input_schema:, output_schema: nil) + def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil) @name = name @description = description @input_schema = input_schema @output_schema = output_schema + @annotations = annotations end end end diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 29a1b16..4d4b78e 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -2,22 +2,73 @@ module MCP class Configuration - DEFAULT_PROTOCOL_VERSION = "2025-06-18" - SUPPORTED_PROTOCOL_VERSIONS = [DEFAULT_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"] + LATEST_STABLE_PROTOCOL_VERSION = "2026-07-28" + ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION = "2026-07-28" + SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ + LATEST_STABLE_PROTOCOL_VERSION, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", + ].freeze + DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" - attr_writer :exception_reporter, :instrumentation_callback + # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575), + # where each request carries its own version in `_meta` and is validated against this list independently, + # with no handshake. These are reachable only through `server/discover` and the per-request envelope. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" + SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze - def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil, - validate_tool_call_arguments: true) + # Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition + # (handshake = stable minus modern) cannot drift when a new revision lands. + # Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish + # a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request + # in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version: + # a client asking `initialize` for one is counter-offered + # `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs. + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze + LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first + + class << self + def modern_protocol_version?(version) + SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version) + end + + def handshake_protocol_version?(version) + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version) + end + + # The one statement of the client-side handshake contract, shared by every transport: + # a modern version cannot ride the legacy `initialize` handshake. + def reject_modern_handshake_version!(version) + return unless version && modern_protocol_version?(version) + + raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \ + "`initialize` handshake; use `mode: :modern` (or `:auto`) instead" + end + end + + attr_writer :exception_reporter, :around_request + + # @deprecated Use {#around_request=} instead. `instrumentation_callback` + # fires only after a request completes and cannot wrap execution in a + # surrounding block (e.g. for Application Performance Monitoring (APM) spans). + # @see #around_request= + attr_writer :instrumentation_callback + + def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, + validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false) @exception_reporter = exception_reporter + @around_request = around_request @instrumentation_callback = instrumentation_callback @protocol_version = protocol_version if protocol_version validate_protocol_version!(protocol_version) end validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) + validate_value_of_validate_tool_call_results!(validate_tool_call_results) + validate_value_of_instrument_server_context!(instrument_server_context) @validate_tool_call_arguments = validate_tool_call_arguments + @validate_tool_call_results = validate_tool_call_results + @instrument_server_context = instrument_server_context end def protocol_version=(protocol_version) @@ -32,8 +83,28 @@ module MCP @validate_tool_call_arguments = validate_tool_call_arguments end + # Opt in to exposing the user-defined `server_context` in the + # `around_request` / `instrumentation_callback` data hash. Off by default: + # the hash is application-supplied and may hold values a tracing backend + # should not receive, so surfacing it has to be a deliberate choice. + def instrument_server_context=(instrument_server_context) + validate_value_of_instrument_server_context!(instrument_server_context) + + @instrument_server_context = instrument_server_context + end + + def validate_tool_call_results=(validate_tool_call_results) + validate_value_of_validate_tool_call_results!(validate_tool_call_results) + + @validate_tool_call_results = validate_tool_call_results + end + + # The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake + # settles on by default. Reading the newest version of any era here would hand back a value + # the writer rejects, and no caller wants the modern revision: a modern connection carries + # its version on every request instead of consulting configuration. def protocol_version - @protocol_version || DEFAULT_PROTOCOL_VERSION + @protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION end def protocol_version? @@ -48,20 +119,43 @@ module MCP !@exception_reporter.nil? end + def around_request + @around_request || default_around_request + end + + def around_request? + !@around_request.nil? + end + + # @deprecated Use {#around_request} instead. `instrumentation_callback` + # fires only after a request completes and cannot wrap execution in a + # surrounding block (e.g. for Application Performance Monitoring (APM) spans). + # @see #around_request def instrumentation_callback @instrumentation_callback || default_instrumentation_callback end + # @deprecated Use {#around_request?} instead. + # @see #around_request? def instrumentation_callback? !@instrumentation_callback.nil? end attr_reader :validate_tool_call_arguments + attr_reader :validate_tool_call_results + + def instrument_server_context? + !!@instrument_server_context + end def validate_tool_call_arguments? !!@validate_tool_call_arguments end + def validate_tool_call_results? + !!@validate_tool_call_results + end + def merge(other) return self if other.nil? @@ -70,33 +164,59 @@ module MCP else @exception_reporter end + + around_request = if other.around_request? + other.around_request + else + @around_request + end + instrumentation_callback = if other.instrumentation_callback? other.instrumentation_callback else @instrumentation_callback end + protocol_version = if other.protocol_version? other.protocol_version else @protocol_version end + validate_tool_call_arguments = other.validate_tool_call_arguments + validate_tool_call_results = other.validate_tool_call_results Configuration.new( - exception_reporter:, - instrumentation_callback:, - protocol_version:, - validate_tool_call_arguments:, + exception_reporter: exception_reporter, + around_request: around_request, + instrumentation_callback: instrumentation_callback, + protocol_version: protocol_version, + validate_tool_call_arguments: validate_tool_call_arguments, + validate_tool_call_results: validate_tool_call_results, + instrument_server_context: other.instrument_server_context?, ) end private + # A pin scopes the `initialize` handshake, so only handshake versions are accepted: + # a modern version has no handshake to pin (its version rides every request in `_meta`), + # and accepting one here would configure nothing. Failing at construction beats + # a setting that silently does not apply. def validate_protocol_version!(protocol_version) - unless SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version) - message = "protocol_version must be #{SUPPORTED_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_PROTOCOL_VERSIONS[-1]}" - raise ArgumentError, message + return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version) + + # A version this SDK serves, rejected only for where it was set, deserves the reason and + # the alternative rather than a list it is missing from: this is the error an upgrade from + # a release that accepted the value lands on, so it doubles as the migration note. + if self.class.modern_protocol_version?(protocol_version) + raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \ + "the pin scopes the `initialize` handshake, which never negotiates a modern version. " \ + "Modern clients carry their version on every request and need no pin; remove the setting." end + + raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \ + "or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}" end def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) @@ -105,10 +225,27 @@ module MCP end end + def validate_value_of_validate_tool_call_results!(validate_tool_call_results) + unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass) + raise ArgumentError, "validate_tool_call_results must be a boolean" + end + end + + def validate_value_of_instrument_server_context!(instrument_server_context) + unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass) + raise ArgumentError, "instrument_server_context must be a boolean" + end + end + def default_exception_reporter @default_exception_reporter ||= ->(exception, server_context) {} end + def default_around_request + @default_around_request ||= ->(_data, &request_handler) { request_handler.call } + end + + # @deprecated Use {#default_around_request} instead. def default_instrumentation_callback @default_instrumentation_callback ||= ->(data) {} end diff --git a/lib/mcp/content.rb b/lib/mcp/content.rb index 7f4f921..591ecb4 100644 --- a/lib/mcp/content.rb +++ b/lib/mcp/content.rb @@ -3,29 +3,60 @@ module MCP module Content class Text - attr_reader :text, :annotations + attr_reader :text, :annotations, :meta - def initialize(text, annotations: nil) + def initialize(text, annotations: nil, meta: nil) @text = text @annotations = annotations + @meta = meta end def to_h - { text:, annotations:, type: "text" }.compact + { text: text, annotations: annotations, _meta: meta, type: "text" }.compact end end class Image - attr_reader :data, :mime_type, :annotations + attr_reader :data, :mime_type, :annotations, :meta - def initialize(data, mime_type, annotations: nil) + def initialize(data, mime_type, annotations: nil, meta: nil) @data = data @mime_type = mime_type @annotations = annotations + @meta = meta end def to_h - { data:, mime_type:, annotations:, type: "image" }.compact + { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "image" }.compact + end + end + + class Audio + attr_reader :data, :mime_type, :annotations, :meta + + def initialize(data, mime_type, annotations: nil, meta: nil) + @data = data + @mime_type = mime_type + @annotations = annotations + @meta = meta + end + + def to_h + { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "audio" }.compact + end + end + + class EmbeddedResource + attr_reader :resource, :annotations, :meta + + def initialize(resource, annotations: nil, meta: nil) + @resource = resource + @annotations = annotations + @meta = meta + end + + def to_h + { resource: resource.to_h, annotations: annotations, _meta: meta, type: "resource" }.compact end end end diff --git a/lib/mcp/elicitation.rb b/lib/mcp/elicitation.rb new file mode 100644 index 0000000..26186c7 --- /dev/null +++ b/lib/mcp/elicitation.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module MCP + # Builders for elicitation `requestedSchema` definitions per MCP 2025-11-25. + # Each builder returns an instance whose `to_h` produces the JSON-Schema-shaped Hash + # a server passes as a property value in `create_form_elicitation(requested_schema:)`. + module Elicitation + autoload :EnumSchema, "mcp/elicitation/enum_schema" + end +end diff --git a/lib/mcp/elicitation/enum_schema.rb b/lib/mcp/elicitation/enum_schema.rb new file mode 100644 index 0000000..191a20f --- /dev/null +++ b/lib/mcp/elicitation/enum_schema.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module MCP + module Elicitation + # Builds the four enum schema variants defined by MCP 2025-11-25 (SEP-1330) plus the legacy `enumNames` form + # retained for backward compatibility. + # + # Each class method returns an `EnumSchema` instance; call `to_h` to get the property-value Hash and + # pass it through `requested_schema` to `ServerSession#create_form_elicitation`. + class EnumSchema + class << self + # Single-select with plain string values: `{ type: "string", enum: [...] }`. + def untitled_single_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "string", + extras: { enum: values }, + default: default, + title: title, + description: description, + ) + end + + # Single-select with display titles per option: `{ type: "string", oneOf: [{ const, title }, ...] }`. + # `options` is an Array of `{ value:, title: }` hashes. + def titled_single_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + new( + type: "string", + extras: { oneOf: options.map { |o| { const: o[:value], title: o[:title] } } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with plain string values: `{ type: "array", items: { type: "string", enum: [...] } }`. + def untitled_multi_select(values:, default: nil, title: nil, description: nil) + validate_values!(values) + + new( + type: "array", + extras: { items: { type: "string", enum: values } }, + default: default, + title: title, + description: description, + ) + end + + # Multi-select with display titles per option: `{ type: "array", items: { anyOf: [{ const, title }, ...] } }`. + def titled_multi_select(options:, default: nil, title: nil, description: nil) + validate_titled_options!(options) + + items_anyof = options.map { |o| { const: o[:value], title: o[:title] } } + new( + type: "array", + extras: { items: { anyOf: items_anyof } }, + default: default, + title: title, + description: description, + ) + end + + # Legacy single-select retained for backward compatibility with clients implementing + # the pre-SEP-1330 form: `{ enum, enumNames }`. + def legacy_titled(values:, value_titles:, default: nil, title: nil, description: nil) + validate_values!(values) + unless value_titles.is_a?(Array) && value_titles.length == values.length + raise ArgumentError, "value_titles must be an Array of the same length as values" + end + + new( + type: "string", + extras: { enum: values, enumNames: value_titles }, + default: default, + title: title, + description: description, + ) + end + + private + + def validate_values!(values) + unless values.is_a?(Array) && !values.empty? + raise ArgumentError, "values must be a non-empty Array" + end + end + + def validate_titled_options!(options) + unless options.is_a?(Array) && !options.empty? + raise ArgumentError, "options must be a non-empty Array of {value:, title:} hashes" + end + + options.each do |option| + unless option.is_a?(Hash) && option.key?(:value) && option.key?(:title) + raise ArgumentError, "each option must be a Hash with :value and :title keys" + end + end + end + end + + def initialize(type:, extras:, default: nil, title: nil, description: nil) + @type = type + @extras = extras + @default = default + @title = title + @description = description + end + + def to_h + hash = { type: @type }.merge(@extras) + hash[:title] = @title if @title + hash[:description] = @description if @description + hash[:default] = @default unless @default.nil? + hash + end + end + end +end diff --git a/lib/mcp/error_codes.rb b/lib/mcp/error_codes.rb new file mode 100644 index 0000000..603a7a9 --- /dev/null +++ b/lib/mcp/error_codes.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MCP + # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`. + # + # All three constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): + # + # - `HEADER_MISMATCH` rejects an HTTP request whose headers do not match the corresponding body values, + # or whose required headers are missing or malformed (no `error.data`). It is reserved for + # the Streamable HTTP transport, since headers do not exist on stdio. + # - `MISSING_REQUIRED_CLIENT_CAPABILITY` rejects a request that requires a client capability + # the request did not declare (`error.data: { requiredCapabilities: {...} }`). + # Raised via `Server::MissingRequiredClientCapabilityError`. + # - `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server + # does not support (`error.data: { supported: [...], requested: "..." }`). Raised via + # `Server::UnsupportedProtocolVersionError`. + # + # The values come from the spec's MCP-specific error code block, which is allocated sequentially from `-32020` + # toward `-32099`. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + module ErrorCodes + HEADER_MISMATCH = -32020 + MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 + UNSUPPORTED_PROTOCOL_VERSION = -32022 + end +end diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb new file mode 100644 index 0000000..56ac6ad --- /dev/null +++ b/lib/mcp/icon.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module MCP + # An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type: + # https://modelcontextprotocol.io/specification/2026-07-28/basic#icons + # + # Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs, + # so an icon that would serialize into something a client rejects fails here instead. What a value means + # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String` + # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String` + # (the specification expects `WxH` or `"any"`). + class Icon + SUPPORTED_THEMES = ["light", "dark"].freeze + + attr_reader :mime_type, :sizes, :src, :theme + + def initialize(mime_type: nil, sizes: nil, src:, theme: nil) + unless src.is_a?(String) && !src.empty? + raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})." + end + + unless mime_type.nil? || mime_type.is_a?(String) + raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})." + end + + if (problem = sizes_problem(sizes)) + raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})." + end + + unless theme.nil? || SUPPORTED_THEMES.include?(theme) + raise ArgumentError, 'The value of theme must specify "light" or "dark".' + end + + @mime_type = mime_type + @sizes = sizes + @src = src + @theme = theme + end + + def to_h + { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact + end + + private + + def sizes_problem(sizes) + return if sizes.nil? + return "got #{sizes.class}" unless sizes.is_a?(Array) + + index = sizes.index { |size| !size.is_a?(String) } + + "got #{sizes[index].class} inside the Array" if index + end + end +end diff --git a/lib/mcp/instrumentation.rb b/lib/mcp/instrumentation.rb index 5632fdd..5f1142f 100644 --- a/lib/mcp/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -2,19 +2,44 @@ module MCP module Instrumentation - def instrument_call(method, &block) + def instrument_call(method, server_context: {}, exception_already_reported: nil, &block) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) begin @instrumentation_data = {} - add_instrumentation_data(method:) + add_instrumentation_data(method: method) + # `self.` is required: the `server_context:` keyword above shadows the + # reader, and the value we want here is the user-defined hash passed to + # `Server.new`, not the per-call reporter context. + if configuration.instrument_server_context? && respond_to?(:server_context) + add_instrumentation_data(server_context: self.server_context) + end - result = yield block + result = configuration.around_request.call(@instrumentation_data, &block) result + rescue => e + already_reported = begin + !!exception_already_reported&.call(e) + rescue Exception + # The predicate is expected to be side-effect-free and return a boolean. + # Any exception raised from it (including non-StandardError such as SystemExit) + # must not shadow the original exception. + false + end + + unless already_reported + add_instrumentation_data(error: :internal_error) unless @instrumentation_data.key?(:error) + configuration.exception_reporter.call(e, server_context) + end + + raise ensure end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) add_instrumentation_data(duration: end_time - start_time) + # Backward compatibility: `instrumentation_callback` is soft-deprecated + # in favor of `around_request`, but existing callers still expect it + # to fire after every request until it is removed in a future version. configuration.instrumentation_callback.call(@instrumentation_data) end end diff --git a/lib/mcp/logging_message_notification.rb b/lib/mcp/logging_message_notification.rb new file mode 100644 index 0000000..044e4d4 --- /dev/null +++ b/lib/mcp/logging_message_notification.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module MCP + class LoggingMessageNotification + LOG_LEVEL_SEVERITY = { + "debug" => 0, + "info" => 1, + "notice" => 2, + "warning" => 3, + "error" => 4, + "critical" => 5, + "alert" => 6, + "emergency" => 7, + }.freeze + + def initialize(level:) + @level = level + end + + def valid_level? + LOG_LEVEL_SEVERITY.keys.include?(@level) + end + + def should_notify?(log_level) + return false unless LOG_LEVEL_SEVERITY.key?(log_level) + + LOG_LEVEL_SEVERITY[log_level] >= LOG_LEVEL_SEVERITY[@level] + end + end +end diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index f30eaf2..60af708 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -5,6 +5,12 @@ module MCP INITIALIZE = "initialize" PING = "ping" LOGGING_SET_LEVEL = "logging/setLevel" + # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575). + SERVER_DISCOVER = "server/discover" + # Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), + # replacing the legacy HTTP GET listening stream. Served at the transport layer + # (Streamable HTTP modern path); transports without streaming support answer `-32601`. + SUBSCRIPTIONS_LISTEN = "subscriptions/listen" PROMPTS_GET = "prompts/get" PROMPTS_LIST = "prompts/list" @@ -19,8 +25,23 @@ module MCP TOOLS_CALL = "tools/call" TOOLS_LIST = "tools/list" + # RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575): + # `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`, + # `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource + # subscription pair by the connectionless model, which leaves nothing to keep alive + # or subscribe on. A modern-era request naming one of these answers with `-32601` + # Method not found (HTTP 404 on Streamable HTTP). + MODERN_REMOVED_METHODS = [ + INITIALIZE, + PING, + LOGGING_SET_LEVEL, + RESOURCES_SUBSCRIBE, + RESOURCES_UNSUBSCRIBE, + ].freeze + ROOTS_LIST = "roots/list" SAMPLING_CREATE_MESSAGE = "sampling/createMessage" + ELICITATION_CREATE = "elicitation/create" # Notification methods NOTIFICATIONS_INITIALIZED = "notifications/initialized" @@ -32,6 +53,10 @@ module MCP NOTIFICATIONS_MESSAGE = "notifications/message" NOTIFICATIONS_PROGRESS = "notifications/progress" NOTIFICATIONS_CANCELLED = "notifications/cancelled" + NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete" + # First message on a `subscriptions/listen` stream (SEP-2575): reports the subset + # of requested notification types the server agreed to honor. + NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged" class MissingRequiredCapabilityError < StandardError attr_reader :method @@ -45,6 +70,10 @@ module MCP end class << self + def notification?(method) + method.is_a?(String) && method.start_with?("notifications/") + end + def ensure_capability!(method, capabilities) case method when PROMPTS_GET, PROMPTS_LIST @@ -71,13 +100,13 @@ module MCP require_capability!(method, capabilities, :completions) when ROOTS_LIST require_capability!(method, capabilities, :roots) - when NOTIFICATIONS_ROOTS_LIST_CHANGED - require_capability!(method, capabilities, :roots) - require_capability!(method, capabilities, :roots, :listChanged) when SAMPLING_CREATE_MESSAGE require_capability!(method, capabilities, :sampling) - when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED - # No specific capability required for initialize, ping, progress or cancelled + when ELICITATION_CREATE + require_capability!(method, capabilities, :elicitation) + when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, + NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE + # No specific capability required. end end diff --git a/lib/mcp/progress.rb b/lib/mcp/progress.rb new file mode 100644 index 0000000..6762d3c --- /dev/null +++ b/lib/mcp/progress.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module MCP + class Progress + def initialize(notification_target:, progress_token:, related_request_id: nil) + @notification_target = notification_target + @progress_token = progress_token + @related_request_id = related_request_id + end + + def report(progress, total: nil, message: nil) + return unless @progress_token + return unless @notification_target + + @notification_target.notify_progress( + progress_token: @progress_token, + progress: progress, + total: total, + message: message, + related_request_id: @related_request_id, + ) + end + end +end diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index a414bdc..c2ed2db 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require_relative "prompt/argument" +require_relative "prompt/message" +require_relative "prompt/result" + module MCP class Prompt class << self @@ -7,6 +11,7 @@ module MCP attr_reader :title_value attr_reader :description_value + attr_reader :icons_value attr_reader :arguments_value attr_reader :meta_value @@ -19,7 +24,8 @@ module MCP name: name_value, title: title_value, description: description_value, - arguments: arguments_value&.map(&:to_h), + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + arguments: arguments_value.empty? ? nil : arguments_value.map(&:to_h), _meta: meta_value, }.compact end @@ -29,7 +35,8 @@ module MCP subclass.instance_variable_set(:@name_value, nil) subclass.instance_variable_set(:@title_value, nil) subclass.instance_variable_set(:@description_value, nil) - subclass.instance_variable_set(:@arguments_value, nil) + subclass.instance_variable_set(:@icons_value, nil) + subclass.instance_variable_set(:@arguments_value, []) subclass.instance_variable_set(:@meta_value, nil) end @@ -61,11 +68,19 @@ module MCP end end + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + def arguments(value = NOT_SET) if value == NOT_SET @arguments_value else - @arguments_value = value + @arguments_value = Array(value) end end @@ -77,25 +92,34 @@ module MCP end end - def define(name: nil, title: nil, description: nil, arguments: [], meta: nil, &block) + def define(name: nil, title: nil, description: nil, icons: [], arguments: [], meta: nil, &block) Class.new(self) do prompt_name name title title description description + icons icons arguments arguments define_singleton_method(:template) do |args, server_context: nil| - instance_exec(args, server_context:, &block) + instance_exec(args, server_context: server_context, &block) end meta meta end end def validate_arguments!(args) + args ||= {} missing = required_args - args.keys return if missing.empty? + # The explicit `error_code` maps a missing prompt argument to Invalid Params (-32602) rather + # than the default Internal Error (-32603); a missing required argument is client input, not a + # server fault. `error_type: :missing_required_arguments` keeps the descriptive message and + # instrumentation label. Mirrors `MCP::Server::ResourceNotFoundError`. raise MCP::Server::RequestHandlerError.new( - "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments + "Missing required arguments: #{missing.join(", ")}", + nil, + error_type: :missing_required_arguments, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, ) end diff --git a/lib/mcp/prompt/message.rb b/lib/mcp/prompt/message.rb index aa20301..906ebd1 100644 --- a/lib/mcp/prompt/message.rb +++ b/lib/mcp/prompt/message.rb @@ -11,7 +11,7 @@ module MCP end def to_h - { role:, content: content.to_h }.compact + { role: role, content: content.to_h }.compact end end end diff --git a/lib/mcp/prompt/result.rb b/lib/mcp/prompt/result.rb index 53a5e21..c1fb740 100644 --- a/lib/mcp/prompt/result.rb +++ b/lib/mcp/prompt/result.rb @@ -3,15 +3,16 @@ module MCP class Prompt class Result - attr_reader :description, :messages + attr_reader :description, :messages, :meta - def initialize(description: nil, messages: []) + def initialize(description: nil, messages: [], meta: nil) @description = description @messages = messages + @meta = meta end def to_h - { description:, messages: messages.map(&:to_h) }.compact + { description: description, messages: messages.map(&:to_h), _meta: meta }.compact end end end diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb new file mode 100644 index 0000000..f79e727 --- /dev/null +++ b/lib/mcp/protocol_deprecations.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "configuration" + +module MCP + # Warning texts for the features SEP-2577 deprecates at 2026-07-28. + # + # Only the client emits these, from the modern connect, where the capabilities it declares are + # the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on + # a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach + # a version these apply to. `LOGGING_MESSAGE` in particular has no caller left + # (a modern client declares no logging capability, and `notify_log_message` on that wire is + # the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and + # callable for embedders, and because deleting it would be a breaking change for no gain. + module ProtocolDeprecations + extend self + + ROOTS_MESSAGE = + "MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version " \ + "2026-07-28 (SEP-2577). Use tool parameters, resource URIs, server configuration, or environment " \ + "variables instead." + SAMPLING_MESSAGE = + "MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version 2026-07-28 (SEP-2577). " \ + "Use direct LLM provider APIs instead." + LOGGING_MESSAGE = + "MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version " \ + "2026-07-28 (SEP-2577). Use stderr or OpenTelemetry instead." + + MESSAGES = { + roots: ROOTS_MESSAGE, + sampling: SAMPLING_MESSAGE, + logging: LOGGING_MESSAGE, + }.freeze + + def deprecated_roots_sampling_logging?(protocol_version) + return false unless protocol_version + + protocol_version >= Configuration::ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION + end + + def warn_for(feature, protocol_version:, uplevel: 1) + return unless deprecated_roots_sampling_logging?(protocol_version) + + Kernel.warn(MESSAGES.fetch(feature), uplevel: uplevel) + end + + def warn_for_client_capabilities(capabilities, protocol_version:, uplevel: 1) + return unless deprecated_roots_sampling_logging?(protocol_version) + return unless capabilities + + warn_for(:roots, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :roots) + warn_for(:sampling, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :sampling) + end + + private + + def capability?(capabilities, key) + capabilities.key?(key) || capabilities.key?(key.to_s) + end + end +end diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb new file mode 100644 index 0000000..3f1b19a --- /dev/null +++ b/lib/mcp/request_envelope.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module MCP + # The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575). + # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version + # and client capabilities through reserved `_meta` keys (plus an optional client identity), + # and the server validates each request independently. Servers MUST NOT infer capabilities from + # prior requests, which is why the envelope is a per-request value object rather than session state. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class RequestEnvelope + PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion" + CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo" + CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities" + + # Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle. + # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format. + LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" + # Notification-side reserved key (SEP-2575): correlates a notification delivered on + # a `subscriptions/listen` stream (and the stream's closing result) with the JSON-RPC id of + # the `subscriptions/listen` request that opened it. Not part of the request envelope triple. + SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" + + # Result-side counterpart of the request envelope: the server's identity rides in + # the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was + # finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it. + SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" + + # `clientInfo` is deliberately absent: it became optional after the SEP was finalized + # (spec PR modelcontextprotocol/modelcontextprotocol#3002), so servers MUST accept + # envelopes without it. The TypeScript and Python SDKs validate the same required pair. + REQUIRED_META_KEYS = [ + PROTOCOL_VERSION_META_KEY, + CLIENT_CAPABILITIES_META_KEY, + ].freeze + + class << self + # A request claims the modern lifecycle when its `_meta` carries `io.modelcontextprotocol/protocolVersion`, + # matching the TypeScript SDK's envelope claim and the Python SDK's `_has_modern_envelope`. + # Classification is deliberately looser than validation: a claimed-but-malformed envelope is + # rejected by {parse!} with `-32602` instead of silently flowing through the legacy path, + # while `_meta` without the claim key (`progressToken`, trace context) stays legacy. + def modern?(params) + meta = extract_meta(params) + return false unless meta.is_a?(Hash) + + !read(meta, PROTOCOL_VERSION_META_KEY).nil? + end + + # Parses and validates the envelope: `protocolVersion` and `clientCapabilities` are required, + # `clientInfo` is optional. A missing or mistyped field is Invalid params (`-32602`) naming + # the offending keys, the code and shape the spec mandates and the reference SDKs emit. + # `request` is only used to enrich the raised error; callers dispatching notifications can omit it. + def parse!(params, request: nil) + meta = extract_meta(params) + meta = {} unless meta.is_a?(Hash) + + protocol_version = read(meta, PROTOCOL_VERSION_META_KEY) + client_info = read(meta, CLIENT_INFO_META_KEY) + client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY) + + invalid_keys = [] + invalid_keys << PROTOCOL_VERSION_META_KEY unless protocol_version.is_a?(String) + invalid_keys << CLIENT_CAPABILITIES_META_KEY unless client_capabilities.is_a?(Hash) + invalid_keys << CLIENT_INFO_META_KEY unless client_info.nil? || client_info.is_a?(Hash) + + unless invalid_keys.empty? + raise Server::RequestHandlerError.new( + "Invalid params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ) + end + + unless Configuration.modern_protocol_version?(protocol_version) + raise Server::UnsupportedProtocolVersionError.new(protocol_version, request) + end + + new( + protocol_version: protocol_version, + client_info: client_info, + client_capabilities: client_capabilities, + log_level: read(meta, LOG_LEVEL_META_KEY), + ) + end + + private + + # `Server#handle` accepts hashes parsed with either symbol or string keys, so read both forms + # (the same tolerance as `Server#handle_cancelled_notification`). + def extract_meta(params) + return unless params.is_a?(Hash) + + meta = params[:_meta] + meta.nil? ? params["_meta"] : meta + end + + def read(meta, key) + value = meta[key.to_sym] + value.nil? ? meta[key] : value + end + end + + # `client_info` is `nil` when the client chose not to identify itself, which is legal: + # it is self-reported data and MUST NOT drive behavior or security decisions anyway. + attr_reader :protocol_version, :client_info, :client_capabilities, :log_level + + def initialize(protocol_version:, client_capabilities:, client_info: nil, log_level: nil) + @protocol_version = protocol_version + @client_info = client_info + @client_capabilities = client_capabilities + @log_level = log_level + freeze + end + end +end diff --git a/lib/mcp/resource.rb b/lib/mcp/resource.rb index 5086a8e..dd87ee2 100644 --- a/lib/mcp/resource.rb +++ b/lib/mcp/resource.rb @@ -1,15 +1,157 @@ # frozen_string_literal: true +require_relative "resource/contents" +require_relative "resource/embedded" + module MCP class Resource - attr_reader :uri, :name, :title, :description, :mime_type + class << self + NOT_SET = Object.new - def initialize(uri:, name:, title: nil, description: nil, mime_type: nil) + attr_reader :uri_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :size_value + attr_reader :meta_value + + def contents(server_context: nil) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uri: uri_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + size: size_value, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_value, nil) + subclass.instance_variable_set(:@name_value, nil) + subclass.instance_variable_set(:@title_value, nil) + subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) + subclass.instance_variable_set(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@size_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri(value = NOT_SET) + if value == NOT_SET + @uri_value + else + @uri_value = value + end + end + + def resource_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def size(value = NOT_SET) + if value == NOT_SET + @size_value + else + @size_value = value + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + Class.new(self) do + uri uri + resource_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + size size + meta meta + define_singleton_method(:contents, &block) if block + end + end + end + + attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta + + def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil) @uri = uri @name = name @title = title @description = description + @icons = icons @mime_type = mime_type + @annotations = annotations + @size = size + @meta = meta end def to_h @@ -18,7 +160,11 @@ module MCP name: name, title: title, description: description, + icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + annotations: annotations&.to_h, + size: size, + _meta: meta, }.compact end end diff --git a/lib/mcp/resource/contents.rb b/lib/mcp/resource/contents.rb index d87ded8..a7569b0 100644 --- a/lib/mcp/resource/contents.rb +++ b/lib/mcp/resource/contents.rb @@ -3,23 +3,24 @@ module MCP class Resource class Contents - attr_reader :uri, :mime_type + attr_reader :uri, :mime_type, :meta - def initialize(uri:, mime_type: nil) + def initialize(uri:, mime_type: nil, meta: nil) @uri = uri @mime_type = mime_type + @meta = meta end def to_h - { uri:, mime_type: }.compact + { uri: uri, mimeType: mime_type, _meta: meta }.compact end end class TextContents < Contents attr_reader :text - def initialize(text:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + def initialize(text:, uri:, mime_type:, meta: nil) + super(uri: uri, mime_type: mime_type, meta: meta) @text = text end @@ -31,13 +32,13 @@ module MCP class BlobContents < Contents attr_reader :data - def initialize(data:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + def initialize(data:, uri:, mime_type:, meta: nil) + super(uri: uri, mime_type: mime_type, meta: meta) @data = data end def to_h - super.merge(data: data) + super.merge(blob: data) end end end diff --git a/lib/mcp/resource/embedded.rb b/lib/mcp/resource/embedded.rb index 343f86e..df94fc4 100644 --- a/lib/mcp/resource/embedded.rb +++ b/lib/mcp/resource/embedded.rb @@ -6,11 +6,12 @@ module MCP attr_reader :resource, :annotations def initialize(resource:, annotations: nil) + @resource = resource @annotations = annotations end def to_h - { resource: resource.to_h, annotations: }.compact + { resource: resource.to_h, annotations: annotations }.compact end end end diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index 9134c22..71db231 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -2,14 +2,166 @@ module MCP class ResourceTemplate - attr_reader :uri_template, :name, :title, :description, :mime_type + class << self + NOT_SET = Object.new - def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil) + # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`. + # Variable names are restricted to valid Regexp named-group names, + # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match. + VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/.freeze + + attr_reader :uri_template_value + attr_reader :title_value + attr_reader :description_value + attr_reader :icons_value + attr_reader :mime_type_value + attr_reader :annotations_value + attr_reader :meta_value + + def contents(server_context: nil, **params) + raise NotImplementedError, "Subclasses must implement contents" + end + + def to_h + { + uriTemplate: uri_template_value, + name: name_value, + title: title_value, + description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + mimeType: mime_type_value, + annotations: annotations_value&.to_h, + _meta: meta_value, + }.compact + end + + def inherited(subclass) + super + subclass.instance_variable_set(:@uri_template_value, nil) + subclass.instance_variable_set(:@uri_template_pattern, nil) + subclass.instance_variable_set(:@name_value, nil) + subclass.instance_variable_set(:@title_value, nil) + subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) + subclass.instance_variable_set(:@mime_type_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) + subclass.instance_variable_set(:@meta_value, nil) + end + + def uri_template(value = NOT_SET) + if value == NOT_SET + @uri_template_value + else + @uri_template_pattern = nil + @uri_template_value = value + end + end + + def resource_template_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value + end + end + + def name_value + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) + end + + def title(value = NOT_SET) + if value == NOT_SET + @title_value + else + @title_value = value + end + end + + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end + + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + + def mime_type(value = NOT_SET) + if value == NOT_SET + @mime_type_value + else + @mime_type_value = value + end + end + + def annotations(value = NOT_SET) + if value == NOT_SET + @annotations_value + else + @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value) + end + end + + def meta(value = NOT_SET) + if value == NOT_SET + @meta_value + else + @meta_value = value + end + end + + # Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions. + # Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match. + # Variables match one or more characters excluding `/`, and values are not percent-decoded. + def match_uri(uri) + match = uri_template_pattern&.match(uri) + match&.named_captures&.transform_keys(&:to_sym) + end + + def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + Class.new(self) do + uri_template uri_template + resource_template_name name + title title + description description + icons icons + mime_type mime_type + self.annotations(annotations) if annotations + meta meta + define_singleton_method(:contents, &block) if block + end + end + + private + + def uri_template_pattern + return if uri_template.nil? + + @uri_template_pattern ||= begin + pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" } + Regexp.new("\\A#{pattern}\\z") + end + end + end + + attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta + + def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil) @uri_template = uri_template @name = name @title = title @description = description + @icons = icons @mime_type = mime_type + @annotations = annotations + @meta = meta end def to_h @@ -18,7 +170,10 @@ module MCP name: name, title: title, description: description, + icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, mimeType: mime_type, + annotations: annotations&.to_h, + _meta: meta, }.compact end end diff --git a/lib/mcp/result_type.rb b/lib/mcp/result_type.rb new file mode 100644 index 0000000..9eeb394 --- /dev/null +++ b/lib/mcp/result_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module MCP + # Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests) + # for the MCP 2026-07-28 draft. + # + # A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map + # of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus + # an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue + # the original request with `inputResponses` and the echoed `requestState`. A missing `resultType` + # or `"complete"` is a final result. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + module ResultType + COMPLETE = "complete" + INPUT_REQUIRED = "input_required" + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index fe8c9bd..051a892 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1,22 +1,151 @@ # frozen_string_literal: true -require "json_rpc_handler" +require "securerandom" + +require_relative "../json_rpc_handler" +require_relative "cancellation" +require_relative "cancelled_error" require_relative "instrumentation" require_relative "methods" +require_relative "logging_message_notification" +require_relative "progress" +require_relative "protocol_deprecations" +require_relative "server_context" +require_relative "server/capabilities" +require_relative "server/input_required_result" +require_relative "server/pagination" +require_relative "server/pending_response" +require_relative "server/request_state_security" +require_relative "server/transports" module MCP + class ToolNotUnique < StandardError + def initialize(duplicated_tool_names) + super(<<~MESSAGE) + Tool names should be unique. Use `tool_name` to assign unique names to: + #{duplicated_tool_names.join(", ")} + MESSAGE + end + end + class Server DEFAULT_VERSION = "0.1.0" - class RequestHandlerError < StandardError - attr_reader :error_type - attr_reader :original_error + UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze + UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze - def initialize(message, request, error_type: :internal_error, original_error: nil) + DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze + + # Servers return an array of completion values ranked by relevance, with maximum 100 items per response. + # https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results + MAX_COMPLETION_VALUES = 100 + + class RequestHandlerError < StandardError + attr_reader :error_type, :original_error, :error_code, :error_data + + def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil) super(message) @request = request @error_type = error_type @original_error = original_error + @error_code = error_code + @error_data = error_data + end + end + + class URLElicitationRequiredError < RequestHandlerError + def initialize(elicitations) + super( + "URL elicitation required", + nil, + error_type: :url_elicitation_required, + error_code: -32042, + error_data: { elicitations: elicitations }, + ) + end + end + + # Raised when a request carries a protocol version the server does not support under the stateless lifecycle of + # MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }` + # so the client can select a mutually supported version and retry. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class UnsupportedProtocolVersionError < RequestHandlerError + # No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash + # into keywords and fail with "unknown keywords". + def initialize(requested, request = nil) + super( + "Unsupported protocol version", + request, + error_type: :unsupported_protocol_version, + error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" }, + ) + end + end + + # Raised when processing a request requires a client capability the request did not declare in `_meta` + # (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities + # the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }` + # listing the missing capabilities. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + class MissingRequiredClientCapabilityError < RequestHandlerError + def initialize(required_capabilities, request = nil) + super( + "Missing required client capability", + request, + error_type: :missing_required_client_capability, + error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, + error_data: { requiredCapabilities: required_capabilities }, + ) + end + end + + # Raised when a requested resource URI does not exist. Per SEP-2164, + # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # with the requested URI in the error `data` member. Raise this from + # a `resources_read_handler` block for unknown URIs: + # + # server.resources_read_handler do |params| + # raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless known?(params[:uri]) + # do_something(params[:uri]) + # end + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164 + class ResourceNotFoundError < RequestHandlerError + def initialize(uri, request = nil) + # The explicit `error_code` keeps the descriptive message in the JSON-RPC + # error response; `error_type: :invalid_params` alone would replace it + # with the generic "Invalid params" string. + super( + "Resource not found: #{uri}", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + error_data: { uri: uri }, + ) + end + end + + # Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout. + # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources, + # and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this. + # These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them. + # + # Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic + # internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not + # spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for + # this condition, so a client that already recognizes it there reads the same meaning here. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts + class RequestTimeoutError < RequestHandlerError + attr_reader :request_id, :timeout + + def initialize(message, request_id:, timeout:) + super(message, nil, error_type: :request_timeout, error_code: -32001) + @request_id = request_id + @timeout = timeout end end @@ -29,14 +158,39 @@ module MCP end end - include Instrumentation + # Raised when a client response fails server-side validation, e.g., a success response + # whose `result` field is missing or has the wrong type. This is distinct from a + # client-returned JSON-RPC error. + class ValidationError < StandardError; end - attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport + include Instrumentation + include Pagination + + # Allowed values for the SEP-2549 `cacheScope` cache hint. + CACHE_SCOPES = ["public", "private"].freeze + + # Methods whose results are cacheable per SEP-2549. + # On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results, + # so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in + # via `apply_cache_metadata`. + CACHEABLE_RESULT_METHODS = [ + Methods::TOOLS_LIST, + Methods::PROMPTS_LIST, + Methods::RESOURCES_LIST, + Methods::RESOURCES_TEMPLATES_LIST, + Methods::RESOURCES_READ, + ].freeze + + attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification + attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security def initialize( + description: nil, + icons: [], name: "model_context_protocol", title: nil, version: DEFAULT_VERSION, + website_url: nil, instructions: nil, tools: [], prompts: [], @@ -45,67 +199,144 @@ module MCP server_context: nil, configuration: nil, capabilities: nil, + page_size: nil, + ttl_ms: nil, + cache_scope: nil, + request_state_security: nil, + input_required_legacy_shim: true, transport: nil ) + @description = description + @icons = icons @name = name @title = title @version = version + @website_url = website_url @instructions = instructions + @tool_names = tools.map(&:name_value) @tools = tools.to_h { |t| [t.name_value, t] } @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) + @resources_list_handler = nil @server_context = server_context + self.page_size = page_size + self.ttl_ms = ttl_ms + self.cache_scope = cache_scope + @request_state_security = request_state_security + + # Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled + # through real server-to-client requests and the handler re-runs, so handlers written + # in the 2026 style serve both eras. `false` restores the strict rejection of `input_required` + # on legacy requests. Matches the TypeScript SDK's default-on legacy shim. + @input_required_legacy_shim = input_required_legacy_shim @configuration = MCP.configuration.merge(configuration) + @client = nil + @client_protocol_version = nil validate! - @capabilities = capabilities || default_capabilities + # Accept either a plain Hash or an `MCP::Server::Capabilities` builder. + @capabilities = if capabilities.is_a?(Capabilities) + capabilities.to_h + else + capabilities || default_capabilities + end + @client_capabilities = nil + @logging_message_notification = nil @handlers = { Methods::RESOURCES_LIST => method(:list_resources), - Methods::RESOURCES_READ => method(:read_resource_no_content), + Methods::RESOURCES_READ => method(:read_resource), Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), + Methods::RESOURCES_SUBSCRIBE => ->(_) { {} }, + Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} }, Methods::TOOLS_LIST => method(:list_tools), Methods::TOOLS_CALL => method(:call_tool), Methods::PROMPTS_LIST => method(:list_prompts), Methods::PROMPTS_GET => method(:get_prompt), Methods::INITIALIZE => method(:init), + Methods::SERVER_DISCOVER => method(:discover), Methods::PING => ->(_) { {} }, Methods::NOTIFICATIONS_INITIALIZED => ->(_) {}, - - # No op handlers for currently unsupported methods - Methods::RESOURCES_SUBSCRIBE => ->(_) {}, - Methods::RESOURCES_UNSUBSCRIBE => ->(_) {}, - Methods::COMPLETION_COMPLETE => ->(_) {}, - Methods::LOGGING_SET_LEVEL => ->(_) {}, + Methods::NOTIFICATIONS_PROGRESS => ->(_) {}, + Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {}, + Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT }, + Methods::LOGGING_SET_LEVEL => method(:configure_logging_level), } @transport = transport end - def handle(request) - JsonRpcHandler.handle(request) do |method| - handle_request(request, method) + # Processes a parsed JSON-RPC request and returns the response as a Hash. + # + # @param request [Hash] A parsed JSON-RPC request. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle` for session-scoped notification delivery. + # When `nil`, progress and logging notifications from tool handlers are silently skipped. + # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. + def handle(request, session: nil) + JsonRpcHandler.handle(request) do |method, request_id| + handle_request(request, method, session: session, related_request_id: request_id) end end - def handle_json(request) - JsonRpcHandler.handle_json(request) do |method| - handle_request(request, method) + # Processes a JSON-RPC request string and returns the response as a JSON string. + # + # @param request [String] A JSON-RPC request as a JSON string. + # @param session [ServerSession, nil] Per-connection session. Passed by + # `ServerSession#handle_json` for session-scoped notification delivery. + # When `nil`, progress and logging notifications from tool handlers are silently skipped. + # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. + def handle_json(request, session: nil) + JsonRpcHandler.handle_json(request) do |method, request_id| + handle_request(request, method, session: session, related_request_id: request_id) end end - def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block) - tool = Tool.define(name:, title:, description:, input_schema:, annotations:, meta:, &block) - @tools[tool.name_value] = tool + def define_tool(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, meta: nil, &block) + tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, output_schema: output_schema, annotations: annotations, meta: meta, &block) + tool_name = tool.name_value + + @tool_names << tool_name + @tools[tool_name] = tool validate! end def define_prompt(name: nil, title: nil, description: nil, arguments: [], &block) - prompt = Prompt.define(name:, title:, description:, arguments:, &block) + prompt = Prompt.define(name: name, title: title, description: description, arguments: arguments, &block) @prompts[prompt.name_value] = prompt + + validate! + end + + def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block) + resource = Resource.define( + uri: uri, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, size: size, meta: meta, + &block + ) + @resources << resource + @resource_index[resource.uri] = resource + + validate! + end + + def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block) + resource_template = ResourceTemplate.define( + uri_template: uri_template, name: name, title: title, description: description, icons: icons, + mime_type: mime_type, annotations: annotations, meta: meta, + &block + ) + @resource_templates << resource_template + + validate! + end + + def resources=(resources) + @resources = resources + @resource_index = index_resources_by_uri(resources) end def define_custom_method(method_name:, &block) @@ -116,6 +347,35 @@ module MCP @handlers[method_name] = block end + def page_size=(page_size) + unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0) + raise ArgumentError, "page_size must be nil or a positive integer" + end + + @page_size = page_size + end + + # SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results + # (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms` + # and `cache_scope` are nil, results are serialized exactly as before. + def ttl_ms=(ttl_ms) + unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0) + raise ArgumentError, "ttl_ms must be nil or a non-negative integer" + end + + @ttl_ms = ttl_ms + end + + # SEP-2549 cache hint: whether shared intermediaries may cache the result ("public") + # or only the requesting client ("private"). + def cache_scope=(cache_scope) + unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope) + raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\"" + end + + @cache_scope = cache_scope + end + def notify_tools_list_changed return unless @transport @@ -140,37 +400,166 @@ module MCP report_exception(e, { notification: "resources_list_changed" }) end - def resources_list_handler(&block) - @handlers[Methods::RESOURCES_LIST] = block + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. + def notify_log_message(data:, level:, logger: nil) + return unless @transport + return unless logging_message_notification&.should_notify?(level) + + params = { "data" => data, "level" => level } + params["logger"] = logger if logger + + @transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params) + rescue => e + report_exception(e, { notification: "log_message" }) end + # Sets a handler for `notifications/roots/list_changed` notifications. + # Called when a client notifies the server that its filesystem roots have changed. + # + # @yield [params] The notification params (typically `nil`). + # @deprecated MCP Roots (`roots/list` and + # `notifications/roots/list_changed`) is deprecated as of MCP protocol + # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, + # server configuration, or environment variables instead. + def roots_list_changed_handler(&block) + @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block + end + + # Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as + # the authenticated principal or granted scope. The block returns the resource collection to serve; + # the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources, + # so the block returns only the array, not the paginated result. + # A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set, + # the constructor-provided `resources` array is served unchanged. + # + # The block is invoked once per page, so it must return a stable ordering across the pages of one logical query; + # the cursor is a positional offset into the returned collection. + # + # @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared. + # @yieldreturn [Array] The resources to paginate. + def resources_list_handler(&block) + @resources_list_handler = block + end + + # Sets a custom handler for `resources/read` requests. + # The block receives the parsed request params and should return resource + # contents. The return value is set as the `contents` field of the response. + # + # @yield [params] The request params containing `:uri`. + # @yieldreturn [Array, Hash] Resource contents. def resources_read_handler(&block) @handlers[Methods::RESOURCES_READ] = block end - def resources_templates_list_handler(&block) - @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block + # Sets a custom handler for `completion/complete` requests. + # The block receives the parsed request params and should return completion values. + # + # @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`. + # @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`. + def completion_handler(&block) + @handlers[Methods::COMPLETION_COMPLETE] = block end - def tools_list_handler(&block) - @handlers[Methods::TOOLS_LIST] = block + # Sets a custom handler for `resources/subscribe` requests. + # The block receives the parsed request params. The response is an empty result, except that + # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, + # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data + # under `_meta`. + # + # @yield [params] The request params containing `:uri`. + # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. + def resources_subscribe_handler(&block) + @handlers[Methods::RESOURCES_SUBSCRIBE] = block end - def tools_call_handler(&block) - @handlers[Methods::TOOLS_CALL] = block + # Sets a custom handler for `resources/unsubscribe` requests. + # The block receives the parsed request params. The response is an empty result, except that + # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, + # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data + # under `_meta`. + # + # @yield [params] The request params containing `:uri`. + # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. + def resources_unsubscribe_handler(&block) + @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block end - def prompts_list_handler(&block) - @handlers[Methods::PROMPTS_LIST] = block - end + def build_sampling_params( + capabilities, + messages:, + max_tokens:, + system_prompt: nil, + model_preferences: nil, + include_context: nil, + temperature: nil, + stop_sequences: nil, + metadata: nil, + tools: nil, + tool_choice: nil + ) + unless capabilities&.dig(:sampling) + raise "Client does not support sampling." + end - def prompts_get_handler(&block) - @handlers[Methods::PROMPTS_GET] = block + if tools && !capabilities.dig(:sampling, :tools) + raise "Client does not support sampling with tools." + end + + if tool_choice && !capabilities.dig(:sampling, :tools) + raise "Client does not support sampling with tool_choice." + end + + { + messages: messages, + maxTokens: max_tokens, + systemPrompt: system_prompt, + modelPreferences: model_preferences, + includeContext: include_context, + temperature: temperature, + stopSequences: stop_sequences, + metadata: metadata, + tools: tools, + toolChoice: tool_choice, + }.compact end private def validate! + validate_tool_name! + + # NOTE: The draft protocol version is the next version after 2025-11-25. + if @configuration.protocol_version <= "2025-06-18" + if server_info.key?(:description) + message = "Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier" + raise ArgumentError, message + end + + tools_with_ref = @tools.each_with_object([]) do |(tool_name, tool), names| + names << tool_name if schema_contains_ref?(tool.input_schema_value.to_h) + end + unless tools_with_ref.empty? + message = "Error occurred in #{tools_with_ref.join(", ")}. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher" + raise ArgumentError, message + end + end + + if @configuration.protocol_version <= "2025-03-26" + if server_info.key?(:title) || server_info.key?(:websiteUrl) + message = "Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier" + raise ArgumentError, message + end + + primitive_titles = [@tools.values, @prompts.values, @resources, @resource_templates].flatten.map(&:title) + + if primitive_titles.any? + message = "Error occurred in #{primitive_titles.join(", ")}. `title` is not supported in protocol version 2025-03-26 or earlier" + raise ArgumentError, message + end + end + if @configuration.protocol_version == "2024-11-05" if @instructions message = "`instructions` supported by protocol version 2025-03-26 or higher" @@ -189,91 +578,665 @@ module MCP end end - def handle_request(request, method) + def validate_tool_name! + duplicated_tool_names = @tool_names.tally.filter_map { |name, count| name if count >= 2 } + + raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty? + end + + def schema_contains_ref?(schema) + case schema + when Hash + schema.any? { |key, value| key.to_s == "$ref" || schema_contains_ref?(value) } + when Array + schema.any? { |element| schema_contains_ref?(element) } + else + false + end + end + + def handle_request(request, method, session: nil, related_request_id: nil) + # A well-formed notification carries no JSON-RPC id and receives no response. + # If a client erroneously sends a notification-only method with an id, the message + # is framed as a request; since notification methods have no request handler, + # returning `nil` here makes `JsonRpcHandler` report "Method not found", matching + # the TypeScript and Python SDKs rather than emitting a spurious `result: null`. + return if Methods.notification?(method) && !related_request_id.nil? + + # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id) + # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry, + # and rescue blocks below. + if method == Methods::NOTIFICATIONS_CANCELLED + return ->(params) { handle_cancelled_notification(params, session: session) } + end + handler = @handlers[method] unless handler - instrument_call("unsupported_method") {} + instrument_call("unsupported_method", server_context: { request: request }) do + client = session&.client || @client + add_instrumentation_data(client: client) if client + end return end - Methods.ensure_capability!(method, capabilities) + # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found + # there regardless of the server's declared capabilities: in this era the method does not exist + # at all, which is why the check precedes `ensure_capability!`. + if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session) + raise RequestHandlerError.new( + "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)", + request, + error_type: :method_not_found, + error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + ) + end + + begin + Methods.ensure_capability!(method, capabilities) + rescue Methods::MissingRequiredCapabilityError => e + # Re-raise through RequestHandlerError, the one channel whose message is + # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError` + # no longer echoes exception messages into the error `data` member (CWE-209). + raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e) + end + + # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), + # so do not track it in the in-flight registry. + cancellation = nil + if related_request_id && method != Methods::INITIALIZE && session + cancellation = session.register_in_flight(related_request_id) + + # The spec puts the uniqueness obligation on the sender - "The request ID MUST NOT have been previously used by + # the requestor within the same session" - and says nothing about what a receiver does with a duplicate. + # Answering one is the only option that stays correct: the id routes request-scoped messages back to + # the request that caused them, and the transport's rule is that those messages "SHOULD relate to + # the originating client request", which a second live request under the same id makes impossible to honor + # for either of them. Refused the same way a duplicate `initialize` is, and for the same reason: + # so that a repeated id cannot silently displace state negotiated by the first one. + if cancellation.nil? + raise RequestHandlerError.new( + "Invalid Request: request id #{related_request_id.inspect} is already in flight", + request, + error_type: :invalid_request, + ) + end + end ->(params) { - instrument_call(method) do - case method - when Methods::TOOLS_LIST - { tools: @handlers[Methods::TOOLS_LIST].call(params) } - when Methods::PROMPTS_LIST - { prompts: @handlers[Methods::PROMPTS_LIST].call(params) } - when Methods::RESOURCES_LIST - { resources: @handlers[Methods::RESOURCES_LIST].call(params) } - when Methods::RESOURCES_READ - { contents: @handlers[Methods::RESOURCES_READ].call(params) } - when Methods::RESOURCES_TEMPLATES_LIST - { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } - else - @handlers[method].call(params) - end - rescue => e - report_exception(e, { request: request }) - if e.is_a?(RequestHandlerError) - add_instrumentation_data(error: e.error_type) - raise e + reported_exception = nil + instrument_call( + method, + server_context: { request: request }, + exception_already_reported: ->(e) { reported_exception.equal?(e) }, + ) do + envelope = lift_request_envelope(params, method: method, session: session) + + # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle: + # it authorizes `notifications/message` for this request only (SEP-2575). Without it, + # `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level + # reads as absent, so delivery stays off (the safe direction), matching the Python SDK. + if envelope&.log_level && session.respond_to?(:configure_logging) + request_logging = LoggingMessageNotification.new(level: envelope.log_level) + session.configure_logging(request_logging) if request_logging.valid_level? end + params = unseal_request_state(params, method: method) if @request_state_security + + result = case method + when Methods::INITIALIZE + init(params, session: session) + when Methods::RESOURCES_READ + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + + # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints. + contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) + when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE + validate_resource_subscription_params!(params) + handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + + subscription_result(handler_result) + when Methods::TOOLS_CALL + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + when Methods::PROMPTS_GET + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + when Methods::COMPLETION_COMPLETE + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + when Methods::LOGGING_SET_LEVEL + configure_logging_level(params, session: session) + else + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + end + client = session&.client || @client + add_instrumentation_data(client: client) if client + + if cancellation&.cancelled? + add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason) + next JsonRpcHandler::NO_RESPONSE + end + + # Runs after the cancellation check so a cancelled request stays suppressed + # instead of turning into a gate error response. + if result.is_a?(InputRequiredResult) + result = if envelope.nil? && @input_required_legacy_shim && session + run_legacy_input_required_shim( + result, + method: method, + params: params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + else + serialize_input_required_result(result, envelope: envelope, request: params, method: method) + end + end + + # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns; + # a missing value is only tolerated FROM earlier protocol versions. Results that already carry + # a discriminator keep it; everything else on the modern wire is the standard shape, + # `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field. + if envelope && result.is_a?(Hash) && !result.key?(:resultType) + result = result.merge(resultType: ResultType::COMPLETE) + end + + # SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28, + # so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`: + # the spec names no default scope, and `"private"` is the side that cannot leak + # a user-dependent result through a shared cache, matching the TypeScript SDK's default + # and `server/discover`. Values already in the result win. + # Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped + # (the stamp above guarantees `resultType` is present on every modern Hash result by this point). + if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) && + result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope)) + result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) + end + + result + rescue CancelledError => e + add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) + next JsonRpcHandler::NO_RESPONSE + rescue RequestHandlerError => e + report_exception(e.original_error || e, { request: request }) + add_instrumentation_data(error: e.error_type) + reported_exception = e + raise e + rescue => e + report_exception(e, { request: request }) add_instrumentation_data(error: :internal_error) - raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) + wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) + reported_exception = wrapped + raise wrapped + ensure + # `cancellation` is non-nil exactly when this request claimed the id above, so this also keeps `initialize` + # (which never registers) from evicting an in-flight registration under a reused id when the duplicate-`initialize` + # refusal raises out of the handler. + session&.unregister_in_flight(related_request_id, cancellation: cancellation) if related_request_id && cancellation end } end + # Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it. + # A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is + # the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds, + # which would otherwise let the first request of a connection reach a method the modern lifecycle does not have. + # The Python SDK gates the same way, on the envelope of each request rather than on connection state that + # is only settled afterwards. + # + # A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there + # with the lifecycle violation, which names the cause better than Method not found. + def modern_request?(request, session) + era = session.respond_to?(:era) ? session.era : nil + return true if era == :modern + return false unless era.nil? + + RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil) + end + + # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries + # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. + # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is + # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules: + # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake) + # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as + # an invalid request because a connection can never change eras. + def lift_request_envelope(params, method:, session:) + return if Methods.notification?(method) + + era = session.respond_to?(:era) ? session.era : nil + + # Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities + # before any negotiation. Under the modern era every request carries the envelope, discovery included: + # the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575). + return if method == Methods::SERVER_DISCOVER && era != :modern + + if RequestEnvelope.modern?(params) + if era == :legacy + raise RequestHandlerError.new( + "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", + params, + error_type: :invalid_request, + ) + end + + RequestEnvelope.parse!(params, request: params) + elsif era == :modern + # A claim-less request on a modern session is a malformed envelope, not a malformed request: + # the spec maps missing required envelope fields to Invalid params (`-32602`), + # and the reference SDKs answer it naming the missing keys. + raise RequestHandlerError.new( + "Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`", + params, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ) + end + end + + # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for + # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle, + # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as + # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on + # (or embed requests for) capabilities the client did not declare, and reports every missing capability at + # once so the client sees the full set. + def serialize_input_required_result(result, envelope:, request:, method:) + if envelope.nil? + raise RequestHandlerError.new( + "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)", + request, + error_type: :internal_error, + ) + end + + missing = result.missing_client_capabilities(envelope.client_capabilities) + raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty? + + add_instrumentation_data(input_required: true) + serialized = result.to_h + + if @request_state_security && serialized[:requestState] + serialized = serialized.merge(requestState: @request_state_security.seal( + serialized[:requestState], + method: method, + target: mrtr_target(request), + arguments_digest: mrtr_arguments_digest(request), + )) + end + + serialized + end + + # Methods whose results may be `input_required` and whose retried requests carry + # `inputResponses`/`requestState` (SEP-2322). + MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze + + # Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`). + LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8 + + # Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result, + # which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver. + # Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with + # the originating request per SEP-2260), the answers are collected under the same keys, and the handler + # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay + # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value + # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply. + def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:) + rounds = 0 + + loop do + missing = result.missing_client_capabilities(session.client_capabilities) + unless missing.empty? + # The explicit `error_code` keeps the descriptive message in the JSON-RPC error response + # (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire + # gets a plain internal error. + raise RequestHandlerError.new( + "input_required requires client capabilities the client did not declare: #{missing.to_json}", + params, + error_type: :internal_error, + error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + ) + end + + responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected| + collected[key] = session.fulfill_input_request( + entry[:method], + legacy_leg_params(entry), + related_request_id: related_request_id, + ) + end + + retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) } + retry_params[:inputResponses] = responses unless responses.empty? + retry_params[:requestState] = result.request_state if result.request_state + + result = redispatch_mrtr_method( + method, + retry_params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + ) + return result unless result.is_a?(InputRequiredResult) + + rounds += 1 + next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS + + raise RequestHandlerError.new( + "Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)", + params, + error_type: :internal_error, + error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + ) + end + end + + # The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with + # one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests, + # a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there), + # so a missing one is synthesized for the leg, matching the TypeScript SDK's shim. + def legacy_leg_params(entry) + params = entry[:params] + return params unless entry[:method] == Methods::ELICITATION_CREATE + return params if params.nil? || (params[:mode] || params["mode"]) != "url" + return params if params.key?(:elicitationId) || params.key?("elicitationId") + + params.merge(elicitationId: SecureRandom.uuid) + end + + # Re-runs the handler of one of the three MRTR-capable methods for + # the legacy shim. Legacy wire, so no envelope is threaded. + def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:) + case method + when Methods::TOOLS_CALL + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + when Methods::PROMPTS_GET + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + when Methods::RESOURCES_READ + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) + else + raise RequestHandlerError.new( + "input_required results are only supported for #{MRTR_METHODS.join(", ")}", + params, + error_type: :internal_error, + ) + end + end + + # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch, + # so handlers always read the state they wrote. A tampered, expired, or cross-request token is + # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior. + def unseal_request_state(params, method:) + return params unless MRTR_METHODS.include?(method) + return params unless params.is_a?(Hash) + + sealed = params[:requestState] || params["requestState"] + return params unless sealed + + plaintext = @request_state_security.unseal( + sealed, + method: method, + target: mrtr_target(params), + arguments_digest: mrtr_arguments_digest(params), + ) + key = params.key?("requestState") ? "requestState" : :requestState + params.merge(key => plaintext) + rescue RequestStateSecurity::InvalidStateError => e + raise RequestHandlerError.new( + "Invalid or expired requestState", + params, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + original_error: e, + ) + end + + def mrtr_target(params) + return "" unless params.is_a?(Hash) + + params[:name] || params["name"] || params[:uri] || params["uri"] || "" + end + + # Digest of the originating arguments, binding a sealed state to retries of + # the same call with the same inputs. Keys are stringified and sorted recursively + # so symbol/string parses of identical JSON digest identically. + def mrtr_arguments_digest(params) + arguments = params.is_a?(Hash) ? params[:arguments] || params["arguments"] : nil + OpenSSL::Digest::SHA256.hexdigest(canonical_json(arguments || {})) + end + + def canonical_json(value) + case value + when Hash + pairs = value.map { |key, nested| [key.to_s, nested] }.sort_by(&:first) + "{#{pairs.map { |key, nested| "#{key.to_json}:#{canonical_json(nested)}" }.join(",")}}" + when Array + "[#{value.map { |element| canonical_json(element) }.join(",")}]" + else + value.to_json + end + end + + # Extracts the SEP-2322 retry fields a client sends when re-issuing a request: + # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`. + # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries. + def mrtr_retry_fields(params) + return { input_responses: nil, request_state: nil } unless params.is_a?(Hash) + + { + input_responses: params[:inputResponses] || params["inputResponses"], + request_state: params[:requestState] || params["requestState"], + } + end + + def handle_cancelled_notification(params, session: nil) + return unless session + return unless params.is_a?(Hash) + + request_id = params[:requestId] || params["requestId"] + return if request_id.nil? + + reason = params[:reason] || params["reason"] + session.cancel_incoming(request_id: request_id, reason: reason) + end + def default_capabilities { tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true }, + logging: {}, } end def server_info @server_info ||= { - name:, - title:, - version:, + description: description, + icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, + name: name, + title: title, + version: version, + websiteUrl: website_url, }.compact end - def init(request) + def init(params, session: nil) + validate_initialize_params!(params) + + # MCP spec: the initialization phase MUST be the first interaction between client and server. + # Reject duplicate `initialize` on an already-initialized session so the negotiated + # client identity and capabilities cannot be silently overwritten. + if session&.initialized? + raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request) + end + + if session + session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities]) + else + @client = params[:clientInfo] + @client_capabilities = params[:capabilities] + end + protocol_version = params[:protocolVersion] + + # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is + # defined by carrying its own version on every request in `_meta`, with no handshake, + # so asking `initialize` for one (or for a version this server does not know) is answered with + # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions + # stay reachable through `server/discover` and the per-request envelope. The counter-offer is + # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there), + # and the latest handshake version otherwise. + negotiated_version = if Configuration.handshake_protocol_version?(protocol_version) + protocol_version + elsif configuration.protocol_version? + configuration.protocol_version + else + Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION + end + + info = server_info.reject do |property| + negotiated_version <= "2025-06-18" && UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18.include?(property) || + negotiated_version <= "2025-03-26" && UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26.include?(property) + end + + response_instructions = instructions + + if negotiated_version == "2024-11-05" + response_instructions = nil + end + + if session + session.mark_initialized!(protocol_version: negotiated_version) + else + @client_protocol_version = negotiated_version + end + { - protocolVersion: configuration.protocol_version, + protocolVersion: negotiated_version, capabilities: capabilities, - serverInfo: server_info, - instructions: instructions, + serverInfo: info, + instructions: response_instructions, }.compact end + def validate_resource_subscription_params!(params) + unless params.is_a?(Hash) && params[:uri].is_a?(String) + raise RequestHandlerError.new("Missing or invalid uri", params, error_type: :invalid_params) + end + end + + # The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta` + # every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`, + # which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns + # anything else keeps the empty `{}` result it had before, so returning a subscription identifier or + # other advisory data means nesting it under `_meta`. + def subscription_result(handler_result) + return {} unless handler_result.is_a?(Hash) + + meta = handler_result[:_meta] || handler_result["_meta"] + meta.is_a?(Hash) ? { _meta: meta } : {} + end + + def validate_initialize_params!(params) + unless params.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) + end + + unless params[:protocolVersion].is_a?(String) + raise RequestHandlerError.new("Missing or invalid protocolVersion", params, error_type: :invalid_params) + end + + unless params[:capabilities].is_a?(Hash) + raise RequestHandlerError.new("Missing or invalid capabilities", params, error_type: :invalid_params) + end + + client_info = params[:clientInfo] + if !client_info.is_a?(Hash) || client_info[:name].nil? || client_info[:version].nil? + raise RequestHandlerError.new("Missing or invalid clientInfo", params, error_type: :invalid_params) + end + end + + # Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery. + # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark + # the session initialized, and responds regardless of capability declarations or initialization state, + # so clients can probe a server before (or instead of) `initialize`. + # + # `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: + # legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope` + # cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results), + # so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only) + # fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides + # in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized + # spec (PR #3002), unfiltered because discovery happens before version negotiation. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + def discover(_request) + { + supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, + capabilities: discover_capabilities, + instructions: instructions, + _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info }, + }.compact.merge( + ttlMs: ttl_ms || 0, + cacheScope: cache_scope || "private", + # `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it; + # `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly. + resultType: ResultType::COMPLETE, + ) + end + + # Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags + # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC + # (e.g. stdio), matching the Python SDK's era-aware capability derivation. + def discover_capabilities + return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen? + + capabilities.each_with_object({}) do |(name, value), stripped| + stripped[name] = if value.is_a?(Hash) + value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) } + else + value + end + end + end + + def configure_logging_level(request, session: nil) + if capabilities[:logging].nil? + raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) + end + + logging_message_notification = LoggingMessageNotification.new(level: request[:level]) + unless logging_message_notification.valid_level? + raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params) + end + + session&.configure_logging(logging_message_notification) + @logging_message_notification = logging_message_notification + + {} + end + def list_tools(request) - @tools.values.map(&:to_h) + page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end - def call_tool(request) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) tool_name = request[:name] + tool = tools[tool_name] unless tool - add_instrumentation_data(error: :tool_not_found) - raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found) + add_instrumentation_data(tool_name: tool_name, error: :tool_not_found) + + raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params) end arguments = request[:arguments] || {} - add_instrumentation_data(tool_name:) + add_instrumentation_data(tool_name: tool_name, tool_arguments: arguments) if tool.input_schema&.missing_required_arguments?(arguments) add_instrumentation_data(error: :missing_required_arguments) - raise RequestHandlerError.new( - "Missing required arguments: #{tool.input_schema.missing_required_arguments(arguments).join(", ")}", - request, - error_type: :missing_required_arguments, - ) + + missing = tool.input_schema.missing_required_arguments(arguments).join(", ") + return error_tool_response("Missing required arguments: #{missing}") end if configuration.validate_tool_call_arguments && tool.input_schema @@ -281,49 +1244,278 @@ module MCP tool.input_schema.validate_arguments(arguments) rescue Tool::InputSchema::ValidationError => e add_instrumentation_data(error: :invalid_schema) - raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema) + + return error_tool_response(e.message) end end - begin - call_tool_with_args(tool, arguments) - rescue => e - raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e) - end + progress_token = request.dig(:_meta, :progressToken) + + response = call_tool_with_args( + tool, + arguments, + server_context_with_meta(request), + progress_token: progress_token, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + retry_fields: mrtr_retry_fields(request), + ) + # An SEP-2322 `input_required` result is not a tool result: output schema + # validation would run against a `nil` `structuredContent` and the structured + # content fallback does not apply. The dispatch lambda serializes it. + return response if response.is_a?(InputRequiredResult) + + result = response.to_h + validate_tool_call_result!(tool, result) + serialize_structured_content_fallback( + result, + content_provided: response.respond_to?(:content_provided?) && response.content_provided?, + ) + rescue RequestHandlerError, CancelledError + # CancelledError is intentionally not wrapped so `handle_request` can turn it into + # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. + raise + rescue => e + # `e.message` is deliberately not included: it can carry internals (class, method + # and host names) that must not reach untrusted clients (CWE-209). The original + # exception still reaches `configuration.exception_reporter` via `original_error`. + raise RequestHandlerError.new( + "Internal error calling tool #{tool_name}", + request, + error_type: :internal_error, + original_error: e, + ) end def list_prompts(request) - @prompts.values.map(&:to_h) + page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end - def get_prompt(request) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt add_instrumentation_data(error: :prompt_not_found) - raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found) + # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than + # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and + # `completion/complete` siblings for the same not-found condition, while `error_type: + # :prompt_not_found` keeps the descriptive message and instrumentation label. + raise RequestHandlerError.new( + "Prompt not found #{prompt_name}", + request, + error_type: :prompt_not_found, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ) end - add_instrumentation_data(prompt_name:) + add_instrumentation_data(prompt_name: prompt_name) prompt_args = request[:arguments] prompt.validate_arguments!(prompt_args) - call_prompt_template_with_args(prompt, prompt_args) + server_context = build_server_context( + request: request, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + ) + + call_prompt_template_with_args(prompt, prompt_args, server_context) end - def list_resources(request) - @resources.map(&:to_h) + def list_resources(request, server_context: nil) + resources = if @resources_list_handler + invoke_resources_list_handler(request, server_context) + else + @resources + end + + page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end - # Server implementation should set `resources_read_handler` to override no-op default - def read_resource_no_content(request) - add_instrumentation_data(resource_uri: request[:uri]) - [] + # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in + # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies). + def invoke_resources_list_handler(request, server_context) + if handler_declares_server_context?(@resources_list_handler) + @resources_list_handler.call(request, server_context: server_context) + else + @resources_list_handler.call(request) + end + end + + # Default `resources/read` handler: routes to class-based resources and resource templates. + # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered, + # unknown URIs keep the historical no-op `[]` response instead of raising. + def read_resource(request, server_context: nil) + uri = request[:uri] + add_instrumentation_data(resource_uri: uri) + + resource = @resource_index[uri] + if resource.is_a?(Class) + return normalize_resource_contents(call_resource_contents(resource, server_context)) + end + + @resource_templates.each do |template| + next unless template.is_a?(Class) + + params = template.match_uri(uri) + next unless params + + add_instrumentation_data(resource_template: template.uri_template) + return normalize_resource_contents(call_template_contents(template, params, server_context)) + end + + return [] unless class_based_resources_registered? + + raise ResourceNotFoundError.new(uri, request) + end + + def call_resource_contents(resource, server_context) + if accepts_server_context?(resource.method(:contents)) + resource.contents(server_context: server_context) + else + resource.contents + end + end + + def call_template_contents(template, params, server_context) + if accepts_server_context?(template.method(:contents)) + template.contents(**params, server_context: server_context) + else + template.contents(**params) + end + end + + def normalize_resource_contents(result) + contents = result.is_a?(Array) ? result : [result] + contents.map(&:to_h) + end + + def class_based_resources_registered? + @resources.any? { |resource| resource.is_a?(Class) } || + @resource_templates.any? { |template| template.is_a?(Class) } end def list_resource_templates(request) - @resource_templates.map(&:to_h) + page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + + apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact) + end + + # Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`"; + # a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level + # `ttlMs`/`cacheScope` cache hints per result (SEP-2549). + def build_read_resource_result(handler_result) + result = if handler_result.is_a?(Hash) && handler_result.key?(:contents) + handler_result + else + { contents: handler_result } + end + + apply_cache_metadata(result) + end + + # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added + # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, + # in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`, + # the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default). + # Values already in the result win, enabling per-result overrides. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549 + def apply_cache_metadata(result) + explicit = result.key?(:ttlMs) || result.key?(:cacheScope) + return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit + + { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) + end + + def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + validate_completion_params!(params) + + result = dispatch_optional_context_handler( + @handlers[Methods::COMPLETION_COMPLETE], + params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + ) + + normalize_completion_result(result) + end + + # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, + # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or + # `server_context.raise_if_cancelled!`. + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + dispatch_optional_context_handler( + @handlers[Methods::RESOURCES_READ], + request, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + ) + end + + # Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`, + # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. + # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` + # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + return handler.call(params) unless handler_declares_server_context?(handler) + + server_context = build_server_context( + request: params, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + ) + handler.call(params, server_context: server_context) + end + + # Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter + # (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT + # treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}` + # Hash as the handler's second positional argument, which is never what the user meant. + # + # `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here, + # because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept + # that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`). + # Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked + # via `tool.call(**args, server_context: …)` without a positional argument. + def handler_declares_server_context?(handler) + return false unless handler.respond_to?(:parameters) + + handler.parameters.any? do |type, name| + name == :server_context && (type == :key || type == :keyreq) + end + end + + # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers + # (progress, cancellation, nested server-to-client requests). + def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil) + meta_source = request.is_a?(Hash) ? request : {} + progress_token = meta_source.dig(:_meta, :progressToken) + progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + retry_fields = mrtr_retry_fields(meta_source) + ServerContext.new( + server_context_with_meta(meta_source), + progress: progress, + notification_target: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + input_responses: retry_fields[:input_responses], + request_state: retry_fields[:request_state], + ) end def report_exception(exception, server_context = {}) @@ -331,33 +1523,154 @@ module MCP end def index_resources_by_uri(resources) - resources.each_with_object({}) do |resource, hash| - hash[resource.uri] = resource - end + resources.to_h { |resource| [resource.uri, resource] } end + def error_tool_response(text) + Tool::Response.new( + [{ + type: "text", + text: text, + }], + error: true, + ).to_h + end + + def validate_tool_call_result!(tool, result) + return unless configuration.validate_tool_call_results + return unless tool.output_schema + return if result[:isError] + + tool.output_schema.validate_result(result[:structuredContent]) + end + + # Per SEP-2106, `structuredContent` may be any JSON value, not only an object. + # Clients on older protocol versions may only read `content`, + # so when a tool returns non-object structured content without explicitly + # providing `content`, mirror the value into serialized JSON text. + def serialize_structured_content_fallback(result, content_provided: false) + structured = result[:structuredContent] + return result if structured.nil? || structured.is_a?(Hash) + return result if content_provided + return result unless result[:content].nil? || result[:content].empty? + + serialized = JSON.generate(structured) + return result if JSON.parse(serialized).is_a?(Hash) + + result.merge(content: [{ type: "text", text: serialized }]) + end + + # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. + # Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument + # (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`. + # Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that + # happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword, + # and a positional slot would receive the `{server_context: ctx}` Hash instead. def accepts_server_context?(method_object) parameters = method_object.parameters - parameters.any? { |type, name| type == :keyrest || name == :server_context } + parameters.any? do |type, name| + type == :keyrest || (name == :server_context && (type == :key || type == :keyreq)) + end end - def call_tool_with_args(tool, arguments) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil) + # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized + # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; + # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. + # See docs/server/tools.md ("Tool argument keys"). args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) - tool.call(**args, server_context: server_context).to_h + progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + server_context = ServerContext.new( + context, + progress: progress, + notification_target: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + input_responses: retry_fields&.fetch(:input_responses, nil), + request_state: retry_fields&.fetch(:request_state, nil), + ) + tool.call(**args, server_context: server_context) else - tool.call(**args).to_h + tool.call(**args) end end - def call_prompt_template_with_args(prompt, args) - if accepts_server_context?(prompt.method(:template)) - prompt.template(args, server_context: server_context).to_h + def call_prompt_template_with_args(prompt, args, server_context) + raw_result = if accepts_server_context?(prompt.method(:template)) + prompt.template(args, server_context: server_context) else - prompt.template(args).to_h + prompt.template(args) end + + raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h + end + + def server_context_with_meta(request) + meta = request[:_meta] + if meta && server_context.is_a?(Hash) + context = server_context.dup + context[:_meta] = meta + context + elsif meta && server_context.nil? + { _meta: meta } + else + server_context + end + end + + def validate_completion_params!(params) + unless params.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) + end + + ref = params[:ref] + if ref.nil? || ref[:type].nil? + raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params) + end + + argument = params[:argument] + if argument.nil? || argument[:name].nil? || !argument.key?(:value) + raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params) + end + + case ref[:type] + when "ref/prompt" + unless @prompts[ref[:name]] + raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params) + end + when "ref/resource" + uri = ref[:uri] + found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri } + unless found + raise ResourceNotFoundError.new(uri, params) + end + else + raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params) + end + end + + def normalize_completion_result(result) + return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash) + + completion = result[:completion] || result["completion"] + return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash) + + values = completion[:values] || completion["values"] || [] + total = completion[:total] || completion["total"] + has_more = completion[:hasMore] || completion["hasMore"] || false + + count = values.length + if count > MAX_COMPLETION_VALUES + has_more = true + total ||= count + values = values.first(MAX_COMPLETION_VALUES) + end + + { completion: { values: values, total: total, hasMore: has_more }.compact } end end end diff --git a/lib/mcp/server/capabilities.rb b/lib/mcp/server/capabilities.rb index 6647eb9..0efe015 100644 --- a/lib/mcp/server/capabilities.rb +++ b/lib/mcp/server/capabilities.rb @@ -6,6 +6,7 @@ module MCP def initialize(capabilities_hash = nil) @completions = nil @experimental = nil + @extensions = nil @logging = nil @prompts = nil @resources = nil @@ -14,6 +15,7 @@ module MCP if capabilities_hash support_completions if capabilities_hash.key?(:completions) support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental) + support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions) support_logging if capabilities_hash.key?(:logging) if capabilities_hash.key?(:prompts) @@ -45,6 +47,17 @@ module MCP @experimental = config || {} end + # Declares support for capability extensions per SEP-2133. Keys are + # extension identifiers using the reverse-DNS prefix convention + # (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); + # values are arbitrary extension-defined configuration objects, + # with an empty hash meaning "supported with no settings". + # Repeated calls merge, so several extensions can be declared independently. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133 + def support_extensions(extensions = {}) + @extensions = (@extensions || {}).merge(extensions || {}) + end + def support_logging @logging ||= {} end @@ -85,6 +98,7 @@ module MCP { completions: @completions, experimental: @experimental, + extensions: @extensions, logging: @logging, prompts: @prompts, resources: @resources, diff --git a/lib/mcp/server/input_required_result.rb b/lib/mcp/server/input_required_result.rb new file mode 100644 index 0000000..16242e7 --- /dev/null +++ b/lib/mcp/server/input_required_result.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../methods" +require_relative "../result_type" + +module MCP + class Server + # A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28). + # Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of + # their normal result to ask the client for additional input: `input_requests` maps server-assigned keys + # to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`), + # and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries + # the original request with `inputResponses` under the same keys. + # + # The server holds no memory between rounds: handlers re-run from the start on every retry and read + # the answers via `server_context.input_responses` / `server_context.request_state` + # (deterministic replay, matching the Python SDK). At least one of the two fields must be present; + # a `request_state`-only result is the load-shedding form ("retry later"). + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + class InputRequiredResult + EMBEDDABLE_METHODS = [ + Methods::ELICITATION_CREATE, + Methods::SAMPLING_CREATE_MESSAGE, + Methods::ROOTS_LIST, + ].freeze + + attr_reader :input_requests, :request_state + + def initialize(input_requests: nil, request_state: nil) + if (input_requests.nil? || input_requests.empty?) && request_state.nil? + raise ArgumentError, "at least one of input_requests or request_state is required" + end + unless request_state.nil? || request_state.is_a?(String) + raise ArgumentError, "request_state must be a String" + end + + @input_requests = normalize_input_requests(input_requests) + @request_state = request_state + freeze + end + + def to_h + serialized_requests = @input_requests&.transform_values do |entry| + { method: entry[:method], params: entry[:params] }.compact + end + + { + resultType: ResultType::INPUT_REQUIRED, + inputRequests: serialized_requests, + requestState: @request_state, + }.compact + end + + # The client capabilities required to fulfill every embedded request, merged into one nested hash. + # The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`: + # `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create` + # requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools` + # (plain sampling otherwise), and `roots/list` requires `roots`. + def required_client_capabilities + (@input_requests || {}).values.reduce({}) do |merged, entry| + deep_merge(merged, entry_capability(entry)) + end + end + + # The subset of {#required_client_capabilities} the request did not declare. + # Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared, + # so a non-empty return means the result must not be sent (`-32021`). + def missing_client_capabilities(declared) + missing = missing_subtree(required_client_capabilities, declared) + prune_implied_form_elicitation(missing, declared) + end + + private + + def normalize_input_requests(input_requests) + return if input_requests.nil? + + raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash) + + normalized = input_requests.each_with_object({}) do |(key, entry), result| + raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash) + + method = entry[:method] || entry["method"] + unless EMBEDDABLE_METHODS.include?(method) + raise ArgumentError, + "input_requests entry #{key.inspect} must have a method of " \ + "#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})" + end + + params = entry[:params] || entry["params"] + raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash) + + result[key.to_s] = { method: method, params: params }.compact.freeze + end + + normalized.freeze + end + + def entry_capability(entry) + params = entry[:params] + + case entry[:method] + when Methods::ELICITATION_CREATE + mode = params && (params[:mode] || params["mode"]) + mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } } + when Methods::SAMPLING_CREATE_MESSAGE + with_tools = params && (params.key?(:tools) || params.key?("tools") || + params.key?(:toolChoice) || params.key?("toolChoice")) + with_tools ? { sampling: { tools: {} } } : { sampling: {} } + when Methods::ROOTS_LIST + { roots: {} } + end + end + + # Walks `required` and keeps only the branches absent from `declared` + # (symbol/string tolerant on the declared side). + def missing_subtree(required, declared) + required.each_with_object({}) do |(name, nested), missing| + declared_value = read_key(declared, name) + + if declared_value.nil? + missing[name] = nested + elsif nested.is_a?(Hash) && !nested.empty? + nested_missing = missing_subtree(nested, declared_value) + missing[name] = nested_missing unless nested_missing.empty? + end + end + end + + # 2025 back-compat implication shared with the TypeScript and Python SDKs: + # a bare `elicitation: {}` declaration implies form elicitation support, while + # an explicit url-only declaration does not. + def prune_implied_form_elicitation(missing, declared) + elicitation_missing = missing[:elicitation] + return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form) + + declared_elicitation = read_key(declared, :elicitation) + return missing unless declared_elicitation.is_a?(Hash) + return missing unless read_key(declared_elicitation, :url).nil? + + pruned = elicitation_missing.reject { |key, _| key == :form } + pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned) + end + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + + def deep_merge(left, right) + left.merge(right) do |_key, left_value, right_value| + if left_value.is_a?(Hash) && right_value.is_a?(Hash) + deep_merge(left_value, right_value) + else + right_value + end + end + end + end + end +end diff --git a/lib/mcp/server/pagination.rb b/lib/mcp/server/pagination.rb new file mode 100644 index 0000000..aa26b21 --- /dev/null +++ b/lib/mcp/server/pagination.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module MCP + class Server + module Pagination + private + + def cursor_from(request) + return if request.nil? + + unless request.is_a?(Hash) + raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params) + end + + request[:cursor] + end + + def paginate(items, cursor:, page_size:, request:, &block) + start_index = 0 + + if cursor + unless cursor.is_a?(String) + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + + start_index = Integer(cursor, exception: false) + if start_index.nil? || start_index < 0 || start_index >= items.size + raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params) + end + end + + end_index = page_size ? start_index + page_size : items.size + page = items[start_index...end_index] + page = page.map(&block) if block + + result = { items: page } + result[:next_cursor] = end_index.to_s if end_index < items.size + result + end + end + end +end diff --git a/lib/mcp/server/pending_response.rb b/lib/mcp/server/pending_response.rb new file mode 100644 index 0000000..a82ae07 --- /dev/null +++ b/lib/mcp/server/pending_response.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module MCP + class Server + # A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response + # and whichever thread resolves it (the client's response, a cancellation, or session teardown). + # + # `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7, + # so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue` + # this replaces, keeping the resolving call sites unchanged. + # + # First writer wins: a second `push` is ignored, so a cancellation that races a real response + # cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when + # the deadline passes with nothing pushed. + class PendingResponse + def initialize + @mutex = Mutex.new + @condition = ConditionVariable.new + @delivered = false + @value = nil + end + + # Resolves the wait. Ignored when a value was already delivered. + def push(value) + @mutex.synchronize do + next if @delivered + + @delivered = true + @value = value + @condition.broadcast + end + end + + # Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller + # on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously, + # so the deadline is re-checked against the monotonic clock. + # + # The expiry block runs after the lock is released: it typically cancels the request, + # which resolves this same object, and Ruby's `Mutex` is not reentrant. + def pop(timeout:) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + expired = false + + value = @mutex.synchronize do + until @delivered + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + if remaining <= 0 + expired = true + break + end + + @condition.wait(@mutex, remaining) + end + + @value + end + + expired ? yield : value + end + end + end +end diff --git a/lib/mcp/server/request_state_security.rb b/lib/mcp/server/request_state_security.rb new file mode 100644 index 0000000..bc96a85 --- /dev/null +++ b/lib/mcp/server/request_state_security.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "json" +require "openssl" + +module MCP + class Server + # Opt-in protection for the SEP-2322 `requestState` echo. The opaque continuation string leaves the server, + # sits in the client's hands, and comes back as client-controlled input, so it must be treated like + # any other untrusted data. Sealing encrypts the state with AES-256-GCM (clients cannot read it) and binds + # a claims envelope that unsealing verifies fail-closed: + # + # - `exp`: a TTL window (re-sealed each round) + # - `m` / `t`: the originating method and target (tool/prompt name or resource URI) + # - `a`: a digest of the originating arguments, so the state only resumes the same call with the same inputs + # - `aud`: an optional audience, so tokens cannot cross servers sharing a key + # + # Pass an instance via `Server.new(request_state_security:)` and the seal/unseal happens transparently; + # handlers keep reading plaintext through `server_context.request_state`. Without it, the state crosses + # the wire exactly as the handler wrote it (the author's responsibility, matching the Python SDK's low-level Server). + # The key must be shared across workers in multi-process + # + # deployments; a per-process random key makes retries that land on another worker fail with an invalid-state error, + # forcing clients to restart the flow. + # + # The token format is `v1.`, with the version prefix bound as GCM associated data, + # following the Python SDK's `AESGCMRequestStateCodec`. + class RequestStateSecurity + class InvalidStateError < StandardError; end + + VERSION_PREFIX = "v1." + KEY_BYTES = 32 + IV_BYTES = 12 + TAG_BYTES = 16 + DEFAULT_TTL = 300 + + def initialize(key:, ttl: DEFAULT_TTL, audience: nil) + unless key.is_a?(String) && key.bytesize == KEY_BYTES + raise ArgumentError, "key must be a #{KEY_BYTES}-byte String" + end + unless ttl.is_a?(Numeric) && ttl.positive? + raise ArgumentError, "ttl must be a positive number of seconds" + end + + @key = key.dup.force_encoding(Encoding::BINARY).freeze + @ttl = ttl + @audience = audience + end + + # Seals a plaintext state into an opaque token bound to the originating request. + def seal(state, method:, target:, arguments_digest:) + claims = { + v: 1, + exp: Time.now.to_i + @ttl, + m: method, + t: target, + a: arguments_digest, + aud: @audience, + s: state, + }.compact + + cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt + cipher.key = @key + iv = cipher.random_iv + cipher.auth_data = VERSION_PREFIX + ciphertext = cipher.update(JSON.generate(claims)) + cipher.final + + VERSION_PREFIX + base64url_encode(iv + ciphertext + cipher.auth_tag) + end + + # Unseals a client-echoed token and verifies every claim, failing closed with + # `InvalidStateError` on tampering, expiry, or a claims mismatch. + def unseal(sealed, method:, target:, arguments_digest:) + unless sealed.is_a?(String) && sealed.start_with?(VERSION_PREFIX) + raise InvalidStateError, "malformed token" + end + + blob = base64url_decode(sealed.delete_prefix(VERSION_PREFIX)) + raise InvalidStateError, "malformed token" if blob.bytesize < IV_BYTES + TAG_BYTES + + iv = blob.byteslice(0, IV_BYTES) + tag = blob.byteslice(-TAG_BYTES, TAG_BYTES) + ciphertext = blob.byteslice(IV_BYTES, blob.bytesize - IV_BYTES - TAG_BYTES) + + cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt + cipher.key = @key + cipher.iv = iv + cipher.auth_tag = tag + cipher.auth_data = VERSION_PREFIX + plaintext = begin + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError + raise InvalidStateError, "authentication failed" + end + + claims = begin + JSON.parse(plaintext, symbolize_names: true) + rescue JSON::ParserError + raise InvalidStateError, "malformed claims" + end + + verify!(claims, method: method, target: target, arguments_digest: arguments_digest) + claims[:s] + end + + private + + def verify!(claims, method:, target:, arguments_digest:) + raise InvalidStateError, "unsupported version" unless claims[:v] == 1 + raise InvalidStateError, "expired" unless claims[:exp].is_a?(Integer) && Time.now.to_i <= claims[:exp] + raise InvalidStateError, "method mismatch" unless claims[:m] == method + raise InvalidStateError, "target mismatch" unless claims[:t] == target + raise InvalidStateError, "arguments mismatch" unless claims[:a] == arguments_digest + raise InvalidStateError, "audience mismatch" unless claims[:aud] == @audience + raise InvalidStateError, "missing state" unless claims[:s].is_a?(String) + end + + def base64url_encode(data) + [data].pack("m0").tr("+/", "-_").delete("=") + end + + def base64url_decode(encoded) + padded = encoded.tr("-_", "+/") + padded += "=" * ((4 - padded.length % 4) % 4) + padded.unpack1("m0") + rescue ArgumentError + raise InvalidStateError, "malformed token" + end + end + end +end diff --git a/lib/mcp/server/transports.rb b/lib/mcp/server/transports.rb new file mode 100644 index 0000000..1c4bc1c --- /dev/null +++ b/lib/mcp/server/transports.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module MCP + class Server + module Transports + autoload :StdioTransport, "mcp/server/transports/stdio_transport" + autoload :StreamableHTTPTransport, "mcp/server/transports/streamable_http_transport" + end + end +end diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index 702af08..8c75812 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "../../transport" require "json" +require_relative "../../transport" module MCP class Server @@ -9,18 +9,50 @@ module MCP class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 - def initialize(server) - @server = server + # Default upper bound on a single newline-delimited frame. CRuby's `IO#gets` + # without a limit accumulates bytes until a newline arrives, so a peer that + # never emits one can grow a single String until the process is OOM-killed. + # 4 MiB is large enough for any realistic JSON-RPC frame, including + # base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + + def initialize(server, max_line_bytes: MAX_LINE_BYTES) + super(server) @open = false + @session = nil + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + + @max_line_bytes = max_line_bytes $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") - super end def open @open = true - while @open && (line = $stdin.gets) - handle_json_request(line.strip) + @session = ServerSession.new(server: @server, transport: self) + while @open + begin + line = read_line($stdin) + rescue RequestHandlerError => e + # Stop accumulating and end the connection gracefully rather than + # letting an unbounded read exhaust memory or escape as an uncaught + # backtrace. Scoped to the read so genuine request errors raised while + # handling a frame are not swallowed here. + @open = false + MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" }) + break + end + break if line.nil? + + line = line.strip + parsed = parse_line(line) + response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line) + send_response(response) if response end rescue Interrupt warn("\nExiting...") @@ -51,6 +83,103 @@ module MCP MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" }) false end + + # NOTE: This signature deliberately matches the abstract `Transport#send_request` contract + # (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts. + # On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with + # explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be + # auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request + # cancellation has very limited value here regardless; servers that need cancellation propagation for nested + # server-to-client requests should use `StreamableHTTPTransport`. + def send_request(method, params = nil) + # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; + # multi round-trip `input_required` results (SEP-2322) replace them. + if @session && @session.era == :modern + raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." + end + + request_id = generate_request_id + request = { jsonrpc: "2.0", id: request_id, method: method } + request[:params] = params if params + + begin + send_response(request) + rescue => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" }) + raise + end + + while @open && (line = read_line($stdin)) + begin + parsed = JSON.parse(line.strip, symbolize_names: true) + rescue JSON::ParserError => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" }) + raise + end + + if parsed[:id] == request_id && !parsed.key?(:method) + if parsed[:error] + raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}" + end + + return parsed[:result] + else + response = @session ? dispatch_with_era(parsed) : @server.handle(parsed) + send_response(response) if response + end + end + + raise "Transport closed while waiting for response to #{method} request." + end + + private + + # Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns + # the line (including its trailing newline) or `nil` at EOF. Raises when the + # limit is reached before a newline arrives, which signals a peer streaming + # an unbounded frame. A short final frame without a trailing newline (EOF) is + # still returned, since its length stays under the limit. + def read_line(io) + line = io.gets("\n", @max_line_bytes) + if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + raise RequestHandlerError.new( + "stdio frame exceeds #{@max_line_bytes} bytes without a newline", + nil, + error_type: :internal_error, + ) + end + + line + end + + # Parses a frame once so era classification can inspect its method and `_meta`. + # Returns `nil` for frames that are not JSON objects; those fall back to + # `ServerSession#handle_json` so protocol-level error responses stay identical. + def parse_line(line) + parsed = JSON.parse(line, symbolize_names: true) + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + end + + # Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks + # the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover` + # or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames + # (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock) + # are rejected in-band by `Server#lift_request_envelope`. + def dispatch_with_era(parsed) + response = @session.handle(parsed) + lock_modern_era_on_success(parsed, response) + response + end + + def lock_modern_era_on_success(parsed, response) + return if @session.era + return if !response.is_a?(Hash) || response.key?(:error) + return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params]) + + @session.lock_era!(:modern) + end end end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index e513862..b406d6d 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -1,21 +1,321 @@ # frozen_string_literal: true -require_relative "../../transport" require "json" -require "securerandom" +require_relative "../../result_type" +require_relative "../../transport" + +# This file is autoloaded only when `StreamableHTTPTransport` is referenced, +# so the `rack` dependency does not affect `StdioTransport` users. +begin + require "rack" +rescue LoadError + raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \ + "Add it to your Gemfile: gem 'rack'" +end module MCP class Server module Transports class StreamableHTTPTransport < Transport - def initialize(server) - super - # { session_id => { stream: stream_object } + class InvalidJsonError < StandardError; end + + # `x-accel-buffering: no` tells reverse proxies (nginx and friends) not to buffer the response, + # which the spec asks of every SSE stream: a buffering proxy holds events back instead of + # delivering them as they are written, and on a long-lived `subscriptions/listen` stream that + # also swallows the keepalive frames a dropped peer would otherwise be detected by. + # The TypeScript and Python SDKs send it on their SSE responses for the same reason. + SSE_HEADERS = { + "content-type" => "text/event-stream", + "cache-control" => "no-cache", + "connection" => "keep-alive", + "x-accel-buffering" => "no", + }.freeze + + # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, + # so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted. + # These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that + # hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport + # rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts + # the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on + # an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to + # opt out of expiry and `max_sessions: nil` to opt out of the cap. + DEFAULT_SESSION_IDLE_TIMEOUT = 1800 + DEFAULT_MAX_SESSIONS = 10_000 + + # Cap on concurrent `subscriptions/listen` streams (SEP-2575). Each stream holds an open SSE connection + # for its lifetime, so without a bound an unauthenticated client can retain unbounded connections, + # like the session-flood case `DEFAULT_MAX_SESSIONS` guards. A listen request past the cap is rejected with HTTP 503; + # pass `max_listen_subscriptions: nil` to opt out. + DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000 + + # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry). + UNSET_IDLE_TIMEOUT = Object.new.freeze + private_constant :UNSET_IDLE_TIMEOUT + + # Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`). + # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust + # the sender's resources; without one, a client that opens a session and simply never replies parks + # a worker thread for good. + # + # Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds + # for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default + # is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation + # prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds + # nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler + # that legitimately waits longer passes `timeout:`. + # + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts + DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600 + + # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole + # body into memory and parses it, so without a cap a single unauthenticated POST + # can allocate gigabytes and OOM the worker. 4 MiB comfortably + # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 + # payload); raise `max_request_bytes:` for unusually large payloads. Matches the + # TypeScript SDK's 4 MB default. + DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024 + + # Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust + # the stack or amplify parse cost (complements the byte cap). + MAX_JSON_NESTING = 64 + + # Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them + # in memory until the handler returns, so a handler that emits notifications in proportion to + # client-supplied input would otherwise let one request grow memory without bound. + # Notifications past the cap are not delivered and the notify helpers report `false`, + # the same non-delivery degradation they have on every other undeliverable path. + MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000 + + # Interval in seconds between SSE keepalive comment frames on a `subscriptions/listen` stream. + # Without them a silently dropped connection holds its slot until the next fan-out write fails, + # so on a quiet server a dead peer would occupy a `max_listen_subscriptions` slot indefinitely. + # The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's + # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream. + DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15 + + # Creates a Streamable HTTP transport that can be mounted as a Rack app. + # + # @param server [MCP::Server] the server whose requests this transport dispatches. + # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained. + # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON + # object instead of an SSE stream. + # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults + # to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables + # expiry. Not supported in stateless mode. + # @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new + # `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap. + # @param allowed_origins [Array, nil] extra `Origin` values accepted in addition to + # same-origin requests, for DNS rebinding protection. + # @param allowed_hosts [Array, nil] extra `Host` values accepted beyond the loopback + # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port) + # or a full `host:port`. + # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and + # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already + # validates them. + # @param session_request_validator [#call, nil] An optional + # `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE + # against an existing session (regular requests, notifications, and client responses alike). + # Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid` + # session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is + # the deploying application's responsibility (the transport never receives the authenticated identity + # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator, + # ownership is not enforced. + # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger + # requests are rejected with HTTP 413. Defaults to 4 MiB. + # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen` + # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables + # the cap. + # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames + # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer + # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable + # when an upstream proxy already keeps the stream alive. + # @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream. + # A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern, + # which builds a fresh transport per request and renders the body) passes `false`: + # the method then answers 404 with JSON-RPC `-32601` like any unimplemented method, + # and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags, + # keeping the advertisement and the actual behavior in agreement. Defaults to `true`. + # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its + # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. + # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. + def initialize( + server, + stateless: false, + enable_json_response: false, + session_idle_timeout: UNSET_IDLE_TIMEOUT, + max_sessions: DEFAULT_MAX_SESSIONS, + allowed_origins: nil, + allowed_hosts: nil, + dns_rebinding_protection: true, + session_request_validator: nil, + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, + max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, + listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, + serve_subscriptions_listen: true, + server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT + ) + super(server) + # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @sessions = {} @mutex = Mutex.new + + @stateless = stateless + @enable_json_response = enable_json_response + @session_request_validator = session_request_validator + @dns_rebinding_protection = dns_rebinding_protection + + # Host names are case-insensitive, so the allow lists are compared down-cased. + @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze + @allowed_origins = Array(allowed_origins).map(&:downcase).freeze + @pending_responses = {} + + # Maps a `subscriptions/listen` request id to + # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575). + # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, + # which is a follow-up. + @listen_subscriptions = {} + + # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits; + # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575). + @modern_request_sinks = {} + + # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, + # which does not apply to stateless mode since it retains no sessions. + @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT) + stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT + else + session_idle_timeout + end + + if @session_idle_timeout + if @stateless + raise ArgumentError, "session_idle_timeout is not supported in stateless mode." + elsif @session_idle_timeout <= 0 + raise ArgumentError, "session_idle_timeout must be a positive number." + end + end + + unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0) + raise ArgumentError, "max_sessions must be a positive Integer or nil" + end + + # The cap guards the stateful session store; stateless mode keeps none. + @max_sessions = stateless ? nil : max_sessions + + unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0 + raise ArgumentError, "max_request_bytes must be a positive Integer" + end + + @max_request_bytes = max_request_bytes + + if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0) + raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil" + end + + @max_listen_subscriptions = max_listen_subscriptions + + if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0) + raise ArgumentError, "listen_keepalive_interval must be a positive number or nil" + end + + @listen_keepalive_interval = listen_keepalive_interval + @serve_subscriptions_listen = serve_subscriptions_listen + + unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive? + raise ArgumentError, "server_to_client_request_timeout must be a positive number" + end + + @server_to_client_request_timeout = server_to_client_request_timeout + + start_reaper_thread if @session_idle_timeout + end + + REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze + REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze + REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze + STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze + SESSION_REAP_INTERVAL = 60 + + # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is + # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. + DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze + + # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575). + NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze + + # Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on + # a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI + # against `resourceSubscriptions` instead. + LISTEN_FILTER_FIELDS = { + Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged, + Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged, + Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged, + }.freeze + + # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404 + # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors, + # stays 200, matching the Python SDK's status ladder. + MODERN_BAD_REQUEST_CODES = [ + ErrorCodes::HEADER_MISMATCH, + ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, + ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + JsonRpcHandler::ErrorCode::PARSE_ERROR, + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + JsonRpcHandler::ErrorCode::INVALID_PARAMS, + ].freeze + + # Rack app interface. This transport can be mounted as a Rack app. + def call(env) + handle_request(Rack::Request.new(env)) + end + + # Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575). + # Gates both the route (a refusing transport answers the method as unimplemented) and + # the `listChanged`/`subscribe` capability flags `Server#discover` advertises, + # so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword. + def serves_subscriptions_listen? + @serve_subscriptions_listen end def handle_request(request) + rebinding_error = validate_dns_rebinding(request) + return rebinding_error if rebinding_error + + # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside + # every supported list routes to the sessionless modern path, so an unknown future version receives + # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error. + # Requests without the header, or with a stable-only version, take the existing paths untouched. + # An empty header value is malformed rather than a version claim, so it stays on the legacy path + # and fails legacy header validation as before. + # + # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it, + # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it): + # 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 session-less POST whose body is `initialize` is + # the legacy-distinctive handshake. 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). + header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] + if header_version && !header_version.empty? && !stable_only_version?(header_version) + unless MCP::Configuration.modern_protocol_version?(header_version) + return handle_modern(request, header_version) + end + + if extract_session_id(request).nil? + return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST" + + # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here, + # bounded, and hands the string to whichever path serves the request. + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + + unless legacy_handshake_body?(body_string) + return handle_modern(request, header_version, body_string: body_string) + end + + return handle_post(request, body_string: body_string) + end + end + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -24,174 +324,1327 @@ module MCP when "DELETE" handle_delete(request) else - [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]] + method_not_allowed_response end end def close - @mutex.synchronize do - @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) } + @reaper_thread&.kill + @reaper_thread = nil + + teardown_listen_subscriptions + + removed_sessions = @mutex.synchronize do + @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) } + end + + removed_sessions.each do |session| + close_stream_safely(session[:get_sse_stream]) + close_post_request_streams(session) end end - def send_notification(method, params = nil, session_id: nil) + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below: + # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too. + # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions, + # and a sink capturing the notification for its own response stream must not hide it from other subscriptions. + deliver_to_listen_subscriptions(method, params) + notification = { jsonrpc: "2.0", - method:, + method: method, } notification[:params] = params if params + # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers + # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response. + # Checked before the stateless guard, since modern requests are served in stateless deployments too. + # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as + # non-delivery (`false`), never handed to the legacy paths below. + sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] } + if sink + return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS + + sink << notification + return true + end + + # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising + # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from + # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. + return false if @stateless + + if session_id + deliver_targeted_notification(notification, session_id, related_request_id) + else + deliver_broadcast_notification(notification) + end + end + + def deliver_targeted_notification(notification, session_id, related_request_id) + # JSON response mode returns a single JSON object as the POST response, + # so request-scoped notifications (e.g. progress, log) cannot be delivered + # alongside it. Session-scoped standalone notifications + # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE. + return false if @enable_json_response && related_request_id + + streams_to_close = [] + + # Resolve the target stream under the lock, then write outside it: a stalled SSE reader + # must not block every other session that needs `@mutex`. + stream = @mutex.synchronize do + next unless (session = @sessions[session_id]) + + if session_expired?(session) + cleanup_and_collect_stream(session_id, streams_to_close) + next + end + + active_stream(session, related_request_id: related_request_id) + end + + close_streams(streams_to_close) + return false unless stream + + write_notification(stream, notification, session_id, related_request_id) + end + + def deliver_broadcast_notification(notification) + streams_to_close = [] + + # Snapshot the connected streams under the lock, then write to each outside it. + targets = @mutex.synchronize do + expired_session_ids = [] + + collected = @sessions.filter_map do |session_id, session| + next unless (stream = session[:get_sse_stream]) + + if session_expired?(session) + expired_session_ids << session_id + next + end + + [session_id, stream] + end + + expired_session_ids.each do |session_id| + cleanup_and_collect_stream(session_id, streams_to_close) + end + + collected + end + + close_streams(streams_to_close) + + targets.count do |session_id, stream| + write_notification(stream, notification, session_id, nil) + end + end + + # Writes a notification to an SSE stream without holding `@mutex`. On a write error, + # drops the broken stream and returns false; on success returns true. + def write_notification(stream, notification, session_id, related_request_id) + send_to_stream(stream, notification) + true + rescue *STREAM_WRITE_ERRORS => e + MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" }) + drop_broken_stream(session_id, stream, related_request_id) + false + end + + # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own; + # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs + # under `@mutex`, and the affected streams are closed outside it. + def drop_broken_stream(session_id, stream, related_request_id) + streams_to_close = [] + @mutex.synchronize do - if session_id - # Send to specific session - session = @sessions[session_id] - return false unless session && session[:stream] + session = @sessions[session_id] + if related_request_id + # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it, + # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped + # failure never reaches the session teardown below. + registered = session&.dig(:post_request_streams, related_request_id) + session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) - begin - send_to_stream(session[:stream], notification) - true - rescue IOError, Errno::EPIPE => e - MCP.configuration.exception_reporter.call( - e, - { session_id: session_id, error: "Failed to send notification" }, - ) - cleanup_session_unsafe(session_id) - false - end + streams_to_close << stream else - # Broadcast to all connected SSE sessions - sent_count = 0 - failed_sessions = [] + cleanup_and_collect_stream(session_id, streams_to_close) + end + end - @sessions.each do |sid, session| - next unless session[:stream] + close_streams(streams_to_close) + end - begin - send_to_stream(session[:stream], notification) - sent_count += 1 - rescue IOError, Errno::EPIPE => e - MCP.configuration.exception_reporter.call( - e, - { session_id: sid, error: "Failed to send notification" }, - ) - failed_sessions << sid - end - end + def close_streams(streams) + streams.each do |stream| + close_stream_safely(stream) + end + end - # Clean up failed sessions - failed_sessions.each { |sid| cleanup_session_unsafe(sid) } + # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until + # the client responds. + # + # Uses a `PendingResponse` for cross-thread synchronization: this method registers one, + # sends the request via SSE stream, then waits on it. When the client POSTs a response, + # `handle_response` matches it by `request_id` and resolves the pending response, + # unblocking this thread. A cancellation and session teardown resolve it the same way. + # + # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`), + # so a client that never answers cannot park the calling thread for good. On expiry the peer is + # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised. + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) + # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; + # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached + # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`, + # but that is incidental: without the rule stated here a modern handler that reaches for elicitation + # or sampling is told "Session not found: ", which points at everything except the actual reason. + # `StdioTransport#send_request` refuses the same way. + if server_session&.era == :modern + raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." + end - sent_count + if @stateless + raise "Stateless mode does not support server-to-client requests." + end + + if @enable_json_response + raise "JSON response mode does not support server-to-client requests." + end + + unless session_id + raise "session_id is required for server-to-client requests." + end + + request_id = generate_request_id + pending_response = PendingResponse.new + wait_timeout = timeout || @server_to_client_request_timeout + cancel_hook = nil + + request = { jsonrpc: "2.0", id: request_id, method: method } + request[:params] = params if params + + # Register the pending response and resolve the stream under the lock, but perform + # the write outside it so a stalled reader cannot block every other session on `@mutex`. + stream = @mutex.synchronize do + unless (session = @sessions[session_id]) + raise "Session not found: #{session_id}." + end + + @pending_responses[request_id] = { queue: pending_response, session_id: session_id } + + active_stream(session, related_request_id: related_request_id) + end + + sent = false + if stream + begin + send_to_stream(stream, request) + sent = true + rescue *STREAM_WRITE_ERRORS + drop_broken_stream(session_id, stream, related_request_id) + end + end + + # TODO: Replace with event store + replay when resumability is implemented. + # Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay, + # event store management) independent of sampling. + # See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery + # + # The TypeScript and Python SDKs buffer messages and replay on reconnect. + # Until then, raise to prevent queue.pop from blocking indefinitely. + unless sent + raise "No active stream for #{method} request." + end + + if parent_cancellation && server_session + cancel_hook = parent_cancellation.on_cancel do |reason| + server_session.send_peer_cancellation( + nested_request_id: request_id, + related_request_id: related_request_id, + reason: reason, + ) + end + end + + response = pending_response.pop(timeout: wait_timeout) do + # Expiry cancels as well as stops waiting, so a client that answers late does not act on + # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here: + # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests + # never register the session this method looks up. Those revisions ask the sender to "issue + # a cancellation notification for that request and stop waiting", letting either side send one. + # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown + # governs the era that has no such requests to cancel.) Both reference SDKs send this same + # courtesy cancel on timeout. + server_session&.send_peer_cancellation( + nested_request_id: request_id, + related_request_id: related_request_id, + reason: "Timed out after #{wait_timeout} seconds", + ) + + raise RequestTimeoutError.new( + "#{method} request timed out after #{wait_timeout} seconds", + request_id: request_id, + timeout: wait_timeout, + ) + end + + if response.is_a?(Hash) && response.key?(:error) + raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}" + end + + if response == :session_closed + raise "SSE session closed while waiting for #{method} response." + end + + if response == :cancelled + reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) } + raise MCP::CancelledError.new( + "#{method} request was cancelled", + request_id: request_id, + reason: reason, + ) + end + + response + ensure + parent_cancellation.off_cancel(cancel_hook) if cancel_hook + if request_id + @mutex.synchronize do + @pending_responses.delete(request_id) + end + end + end + + # Unblocks a `send_request` awaiting a response when the peer is being cancelled. + # The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`. + # + # Race note: this is first-writer-wins on the pending-response queue. If a real response + # has already been pushed (client responded before the cancel hook fired), that response + # wins and `:cancelled` is enqueued behind it but never read - `send_request` returns + # the real response and deletes the pending entry in its `ensure` block. Conversely, + # if `:cancelled` arrives first, any later client response is silently dropped in `handle_response` + # because the pending entry has been removed. + def cancel_pending_request(request_id, reason: nil) + @mutex.synchronize do + if (pending = @pending_responses[request_id]) + pending[:cancel_reason] = reason + pending[:queue].push(:cancelled) end end end private + def start_reaper_thread + @reaper_thread = Thread.new do + loop do + sleep(SESSION_REAP_INTERVAL) + reap_expired_sessions + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, error: "Session reaper error") + end + end + end + + def reap_expired_sessions + return unless @session_idle_timeout + + removed_sessions = @mutex.synchronize do + @sessions.each_key.filter_map do |session_id| + next unless session_expired?(@sessions[session_id]) + + cleanup_session_unsafe(session_id) + end + end + + removed_sessions.each do |session| + close_stream_safely(session[:get_sse_stream]) + close_post_request_streams(session) + end + end + def send_to_stream(stream, data) message = data.is_a?(String) ? data : data.to_json stream.write("data: #{message}\n\n") - stream.flush if stream.respond_to?(:flush) + stream.flush end def send_ping_to_stream(stream) stream.write(": ping #{Time.now.iso8601}\n\n") - stream.flush if stream.respond_to?(:flush) + stream.flush end - def handle_post(request) - body_string = request.body.read - session_id = extract_session_id(request) + # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575): + # a single POST/JSON exchange with no session. The modern path never consults + # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts + # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`) + # and DELETE (session termination) have no modern meaning. + def handle_modern(request, header_version, body_string: nil) + return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST" - body = parse_request_body(body_string) - return body unless body.is_a?(Hash) # Error response + accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE) + return accept_error if accept_error - if body["method"] == "initialize" - handle_initialization(body_string, body) - elsif notification?(body) || response?(body) - handle_accepted + content_type_error = validate_content_type(request) + return content_type_error if content_type_error + + if body_string.nil? + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + end + + begin + body = parse_request_body(body_string) + rescue InvalidJsonError + return invalid_json_response + end + + unless body.is_a?(Hash) + return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") + end + + # The version check precedes everything else that depends on request content, + # so a client probing with an unknown future version always receives the `-32022` signal + # (with the supported list to select from) rather than an incidental error. + unless MCP::Configuration.modern_protocol_version?(header_version) + return json_rpc_error_response( + status: 400, + code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, + message: "Unsupported protocol version", + data: { + supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, + requested: header_version, + }, + id: body[:id], + ) + end + + if extract_session_id(request) + return json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle", + id: body[:id], + ) + end + + mismatch_error = validate_modern_headers(request, body, header_version) + return mismatch_error if mismatch_error + + # `subscriptions/listen` is a long-lived notification stream served at the transport layer; + # it never dispatches through `Server#handle`. A transport constructed with + # `serve_subscriptions_listen: false` skips the interception, so the method falls through + # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot + # serve an open SSE stream needs, instead of a `Proc` body it can never call. + if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen? + return handle_subscriptions_listen(body) + end + + session = modern_session + notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } + begin + response = @server.handle(body, session: session) + ensure + @mutex.synchronize { @modern_request_sinks.delete(session.session_id) } + end + + # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path. + return handle_accepted if response.nil? + + # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of + # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder. + # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving + # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count. + # Live streaming can follow without changing the wire shape. + if notifications.empty? + [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]] else - handle_regular_request(body_string, session_id) + frames = notifications + [response] + sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join + [200, SSE_HEADERS.dup, [sse_body]] end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) - [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]] + json_rpc_error_response( + status: 500, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal server error", + ) + end + + # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header + # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match + # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks + # converge on enforcement. + def validate_modern_headers(request, body, header_version) + params = body[:params] + meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil + if meta_version && meta_version != header_version + return header_mismatch_response( + "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'", + body[:id], + ) + end + + # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods: + # the spec lists a missing required standard header among the validation failures that MUST be rejected, + # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare": + # the headers exist so intermediaries can route without parsing bodies, and a client that omits them + # defeats that contract silently. + method_header = request.env["HTTP_MCP_METHOD"] + if method_header.to_s.empty? + return header_mismatch_response( + "Mcp-Method header is required on the modern path", + body[:id], + ) + end + if method_header != body[:method] + return header_mismatch_response( + "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'", + body[:id], + ) + end + + if NAME_BEARING_METHODS.include?(body[:method]) + body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil + name_header = request.env["HTTP_MCP_NAME"] + if body_name + if name_header.to_s.empty? + return header_mismatch_response( + "Mcp-Name header is required for `#{body[:method]}`", + body[:id], + ) + end + + decoded_name = decode_header_value(name_header) + if decoded_name != body_name + return header_mismatch_response( + "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", + body[:id], + ) + end + end + end + + nil + end + + # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is + # `notifications/subscriptions/acknowledged` with the subset of requested notification types + # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId` + # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` + # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every + # `listen_keepalive_interval` seconds so a dropped connection frees its slot. + def handle_subscriptions_listen(body) + request_id = body[:id] + params = body[:params] + + # A listen frame without an id could never receive stream teardown correlation. + unless request_id + return invalid_request_response("Invalid Request: subscriptions/listen requires an id") + end + + begin + if RequestEnvelope.modern?(params) + RequestEnvelope.parse!(params, request: params) + else + return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope") + end + rescue Server::RequestHandlerError => e + return json_rpc_error_response( + status: 400, + code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: e.message, + data: e.error_data, + id: request_id, + ) + end + + filter = params[:notifications] + unless filter.is_a?(Hash) + return json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + message: "Invalid params: subscriptions/listen requires a `notifications` filter object", + id: request_id, + ) + end + + # Best-effort cap check before committing to the SSE response; the registration inside + # `listen_sse_body` re-checks atomically for the race between two concurrent listens + # crossing the cap together. + if listen_subscriptions_full? + return too_many_listen_subscriptions_response(request_id) + end + + [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))] + end + + def listen_subscriptions_full? + return false unless @max_listen_subscriptions + + @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions } + end + + def too_many_listen_subscriptions_response(request_id) + json_rpc_error_response( + status: 503, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached", + id: request_id, + ) + end + + # The Rack streaming body of a `subscriptions/listen` response. It responds to `call` + # and deliberately not to `each`, so Rack keeps classifying it as a streaming body; + # `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)` + # in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix. + class ListenStreamBody + def initialize(&block) + @block = block + end + + def call(stream) + @block.call(stream) + end + + def first + raise <<~MESSAGE + subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \ + A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \ + so the method is answered as unimplemented instead. + See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns. + MESSAGE + end + end + private_constant :ListenStreamBody + + # The body registers the stream and returns, leaving the response open like + # the legacy GET stream (`create_sse_body`). + # + # Registration and activation are split on purpose: the entry is inserted inactive + # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock, + # and only then does the entry become eligible for delivery. A concurrent notification between + # the insert and the acknowledgement write skips the inactive entry, + # enforcing the SEP-2575 rule that no notification precedes the acknowledgement. + def listen_sse_body(request_id, honored) + ListenStreamBody.new do |stream| + rejected = false + @mutex.synchronize do + if @listen_subscriptions.key?(request_id) || + (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) + rejected = true + else + @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new } + end + end + + if rejected + close_stream_safely(stream) + else + acknowledgement = { + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED, + params: { + notifications: honored, + _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, + }, + } + + begin + send_to_stream(stream, acknowledgement) + activate_listen_subscription(request_id) + start_listen_keepalive_thread(request_id) + rescue *STREAM_WRITE_ERRORS + remove_listen_subscription(request_id) + close_stream_safely(stream) + end + end + end + end + + # Marks a listen subscription eligible for delivery once its acknowledgement write has completed. + # The entry may already be gone when the transport closed concurrently. + def activate_listen_subscription(request_id) + @mutex.synchronize do + subscription = @listen_subscriptions[request_id] + subscription[:active] = true if subscription + end + end + + # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped + # connection is detected and its slot freed, rather than held until the next fan-out write. + # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame) + # cannot corrupt an interleaved notification's JSON. + def start_listen_keepalive_thread(request_id) + return unless @listen_keepalive_interval + + Thread.new do + while listen_subscription_active?(request_id) + sleep(@listen_keepalive_interval) + send_listen_keepalive_ping(request_id) + end + rescue *STREAM_WRITE_ERRORS + # The peer went away; the ensure frees the slot. A dropped listen stream is the normal + # way this loop ends, so it is not reported. + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { subscription_id: request_id }) + ensure + stream = @mutex.synchronize do + subscription = @listen_subscriptions.delete(request_id) + subscription && subscription[:stream] + end + close_stream_safely(stream) if stream + end + end + + def listen_subscription_active?(request_id) + @mutex.synchronize { @listen_subscriptions.key?(request_id) } + end + + # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block + # every other subscription on `@mutex`. A write error propagates to end the keepalive loop. + def send_listen_keepalive_ping(request_id) + stream = @mutex.synchronize do + subscription = @listen_subscriptions[request_id] + subscription && subscription[:stream] + end + return unless stream + + send_ping_to_stream(stream) + end + + # Per SEP-2575, the server MUST NOT send notification types the client has not requested, + # and the acknowledgement only includes types the server actually supports + # (derived from its declared capabilities). + def honored_filter(filter) + capabilities = @server.capabilities + honored = {} + honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged) + honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged) + honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged) + + subscriptions = filter[:resourceSubscriptions] + if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty? + honored[:resourceSubscriptions] = subscriptions + end + + honored + end + + # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive + # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation + # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough. + def capability_flag?(capabilities, name, flag) + value = capabilities[name] || capabilities[name.to_s] + return false unless value.is_a?(Hash) + + !!(value[flag] || value[flag.to_s]) + end + + # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it, + # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter + # (not the requested one) enforces the MUST NOT-send-unrequested-types rule. + def deliver_to_listen_subscriptions(method, params) + field = LISTEN_FILTER_FIELDS[method] + return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED + + # The matching snapshot is taken under `@mutex`, but stream writes happen outside it: + # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths. + matched = @mutex.synchronize do + @listen_subscriptions.filter_map do |request_id, subscription| + # An inactive entry has not finished writing its acknowledgement yet; + # delivering to it would put a notification ahead of the acknowledgement. + next unless subscription[:active] + + hit = if field + subscription[:filter][field] + else + uris = subscription[:filter][:resourceSubscriptions] + uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil + uris.is_a?(Array) && uris.include?(uri) + end + + [request_id, subscription] if hit + end + end + + matched.each do |request_id, subscription| + meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id } + notification_params = (params || {}).merge(_meta: meta) + notification = { jsonrpc: "2.0", method: method, params: notification_params } + + begin + # The per-stream write mutex orders this write against a concurrent graceful teardown: + # once teardown has marked the entry closed and written its `SubscriptionsListenResult`, + # a delivery that snapshotted the entry before the registry was cleared skips it instead + # of writing after the final message. + subscription[:write_mutex].synchronize do + next if subscription[:closed] + + send_to_stream(subscription[:stream], notification) + end + rescue *STREAM_WRITE_ERRORS => e + MCP.configuration.exception_reporter.call( + e, + { subscription_id: request_id, error: "Failed to send notification" }, + ) + remove_listen_subscription(request_id) + close_stream_safely(subscription[:stream]) + end + end + end + + def remove_listen_subscription(request_id) + @mutex.synchronize { @listen_subscriptions.delete(request_id) } + end + + # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response + # before the stream closes. + def teardown_listen_subscriptions + removed = @mutex.synchronize do + subscriptions = @listen_subscriptions.dup + @listen_subscriptions.clear + subscriptions + end + + removed.each do |request_id, subscription| + # Marking the entry closed and writing the result under the stream's write mutex orders + # this against in-flight deliveries: each one either lands before the result or observes + # `closed` and skips, keeping the graceful result the stream's final message. + subscription[:write_mutex].synchronize do + subscription[:closed] = true + + begin + send_to_stream(subscription[:stream], { + jsonrpc: "2.0", + id: request_id, + result: { + # `SubscriptionsListenResult` is served at the transport layer and never + # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is + # stamped at its construction site. + resultType: ResultType::COMPLETE, + _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, + }, + }) + rescue *STREAM_WRITE_ERRORS + nil + end + end + close_stream_safely(subscription[:stream]) + end + end + + def header_mismatch_response(message, id) + json_rpc_error_response( + status: 400, + code: ErrorCodes::HEADER_MISMATCH, + message: "Header mismatch: #{message}", + id: id, + ) + end + + # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64??=` decodes to + # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport + # requires faraday, which servers do not depend on. + def decode_header_value(value) + match = value.match(/\A=\?base64\?(.*)\?=\z/m) + return value unless match + + match[1].unpack1("m0").force_encoding(Encoding::UTF_8) + rescue ArgumentError + value + end + + # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to + # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing + # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions + # via the `session_id.nil?` branch. + def modern_session + ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern) + end + + def modern_http_status(response) + error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil + if error_code.nil? + 200 + elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND + 404 + elsif MODERN_BAD_REQUEST_CODES.include?(error_code) + 400 + else + 200 + end + end + + def handle_post(request, body_string: nil) + required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE + accept_error = validate_accept_header(request, required_types) + return accept_error if accept_error + + content_type_error = validate_content_type(request) + return content_type_error if content_type_error + + if body_string.nil? + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + end + + session_id = extract_session_id(request) + + begin + body = parse_request_body(body_string) + rescue InvalidJsonError + return invalid_json_response + end + + # Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST. + # Batched/array bodies are not supported; reject with `-32600` instead of falling through to + # a malformed Rack response. + unless body.is_a?(Hash) + return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") + end + + # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs, + # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only. + # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session + # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras + # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version, + # which violates the header/body match requirement and would fall through the legacy path via + # the header default; reject that as a header mismatch. + if RequestEnvelope.modern?(body[:params]) + header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] + if header_version && MCP::Configuration.modern_protocol_version?(header_version) + return invalid_request_response( + "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", + request_id: body[:id], + ) + end + + return header_mismatch_response( + "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope", + body[:id], + ) + end + + # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` + # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. + # This matches the TypeScript and Python SDKs. + # `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that + # happens before (or instead of) negotiation. + unless initialize_request?(body) || discover_request?(body) + return missing_session_id_response if !@stateless && !session_id + + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + end + + if initialize_request?(body) + if !@stateless && session_id + # An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate + # initialization attempt against a live session, or a retry against an unknown/expired + # one. In the live case, reject with `-32600` so the original session is not abandoned. + # In the unknown/expired case, return 404 so the client retries from scratch instead + # of silently inheriting a fresh session under the old ID. + return already_initialized_response(body[:id]) if session_active?(session_id) + + return session_not_found_response + end + + handle_initialization(request, body_string, body) + else + # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses, + # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would + # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session). + if !@stateless && session_id && !validate_session_request(request, session_id) + return forbidden_response + end + + if notification?(body) + # Reject a notification carrying an unknown or expired session ID instead of + # dispatching it against the shared `Server`. Mirrors the response and regular-request + # branches; without it a custom notification handler could run without a live session. + return session_not_found_response if !@stateless && !session_active?(session_id) + + dispatch_notification(body_string, session_id) + handle_accepted + elsif response?(body) + return session_not_found_response if !@stateless && !session_exists?(session_id) + + handle_response(body, session_id: session_id) + else + handle_regular_request(body_string, session_id, related_request_id: body[:id]) + end + end + rescue StandardError => e + MCP.configuration.exception_reporter.call(e, { request: body_string }) + json_rpc_error_response( + status: 500, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Internal server error", + ) end def handle_get(request) + if @stateless + return method_not_allowed_response + end + + accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES) + return accept_error if accept_error + session_id = extract_session_id(request) return missing_session_id_response unless session_id - return session_not_found_response unless session_exists?(session_id) + + error_response = validate_and_touch_session(session_id) + return error_response if error_response + return forbidden_response unless validate_session_request(request, session_id) + + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + + return session_already_connected_response if get_session_stream(session_id) setup_sse_stream(session_id) end def handle_delete(request) - session_id = request.env["HTTP_MCP_SESSION_ID"] + success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]] - return [ - 400, - { "Content-Type" => "application/json" }, - [{ error: "Missing session ID" }.to_json], - ] unless session_id + if @stateless + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error + + # Stateless mode doesn't support sessions, so we can just return a success response + return success_response + end + + return missing_session_id_response unless (session_id = extract_session_id(request)) + return session_not_found_response unless session_exists?(session_id) + return forbidden_response unless validate_session_request(request, session_id) + + protocol_version_error = validate_protocol_version_header(request) + return protocol_version_error if protocol_version_error cleanup_session(session_id) - [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]] + + success_response end def cleanup_session(session_id) - @mutex.synchronize do + session = @mutex.synchronize do cleanup_session_unsafe(session_id) end + + if session + close_stream_safely(session[:get_sse_stream]) + close_post_request_streams(session) + end end + # Removes a session from `@sessions` and returns it. Does not close the stream. + # Callers must close the stream outside the mutex to avoid holding the lock during + # potentially blocking I/O. def cleanup_session_unsafe(session_id) - session = @sessions[session_id] - return unless session + session = @sessions.delete(session_id) - begin - session[:stream]&.close - rescue - nil + # Unblock threads waiting on pending responses for this session. + @pending_responses.each_value do |pending_response| + if pending_response[:session_id] == session_id + pending_response[:queue].push(:session_closed) + end + end + + session + end + + def cleanup_and_collect_stream(session_id, streams_to_close) + return unless (removed = cleanup_session_unsafe(session_id)) + + streams_to_close << removed[:get_sse_stream] + removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream } + end + + def close_stream_safely(stream) + stream&.close + rescue StandardError + # Ignore close-related errors from already closed/broken streams. + end + + def close_post_request_streams(session) + return unless (post_request_streams = session[:post_request_streams]) + + post_request_streams.each_value do |stream| + close_stream_safely(stream) end - @sessions.delete(session_id) end def extract_session_id(request) request.env["HTTP_MCP_SESSION_ID"] end + # Session-ownership gate for requests against an existing session (the spec's session-binding guidance). + # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access. + # Two layers, both returning `false` to trigger a 403: + # + # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin` + # at `initialize` and this request carries a different one, reject. Both must be present to compare, + # so non-browser clients that send no `Origin` are unaffected. + # - The application-supplied `session_request_validator`, which can enforce true ownership when it has + # an authenticated principal. + def validate_session_request(request, session_id) + session = @mutex.synchronize { @sessions[session_id] } + return true unless session + + session_origin = session[:origin] + request_origin = request.env["HTTP_ORIGIN"] + return false if session_origin && request_origin && session_origin != request_origin + return @session_request_validator.call(request, session_id) if @session_request_validator + + true + end + + def validate_accept_header(request, required_types) + accept_header = request.env["HTTP_ACCEPT"] + return not_acceptable_response(required_types) unless accept_header + + accepted_types = parse_accept_header(accept_header) + return if accepted_types.include?("*/*") + + missing_types = required_types - accepted_types + return not_acceptable_response(required_types) unless missing_types.empty? + + nil + end + + def parse_accept_header(header) + header.split(",").map do |part| + part.split(";").first.strip.downcase + end + end + + def validate_content_type(request) + content_type = request.env["CONTENT_TYPE"] + media_type = content_type&.split(";")&.first&.strip&.downcase + return if media_type == "application/json" + + json_rpc_error_response( + status: 415, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Unsupported Media Type: Content-Type must be application/json", + ) + end + + def not_acceptable_response(required_types) + json_rpc_error_response( + status: 406, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}", + ) + end + + # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust + # memory. A declared `Content-Length` over the cap is rejected + # without reading; the actual read is also bounded to one byte past the cap, so + # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught. + # Returns `nil` when the body exceeds the cap. + def read_bounded_body(request) + content_length = request.content_length + return if content_length && content_length.to_i > @max_request_bytes + + body = request.body.read(@max_request_bytes + 1) + return "" if body.nil? + return if body.bytesize > @max_request_bytes + + body + end + + def payload_too_large_response + json_rpc_error_response( + status: 413, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Payload too large: request body exceeds #{@max_request_bytes} bytes", + ) + end + def parse_request_body(body_string) - JSON.parse(body_string) + # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`, + # a subclass of `JSON::ParserError`, so it is caught below as a parse error. + JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING) rescue JSON::ParserError, TypeError - [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] + raise InvalidJsonError + end + + def invalid_json_response + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::PARSE_ERROR, + message: "Parse error: Invalid JSON", + ) + end + + def initialize_request?(body) + body.is_a?(Hash) && body[:method] == Methods::INITIALIZE + end + + def discover_request?(body) + body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER + end + + # A version with no modern meaning, whose header can only accompany legacy traffic. + # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it, + # but requests of an established legacy session may stamp it - so that value needs further disambiguation. + def stable_only_version?(version) + MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) && + !MCP::Configuration.modern_protocol_version?(version) + end + + # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body + # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying + # the envelope comes from a modern client naming a method its lifecycle removed, so it stays + # on the modern path and answers with -32601/404 (SEP-2575). + # Unparsable or non-object bodies go to the modern path, whose error responses cover them. + def legacy_handshake_body?(body_string) + body = parse_request_body(body_string) + return false unless initialize_request?(body) + + !RequestEnvelope.modern?(body[:params]) + rescue InvalidJsonError + false + end + + def validate_protocol_version_header(request) + header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION + return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) + + supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ") + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}", + ) + end + + def json_rpc_error_response(status:, code:, message:, data: nil, id: nil) + error = { code: code, message: message } + error[:data] = data if data + body = { jsonrpc: "2.0", id: id, error: error } + [status, { "content-type" => "application/json" }, [body.to_json]] end def notification?(body) - !body["id"] && !!body["method"] + !body[:id] && !!body[:method] + end + + # Dispatches a client-originated notification (e.g. `notifications/cancelled`, + # `notifications/initialized`) through the server so it can update session state. + def dispatch_notification(body_string, session_id) + server_session = nil + if @stateless + server_session = ephemeral_session + elsif session_id + @mutex.synchronize do + session = @sessions[session_id] + server_session = session[:server_session] if session + end + end + + dispatch_handle_json(body_string, server_session) + rescue => e + MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" }) end def response?(body) - !!body["id"] && !body["method"] + !!body[:id] && !body[:method] end - def handle_initialization(body_string, body) - session_id = SecureRandom.uuid - + # Verifies that the response came from the expected session to prevent + # cross-session response injection if request IDs are ever leaked. + def handle_response(body, session_id:) + request_id = body[:id] @mutex.synchronize do - @sessions[session_id] = { - stream: nil, - } + if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id + if body.key?(:error) + error = body[:error] + pending_response[:queue].push(error: { code: error[:code], message: error[:message] }) + else + pending_response[:queue].push(body[:result]) + end + end end - response = @server.handle_json(body_string) + handle_accepted + end + + def handle_initialization(request, body_string, body) + session_id = nil + + if @stateless + server_session = ephemeral_session + else + session_id = SecureRandom.uuid + server_session = ServerSession.new(server: @server, transport: self, session_id: session_id) + + # Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted. + # The check and insert share the mutex so concurrent initializes cannot race past the limit. + reclaimed = [] + inserted = @mutex.synchronize do + # When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected, + # so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired. + if @max_sessions && @sessions.size >= @max_sessions + @sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id| + cleanup_and_collect_stream(id, reclaimed) + end + end + + next false if @max_sessions && @sessions.size >= @max_sessions + + @sessions[session_id] = { + get_sse_stream: nil, + server_session: server_session, + last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), + # Captured for the built-in Origin-consistency defense in `validate_session_request`. + # Not authentication. + origin: request.env["HTTP_ORIGIN"], + } + true + end + + reclaimed.each { |stream| close_stream_safely(stream) } + return too_many_sessions_response unless inserted + end + + response = server_session.handle_json(body_string) + + # `initialize_request?` matches on the method alone, so an `initialize` sent without + # an id (framed as a notification) reaches here. `Server#init` marks the session initialized, + # but JSON-RPC emits no response for an id-less request, so `response` is nil. + # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize), + # and the session would be retained since it is marked initialized. Discard the orphaned session + # and ack with 202, mirroring the nil-response handling in a regular request. + if response.nil? + cleanup_session(session_id) if session_id + return handle_accepted + end + + # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope), + # `mark_initialized!` was never called. Discard the orphaned session and omit + # the `Mcp-Session-Id` header so the client retries from a clean state instead of + # reusing a never-initialized ID that would later look like a duplicate `initialize`. + if session_id && !server_session.initialized? + cleanup_session(session_id) + session_id = nil + end headers = { - "Content-Type" => "application/json", - "Mcp-Session-Id" => session_id, + "content-type" => "application/json", } + headers["mcp-session-id"] = session_id if session_id + [200, headers, [response]] end @@ -199,76 +1652,333 @@ module MCP [202, {}, []] end - def handle_regular_request(body_string, session_id) - # If session ID is provided, but not in the sessions hash, return an error - if session_id && !@sessions.key?(session_id) - return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]] + def too_many_sessions_response + json_rpc_error_response( + status: 503, + code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, + message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached", + ) + end + + def handle_regular_request(body_string, session_id, related_request_id: nil) + server_session = nil + + if @stateless + server_session = ephemeral_session + elsif session_id + error_response = validate_and_touch_session(session_id) + return error_response if error_response + + @mutex.synchronize do + session = @sessions[session_id] + server_session = session[:server_session] if session + end end - response = @server.handle_json(body_string) - stream = get_session_stream(session_id) if session_id + # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below + # registers this request's stream under that id first, so without this check the colliding request + # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then + # clear the entry the original request still needs. + if related_request_id && server_session&.in_flight?(related_request_id) + return request_id_conflict_response + end - if stream - send_response_to_stream(stream, response, session_id) + if session_id && !@stateless && !@enable_json_response + handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) else - [200, { "Content-Type" => "application/json" }, [response]] + response = dispatch_handle_json(body_string, server_session) + + # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec. + # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body, + # which would produce an empty body the client cannot parse as JSON. + return handle_accepted if response.nil? + + [200, { "content-type" => "application/json" }, [response]] end end + # Returns the POST response as an SSE stream so the server can send + # JSON-RPC requests and notifications during request processing. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil) + body = proc do |stream| + @mutex.synchronize do + session = @sessions[session_id] + if session && related_request_id + session[:post_request_streams] ||= {} + + # Claim the id only while it is free. `handle_regular_request` already refused the colliding request, + # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps + # its messages going where they belong. + session[:post_request_streams][related_request_id] ||= stream + end + end + + begin + response = dispatch_handle_json(body_string, server_session) + + send_to_stream(stream, response) if response + ensure + if related_request_id + @mutex.synchronize do + session = @sessions[session_id] + # Only retire our own registration: a request that never claimed the id, or one whose claim has + # already been replaced, must not unregister the stream that owns it. + registered = session&.dig(:post_request_streams, related_request_id) + + session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) + end + end + + begin + stream.close + rescue StandardError + # Ignore close-related errors from already closed/broken streams. + end + end + end + + [200, SSE_HEADERS.dup, body] + end + + # Returns the SSE stream available for server-to-client messages. + # When `related_request_id` is given, returns only the POST response + # stream for that request (no fallback to GET SSE). This prevents + # request-scoped messages from leaking to the wrong stream. + # When `related_request_id` is nil, returns the GET SSE stream. + def active_stream(session, related_request_id: nil) + if related_request_id + session.dig(:post_request_streams, related_request_id) + else + session[:get_sse_stream] + end + end + + def dispatch_handle_json(body_string, server_session) + if server_session + server_session.handle_json(body_string) + else + @server.handle_json(body_string) + end + end + + def validate_and_touch_session(session_id) + removed = nil + + response = @mutex.synchronize do + next session_not_found_response unless (session = @sessions[session_id]) + next unless @session_idle_timeout + + if session_expired?(session) + removed = cleanup_session_unsafe(session_id) + next session_not_found_response + end + + session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + nil + end + + if removed + close_stream_safely(removed[:get_sse_stream]) + + removed[:post_request_streams]&.each_value do |stream| + close_stream_safely(stream) + end + end + + response + end + def get_session_stream(session_id) - @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) } - end - - def send_response_to_stream(stream, response, session_id) - message = JSON.parse(response) - send_to_stream(stream, message) - [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]] - rescue IOError, Errno::EPIPE => e - MCP.configuration.exception_reporter.call( - e, - { session_id: session_id, error: "Stream closed during response" }, - ) - cleanup_session(session_id) - [200, { "Content-Type" => "application/json" }, [response]] + @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) } end def session_exists?(session_id) @mutex.synchronize { @sessions.key?(session_id) } end + # Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession` + # so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567 + def ephemeral_session + ServerSession.new(server: @server, transport: self, session_id: nil) + end + + # Returns true iff a session exists and is not past its idle timeout. Expired sessions + # are evicted as a side effect so a live request never observes a zombie session that + # the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are + # rejecting a request must not extend the session's lifetime. + def session_active?(session_id) + removed = nil + active = @mutex.synchronize do + next false unless (session = @sessions[session_id]) + + if session_expired?(session) + removed = cleanup_session_unsafe(session_id) + next false + end + + true + end + + if removed + close_stream_safely(removed[:get_sse_stream]) + close_post_request_streams(removed) + end + + active + end + + # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost + # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default; + # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already + # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`, + # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`. + def validate_dns_rebinding(request) + return unless @dns_rebinding_protection + + validate_host(request) || validate_origin(request) + end + + # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1). + # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one. + def validate_host(request) + host = request.env["HTTP_HOST"] + return if host.nil? + + # An `allowed_hosts:` entry matches either the bare host name (any port) + # or the full `host:port` value, so both `"app.example.com"` and + # `"app.example.com:8443"` can be configured. + normalized = host.downcase + return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized) + + forbidden_response("Forbidden: Invalid Host header") + end + + # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is + # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`. + def validate_origin(request) + origin = request.env["HTTP_ORIGIN"] + return if origin.nil? + return if same_origin?(origin, request) + return if @allowed_origins.include?(origin.downcase) + + forbidden_response("Forbidden: Invalid Origin header") + end + + # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets + # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`). + def request_hostname(host) + return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[") + + host.split(":").first + end + + # Compares the `Origin` authority (host:port) against the request's own `Host`. + # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies), + # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from + # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive. + def same_origin?(origin, request) + host = request.env["HTTP_HOST"] + return false if host.nil? + + normalized = origin.downcase + default_port = normalized.start_with?("https://") ? ":443" : ":80" + authority = normalized.sub(%r{\Ahttps?://}, "") + + authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) + end + + def forbidden_response(message = "Forbidden: session request validation failed") + json_rpc_error_response( + status: 403, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + ) + end + + def method_not_allowed_response + json_rpc_error_response( + status: 405, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Method not allowed", + ) + end + def missing_session_id_response - [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]] + json_rpc_error_response( + status: 400, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Missing session ID", + ) end def session_not_found_response - [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] + json_rpc_error_response( + status: 404, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Session not found", + ) + end + + def already_initialized_response(request_id) + invalid_request_response("Invalid Request: Server already initialized", request_id: request_id) + end + + def invalid_request_response(message, request_id: nil) + body = { + jsonrpc: "2.0", + id: request_id, + error: { + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + }, + } + [400, { "content-type" => "application/json" }, [body.to_json]] + end + + def session_already_connected_response + json_rpc_error_response( + status: 409, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Conflict: Only one SSE stream is allowed per session", + ) + end + + # The POST counterpart of the GET conflict above. A request id already in flight cannot be given + # a stream of its own, because the id is what routes request-scoped messages back. + def request_id_conflict_response + json_rpc_error_response( + status: 409, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Conflict: Request id is already in flight for this session", + ) end def setup_sse_stream(session_id) body = create_sse_body(session_id) - headers = { - "Content-Type" => "text/event-stream", - "Cache-Control" => "no-cache", - "Connection" => "keep-alive", - } - - [200, headers, body] + [200, SSE_HEADERS.dup, body] end def create_sse_body(session_id) proc do |stream| - store_stream_for_session(session_id, stream) - start_keepalive_thread(session_id) + stored = store_stream_for_session(session_id, stream) + start_keepalive_thread(session_id) if stored end end def store_stream_for_session(session_id, stream) @mutex.synchronize do - if @sessions[session_id] - @sessions[session_id][:stream] = stream + session = @sessions[session_id] + if session && !session[:get_sse_stream] + session[:get_sse_stream] = stream else + # Either session was removed, or another request already established a stream. stream.close + # `stream.close` may return a truthy value depending on the stream class. + # Explicitly return nil to guarantee a falsy return for callers. + nil end end end @@ -287,22 +1997,32 @@ module MCP end def session_active_with_stream?(session_id) - @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] } + @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] } end def send_keepalive_ping(session_id) - @mutex.synchronize do - if @sessions[session_id] && @sessions[session_id][:stream] - send_ping_to_stream(@sessions[session_id][:stream]) - end + # Resolve the stream under the lock, then write outside it so a stalled reader + # cannot block every other session on `@mutex`. + stream = @mutex.synchronize do + session = @sessions[session_id] + session && session[:get_sse_stream] end - rescue IOError, Errno::EPIPE => e + return unless stream + + send_ping_to_stream(stream) + rescue *STREAM_WRITE_ERRORS => e MCP.configuration.exception_reporter.call( e, { session_id: session_id, error: "Stream closed" }, ) raise # Re-raise to exit the keepalive loop end + + def session_expired?(session) + return false unless @session_idle_timeout + + Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout + end end end end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb new file mode 100644 index 0000000..33b2075 --- /dev/null +++ b/lib/mcp/server_context.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module MCP + class ServerContext + attr_reader :cancellation + + # The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern; + # `nil` on legacy requests. + attr_reader :envelope + + # SEP-2322 multi round-trip retry fields, present when the client re-issued the request after + # an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to + # the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly. + # Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them + # (the same access model as the envelope readers). + attr_reader :input_responses, :request_state + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, + input_responses: nil, request_state: nil) + @context = context + @progress = progress + @notification_target = notification_target + @related_request_id = related_request_id + @cancellation = cancellation + @envelope = envelope + @input_responses = input_responses + @request_state = request_state + end + + # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys. + def input_response(key) + return unless @input_responses.is_a?(Hash) + + value = @input_responses[key.to_sym] + value.nil? ? @input_responses[key.to_s] : value + end + + def cancelled? + !!@cancellation&.cancelled? + end + + def raise_if_cancelled! + @cancellation&.raise_if_cancelled! + end + + # Whether the current request follows the stateless modern lifecycle (SEP-2575). + def modern? + !@envelope.nil? + end + + # Client identity for the current request. Modern requests carry it in the `_meta` envelope; + # legacy sessions fall back to the state stored by `initialize`. The envelope always wins + # because servers MUST NOT infer identity from prior requests. + def client_info + return @envelope.client_info if @envelope + + @notification_target.client if @notification_target.respond_to?(:client) + end + + # Client capabilities for the current request, with the same envelope-first resolution as {#client_info}. + def client_capabilities + return @envelope.client_capabilities if @envelope + + @notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities) + end + + # The protocol version the current request was made with. `nil` on legacy requests, + # where the version is a session-level negotiation result rather than per-request data. + def protocol_version + @envelope&.protocol_version + end + + # Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys, + # e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError` + # (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared. + def require_client_capability!(*path) + raise ArgumentError, "at least one capability key is required" if path.empty? + + declared = client_capabilities + value = path.reduce(declared) do |acc, key| + break unless acc.is_a?(Hash) + + symbol_value = acc[key.to_sym] + symbol_value.nil? ? acc[key.to_s] : symbol_value + end + return unless value.nil? + + required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } } + raise Server::MissingRequiredClientCapabilityError, required + end + + # Reports progress for the current tool operation. + # The notification is automatically scoped to the originating session. + # + # @param progress [Numeric] Current progress value. + # @param total [Numeric, nil] Total expected value. + # @param message [String, nil] Human-readable status message. + def report_progress(progress, total: nil, message: nil) + @progress.report(progress, total: total, message: message) + end + + # Sends a log message notification scoped to the originating session. + # + # @param data [Object] The log data to send. + # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`). + # @param logger [String, nil] Logger name. + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. + def notify_log_message(data:, level:, logger: nil) + return unless @notification_target + + # Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`, + # the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops + # the message the same way. Session- or server-level gating still applies downstream on delegation. + if @envelope + threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level) + return unless threshold&.valid_level? && threshold.should_notify?(level) + end + + @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) + end + + # Sends a resource updated notification scoped to the originating session. + # + # @param uri [String] The URI of the updated resource. + def notify_resources_updated(uri:) + return unless @notification_target + + @notification_target.notify_resources_updated(uri: uri) + end + + # Delegates to the session so the request is scoped to the originating client. + # The originating request id is stamped as `related_request_id`, satisfying + # SEP-2260's requirement that server-to-client requests be associated with + # the client request being processed. + # @deprecated MCP Roots (`roots/list` and + # `notifications/roots/list_changed`) is deprecated as of MCP protocol + # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, + # server configuration, or environment variables instead. + def list_roots(timeout: nil) + if @notification_target.respond_to?(:list_roots) + @notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout)) + else + raise NoMethodError, "undefined method 'list_roots' for #{self}" + end + end + + # Sends a `ping` request to the originating client to verify it is still responsive. + # Per the MCP spec, the client MUST respond promptly with an empty result. + # + # @return [Hash] An empty hash on success. + # @raise [Server::ValidationError] If the response `result` is not a Hash. + # @raise [NoMethodError] If the session does not support sending pings. + # + # @example + # def self.call(server_context:) + # server_context.ping # => {} + # # ... + # end + # + # @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping + def ping(timeout: nil) + if @notification_target.respond_to?(:ping) + @notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout)) + else + raise NoMethodError, "undefined method 'ping' for #{self}" + end + end + + # Delegates to the session so the request is scoped to the originating client. + # Falls back to `@context` (via `method_missing`) when `@notification_target` + # does not support sampling. + # + # The originating request id is stamped as `related_request_id` and cannot be + # overridden by callers (the literal keyword after `**kwargs` wins), + # satisfying SEP-2260's requirement that server-to-client requests be + # associated with the client request being processed. + # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of + # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider + # APIs instead. + def create_sampling_message(**kwargs) + if @notification_target.respond_to?(:create_sampling_message) + @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id) + elsif @context.respond_to?(:create_sampling_message) + @context.create_sampling_message(**kwargs, related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'create_sampling_message' for #{self}" + end + end + + # Delegates to the session so the request is scoped to the originating client. + # Falls back to `@context` (via `method_missing`) when `@notification_target` + # does not support elicitation. + # The originating request id is stamped as a non-overridable + # `related_request_id`, as with `create_sampling_message` (SEP-2260). + def create_form_elicitation(**kwargs) + if @notification_target.respond_to?(:create_form_elicitation) + @notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id) + elsif @context.respond_to?(:create_form_elicitation) + @context.create_form_elicitation(**kwargs, related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}" + end + end + + # Delegates to the session so the request is scoped to the originating client. + # Falls back to `@context` when `@notification_target` does not support URL mode elicitation. + # The originating request id is stamped as a non-overridable `related_request_id`, + # as with `create_sampling_message` (SEP-2260). + def create_url_elicitation(**kwargs) + if @notification_target.respond_to?(:create_url_elicitation) + @notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id) + elsif @context.respond_to?(:create_url_elicitation) + @context.create_url_elicitation(**kwargs, related_request_id: @related_request_id) + else + raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}" + end + end + + # Delegates to the session so the notification is scoped to the originating client. + def notify_elicitation_complete(**kwargs) + if @notification_target.respond_to?(:notify_elicitation_complete) + @notification_target.notify_elicitation_complete(**kwargs) + elsif @context.respond_to?(:notify_elicitation_complete) + @context.notify_elicitation_complete(**kwargs) + else + raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}" + end + end + + # Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax. + # The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8, + # so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it + # raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps + # this method loadable on Ruby 2.7.0. + def method_missing(name, *args, **kwargs, &block) + if @context.respond_to?(name) + @context.public_send(name, *args, **kwargs, &block) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + @context.respond_to?(name) || super + end + + private + + # An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had + # before per-request timeouts existed. A notification target that predates the keyword + # (a custom object standing in for a session) keeps working until a caller actually asks for a timeout, + # and the transport applies its own default in that case. + def timeout_kwarg(timeout) + timeout.nil? ? {} : { timeout: timeout } + end + end +end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb new file mode 100644 index 0000000..cfee2ab --- /dev/null +++ b/lib/mcp/server_session.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require_relative "cancellation" +require_relative "methods" + +module MCP + # Holds per-connection state for a single client session. + # Created by the transport layer; delegates request handling to the shared `Server`. + class ServerSession + ERAS = [:legacy, :modern].freeze + + attr_reader :session_id, :client, :logging_message_notification, :protocol_version + + # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds, + # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions + # with `era: :modern` up front. + attr_reader :era + + def initialize(server:, transport:, session_id: nil, era: nil) + validate_era!(era) if era + + @server = server + @transport = transport + @session_id = session_id + @era = era + @client = nil + @client_capabilities = nil + @logging_message_notification = nil + @protocol_version = nil + @in_flight = {} + @in_flight_mutex = Mutex.new + @initialized = false + end + + # Whether `initialize` has already completed for this session. + def initialized? + @initialized + end + + # Called by `Server#init` after a successful `initialize` response, so subsequent + # `initialize` requests on the same session can be rejected per MCP spec + # (the initialization phase MUST be the first interaction). + def mark_initialized!(protocol_version: nil) + @initialized = true + @protocol_version = protocol_version + # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575), + # so it also locks the connection era. + @era ||= :legacy + end + + # One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises, + # because a connection can never change eras. + def lock_era!(era) + validate_era!(era) + return if @era == era + raise "Session era already locked to #{@era}" if @era + + @era = era + end + + # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight. + # The request id is the only key that routes request-scoped notifications, server-to-client requests, + # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id + # has no destination of its own. Rather than let the newcomer displace the registration, report the collision + # and leave the first request intact; the caller turns that into an Invalid Request. + def register_in_flight(request_id) + return if request_id.nil? + + cancellation = Cancellation.new(request_id: request_id) + registered = @in_flight_mutex.synchronize do + next false if @in_flight.key?(request_id) + + @in_flight[request_id] = cancellation + true + end + + registered ? cancellation : nil + end + + # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes + # the entry only while it is still that one, so a request can never evict a registration it does not own. + def unregister_in_flight(request_id, cancellation: nil) + return if request_id.nil? + + @in_flight_mutex.synchronize do + next if cancellation && !@in_flight[request_id].equal?(cancellation) + + @in_flight.delete(request_id) + end + end + + # Whether `request_id` is currently in flight, so a transport can refuse a colliding request + # before registering any state of its own for it. + def in_flight?(request_id) + !lookup_in_flight(request_id).nil? + end + + def lookup_in_flight(request_id) + @in_flight_mutex.synchronize { @in_flight[request_id] } + end + + # Flips the `Cancellation` for a matching in-flight request received from the peer. + # Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5). + def cancel_incoming(request_id:, reason: nil) + cancellation = lookup_in_flight(request_id) + cancellation&.cancel(reason: reason) + end + + # Sends `notifications/cancelled` to the peer for a previously-issued request. + # Also unblocks any transport-level `send_request` waiting on a response for `request_id`. + def cancel_request(request_id:, reason: nil) + params = { requestId: request_id } + params[:reason] = reason if reason + send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params) + + if @transport.respond_to?(:cancel_pending_request) + @transport.cancel_pending_request(request_id, reason: reason) + end + rescue => e + MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id }) + end + + def handle(request) + @server.handle(request, session: self) + end + + def handle_json(request_json) + @server.handle_json(request_json, session: self) + end + + # Called by `Server#init` during the initialization handshake. + def store_client_info(client:, capabilities: nil) + @client = client + @client_capabilities = capabilities + end + + # Called by `Server#configure_logging_level`. + def configure_logging(logging_message_notification) + @logging_message_notification = logging_message_notification + end + + # Returns per-session client capabilities, falling back to global. + def client_capabilities + @client_capabilities || @server.client_capabilities + end + + # Sends a `roots/list` request scoped to this session. + # + # Per SEP-2260, the request must be associated with an originating client + # request; prefer `server_context.list_roots` inside a handler, which stamps + # the association automatically. + # @deprecated MCP Roots (`roots/list` and + # `notifications/roots/list_changed`) is deprecated as of MCP protocol + # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, + # server configuration, or environment variables instead. + def list_roots(related_request_id: nil, timeout: nil) + warn_unassociated_request(__method__, related_request_id) + + unless client_capabilities&.dig(:roots) + raise "Client does not support roots." + end + + send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout) + end + + # Sends a `ping` request scoped to this session. + def ping(related_request_id: nil, timeout: nil) + result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout) + raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash) + + result + end + + # Sends a `sampling/createMessage` request scoped to this session. + # + # Per SEP-2260, the request must be associated with an originating client + # request; prefer `server_context.create_sampling_message` inside a handler, + # which stamps the association automatically. + # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of + # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider + # APIs instead. + def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) + warn_unassociated_request(__method__, related_request_id) + + params = @server.build_sampling_params(client_capabilities, **kwargs) + send_to_transport_request( + Methods::SAMPLING_CREATE_MESSAGE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) + end + + # Sends an `elicitation/create` request (form mode) scoped to this session. + # + # Per SEP-2260, the request must be associated with an originating client + # request; prefer `server_context.create_form_elicitation` inside a handler, + # which stamps the association automatically. + def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil) + warn_unassociated_request(__method__, related_request_id) + + unless client_capabilities&.dig(:elicitation) + raise "Client does not support elicitation. " \ + "The client must declare the `elicitation` capability during initialization." + end + + params = { mode: "form", message: message, requestedSchema: requested_schema } + send_to_transport_request( + Methods::ELICITATION_CREATE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) + end + + # Sends an `elicitation/create` request (URL mode) scoped to this session. + # + # Per SEP-2260, the request must be associated with an originating client + # request; prefer `server_context.create_url_elicitation` inside a handler, + # which stamps the association automatically. + def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil) + warn_unassociated_request(__method__, related_request_id) + + unless client_capabilities&.dig(:elicitation, :url) + raise "Client does not support URL mode elicitation. " \ + "The client must declare the `elicitation.url` capability during initialization." + end + + params = { mode: "url", message: message, url: url, elicitationId: elicitation_id } + send_to_transport_request( + Methods::ELICITATION_CREATE, + params, + related_request_id: related_request_id, + timeout: timeout, + ) + end + + # Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire, + # for the server's dual-era fulfilment shim. + # The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent + # standalone request - and stays associated with the originating client request per SEP-2260. + # Returns the client's result. + def fulfill_input_request(method, params, related_request_id:) + send_to_transport_request(method, params, related_request_id: related_request_id) + end + + # Sends `notifications/cancelled` to the peer for a nested server-to-client request + # that was started inside a now-cancelled parent request. `related_request_id` + # is the parent request id so the notification is routed to the same stream + # (e.g. the parent's POST response stream on `StreamableHTTPTransport`) rather than + # the GET SSE stream. + def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil) + params = { requestId: nested_request_id } + params[:reason] = reason if reason + send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id) + + if @transport.respond_to?(:cancel_pending_request) + @transport.cancel_pending_request(nested_request_id, reason: reason) + end + rescue => e + MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id }) + end + + # Sends an elicitation complete notification scoped to this session. + def notify_elicitation_complete(elicitation_id:) + send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id }) + rescue => e + @server.report_exception(e, notification: "elicitation_complete") + end + + # Sends a resource updated notification to this session only. + def notify_resources_updated(uri:) + send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri }) + rescue => e + @server.report_exception(e, notification: "resources_updated") + end + + # Sends a progress notification to this session only. + def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil) + params = { + "progressToken" => progress_token, + "progress" => progress, + "total" => total, + "message" => message, + }.compact + + send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id) + rescue => e + @server.report_exception(e, notification: "progress") + end + + # Sends a log message notification to this session only. + # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`) + # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). + # Use stderr or OpenTelemetry instead. + def notify_log_message(data:, level:, logger: nil, related_request_id: nil) + # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member + # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level + # does not apply (SEP-2575). + effective_logging = if @era == :modern + @logging_message_notification + else + @logging_message_notification || @server.logging_message_notification + end + + return unless effective_logging&.should_notify?(level) + + params = { "data" => data, "level" => level } + params["logger"] = logger if logger + + send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id) + rescue => e + @server.report_exception(e, { notification: "log_message" }) + end + + private + + def validate_era!(era) + raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era) + end + + # Forwards `send_notification` to the transport with only the kwargs the transport's method signature + # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)` + # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:` + # receive the session-scoped routing information. + def send_to_transport(method, params, related_request_id: nil) + kwargs = { + session_id: @session_id, + related_request_id: related_request_id, + }.compact + + forward_to_transport(@transport.method(:send_notification), method, params, kwargs) + end + + # Forwards `send_request` to the transport with only the kwargs the transport's method signature + # actually accepts. Custom transports that implement the abstract `send_request(method, params = nil)` + # contract continue to work; bundled transports that declare `session_id:` / `related_request_id:` / + # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing. + # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up + # so that cancelling the parent also cancels this nested server-to-client request. + def send_to_transport_request(method, params, related_request_id: nil, timeout: nil) + parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil + + kwargs = { + session_id: @session_id, + related_request_id: related_request_id, + parent_cancellation: parent_cancellation, + server_session: self, + timeout: timeout, + }.compact + + forward_to_transport(@transport.method(:send_request), method, params, kwargs) + end + + # Calls `transport_method(method, params, **supported)` where `supported` contains only the keys + # the transport's method signature accepts. This keeps bundled transports (which declare the new kwargs) + # working while preserving compatibility with custom transports that implement only the abstract + # `(method, params = nil)` contract. + def forward_to_transport(transport_method, method, params, kwargs) + parameters = transport_method.parameters + accepts_keyrest = parameters.any? { |type, _| type == :keyrest } + supported = if accepts_keyrest + kwargs + else + allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq } + kwargs.slice(*allowed) + end + + # Always splat `**supported` even when empty: on Ruby 2.7 the bare `transport_method.call(method, params)` + # form would let the trailing `params` Hash be auto-promoted to keyword arguments when the receiver + # accepts `**kwargs`, breaking handlers that rely on `params` arriving as a positional Hash. + # The explicit splat suppresses that conversion and is a no-op when `supported` is empty. + transport_method.call(method, params, **supported) + end + + # Per SEP-2260, servers MUST send `roots/list`, `sampling/createMessage`, and `elicitation/create` only + # in association with an originating client request (`ping` is exempt). A request without `related_request_id:` is + # delivered on the standalone GET stream by the Streamable HTTP transport, which the 2026-07-28 spec forbids; + # warn so callers migrate to the `server_context` helpers before this becomes an error. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260 + def warn_unassociated_request(method_name, related_request_id) + return if related_request_id + + warn( + "Calling `ServerSession##{method_name}` without `related_request_id:` is deprecated (SEP-2260): " \ + "server-to-client #{method_name} requests must be associated with an originating client request. " \ + "Use `server_context.#{method_name}` inside a handler, or pass `related_request_id:` explicitly.", + uplevel: 2, + ) + end + end +end diff --git a/lib/mcp/string_utils.rb b/lib/mcp/string_utils.rb index 07e153e..c39c810 100644 --- a/lib/mcp/string_utils.rb +++ b/lib/mcp/string_utils.rb @@ -15,9 +15,9 @@ module MCP end def underscore(camel_cased_word) - camel_cased_word.dup - .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') - .gsub(/([a-z\d])([A-Z])/, '\1_\2') + camel_cased_word + .gsub(/(?<=[A-Z])(?=[A-Z][a-z])/, "_") + .gsub(/(?<=[a-z\d])(?=[A-Z])/, "_") .tr("-", "_") .downcase end diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 6ddc7e6..1b76b80 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -1,12 +1,19 @@ # frozen_string_literal: true +require_relative "tool/annotations" +require_relative "tool/input_schema" +require_relative "tool/output_schema" +require_relative "tool/response" + module MCP class Tool class << self NOT_SET = Object.new + MAX_LENGTH_OF_NAME = 128 attr_reader :title_value attr_reader :description_value + attr_reader :icons_value attr_reader :annotations_value attr_reader :meta_value @@ -19,6 +26,7 @@ module MCP name: name_value, title: title_value, description: description_value, + icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) }, inputSchema: input_schema_value.to_h, outputSchema: @output_schema_value&.to_h, annotations: annotations_value&.to_h, @@ -31,6 +39,7 @@ module MCP subclass.instance_variable_set(:@name_value, nil) subclass.instance_variable_set(:@title_value, nil) subclass.instance_variable_set(:@description_value, nil) + subclass.instance_variable_set(:@icons_value, nil) subclass.instance_variable_set(:@input_schema_value, nil) subclass.instance_variable_set(:@output_schema_value, nil) subclass.instance_variable_set(:@annotations_value, nil) @@ -42,11 +51,13 @@ module MCP name_value else @name_value = value + + validate! end end def name_value - @name_value || StringUtils.handle_from_class_name(name) + @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name)) end def input_schema_value @@ -71,13 +82,19 @@ module MCP end end + def icons(value = NOT_SET) + if value == NOT_SET + @icons_value + else + @icons_value = value + end + end + def input_schema(value = NOT_SET) if value == NOT_SET input_schema_value elsif value.is_a?(Hash) - properties = value[:properties] || value["properties"] || {} - required = value[:required] || value["required"] || [] - @input_schema_value = InputSchema.new(properties:, required:) + @input_schema_value = InputSchema.new(value) elsif value.is_a?(InputSchema) @input_schema_value = value end @@ -109,16 +126,33 @@ module MCP end end - def define(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, meta: nil, annotations: nil, &block) + def define(name: nil, title: nil, description: nil, icons: [], input_schema: nil, output_schema: nil, meta: nil, annotations: nil, &block) Class.new(self) do tool_name name title title description description + icons icons input_schema input_schema meta meta output_schema output_schema self.annotations(annotations) if annotations define_singleton_method(:call, &block) if block + end.tap(&:validate!) + end + + # It complies with the following tool name specification: + # https://modelcontextprotocol.io/specification/latest/server/tools#tool-names + def validate! + return true unless tool_name + + if tool_name.empty? || tool_name.length > MAX_LENGTH_OF_NAME + raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)." + end + + unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/) + raise ArgumentError, <<~MESSAGE + Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.). + MESSAGE end end end diff --git a/lib/mcp/tool/annotations.rb b/lib/mcp/tool/annotations.rb index 328a24c..381cda7 100644 --- a/lib/mcp/tool/annotations.rb +++ b/lib/mcp/tool/annotations.rb @@ -19,7 +19,7 @@ module MCP idempotentHint: idempotent_hint, openWorldHint: open_world_hint, readOnlyHint: read_only_hint, - title:, + title: title, }.compact end end diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index d6885e0..1fca775 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -1,77 +1,28 @@ # frozen_string_literal: true -require "json-schema" +require_relative "schema" module MCP class Tool - class InputSchema + class InputSchema < Schema class ValidationError < StandardError; end - attr_reader :properties, :required - - def initialize(properties: {}, required: []) - @properties = properties - @required = required.map(&:to_sym) - validate_schema! - end - - def ==(other) - other.is_a?(InputSchema) && properties == other.properties && required == other.required - end - - def to_h - { type: "object" }.tap do |hsh| - hsh[:properties] = properties if properties.any? - hsh[:required] = required if required.any? - end - end - def missing_required_arguments?(arguments) missing_required_arguments(arguments).any? end def missing_required_arguments(arguments) - (required - arguments.keys.map(&:to_sym)) + return [] unless @schema[:required].is_a?(Array) + + (@schema[:required] - arguments.keys.map(&:to_s)) end def validate_arguments(arguments) - errors = JSON::Validator.fully_validate(to_h, arguments) + errors = fully_validate(arguments) if errors.any? raise ValidationError, "Invalid arguments: #{errors.join(", ")}" end end - - private - - def validate_schema! - check_for_refs! - schema = to_h - schema_reader = JSON::Schema::Reader.new( - accept_uri: false, - accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) }, - ) - metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema) - # Converts metaschema to a file URI for cross-platform compatibility - metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/")) - metaschema = metaschema_uri.to_s - errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) - if errors.any? - raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" - end - end - - def check_for_refs!(obj = properties) - case obj - when Hash - if obj.key?("$ref") || obj.key?(:$ref) - raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas" - end - - obj.each_value { |value| check_for_refs!(value) } - when Array - obj.each { |item| check_for_refs!(item) } - end - end end end end diff --git a/lib/mcp/tool/output_schema.rb b/lib/mcp/tool/output_schema.rb index af5405c..1d6e2a5 100644 --- a/lib/mcp/tool/output_schema.rb +++ b/lib/mcp/tool/output_schema.rb @@ -1,30 +1,18 @@ # frozen_string_literal: true -require "json-schema" +require_relative "schema" module MCP class Tool - class OutputSchema + class OutputSchema < Schema class ValidationError < StandardError; end - attr_reader :schema - - def initialize(schema = {}) - @schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym) - @schema[:type] ||= "object" - validate_schema! - end - - def ==(other) - other.is_a?(OutputSchema) && schema == other.schema - end - - def to_h - @schema - end + # Root-level keywords whose presence means the user already chose a root schema shape, + # so no `type: "object"` default should be merged in. + ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze def validate_result(result) - errors = JSON::Validator.fully_validate(to_h, result) + errors = fully_validate(result) if errors.any? raise ValidationError, "Invalid result: #{errors.join(", ")}" end @@ -32,37 +20,15 @@ module MCP private - def deep_transform_keys(schema, &block) - case schema - when Hash - schema.each_with_object({}) do |(key, value), result| - if key.casecmp?("$ref") - raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas" - end + # Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive, + # or a root-level composition. + # Default the root to an object only when no root schema keyword is present, which preserves the wire output + # of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched + # (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema). + def apply_default_root_type! + return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) } - result[yield(key)] = deep_transform_keys(value, &block) - end - when Array - schema.map { |e| deep_transform_keys(e, &block) } - else - schema - end - end - - def validate_schema! - schema = to_h - schema_reader = JSON::Schema::Reader.new( - accept_uri: false, - accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) }, - ) - metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema) - # Converts metaschema to a file URI for cross-platform compatibility - metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/")) - metaschema = metaschema_uri.to_s - errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader) - if errors.any? - raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" - end + super end end end diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index af3ec8f..9e4b2ec 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -5,17 +5,24 @@ module MCP class Response NOT_GIVEN = Object.new.freeze - attr_reader :content, :structured_content + attr_reader :content, :structured_content, :meta - def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil) + def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) if deprecated_error != NOT_GIVEN warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1) error = deprecated_error end - @content = content || [] + content_given = !content.equal?(NOT_GIVEN) + @content_provided = content_given && !content.nil? + @content = content_given ? (content || []) : [] @error = error @structured_content = structured_content + @meta = meta + end + + def content_provided? + @content_provided end def error? @@ -23,7 +30,7 @@ module MCP end def to_h - { content:, isError: error?, structuredContent: @structured_content }.compact + { content: content, isError: error?, structuredContent: @structured_content, _meta: meta }.compact end end end diff --git a/lib/mcp/tool/schema.rb b/lib/mcp/tool/schema.rb new file mode 100644 index 0000000..6811d20 --- /dev/null +++ b/lib/mcp/tool/schema.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "digest" +require "json_schemer" + +module MCP + class Tool + class Schema + # Metaschema validation depends only on schema content, so a given schema + # never needs to be validated more than once. Caching the result lets repeated + # (e.g. dynamically rebuilt) schemas skip the costly traversal. + class ValidationCache + DEFAULT_MAX_SIZE = 1000 + + def initialize(max_size: DEFAULT_MAX_SIZE) + @max_size = max_size + @entries = {} + @mutex = Mutex.new + end + + def validated?(key) + @mutex.synchronize { @entries.key?(key) } + end + + def store(key) + @mutex.synchronize do + @entries.delete(key) + @entries[key] = true + @entries.shift while @entries.size > @max_size + end + end + + def clear + @mutex.synchronize { @entries.clear } + end + end + VALIDATION_CACHE = ValidationCache.new + + # JSON Schema 2020-12 is the default dialect for MCP schema definitions per MCP 2025-11-25 (SEP-1613), + # and SEP-2106 requires tool schemas to conform to the full 2020-12 vocabulary. Both emission and + # runtime validation use this dialect. Because MCP mandates 2020-12, the SDK validates against it + # regardless of any `$schema` a document embeds; for compliant schemas this is the same dialect + # the Python SDK's `jsonschema.validate` resolves to. + JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema" + + # Resource bounds for schema compilation, mirroring the TypeScript SDK's schema bounds (SEP-2106): + # schemas may use the full JSON Schema 2020-12 vocabulary including composition keywords and `$ref`, + # so adversarial documents must be rejected before they can cause excessive validation cost. + # Only same-document references (starting with `#`) are accepted, so schema handling can never trigger network + # or file access. + MAX_SCHEMA_DEPTH = 64 + MAX_SUBSCHEMA_COUNT = 10_000 + + # Reference keywords whose targets the SDK refuses to dereference. Both `$ref` and `$dynamicRef` may carry + # an absolute URI under JSON Schema 2020-12, so a non-same-document value is an external reference. + REFERENCE_KEYWORDS = [:"$ref", :"$dynamicRef"].freeze + + def initialize(schema = {}) + @schema = JSON.parse(JSON.dump(schema), symbolize_names: true) + apply_default_root_type! + validate_schema_bounds! + validate_schema! + end + + def ==(other) + other.is_a?(self.class) && @schema == other.instance_variable_get(:@schema) + end + + def to_h + return @schema if @schema.key?(:"$schema") + + { "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema) + end + + private + + # Root-type defaulting hook. The base class preserves the historical behavior of defaulting the root + # to an object schema; `OutputSchema` overrides this because SEP-2106 allows any root schema there. + def apply_default_root_type! + @schema[:type] ||= "object" + end + + # Enforces `MAX_SCHEMA_DEPTH` / `MAX_SUBSCHEMA_COUNT` and the same-document reference rule over + # the whole schema document. + def validate_schema_bounds! + subschema_count = 0 + stack = [[@schema, 1]] + + until stack.empty? + node, depth = stack.pop + if depth > MAX_SCHEMA_DEPTH + raise ArgumentError, + "Invalid JSON Schema: nesting exceeds the maximum depth of #{MAX_SCHEMA_DEPTH}." + end + + case node + when Hash + subschema_count += 1 + if subschema_count > MAX_SUBSCHEMA_COUNT + raise ArgumentError, + "Invalid JSON Schema: document exceeds the maximum of #{MAX_SUBSCHEMA_COUNT} subschema objects." + end + + REFERENCE_KEYWORDS.each do |keyword| + ref = node[keyword] + next unless ref.is_a?(String) && !ref.start_with?("#") + + raise ArgumentError, + "Invalid JSON Schema: only same-document #{keyword} (starting with '#') is supported, got #{ref.inspect}." + end + + node.each_value { |child| stack << [child, depth + 1] } + when Array + node.each { |child| stack << [child, depth + 1] } + end + end + end + + def stringify(obj) + case obj + when Hash + obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) } + when Array + obj.map { |v| stringify(v) } + when Symbol + obj.to_s + else + obj + end + end + + # Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost. + # Memoized per Schema instance because schema content is fixed at construction, + # so the compiled schemer is reusable across many `fully_validate` calls. + # + # Validated against the JSON Schema 2020-12 metaschema per SEP-2106, so `$defs`/`$ref` and + # the rest of the 2020-12 vocabulary resolve natively. + # + # `format: false` preserves the legacy behavior of the previous `json-schema` based implementation, + # which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as + # `ArgumentError` so callers see the same exception class they used to. + def schemer + @schemer ||= JSONSchemer.schema( + stringify(schema_for_validation), + meta_schema: JSON_SCHEMA_2020_12_URI, + format: false, + ) + rescue RegexpError => e + raise ArgumentError, "Invalid JSON Schema: #{e.message}" + end + + def fully_validate(data) + schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") } + end + + def validate_schema! + target = schema_for_validation + + # `max_nesting: false` because normalization uses `JSON.dump` (no nesting limit), + # so the default `JSON.generate` limit would raise on a deeply nested schema that + # the initializer already accepted. + key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false)) + return if VALIDATION_CACHE.validated?(key) + + errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") } + if errors.any? + raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}" + end + + VALIDATION_CACHE.store(key) + end + + # Strip the top-level `$schema` before validation so the SDK always validates against + # the 2020-12 metaschema (SEP-2106) regardless of any dialect URI a caller embedded in the document. + def schema_for_validation + return @schema unless @schema.key?(:"$schema") + + copy = @schema.dup + copy.delete(:"$schema") + copy + end + end + end +end diff --git a/lib/mcp/trace_context.rb b/lib/mcp/trace_context.rb new file mode 100644 index 0000000..0d69ec5 --- /dev/null +++ b/lib/mcp/trace_context.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MCP + # Reserved `_meta` keys for W3C Trace Context propagation, per SEP-414. + # + # The MCP spec reserves the un-prefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` + # (an explicit exception to the reverse-DNS prefix rule for `_meta` keys) so that clients and + # servers can propagate distributed-tracing context across MCP requests. + # The SDK guarantees these keys pass through incoming request `_meta` untouched; tool, prompt, + # and resource handlers can read them from `server_context[:_meta]` and bridge them to a tracing + # system such as the `opentelemetry-ruby` gems. The SDK itself does not depend on OpenTelemetry. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414 + # - https://www.w3.org/TR/trace-context/ + # - https://www.w3.org/TR/baggage/ + module TraceContext + TRACEPARENT_META_KEY = "traceparent" + TRACESTATE_META_KEY = "tracestate" + BAGGAGE_META_KEY = "baggage" + + META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY].freeze + end +end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index a995c3a..c48e3c9 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true +require "securerandom" + module MCP class Transport # Initialize the transport with the server instance def initialize(server) @server = server + server.transport = self end # Send a response to the client @@ -36,10 +39,28 @@ module MCP send_response(response) if response end - # Send a notification to the client - # Returns true if the notification was sent successfully + # Send a notification to the client. + # Returns true if the notification was sent successfully. def send_notification(method, params = nil) raise NotImplementedError, "Subclasses must implement send_notification" end + + # Send a JSON-RPC request to the client and wait for a response. + def send_request(method, params = nil) + raise NotImplementedError, "Subclasses must implement send_request" + end + + # Whether the transport serves the `subscriptions/listen` notification stream (MCP 2026-07-28, SEP-2575). + # `Server#discover` strips the `listChanged`/`subscribe` capability flags when the transport cannot deliver + # those notifications in the modern lifecycle. + def serves_subscriptions_listen? + false + end + + private + + def generate_request_id + SecureRandom.uuid + end end end diff --git a/lib/mcp/transports/stdio.rb b/lib/mcp/transports/stdio.rb deleted file mode 100644 index 3050f26..0000000 --- a/lib/mcp/transports/stdio.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative "../server/transports/stdio_transport" - -warn <<~MESSAGE, uplevel: 3 - Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`. - Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`. - This API is deprecated and will be removed in a future release. -MESSAGE - -module MCP - module Transports - StdioTransport = Server::Transports::StdioTransport - end -end diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 821c166..2d0dff8 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "0.4.0" + VERSION = "1.5.1" end diff --git a/mcp.gemspec b/mcp.gemspec index fefe3b3..9c4d602 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -10,23 +10,25 @@ Gem::Specification.new do |spec| spec.summary = "The official Ruby SDK for Model Context Protocol servers and clients" spec.description = spec.summary - spec.homepage = "https://github.com/modelcontextprotocol/ruby-sdk" - spec.license = "MIT" + spec.homepage = "https://ruby.sdk.modelcontextprotocol.io" + spec.license = "Apache-2.0" - spec.required_ruby_version = ">= 3.2.0" + # Since this library is used by a broad range of users, it does not align its support policy with Ruby's EOL. + spec.required_ruby_version = ">= 2.7.0" spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["changelog_uri"] = "https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v#{spec.version}" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/modelcontextprotocol/ruby-sdk" + spec.metadata["bug_tracker_uri"] = "#{spec.metadata["source_code_uri"]}/issues" + spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/mcp" - spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do - %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - end + spec.files = Dir["lib/**/*"] + spec.extra_rdoc_files = ["LICENSE", "README.md"] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency("json_rpc_handler", "~> 0.1") - spec.add_dependency("json-schema", ">= 4.1") + spec.add_dependency("json_schemer", ">= 2.4") end diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb new file mode 100644 index 0000000..ccee933 --- /dev/null +++ b/test/initialize_params_test_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module InitializeParamsTestHelper + # `initialize` params satisfying the fields required by the MCP schema + # (protocolVersion, capabilities, and clientInfo). Offers the latest handshake version + # so helper-built sessions run the version they request; the counter-offer for + # modern versions has its own dedicated tests. + def initialize_params(**overrides) + { + protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }.merge(overrides) + end +end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb new file mode 100644 index 0000000..07db0ec --- /dev/null +++ b/test/json_rpc_handler_test.rb @@ -0,0 +1,892 @@ +# frozen_string_literal: true + +require "test_helper" + +describe JsonRpcHandler do + before do + @registry = {} + @response = nil + @response_json = nil + end + + describe "#handle" do + # Comments verbatim from https://www.jsonrpc.org/specification + # + # JSON-RPC 2.0 Specification + # + # 1 Overview + # ... + # 2 Conventions + # ... + # 3 Compatibility + # ... + # 4 Request object + # + # A rpc call is represented by sending a Request object to a Server. The Request object has the following members: + # + # jsonrpc + # A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". + + it "returns a result when jsonrpc is 2.0" do + register("add") { |params| params[:a] + params[:b] } + + handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + end + + it "returns an error when jsonrpc is not 2.0" do + handle jsonrpc: "3.0", id: 1, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_equal 1, @response[:id] + end + + it "returns an error preserving the request id when jsonrpc is missing" do + handle id: 4, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_equal 4, @response[:id] + end + + # method + # A String containing the name of the method to be invoked. Method names that begin with the word rpc followed by + # a period character (U+002E or ASCII 46) are reserved for rpc-internal methods and extensions and MUST NOT be + # used for anything else. + + it "returns an error when method is not a string" do + handle jsonrpc: "2.0", id: 1, method: 42, params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: 'Method name must be a string and not start with "rpc."', + } + assert_equal 1, @response[:id] + end + + it "returns an error when method begins with 'rpc.'" do + handle jsonrpc: "2.0", id: 1, method: "rpc.add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: 'Method name must be a string and not start with "rpc."', + } + assert_equal 1, @response[:id] + end + + # params + # A Structured value that holds the parameter values to be used during the invocation of the method. This member + # MAY be omitted. + + it "returns a result when parameters are omitted" do + register("greet") { "Hello, world!" } + + handle jsonrpc: "2.0", id: 1, method: "greet" + + assert_rpc_success expected_result: "Hello, world!" + end + + # id + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is + # not included it is assumed to be a notification. The value SHOULD normally not be Null and Numbers SHOULD NOT + # contain fractional parts. + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the + # context between the two objects. + + it "returns a response with the same request id when the id is a valid string" do + register("add") { |params| params[:a] + params[:b] } + id = "request-123_abc" + + handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + assert_equal id, @response[:id] + end + + it "returns a response with the same request id when the id is an integer" do + register("add") { |params| params[:a] + params[:b] } + id = 42 + + handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + assert_equal id, @response[:id] + end + + it "returns an error when request id is not of a valid type" do + handle jsonrpc: "2.0", id: true, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "accepts string id with alphanumerics, dashes, and underscores" do + register("add") { |params| params[:a] + params[:b] } + id = "request-123_ABC" + + handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + assert_equal id, @response[:id] + end + + it "accepts UUID format strings" do + register("add") { |params| params[:a] + params[:b] } + id = "550e8400-e29b-41d4-a716-446655440000" + + handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + assert_equal id, @response[:id] + end + + it "returns an error when request id contains HTML content (XSS prevention)" do + handle jsonrpc: "2.0", id: "", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "returns an error when request id contains spaces" do + handle jsonrpc: "2.0", id: "request 123", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "returns an error when request id contains special characters" do + handle jsonrpc: "2.0", id: "request@123", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "returns an error when request id is an empty string" do + handle jsonrpc: "2.0", id: "", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "returns an error when id is a number with a fractional part" do + handle jsonrpc: "2.0", id: 3.14, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + # 4.1 Notification + # + # A Notification is a Request object without an "id" member. A Request object that is a Notification signifies the + # Client's lack of interest in the corresponding Response object, and as such no Response object needs to be + # returned to the client. The Server MUST NOT reply to a Notification, including those that are within a batch + # request. + # + # Notifications are not confirmable by definition, since they do not have a Response object to be returned. As such, + # the Client would not be aware of any errors (like e.g. "Invalid params","Internal error"). + + describe "with a notification request" do + it "returns nil even if the method returns a result" do + register("ping") { "pong" } + + handle jsonrpc: "2.0", method: "ping" + + assert_nil @response + end + + it "returns nil even if the method raises an error" do + register("ping") { raise StandardError, "Something bad happened" } + + handle jsonrpc: "2.0", method: "ping" + + assert_nil @response + end + end + + # 4.2 Parameter Structures + # + # If present, parameters for the rpc call MUST be provided as a Structured value. Either by-position through an + # Array or by-name through an Object. + # + # * by-position: params MUST be an Array, containing the values in the Server expected order. + # * by-name: params MUST be an Object, with member names that match the Server expected parameter names. The absence + # of expected names MAY result in an error being generated. The names MUST match exactly, including case, to the + # method's expected parameters. + + it "with array params returns a result" do + register("sum", &:sum) + + handle jsonrpc: "2.0", id: 1, method: "sum", params: [1, 2, 3] + + assert_rpc_success expected_result: 6 + end + + it "with hash params returns a result" do + register("sum") { |params| params[:a] + params[:b] } + + handle jsonrpc: "2.0", id: 1, method: "sum", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + end + + # 5 Response object + # + # When a rpc call is made, the Server MUST reply with a Response, except for in the case of Notifications. The + # Response is expressed as a single JSON Object, with the following members: + + # jsonrpc + # A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". + + it "returns a result with jsonrpc set to 2.0" do + register("add") { |params| params[:a] + params[:b] } + + handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 } + + assert_equal "2.0", @response[:jsonrpc] + end + + # result + # This member is REQUIRED on success. + # This member MUST NOT exist if there was an error invoking the method. + # The value of this member is determined by the method invoked on the Server. + # + # error + # This member is REQUIRED on error. + # This member MUST NOT exist if there was no error triggered during invocation. + # The value for this member MUST be an Object as defined in section 5.1. + # + # id + # This member is REQUIRED. + # It MUST be the same as the value of the id member in the Request Object. + # If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be + # Null. + # + # Either the result member or error member MUST be included, but both members MUST NOT be included. + + it "returns a result object and no error object on success" do + register("ping") { "pong" } + + handle jsonrpc: "2.0", id: 1, method: "ping" + + assert_rpc_success expected_result: "pong" + assert_equal 1, @response[:id] + assert_nil @response[:error] + end + + it "returns an error object and no result object on error" do + register("ping") { raise StandardError, "Something bad happened" } + + handle jsonrpc: "2.0", id: 1, method: "ping" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + } + assert_equal 1, @response[:id] + assert_nil @response[:result] + end + + it "returns nil for id when there is an error and and error detecting the id" do + register("ping") { "pong" } + + handle jsonrpc: "2.0", id: {}, method: "ping" + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + assert_nil @response[:id] + end + + it "returns the same request id on an Invalid Request error when the id is detectable" do + handle jsonrpc: "1.0", id: 3, method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_equal 3, @response[:id] + end + + it "returns nil for id on an Invalid Request error when the request has no id" do + handle jsonrpc: "1.0", method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + + it "returns nil for id on an Invalid Request error when the request id is explicitly null" do + handle jsonrpc: "1.0", id: nil, method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + + it "returns nil for id on an Invalid Request error when the id fails validation" do + handle jsonrpc: "1.0", id: "", method: "ping", params: {} + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "JSON-RPC version must be 2.0", + } + assert_nil @response[:id] + end + + # 5.1 Error object + # + # When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a + # Object with the following members: + # + # code + # A Number that indicates the error type that occurred. + # This MUST be an integer. + # message + # A String providing a short description of the error. + # The message SHOULD be limited to a concise single sentence. + # data + # A Primitive or Structured value that contains additional information about the error. + # This may be omitted. + # The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.). + # + # | code | message | meaning | + # | ------ | ---------------- | --------------------------------------------- | + # | -32700 | Parse error | Invalid JSON was received by the server. | + # | -32600 | Invalid Request | The JSON sent is not a valid Request object. | + # | -32601 | Method not found | The method does not exist / is not available. | + # | -32602 | Invalid params | Invalid method parameter(s). | + # | -32603 | Internal error | Internal JSON-RPC error. | + + it "returns an error with the code set to -32700 there is a JSON parse error" do + # Defer to handle_json for JSON parsing + handle_json "Invalid JSON" + + assert_rpc_error expected_error: { + code: -32700, + message: "Parse error", + data: "Invalid JSON", + } + end + + it "returns an error with code set to -32600 when the request is not an array or a hash" do + handle true + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request must be an array or a hash", + } + end + + it "returns an error with the code set to -32601 when the method does not exist" do + handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32601, + message: "Method not found", + data: "add", + } + end + + it "returns nil when the method does not exist and the id is nil" do + handle jsonrpc: "2.0", method: "add", params: { a: 1, b: 2 } + + assert_nil @response + end + + it "returns an error with the code set to -32602 when the method parameters are invalid" do + handle jsonrpc: "2.0", id: 1, method: "set_active", params: true + + assert_rpc_error expected_error: { + code: -32602, + message: "Invalid params", + data: "Method parameters must be an array or an object or null", + } + end + + it "returns an error with the code set to -32603 when there is an internal error" do + register("add") { raise StandardError, "Something bad happened" } + + handle jsonrpc: "2.0", id: 1, method: "add" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + } + end + + it "returns an error with the code set to -32600 when error_type of RequestHandlerError is :invalid_request" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Invalid request data", + {}, + error_type: :invalid_request, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Invalid request data", + } + end + + it "returns an error with the code set to -32602 when error_type of RequestHandlerError is :invalid_params" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Parameter validation failed", + {}, + error_type: :invalid_params, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32602, + message: "Invalid params", + data: "Parameter validation failed", + } + end + + it "returns an error with the code set to -32700 when error_type of RequestHandlerError is :parse_error" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Failed to parse input", + {}, + error_type: :parse_error, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32700, + message: "Parse error", + data: "Failed to parse input", + } + end + + it "returns an error with the code set to -32603 when error_type of RequestHandlerError is :internal_error" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Internal processing error", + {}, + error_type: :internal_error, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + data: "Internal processing error", + } + end + + it "returns an error with the code set to -32603 when error_type of RequestHandlerError is unknown" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Unknown error occurred", + {}, + error_type: :unknown, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + data: "Unknown error occurred", + } + end + + it "returns a custom error code when RequestHandlerError has error_code set" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Custom error", + nil, + error_code: -32042, + error_data: { elicitations: [{ id: "abc" }] }, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32042, + message: "Custom error", + data: { elicitations: [{ id: "abc" }] }, + } + end + + it "falls back to error_type when error_code is nil" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Invalid params error", + nil, + error_type: :invalid_params, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32602, + message: "Invalid params", + data: "Invalid params error", + } + end + + # 6 Batch + # + # To send several Request objects at the same time, the Client MAY send an Array filled with Request objects. + # + # The Server should respond with an Array containing the corresponding Response objects, after all of the batch + # Request objects have been processed. A Response object SHOULD exist for each Request object, except that there + # SHOULD NOT be any Response objects for notifications. The Server MAY process a batch rpc call as a set of + # concurrent tasks, processing them in any order and with any width of parallelism. + # + # The Response objects being returned from a batch call MAY be returned in any order within the Array. The Client + # SHOULD match contexts between the set of Request objects and the resulting set of Response objects based on the id + # member within each Object. + # + # If the batch rpc call itself fails to be recognized as an valid JSON or as an Array with at least one value, the + # response from the Server MUST be a single Response object. If there are no Response objects contained within the + # Response array as it is to be sent to the client, the server MUST NOT return an empty Array and should return + # nothing at all. + + describe "with batch request" do + it "returns an invalid request error when the request is an empty array" do + handle [] + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request is an empty array", + } + end + + it "returns an array of Response objects" do + register("add") { |params| params[:a] + params[:b] } + register("mul") { |params| params[:a] * params[:b] } + + handle [ + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + { jsonrpc: "2.0", id: 200, method: "mul", params: { a: 3, b: 4 } }, + ] + + assert @response.is_a?(Array) + assert @response.all? { |result| result[:jsonrpc] == "2.0" } + assert_equal [100, 200], @response.map { |result| result[:id] } + assert_equal [3, 12], @response.map { |result| result[:result] } + assert @response.all? { |result| result[:error].nil? } + end + + it "returns an array of Response objects excluding notifications" do + register("ping") {} + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", method: "ping" }, + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + { jsonrpc: "2.0", id: 200, method: "add", params: { a: 2, b: 3 } }, + ] + + assert @response.is_a?(Array) + assert @response.all? { |result| result[:jsonrpc] == "2.0" } + assert_equal [100, 200], @response.map { |result| result[:id] } + assert_equal [3, 5], @response.map { |result| result[:result] } + assert @response.all? { |result| result[:error].nil? } + end + + it "returns a single response object when the batch has only a single response" do + register("ping") {} + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", method: "ping" }, + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + ] + + assert_rpc_success expected_result: 3 + end + + it "returns nil when the batch has only notifications" do + register("ping") {} + register("pong") {} + + handle [ + { jsonrpc: "2.0", method: "ping" }, + { jsonrpc: "2.0", method: "pong" }, + ] + + assert_nil @response + end + + it "preserves the request id of an invalid entry within a batch" do + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + { jsonrpc: "1.0", id: 200, method: "add", params: { a: 3, b: 4 } }, + ] + + assert @response.is_a?(Array) + assert_equal [100, 200], @response.map { |result| result[:id] } + assert_equal 3, @response.first[:result] + assert_equal(-32600, @response.last.dig(:error, :code)) + end + + it "returns an invalid request error for a non-object batch element instead of raising" do + [[], [1], ["x"], [nil], [true]].each do |batch| + handle batch + + assert_equal(-32600, @response.dig(:error, :code)) + assert_equal "Invalid Request", @response.dig(:error, :message) + assert_nil @response[:id] + end + end + + it "handles a valid request followed by a non-object element without raising" do + register("add") { |params| params[:a] + params[:b] } + + handle [ + { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } }, + [], + ] + + assert @response.is_a?(Array) + assert_equal 3, @response.first[:result] + assert_equal(-32600, @response.last.dig(:error, :code)) + assert_nil @response.last[:id] + end + + it "does not raise from handle_json when a batch element is not an object" do + result = handle_json("[[]]") + + assert_equal(-32600, @response.dig(:error, :code)) + refute_nil result + end + end + + # 7 Examples + # ... + # 8 Extensions + # + # Method names that begin with rpc. are reserved for system extensions, and MUST NOT be used for anything else. Each + # system extension is defined in a related specification. All system extensions are OPTIONAL. + + describe "ID pattern configuration" do + it "uses the default pattern by default" do + register("add") { |params| params[:a] + params[:b] } + + handle jsonrpc: "2.0", id: "valid-id_123", method: "add", params: { a: 1, b: 2 } + + assert_rpc_success expected_result: 3 + end + + it "rejects IDs that don't match the default pattern" do + handle jsonrpc: "2.0", id: "invalid@id", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "uses default pattern and rejects @ signs" do + register("add") { |params| params[:a] + params[:b] } + + # Default pattern should reject @ signs + handle jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Request ID must match validation pattern, or be an integer or null", + } + end + + it "accepts custom pattern as parameter to handle" do + register("add") { |params| params[:a] + params[:b] } + custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/ + + @response = JsonRpcHandler.handle( + { jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } }, + id_validation_pattern: custom_pattern, + ) { |method_name, _request_id| @registry[method_name] } + + assert_rpc_success expected_result: 3 + assert_equal "user@example.com", @response[:id] + end + + it "validates against custom pattern parameter" do + custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/ + + @response = JsonRpcHandler.handle( + { jsonrpc: "2.0", id: "id", method: "add", params: { a: 1, b: 2 } }, + id_validation_pattern: nil, + ) { |method_name, _request_id| @registry[method_name] } + + assert_rpc_success expected_result: 3 + assert_equal "", @response[:id] + end + end + end + + describe "#handle_json" do + it "returns a Response object when the request is valid and not a notification" do + register("add") { |params| params[:a] + params[:b] } + + handle_json({ jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json) + + assert_rpc_success(expected_result: 3) + end + + it "returns nil for notifications" do + register("ping") {} + + handle_json({ jsonrpc: "2.0", method: "ping" }.to_json) + + assert_nil @response + end + + it "returns an error preserving the request id when the request is invalid" do + handle_json({ jsonrpc: "0.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json) + + assert_equal 1, @response[:id] + assert_equal(-32600, @response.dig(:error, :code)) + end + end + + private + + def register(method_name, &block) + @registry[method_name] = block + end + + def handle(request) + @response = JsonRpcHandler.handle(request) { |method_name, _request_id| @registry[method_name] } + end + + def handle_json(request_json) + @response_json = JsonRpcHandler.handle_json(request_json) { |method_name, _request_id| @registry[method_name] } + @response = JSON.parse(@response_json, symbolize_names: true) if @response_json + end + + def assert_rpc_success(expected_result:) + assert_equal(expected_result, @response[:result]) + assert_nil(@response[:error]) + end + + def assert_rpc_error(expected_error:) + assert_equal(expected_error, @response[:error]) + assert_nil(@response[:result]) + end +end diff --git a/test/mcp/annotations_test.rb b/test/mcp/annotations_test.rb new file mode 100644 index 0000000..1e5d3df --- /dev/null +++ b/test/mcp/annotations_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class AnnotationsTest < ActiveSupport::TestCase + def test_initialization + annotations = Annotations.new(audience: ["user"], priority: 0.8) + + assert_equal(["user"], annotations.audience) + assert_equal(0.8, annotations.priority) + assert_nil(annotations.last_modified) + + assert_equal({ audience: ["user"], priority: 0.8 }, annotations.to_h) + end + + def test_initialization_with_all_attributes + timestamp = Time.utc(2025, 1, 12, 15, 0, 58).iso8601 + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: timestamp) + + assert_equal(["user"], annotations.audience) + assert_equal(0.8, annotations.priority) + assert_equal(timestamp, annotations.last_modified) + + assert_equal({ audience: ["user"], priority: 0.8, lastModified: timestamp }, annotations.to_h) + end + + def test_initialization_by_default + annotations = Annotations.new + + assert_nil(annotations.audience) + assert_nil(annotations.priority) + assert_nil(annotations.last_modified) + + assert_equal({}, annotations.to_h) + end + + def test_initialization_with_partial_attributes + annotations = Annotations.new(audience: ["user"]) + + assert_equal(["user"], annotations.audience) + assert_nil(annotations.priority) + assert_nil(annotations.last_modified) + + assert_equal({ audience: ["user"] }, annotations.to_h) + end + + def test_initialization_with_last_modified_only + timestamp = Time.utc(2025, 1, 12, 15, 0, 58).iso8601 + annotations = Annotations.new(last_modified: timestamp) + + assert_nil(annotations.audience) + assert_nil(annotations.priority) + assert_equal(timestamp, annotations.last_modified) + + assert_equal({ lastModified: timestamp }, annotations.to_h) + end + + def test_valid_priority_at_lower_bound + assert_nothing_raised do + Annotations.new(priority: 0) + end + end + + def test_valid_priority_at_upper_bound + assert_nothing_raised do + Annotations.new(priority: 1) + end + end + + def test_invalid_priority_above_upper_bound + exception = assert_raises(ArgumentError) do + Annotations.new(priority: 1.5) + end + assert_equal("The value of priority must be between 0 and 1.", exception.message) + end + + def test_invalid_priority_below_lower_bound + exception = assert_raises(ArgumentError) do + Annotations.new(priority: -0.1) + end + assert_equal("The value of priority must be between 0 and 1.", exception.message) + end + + def test_valid_audience_with_user + assert_nothing_raised do + Annotations.new(audience: ["user"]) + end + end + + def test_valid_audience_with_assistant + assert_nothing_raised do + Annotations.new(audience: ["assistant"]) + end + end + + def test_valid_audience_with_multiple_roles + assert_nothing_raised do + Annotations.new(audience: ["user", "assistant"]) + end + end + + def test_valid_audience_with_empty_array + assert_nothing_raised do + Annotations.new(audience: []) + end + end + + def test_invalid_audience_with_unknown_role + exception = assert_raises(ArgumentError) do + Annotations.new(audience: ["developers"]) + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end + + def test_invalid_audience_with_mixed_roles + exception = assert_raises(ArgumentError) do + Annotations.new(audience: ["user", "developers"]) + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end + + def test_invalid_audience_when_not_array + exception = assert_raises(ArgumentError) do + Annotations.new(audience: "user") + end + assert_equal('The value of audience must be an array of "user" or "assistant".', exception.message) + end + end +end diff --git a/test/mcp/apps_test.rb b/test/mcp/apps_test.rb new file mode 100644 index 0000000..ad00d2a --- /dev/null +++ b/test/mcp/apps_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class AppsTest < ActiveSupport::TestCase + test "exposes the SEP-1865 wire vocabulary" do + # The exact strings are shared with the reference ext-apps package and the Python SDK. + assert_equal "io.modelcontextprotocol/ui", Apps::EXTENSION_ID + assert_equal "text/html;profile=mcp-app", Apps::RESOURCE_MIME_TYPE + assert_equal "ui://", Apps::URI_SCHEME + assert_equal "ui/resourceUri", Apps::RESOURCE_URI_META_KEY + end + + test ".capability builds the extensions fragment" do + assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } }, Apps.capability) + assert_equal({ "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } }, Apps.capability(mime_types: ["text/html"])) + end + + test ".ui_resource builds a Resource with the spec defaults and validates the scheme" do + resource = Apps.ui_resource(uri: "ui://weather/dashboard", name: "dashboard", description: "Weather UI") + + assert_instance_of Resource, resource + assert_equal "ui://weather/dashboard", resource.uri + assert_equal "text/html;profile=mcp-app", resource.mime_type + assert_equal "Weather UI", resource.description + assert_raises(ArgumentError) do + Apps.ui_resource(uri: "file:///dashboard.html", name: "dashboard") + end + end + + test ".tool_meta links a tool to its template without clobbering caller meta" do + meta = Apps.tool_meta( + resource_uri: "ui://weather/dashboard", + visibility: ["model", "app"], + meta: { custom: "value" }, + ) + + assert_equal({ custom: "value", ui: { resourceUri: "ui://weather/dashboard", visibility: ["model", "app"] } }, meta) + assert_raises(ArgumentError) do + Apps.tool_meta(resource_uri: "https://example.com") + end + end + + test ".tool_meta emits the legacy flat key when requested" do + meta = Apps.tool_meta(resource_uri: "ui://weather/dashboard", legacy: true) + + assert_equal "ui://weather/dashboard", meta.dig(:ui, :resourceUri) + assert_equal "ui://weather/dashboard", meta[:"ui/resourceUri"] + end + + test ".client_supports? checks the declared extension and mime types" do + declared = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html;profile=mcp-app"] } } } + string_keys = { + "extensions" => { "io.modelcontextprotocol/ui" => { "mimeTypes" => ["text/html;profile=mcp-app"] } }, + } + without_mime_types = { extensions: { "io.modelcontextprotocol/ui" => {} } } + other_mime_type = { extensions: { "io.modelcontextprotocol/ui" => { mimeTypes: ["text/html"] } } } + + assert Apps.client_supports?(declared) + assert Apps.client_supports?(string_keys) + assert Apps.client_supports?(without_mime_types) + refute Apps.client_supports?(other_mime_type) + refute Apps.client_supports?({}) + refute Apps.client_supports?(nil) + refute Apps.client_supports?({ extensions: {} }) + end + end +end diff --git a/test/mcp/cancellation_test.rb b/test/mcp/cancellation_test.rb new file mode 100644 index 0000000..41449eb --- /dev/null +++ b/test/mcp/cancellation_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class CancellationTest < ActiveSupport::TestCase + test "starts not cancelled" do + cancellation = Cancellation.new + + refute_predicate cancellation, :cancelled? + assert_nil cancellation.reason + end + + test "#cancel flips state and stores reason" do + cancellation = Cancellation.new + + assert cancellation.cancel(reason: "user requested") + assert_predicate cancellation, :cancelled? + assert_equal "user requested", cancellation.reason + end + + test "#cancel is idempotent" do + cancellation = Cancellation.new + + assert cancellation.cancel(reason: "first") + refute cancellation.cancel(reason: "second") + assert_equal "first", cancellation.reason + end + + test "#on_cancel fires once on cancellation" do + cancellation = Cancellation.new + fired = [] + + cancellation.on_cancel { |reason| fired << reason } + cancellation.cancel(reason: "stop") + cancellation.cancel(reason: "again") + + assert_equal ["stop"], fired + end + + test "#on_cancel fires immediately when already cancelled" do + cancellation = Cancellation.new + cancellation.cancel(reason: "done") + fired = [] + + cancellation.on_cancel { |reason| fired << reason } + + assert_equal ["done"], fired + end + + test "#raise_if_cancelled! raises CancelledError when cancelled" do + cancellation = Cancellation.new(request_id: "req-1") + cancellation.cancel(reason: "abort") + + error = assert_raises(CancelledError) { cancellation.raise_if_cancelled! } + assert_equal "req-1", error.request_id + assert_equal "abort", error.reason + end + + test "#raise_if_cancelled! is a no-op when not cancelled" do + cancellation = Cancellation.new + + assert_nil cancellation.raise_if_cancelled! + end + end +end diff --git a/test/mcp/client/elicitation_test.rb b/test/mcp/client/elicitation_test.rb new file mode 100644 index 0000000..a6d936f --- /dev/null +++ b/test/mcp/client/elicitation_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/elicitation" + +module MCP + class Client + class ElicitationTest < Minitest::Test + REQUESTED_SCHEMA = { + "type" => "object", + "properties" => { + "name" => { "type" => "string", "default" => "John Doe" }, + "age" => { "type" => "integer", "default" => 30 }, + "score" => { "type" => "number", "default" => 95.5 }, + "status" => { "type" => "string", "enum" => ["active", "inactive", "pending"], "default" => "active" }, + "verified" => { "type" => "boolean", "default" => true }, + }, + "required" => [], + }.freeze + + def test_apply_defaults_fills_all_omitted_fields + content = Elicitation.apply_defaults(REQUESTED_SCHEMA) + + assert_equal( + { + "name" => "John Doe", + "age" => 30, + "score" => 95.5, + "status" => "active", + "verified" => true, + }, + content, + ) + end + + def test_apply_defaults_does_not_overwrite_provided_values + content = Elicitation.apply_defaults(REQUESTED_SCHEMA, { "name" => "Alice", "verified" => false }) + + assert_equal( + { + "name" => "Alice", + "verified" => false, + "age" => 30, + "score" => 95.5, + "status" => "active", + }, + content, + ) + end + + def test_apply_defaults_skips_properties_without_a_default + schema = { + "type" => "object", + "properties" => { + "name" => { "type" => "string", "default" => "John Doe" }, + "email" => { "type" => "string" }, + }, + } + + content = Elicitation.apply_defaults(schema) + + assert_equal({ "name" => "John Doe" }, content) + refute(content.key?("email")) + end + + def test_apply_defaults_applies_a_false_default + schema = { + "type" => "object", + "properties" => { + "verified" => { "type" => "boolean", "default" => false }, + }, + } + + content = Elicitation.apply_defaults(schema) + + assert_equal({ "verified" => false }, content) + end + + def test_apply_defaults_supports_symbol_keyed_schemas_and_content + schema = { + type: "object", + properties: { + name: { type: "string", default: "John Doe" }, + age: { type: "integer", default: 30 }, + }, + } + + content = Elicitation.apply_defaults(schema, { age: 42 }) + + assert_equal({ "age" => 42, "name" => "John Doe" }, content) + end + + def test_apply_defaults_returns_content_when_schema_has_no_properties + content = Elicitation.apply_defaults({ "type" => "object" }, { "name" => "Alice" }) + + assert_equal({ "name" => "Alice" }, content) + end + end + end +end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index e7f5b58..0b9960a 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "event_stream_parser" require "faraday" require "webmock/minitest" require "mcp/client/http" @@ -10,6 +11,8 @@ require "mcp/client" module MCP class Client class HTTPTest < Minitest::Test + include DeprecationWarningTestHelper + def test_raises_load_error_when_faraday_not_available client = HTTP.new(url: url) @@ -25,6 +28,26 @@ module MCP assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'") end + def test_raises_load_error_when_event_stream_parser_not_available + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: {}\n\n", + ) + + HTTP.any_instance.stubs(:require).with("faraday").returns(true) + HTTP.const_get(:SSEStream).any_instance.stubs(:require).with("event_stream_parser") + .raises(LoadError, "cannot load such file -- event_stream_parser") + + error = assert_raises(LoadError) do + client.send_request(request: { method: "tools/list" }) + end + + assert_includes(error.message, "The 'event_stream_parser' gem is required to parse SSE responses") + assert_includes(error.message, "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'") + end + def test_headers_are_added_to_the_request headers = { "Authorization" => "Bearer token" } client = HTTP.new(url: url, headers: headers) @@ -40,6 +63,7 @@ module MCP headers: { "Authorization" => "Bearer token", "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", }, body: request.to_json, ) @@ -54,6 +78,285 @@ module MCP client.send_request(request: request) end + def test_accept_header_is_included_in_requests + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with( + headers: { + "Accept" => "application/json, text/event-stream", + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: request) + end + + def test_custom_accept_header_overrides_default + custom_accept = "application/json" + custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept }) + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with( + headers: { + "Accept" => custom_accept, + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + custom_client.send_request(request: request) + end + + def test_mcp_method_and_name_headers_for_tools_call + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "get_weather", arguments: { city: "Tokyo" } }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_falls_back_to_uri_for_resources_read + request = { + jsonrpc: "2.0", + id: "test_id", + method: "resources/read", + params: { uri: "file:///README.md" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "resources/read", "Mcp-Name" => "file:///README.md" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_and_name_headers_for_prompts_get + request = { + jsonrpc: "2.0", + id: "test_id", + method: "prompts/get", + params: { name: "greeting" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "prompts/get", "Mcp-Name" => "greeting" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_without_name_when_params_lack_name_and_uri + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/list" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_unsafe + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "café" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?Y2Fmw6k=?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_for_notification_without_id + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "notifications/initialized" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 202, + headers: { "Content-Type" => "application/json" }, + body: "", + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_for_initialize_without_params + request = { + jsonrpc: "2.0", + id: "test_id", + method: "initialize", + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "initialize" }, + ) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_with_string_keyed_params + request = { + "jsonrpc" => "2.0", + "id" => "test_id", + "method" => "tools/call", + "params" => { "name" => "get_weather" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_value_has_surrounding_whitespace + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: " padded " }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?IHBhZGRlZCA=?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_is_base64_encoded_when_value_has_crlf + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "evil\r\nX-Injected: 1" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?ZXZpbA0KWC1JbmplY3RlZDogMQ==?=" }, + ) do |req| + !req.headers.key?("X-Injected") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_name_header_re_encodes_value_matching_base64_sentinel + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "=?base64?literal?=" }, + } + + stub_request(:post, url).with( + headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + + def test_mcp_method_header_without_name_for_non_hash_params + request = { + jsonrpc: "2.0", + id: "test_id", + method: "custom/method", + params: ["positional"], + } + + stub_request(:post, url).with(headers: { "Mcp-Method" => "custom/method" }) do |req| + !req.headers.key?("Mcp-Name") + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: {} }.to_json, + ) + + client.send_request(request: request) + end + def test_send_request_returns_faraday_response request = { jsonrpc: "2.0", @@ -134,7 +437,7 @@ module MCP assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_not_found_error + def test_send_request_raises_not_found_error_on_404_without_session request = { jsonrpc: "2.0", id: "test_id", @@ -149,11 +452,56 @@ module MCP client.send_request(request: request) end + refute_kind_of(SessionExpiredError, error) assert_equal("The tools/list request is not found", error.message) assert_equal(:not_found, error.error_type) assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_send_request_raises_session_expired_error_on_404_with_session + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_equal(:not_found, error.error_type) + end + + def test_session_expired_error_is_a_request_handler_error + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_kind_of(SessionExpiredError, error) + end + def test_send_request_raises_unprocessable_entity_error request = { jsonrpc: "2.0", @@ -194,12 +542,2062 @@ module MCP assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_block_customizes_faraday_connection + custom_client = HTTP.new(url: url) do |faraday| + faraday.headers["X-Custom"] = "test-value" + end + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url).with( + headers: { + "X-Custom" => "test-value", + "Accept" => "application/json, text/event-stream", + }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + custom_client.send_request(request: request) + end + + def test_send_request_raises_error_for_unsupported_content_type + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/html" }, + body: "", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal( + 'Unsupported Content-Type: "text/html". Expected application/json or text/event-stream.', + error.message, + ) + assert_equal(:unsupported_media_type, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_parses_sse_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + : comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[{"name":"echo"}]}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal({ "tools" => [{ "name" => "echo" }] }, response["result"]) + end + + def test_send_request_parses_sse_error_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + data: {"jsonrpc":"2.0","id":"test_id","error":{"code":-32600,"message":"Invalid request"}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal(-32600, response.dig("error", "code")) + assert_equal("Invalid request", response.dig("error", "message")) + end + + def test_send_request_returns_nil_for_202_accepted_response + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_nil(response) + end + + def test_send_request_raises_error_for_sse_without_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + sse_body = <<~SSE + : just a comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + SSE + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "No valid JSON-RPC response found in SSE stream") + assert_equal(:parse_error, error.error_type) + assert_not_requested(:get, url) + end + + def test_send_request_reconnects_with_last_event_id_after_primed_graceful_close + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 100\ndata:\n\n", + ) + + get_body = "id: event-2\nretry: 100\ndata:\n\n" \ + "event: message\nid: event-3\n" \ + 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + get_stub = stub_request(:get, url).with( + headers: { "Accept" => "text/event-stream", "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + response = client.send_request(request: request) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(get_stub) + + # The server-specified `retry:` interval must elapse before the reconnection GET. + assert_operator(elapsed, :>=, 0.1) + end + + def test_send_request_uses_default_reconnection_delay_when_retry_field_absent + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\ndata:\n\n", + ) + + get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_releases_the_calling_thread_on_an_excessive_reconnection_delay + # A server priming a stream, closing it, and asking for a day-long `retry:` used to park + # the calling thread for that long. The default budget now stops the resume without sleeping. + stub_reconnection_with_retry(86_400_000) + client.expects(:sleep).never + + error = assert_raises(MCP::Client::RequestHandlerError) do + client.send_request(request: reconnection_request) + end + + assert_includes error.message, "reconnection budget" + end + + def test_send_request_raises_a_server_reconnection_delay_of_zero_to_the_minimum + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 0\ndata:\n\n", + ) + + get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ + "\n\n" + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: get_body, + ) + + client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_honors_a_reconnection_delay_that_fits_the_budget + # Nothing is shortened while the server's `retry:` fits: the client waits it out and resumes. + custom_client = HTTP.new(url: url, max_reconnection_wait: 60) + + stub_reconnection_with_retry(30_000) + custom_client.expects(:sleep).with(30.0) + + response = custom_client.send_request(request: reconnection_request) + + assert_equal({ "content" => [] }, response["result"]) + end + + def test_send_request_gives_up_rather_than_reconnect_before_the_server_asked + # A delay past the budget is never shortened; the client stops trying to resume instead, so + # the calling thread is released immediately rather than after the server's chosen interval. + custom_client = HTTP.new(url: url, max_reconnection_wait: 10) + + stub_reconnection_with_retry(86_400_000) + custom_client.expects(:sleep).never + + error = assert_raises(MCP::Client::RequestHandlerError) do + custom_client.send_request(request: reconnection_request) + end + + assert_includes error.message, "would exceed the 10 second reconnection budget" + end + + def test_send_request_falls_back_to_the_default_delay_for_an_unusable_retry_value + # The SSE parser only accepts a run of digits, so a negative or non-numeric `retry:` never reaches + # the delay calculation as a value; both arrive as "the server sent none". + ["-5000", "abc", "1e6", "500ms"].each do |value| + WebMock.reset! + fresh_client = HTTP.new(url: url) + + stub_reconnection_with_retry(value) + fresh_client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0) + + response = fresh_client.send_request(request: reconnection_request) + + assert_equal({ "content" => [] }, response["result"]) + end + end + + def test_listener_applies_the_delay_floor_to_a_zero_retry_value + # The listening stream reconnects indefinitely after a graceful close, so a `retry: 0` would spin + # without the floor. The 500s that follow let the listener reach its failure cap and stop. + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return( + { status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "id: e1\nretry: 0\ndata:\n\n" }, + { status: 500 }, + { status: 500 }, + ) + + client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0).at_least_once + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + ensure + client.close + end + + def test_raises_argument_error_when_max_reconnection_wait_is_not_positive + [0, -1, "60", nil].each do |value| + error = assert_raises(ArgumentError) { HTTP.new(url: url, max_reconnection_wait: value) } + + assert_equal("max_reconnection_wait must be a positive number", error.message) + end + end + + def test_send_request_raises_after_reconnection_attempts_are_exhausted + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 10\ndata:\n\n", + ) + + first_get = stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-2\nretry: 10\ndata:\n\n", + ) + second_get = stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-2" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-3\nretry: 10\ndata:\n\n", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "after 2 reconnection attempts") + assert_equal(:internal_error, error.error_type) + assert_requested(first_get) + assert_requested(second_get) + end + + def test_send_request_parses_json_response_when_adapter_does_not_stream + # The Faraday test adapter ignores `on_data`, like adapters without + # streaming support; the body must be read from `response.body`. + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + + assert_equal({ "result" => { "tools" => [] } }, response) + end + + def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_only + # json 3.0 accepts parser options as keywords only, and Faraday's json response middleware + # passes them as a positional Hash, which Ruby 3 no longer converts. The stand-in parser has + # the json 3.0 signature, so the body must reach `JSON.parse` through the client's own call. + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + parse = JSON.method(:parse) + keyword_options_only_parse = ->(source, **options) { parse.call(source, **options) } + + response = JSON.stub(:parse, keyword_options_only_parse) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_equal({ "result" => { "tools" => [] } }, response) + end + + def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers + # SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header` + # declarations, and the following `tools/call` mirrors the annotated arguments into + # `Mcp-Param-*` headers. + call_headers = nil + client = mcp_param_test_client( + tools: [mcp_param_annotated_tool], + on_call: ->(headers) { call_headers = headers }, + ) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "test_custom_headers", + arguments: { region: "us-west1", priority: 42, non_ascii_val: "Hello, 世界", null_val: nil }, + }, + }) + + assert_equal("us-west1", call_headers["Mcp-Param-Region"]) + assert_equal("42", call_headers["Mcp-Param-Priority"]) + assert_equal("=?base64?#{["Hello, 世界"].pack("m0")}?=", call_headers["Mcp-Param-NonAsciiVal"]) + refute(call_headers.key?("Mcp-Param-NullVal"), "a null argument must omit its header") + end + + def test_send_request_mirrors_nothing_for_a_tool_with_an_invalid_x_mcp_header_declaration + # An invalid tool definition (here a case-insensitive duplicate) mirrors nothing rather than + # emitting a partial or malformed header set. + invalid_tool = { + name: "invalid_duplicate", + inputSchema: { + type: "object", + properties: { + one: { type: "string", "x-mcp-header": "Region" }, + two: { type: "string", "x-mcp-header": "REGION" }, + }, + }, + } + call_headers = nil + client = mcp_param_test_client(tools: [invalid_tool], on_call: ->(headers) { call_headers = headers }) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "invalid_duplicate", arguments: { one: "a", two: "b" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + assert_equal("tools/call", call_headers["Mcp-Method"]) + end + + def test_send_request_mirrors_nothing_on_a_legacy_connection + # The custom headers exist on the modern lifecycle only (SEP-2243), matching + # the TypeScript and Python SDKs: without modern adoption nothing is learned or mirrored. + call_headers = nil + client = mcp_param_test_client( + tools: [mcp_param_annotated_tool], + on_call: ->(headers) { call_headers = headers }, + ) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + assert_equal("tools/call", call_headers["Mcp-Method"]) + end + + def test_send_request_prunes_declarations_dropped_by_a_complete_listing + # A complete (uncursored, `nextCursor`-less) listing is the full tool universe, + # so declarations of unlisted tools are stale and stop mirroring. + call_headers = nil + listings = [[mcp_param_annotated_tool], []] + client = mcp_param_test_client( + tools: -> { listings.shift || [] }, + on_call: ->(headers) { call_headers = headers }, + ) + client.connect(mode: :modern) + + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + client.send_request(request: { jsonrpc: "2.0", id: 2, method: "tools/list" }) + client.send_request(request: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, + }) + + refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) + end + + def test_send_request_parses_sse_response_when_adapter_does_not_stream + sse_body = "event: message\n" \ + 'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \ + "\n\n" + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "text/event-stream" }, sse_body] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + + assert_equal({ "tools" => [] }, response["result"]) + end + + def test_sse_stream_parses_buffered_chunks_when_env_is_unavailable + # Faraday < 2.1 invokes `on_data` without `env`; the content type + # cannot be detected, so SSE chunks accumulate in the buffer and are + # parsed by the `ingest_pending!` fallback. + stream = HTTP.const_get(:SSEStream).new(abortable: true) + chunk = "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{}}\n\n" + + stream.on_data.call(chunk, chunk.bytesize) + + assert_nil(stream.response) + assert_equal(chunk, stream.buffer) + + stream.ingest_pending!(nil) + + assert_equal({ "jsonrpc" => "2.0", "id" => "test_id", "result" => {} }, stream.response) + assert_empty(stream.buffer) + end + + def test_send_request_rejects_sse_event_exceeding_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 64) + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: #{"a" * 128}\n", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "Server SSE event exceeds 64 bytes") + assert_equal(:internal_error, error.error_type) + end + + def test_send_request_rejects_json_body_exceeding_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 64) + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: ["a" * 128] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_includes(error.message, "Server response body exceeds 64 bytes") + assert_equal(:internal_error, error.error_type) + end + + def test_send_request_accepts_sse_events_that_each_stay_within_max_message_bytes + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + client = HTTP.new(url: url, max_message_bytes: 256) + + # The notifications and the response together exceed the limit; each event stays + # under it, so dispatched events must not count against the ones that follow. + notification = { jsonrpc: "2.0", method: "notifications/progress", params: { token: "a" * 100 } }.to_json + sse_body = "data: #{notification}\n\n" * 3 + + "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{\"tools\":[]}}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = client.send_request(request: request) + + assert_equal({ "tools" => [] }, response["result"]) + end + + def test_sse_stream_rejects_unterminated_event_exceeding_max_message_bytes + stream = HTTP.const_get(:SSEStream).new(abortable: false, max_message_bytes: 64) + env = stub(response_headers: { "content-type" => "text/event-stream" }) + chunk = "data: #{"a" * 128}" + + error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do + stream.on_data.call(chunk, chunk.bytesize, env) + end + + assert_includes(error.message, "Server SSE event exceeds 64 bytes") + end + + def test_sse_stream_bounds_buffered_chunks_when_env_is_unavailable + stream = HTTP.const_get(:SSEStream).new(abortable: true, max_message_bytes: 64) + + error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do + stream.on_data.call("a" * 65, 65) + end + + assert_includes(error.message, "Server response body exceeds 64 bytes") + end + + def test_initialize_rejects_max_message_bytes_that_is_not_a_positive_integer + [0, -1, nil, "4"].each do |value| + error = assert_raises(ArgumentError) do + HTTP.new(url: url, max_message_bytes: value) + end + + assert_equal("max_message_bytes must be a positive Integer", error.message) + end + end + + def test_send_request_dispatches_server_request_to_registered_handler + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_client_elicitation_defaults", arguments: {} }, + } + + elicitation_request = { + jsonrpc: "2.0", + id: 0, + method: "elicitation/create", + params: { + message: "Please accept with defaults", + requestedSchema: { + type: "object", + properties: { name: { type: "string", default: "John Doe" } }, + required: [], + }, + }, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{elicitation_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { + jsonrpc: "2.0", + id: 0, + result: { action: "accept", content: { name: "John Doe" } }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return( + status: 202, body: "", + ) + + received_params = nil + client.on_server_request("elicitation/create") do |params| + received_params = params + { + action: "accept", + content: { name: params.dig("requestedSchema", "properties", "name", "default") }, + } + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + assert_equal("Please accept with defaults", received_params["message"]) + end + + def test_send_request_dispatches_sampling_request_to_registered_handler + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "ask_llm", arguments: {} }, + } + + sampling_request = { + jsonrpc: "2.0", + id: 0, + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Hi" } }], + maxTokens: 100, + }, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{sampling_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { + jsonrpc: "2.0", + id: 0, + result: { + role: "assistant", + content: { type: "text", text: "Hello there" }, + model: "test-model", + stopReason: "endTurn", + }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return(status: 202, body: "") + + received_params = nil + client.on_server_request("sampling/createMessage") do |params| + received_params = params + { + role: "assistant", + content: { type: "text", text: "Hello there" }, + model: "test-model", + stopReason: "endTurn", + } + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + assert_equal(100, received_params["maxTokens"]) + end + + def test_send_request_answers_a_server_ping_with_an_empty_result + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 7, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + pong = { jsonrpc: "2.0", id: 7, result: {} } + pong_stub = stub_request(:post, url).with( + body: pong.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(pong_stub) + end + + def test_a_registered_ping_handler_overrides_the_default_pong + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 8, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { jsonrpc: "2.0", id: 8, result: { custom: "pong" } } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return(status: 202, body: "") + + client.on_server_request("ping") { { custom: "pong" } } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + end + + def test_a_ping_with_a_malformed_id_is_answered_with_method_not_found + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames at schema + # validation, so the default pong does not fire and the frame falls through. + server_ping = { jsonrpc: "2.0", id: { x: 1 }, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error = { + jsonrpc: "2.0", + id: { x: 1 }, + error: { + code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found: ping", + }, + } + error_stub = stub_request(:post, url).with( + body: expected_error.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_send_request_answers_unregistered_server_request_with_method_not_found + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 5, + method: "sampling/createMessage", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 5, + error: { code: -32601, message: "Method not found: sampling/createMessage" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_send_request_answers_raising_handler_with_internal_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 9, + method: "elicitation/create", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 9, + error: { code: -32603, message: "Internal error handling elicitation/create request: boom" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.on_server_request("elicitation/create") { raise "boom" } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_send_request_answers_server_request_error_with_its_code + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "ask_llm", arguments: {} }, + } + + server_request = { + jsonrpc: "2.0", + id: 11, + method: "sampling/createMessage", + params: {}, + } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \ + "event: message\ndata: #{tool_result.to_json}\n\n" + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error_response = { + jsonrpc: "2.0", + id: 11, + error: { code: -1, message: "User rejected sampling request" }, + } + error_stub = stub_request(:post, url).with( + body: expected_error_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.on_server_request("sampling/createMessage") do + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) + end + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + + def test_on_server_request_requires_a_block + assert_raises(ArgumentError) do + client.on_server_request("elicitation/create") + end + end + + def test_registering_a_handler_after_connect_listens_on_standalone_get_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + + elicitation_request = { + jsonrpc: "2.0", + id: 7, + method: "elicitation/create", + params: { + message: "Please accept with defaults", + requestedSchema: { + type: "object", + properties: { name: { type: "string", default: "John Doe" } }, + required: [], + }, + }, + } + stub_request(:get, url) + .with( + headers: { "Accept" => "text/event-stream", "Mcp-Session-Id" => "session-abc" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "event: message\ndata: #{elicitation_request.to_json}\n\n", + ) + + expected_response = { + jsonrpc: "2.0", + id: 7, + result: { action: "accept", content: { name: "John Doe" } }, + } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return( + status: 202, body: "", + ) + + client.connect + client.on_server_request("elicitation/create") do |params| + { + action: "accept", + content: { name: params.dig("requestedSchema", "properties", "name", "default") }, + } + end + + wait_until { requested?(response_stub) } + + assert_requested(response_stub) + ensure + client.close + end + + def test_connect_listens_on_standalone_get_stream_when_a_handler_is_registered + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + + get_stub = stub_request(:get, url).with( + headers: { "Accept" => "text/event-stream" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "", + ) + + client.on_server_request("elicitation/create") { { action: "decline" } } + + assert_not_requested(:get, url) + + client.connect + + wait_until { requested?(get_stub) } + + assert_requested(get_stub) + ensure + client.close + end + + def test_listener_stops_after_consecutive_connection_failures + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return(status: 500) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + assert_requested(:get, url, times: HTTP::MAX_RECONNECTION_ATTEMPTS) + ensure + client.close + end + + def test_listener_failure_count_resets_after_a_successful_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return( + { status: 500 }, + { status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "" }, + { status: 500 }, + { status: 500 }, + ) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + # Without the reset, the second failure (the third request) would + # already reach the cap and the fourth request would never be made. + assert_requested(:get, url, times: 4) + ensure + client.close + end + + def test_listener_stops_immediately_when_server_does_not_offer_a_get_stream + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + stub_request(:get, url).to_return(status: 405) + + client.stubs(:sleep) + client.connect + client.on_server_request("elicitation/create") { { action: "decline" } } + listener = client.instance_variable_get(:@listener_thread) + + wait_until { !listener.alive? } + + assert_requested(:get, url, times: 1) + ensure + client.close + end + + def test_captures_session_id_and_protocol_version_on_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_includes_session_and_protocol_version_headers_after_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url) + .with( + headers: { + "Mcp-Session-Id" => "session-abc", + "MCP-Protocol-Version" => "2025-11-25", + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + def test_adopts_a_counter_offered_protocol_version_from_the_handshake + # A server may answer `initialize` with a version other than the one offered, and everything + # after the handshake speaks the answer rather than the offer. This server counter-offers + # a handshake version to clients asking `initialize` for a modern one, which only resolves + # anything because clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + offered = nil + stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: counter_offered } }.to_json, + ) + stub_notification + + client.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, client.protocol_version) + + # Scoped to the body as well: `notifications/initialized` already went out under + # the adopted version, and a header-only stub would count that too. + header_stub = stub_request(:post, url).with( + headers: { "MCP-Protocol-Version" => counter_offered }, + ) do |req| + JSON.parse(req.body)["method"] == "tools/list" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_requested(header_stub) + end + + def test_does_not_send_protocol_version_header_before_initialize + stub_request(:post, url) + .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + end + + def test_ignores_empty_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + end + + def test_session_id_not_overwritten_by_subsequent_responses + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "original-session", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("original-session", client.session_id) + + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "different-session", + }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_equal("original-session", client.session_id) + end + + def test_stateless_server_without_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_clears_session_state_on_404 + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + + stub_request(:post, url).to_return(status: 404) + + assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_sends_delete_with_session_headers + initialize_session + + stub_request(:delete, url) + .with( + headers: { + "Mcp-Session-Id" => "session-abc", + "MCP-Protocol-Version" => "2025-11-25", + }, + ) + .to_return(status: 200) + + client.close + end + + def test_close_clears_session_state + initialize_session + stub_request(:delete, url).to_return(status: 200) + + client.close + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_without_session_is_noop + client.close + + assert_not_requested(:delete, url) + assert_nil(client.session_id) + end + + def test_close_clears_stateless_connection_state + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + stub_notification + + client.connect + client.close + + assert_not_requested(:delete, url) + refute_predicate(client, :connected?) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_close_tolerates_405_response + initialize_session + stub_request(:delete, url).to_return(status: 405) + + client.close + + assert_nil(client.session_id) + end + + def test_close_tolerates_404_response + initialize_session + stub_request(:delete, url).to_return(status: 404) + + client.close + + assert_nil(client.session_id) + end + + def test_close_propagates_server_error_and_still_clears_state + initialize_session + stub_request(:delete, url).to_return(status: 500) + + assert_raises(Faraday::ServerError) do + client.close + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_propagates_unauthorized_and_still_clears_state + initialize_session + stub_request(:delete, url).to_return(status: 401) + + assert_raises(Faraday::UnauthorizedError) do + client.close + end + + assert_nil(client.session_id) + end + + def test_close_propagates_connection_failure_and_still_clears_state + initialize_session + stub_request(:delete, url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + + assert_raises(Faraday::ConnectionFailed) do + client.close + end + + assert_nil(client.session_id) + end + + def test_close_is_idempotent + initialize_session + stub_request(:delete, url).to_return(status: 200) + + client.close + client.close + + assert_requested(:delete, url, times: 1) + end + + def test_connect_performs_initialize_handshake + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" }, + body: { + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0" }, + }, + }.to_json, + ) + + notification_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 202, body: "") + + result = client.connect + + assert_requested(init_stub) + assert_requested(notification_stub) + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal({ "tools" => {} }, result["capabilities"]) + assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"]) + end + + def test_connect_caches_server_info + stub_initialize + stub_notification + + client.connect + + assert_equal("2025-11-25", client.server_info["protocolVersion"]) + end + + def test_connect_uses_default_client_info_and_protocol_version + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with do |req| + body = JSON.parse(req.body) + body["method"] == "initialize" && + body["params"]["protocolVersion"] == MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION && + body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } && + body["params"]["capabilities"] == {} + end + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, + ) + + client.connect + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_accepts_custom_parameters + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with do |req| + body = JSON.parse(req.body) + body["method"] == "initialize" && + body["params"]["protocolVersion"] == "2025-03-26" && + body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } && + body["params"]["capabilities"] == { "roots" => { "listChanged" => true } } + end + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-03-26" } }.to_json, + ) + + client.connect( + client_info: { name: "my-app", version: "9.9" }, + protocol_version: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + ) + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_offers_the_latest_handshake_version_by_default + # The handshake negotiates legacy versions only (SEP-2575 era model); modern versions are + # selected via `mode: :modern`/`:auto`, so the default offer is the latest handshake version. + offered = nil + init_stub = stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + stub_notification + + client.connect + + assert_requested(init_stub) + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients + # refuse a modern counter-offer the same way. + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2026-07-28" } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, "2026-07-28") + assert_requested(init_stub) + end + + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + error = assert_raises(ArgumentError) do + client.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") + end + + def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older + notification_stub = stub_notification + + init_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + assert_no_deprecation_warning do + client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_requested(init_stub) + assert_requested(notification_stub) + end + + def test_connect_is_idempotent + init_stub = stub_initialize + notification_stub = stub_notification + + first_result = client.connect + second_result = client.connect + + assert_same(first_result, second_result) + assert_requested(init_stub, times: 1) + assert_requested(notification_stub, times: 1) + end + + def test_connect_raises_on_jsonrpc_error_response + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, "Unsupported protocol version") + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_raises_on_missing_result + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { jsonrpc: "2.0", id: "x" }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_raises_on_unsupported_negotiated_protocol_version + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: "2099-01-01" } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect + end + + assert_includes(error.message, 'unsupported protocol version "2099-01-01"') + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connect_clears_session_when_initialized_notification_fails + stub_initialize + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 500) + + assert_raises(RequestHandlerError) do + client.connect + end + + refute_predicate(client, :connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + + def test_connected_lifecycle + refute_predicate(client, :connected?) + + stub_initialize + stub_notification + client.connect + + assert_predicate(client, :connected?) + + stub_request(:delete, url).to_return(status: 200) + client.close + + refute_predicate(client, :connected?) + end + + def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle + discover_stub = stub_discover + + result = client.connect(mode: :modern) + + assert_requested(discover_stub) + assert_predicate(client, :connected?) + assert_predicate(client, :modern?) + assert_equal(["2026-07-28"], result["supportedVersions"]) + assert_equal(result, client.server_info) + assert_equal("2026-07-28", client.protocol_version) + end + + def test_modern_requests_carry_the_envelope_and_matching_header + stub_discover + client.connect( + mode: :modern, + client_info: { name: "my-app", version: "9.9" }, + capabilities: { elicitation: {} }, + ) + + request_stub = stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + meta = body.dig("params", "_meta") + body["method"] == "tools/list" && + req.headers["Mcp-Protocol-Version"] == "2026-07-28" && + meta == { + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" }, + "io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} }, + } + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_requested(request_stub) + end + + def test_connect_modern_fails_without_a_mutual_modern_version + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) } + + assert_includes(error.message, "no mutually supported modern protocol version") + refute_predicate(client, :connected?) + refute_predicate(client, :modern?) + end + + def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { error: { code: -32601, message: "Method not found" } }.to_json, + ) + init_stub = stub_initialize + notification_stub = stub_notification + + result = client.connect(mode: :auto) + + assert_requested(init_stub) + assert_requested(notification_stub) + refute_predicate(client, :modern?) + assert_predicate(client, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + end + + def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version + # Rollout tolerance: a server may answer discovery while only serving legacy versions; + # auto mode falls back to the legacy handshake. + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + init_stub = stub_initialize + stub_notification + + client.connect(mode: :auto) + + assert_requested(init_stub) + refute_predicate(client, :modern?) + assert_predicate(client, :connected?) + end + + def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode + assert_raises(ArgumentError) { client.connect(mode: :bogus) } + assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") } + end + + def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_version + # An explicitly requested modern version is never downgraded by the fallback: + # the legacy handshake cannot negotiate it, so the probe's failure is the real answer. + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect(mode: :auto, protocol_version: "2026-07-28") + end + + assert_includes(error.message, "no mutually supported modern protocol version") + refute_predicate(client, :connected?) + end + + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every + # modern connection speaks. + stub_discover + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + client.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(client, :modern?) + end + + def test_reconnect_after_close + stub_initialize + stub_notification + client.connect + stub_request(:delete, url).to_return(status: 200) + client.close + + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.connect + + assert_predicate(client, :connected?) + assert_equal("s2", client.session_id) + end + + def test_close_allows_reinitializing_a_fresh_session + initialize_session + stub_request(:delete, url).to_return(status: 200) + client.close + + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-xyz", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" }) + + assert_equal("session-xyz", client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_send_notification_posts_body_and_accepts_202 + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "req-1", reason: "user cancel" }, + } + + stub_request(:post, url).with(body: notification.to_json).to_return(status: 202, body: "") + + result = client.send_notification(notification: notification) + + assert_nil(result, "send_notification must return nil") + end + + def test_send_notification_surfaces_faraday_errors + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "req-1" }, + } + + stub_request(:post, url).to_return(status: 500) + + error = assert_raises(RequestHandlerError) do + client.send_notification(notification: notification) + end + + assert_equal(:internal_error, error.error_type) + assert_match(%r{notifications/cancelled}, error.message) + end + private + def initialize_session + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + end + + def stub_initialize + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + end + + def stub_notification + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 202, body: "") + end + + def stub_discover + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + result: { + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + _meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } }, + ttlMs: 0, + cacheScope: "private", + }, + }.to_json, + ) + end + def stub_request(method, url) WebMock.stub_request(method, url) end + # Polls until the block is truthy; the listener runs on a background + # thread, so its requests are observed asynchronously. + def wait_until(timeout: 5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until yield + flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + end + + def requested?(stub) + WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive? + end + + # Builds an `HTTP` client over the Faraday test adapter for the SEP-2243 mirroring tests: + # the stub serves `server/discover` (so `connect(mode: :modern)` works), answers `tools/list` + # with `tools` (an Array, or a Proc returning the listing per request), and captures + # the request headers of any other POST via `on_call`. + def mcp_param_test_client(tools:, on_call:) + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do |env| + case JSON.parse(env.request_body)["method"] + when "server/discover" + discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" } + [200, { "Content-Type" => "application/json" }, { result: discover }.to_json] + when "tools/list" + listing = tools.respond_to?(:call) ? tools.call : tools + [200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json] + else + on_call.call(env.request_headers) + [200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json] + end + end + end + HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + end + + def mcp_param_annotated_tool + { + name: "test_custom_headers", + inputSchema: { + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + priority: { type: "integer", "x-mcp-header": "Priority" }, + non_ascii_val: { type: "string", "x-mcp-header": "NonAsciiVal" }, + null_val: { type: "string", "x-mcp-header": "NullVal" }, + }, + }, + } + end + def url "http://example.com" end @@ -207,6 +2605,35 @@ module MCP def client @client ||= HTTP.new(url: url) end + + # The SEP-1699 reconnection exchange: a `tools/call` whose SSE stream carries a priming event + # and the given `retry:` before closing, and a GET that replays the result for `Last-Event-ID`. + def reconnection_request + { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "test_reconnection", arguments: {} }, + } + end + + def stub_reconnection_with_retry(retry_ms) + stub_request(:post, url).with( + body: reconnection_request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: #{retry_ms}\ndata:\n\n", + ) + + stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: %(data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}\n\n), + ) + end end end end diff --git a/test/mcp/client/mcp_param_headers_test.rb b/test/mcp/client/mcp_param_headers_test.rb new file mode 100644 index 0000000..639a0ab --- /dev/null +++ b/test/mcp/client/mcp_param_headers_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/mcp_param_headers" + +module MCP + class Client + class McpParamHeadersTest < Minitest::Test + def test_scan_collects_declarations_from_properties + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { + "region" => { "type" => "string", "x-mcp-header" => "Region" }, + "priority" => { "type" => "integer", "x-mcp-header" => "Priority" }, + "verbose" => { "type" => "boolean", "x-mcp-header" => "Verbose" }, + "plain" => { "type" => "string" }, + }, + }) + + assert(scan[:valid]) + assert_equal( + [ + { path: ["region"], header_name: "Region", type: "string" }, + { path: ["priority"], header_name: "Priority", type: "integer" }, + { path: ["verbose"], header_name: "Verbose", type: "boolean" }, + ], + scan[:declarations], + ) + end + + def test_scan_collects_nested_declarations_and_symbol_keys + scan = McpParamHeaders.scan({ + type: "object", + properties: { + options: { + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + }, + }, + }, + }) + + assert(scan[:valid]) + assert_equal([{ path: ["options", "region"], header_name: "Region", type: "string" }], scan[:declarations]) + end + + def test_scan_accepts_number_typed_declarations + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "float_val" => { "type" => "number", "x-mcp-header" => "FloatVal" } }, + }) + + assert(scan[:valid]) + assert_equal("number", scan[:declarations].first[:type]) + end + + def test_scan_rejects_an_annotation_at_the_schema_root + scan = McpParamHeaders.scan({ "type" => "object", "x-mcp-header" => "Root" }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "statically reachable") + end + + def test_scan_rejects_annotations_under_non_reachable_keywords + ["oneOf", "items", "$defs"].each do |keyword| + nested = { "type" => "string", "x-mcp-header" => "Hidden" } + sub = keyword == "$defs" ? { "entry" => nested } : [nested] + scan = McpParamHeaders.scan({ "type" => "object", keyword => sub }) + + refute(scan[:valid], "expected the annotation under #{keyword} to invalidate the schema") + end + end + + def test_scan_rejects_empty_and_non_string_annotations + ["", 42].each do |annotation| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "non-empty string") + end + end + + def test_scan_rejects_non_token_annotation_names + ["has space", "colon:name", "日本語", "line\nbreak"].each do |annotation| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, + }) + + refute(scan[:valid], "expected #{annotation.inspect} to be rejected") + assert_includes(scan[:reason], "RFC 9110") + end + end + + def test_scan_rejects_non_primitive_declaring_properties + [{ "type" => "object" }, { "type" => "array" }, {}].each do |extra| + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { "value" => extra.merge("x-mcp-header" => "Value") }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "primitive-typed") + end + end + + def test_scan_rejects_case_insensitively_duplicated_names + scan = McpParamHeaders.scan({ + "type" => "object", + "properties" => { + "one" => { "type" => "string", "x-mcp-header" => "Region" }, + "two" => { "type" => "string", "x-mcp-header" => "REGION" }, + }, + }) + + refute(scan[:valid]) + assert_includes(scan[:reason], "case-insensitively unique") + end + + def test_primitive_to_string_conversions + assert_equal("us-west1", McpParamHeaders.primitive_to_string("us-west1")) + assert_equal("true", McpParamHeaders.primitive_to_string(true)) + assert_equal("false", McpParamHeaders.primitive_to_string(false)) + assert_equal("42", McpParamHeaders.primitive_to_string(42)) + assert_equal("42", McpParamHeaders.primitive_to_string(42.0)) + assert_equal("3.14159", McpParamHeaders.primitive_to_string(3.14159)) + assert_nil(McpParamHeaders.primitive_to_string(2**53 + 1)) + assert_nil(McpParamHeaders.primitive_to_string(Float::INFINITY)) + assert_nil(McpParamHeaders.primitive_to_string({ "nested" => true })) + end + + def test_encode_value_passes_safe_ascii_through + assert_equal("us-west1", McpParamHeaders.encode_value("us-west1")) + assert_equal("SELECT * FROM users", McpParamHeaders.encode_value("SELECT * FROM users")) + end + + def test_encode_value_wraps_unsafe_values_in_the_base64_sentinel + { + "" => "", + "Hello, 世界" => "Hello, 世界", + " padded " => " padded ", + "\tindented" => "\tindented", + "line1\nline2" => "line1\nline2", + "line1\r\nline2" => "line1\r\nline2", + "=?base64?Zm9v?=" => "=?base64?Zm9v?=", + }.each do |raw, decoded| + encoded = McpParamHeaders.encode_value(raw) + + assert_match(/\A=\?base64\?.*\?=\z/, encoded, "expected #{raw.inspect} to be wrapped") + payload = encoded.delete_prefix("=?base64?").delete_suffix("?=") + assert_equal(decoded, payload.unpack1("m0").force_encoding(Encoding::UTF_8)) + end + end + + def test_build_mirrors_declared_arguments_into_prefixed_headers + declarations = [ + { path: ["region"], header_name: "Region", type: "string" }, + { path: ["priority"], header_name: "Priority", type: "integer" }, + { path: ["method_val"], header_name: "Method", type: "string" }, + ] + + headers = McpParamHeaders.build(declarations, { "region" => "us-west1", priority: 42, "method_val" => "test-method" }) + + assert_equal( + { + "Mcp-Param-Region" => "us-west1", + "Mcp-Param-Priority" => "42", + "Mcp-Param-Method" => "test-method", + }, + headers, + ) + end + + def test_build_omits_null_absent_and_non_primitive_values + declarations = [ + { path: ["null_val"], header_name: "NullVal", type: "string" }, + { path: ["absent"], header_name: "Absent", type: "string" }, + { path: ["object_val"], header_name: "ObjectVal", type: "string" }, + ] + + headers = McpParamHeaders.build(declarations, { "null_val" => nil, "object_val" => { "a" => 1 } }) + + assert_empty(headers) + end + + def test_build_reads_nested_paths + declarations = [{ path: ["options", "region"], header_name: "Region", type: "string" }] + + headers = McpParamHeaders.build(declarations, { "options" => { "region" => "us-east1" } }) + + assert_equal({ "Mcp-Param-Region" => "us-east1" }, headers) + end + + def test_build_omits_values_that_cannot_be_represented_as_utf_8 + declarations = [{ path: ["blob"], header_name: "Blob", type: "string" }] + binary = (+"\xff\xfe").force_encoding(Encoding::ASCII_8BIT) + + headers = McpParamHeaders.build(declarations, { "blob" => binary }) + + assert_empty(headers) + end + end + end +end diff --git a/test/mcp/client/oauth/bounded_body_test.rb b/test/mcp/client/oauth/bounded_body_test.rb new file mode 100644 index 0000000..aeaf118 --- /dev/null +++ b/test/mcp/client/oauth/bounded_body_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "test_helper" +require "faraday" +require "socket" +require "stringio" +require "zlib" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class BoundedBodyTest < Minitest::Test + def test_on_data_rejects_chunks_that_together_exceed_the_limit + bounded = bounded_body(max_bytes: 64) + + error = assert_raises(too_large_error) do + 4.times do + bounded.on_data.call("a" * 32, 32, nil) + end + end + + assert_includes(error.message, "exceeds 64 bytes") + end + + def test_on_data_accepts_a_body_that_stops_at_the_limit + bounded = bounded_body(max_bytes: 64) + + 2.times do + bounded.on_data.call("a" * 32, 32, nil) + end + + assert_equal("a" * 64, bounded.response_for(stub_response(body: "")).body) + end + + def test_response_for_rejects_an_over_limit_body_when_the_adapter_did_not_stream + bounded = bounded_body(max_bytes: 64) + + error = assert_raises(too_large_error) do + bounded.response_for(stub_response(body: "a" * 65)) + end + + assert_includes(error.message, "exceeds 64 bytes") + end + + def test_response_for_falls_back_to_the_response_body_when_the_adapter_did_not_stream + bounded = bounded_body(max_bytes: 64) + + assert_equal("a" * 64, bounded.response_for(stub_response(body: "a" * 64)).body) + end + + def test_response_for_coerces_a_non_string_body + bounded = bounded_body(max_bytes: 64) + + assert_equal("", bounded.response_for(stub_response(body: nil)).body) + end + + def test_response_for_carries_the_status_through + bounded = bounded_body(max_bytes: 64) + + assert_equal(404, bounded.response_for(stub_response(status: 404, body: "")).status) + end + + # Served over a real socket rather than WebMock: WebMock hands back the stubbed body verbatim + # whatever its `Content-Encoding`, so the adapter's inflater never runs and a stubbed version + # of this test would assert the opposite of what it looks like. + def test_the_cap_counts_decompressed_bytes + bounded = bounded_body(max_bytes: 1024 * 1024) + compressed = gzip("a" * (5 * 1024 * 1024)) + + assert_operator(compressed.bytesize, :<, 1024 * 1024, "the compressed body must fit under the cap") + + serving_gzip(compressed) do |url| + assert_raises(too_large_error) do + Faraday.new.get(url) do |req| + req.options.on_data = bounded.on_data + req.options.timeout = 5 + end + end + end + end + + private + + def gzip(content) + io = StringIO.new + + writer = Zlib::GzipWriter.new(io) + writer.write(content) + writer.close + + io.string + end + + def serving_gzip(body) + # Other test files load `webmock/minitest`, which blocks real connections process-wide. + # This is the one place that needs the adapter's own request path. + WebMock.disable! if defined?(WebMock) + + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + + loop do + line = socket.gets + + break if line.nil? || line == "\r\n" + end + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Encoding: gzip\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n\r\n", + ) + socket.write(body) + + socket.close + rescue IOError, SystemCallError + nil + end + + # Scoped to start after both are assigned, so the cleanup below never runs against `nil` + # and a failure to open the socket surfaces as itself rather than as a `NoMethodError`. + begin + yield("http://127.0.0.1:#{server.addr[1]}/") + ensure + thread.kill + server.close + end + ensure + WebMock.enable! if defined?(WebMock) + end + + def bounded_body(max_bytes:) + OAuth.const_get(:BoundedBody).new(max_bytes: max_bytes) + end + + def too_large_error + OAuth.const_get(:BoundedBody)::TooLargeError + end + + def stub_response(body:, status: 200) + Struct.new(:status, :body).new(status, body) + end + end + end + end +end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb new file mode 100644 index 0000000..6c679e3 --- /dev/null +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class ClientCredentialsProviderTest < Minitest::Test + # Every parameter the flow sets on a token request, spelled out rather than read from + # `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here. + RESERVED_TOKEN_REQUEST_PARAMS = [ + "grant_type", + "client_id", + "client_secret", + "client_assertion", + "client_assertion_type", + "scope", + "resource", + "code", + "code_verifier", + "redirect_uri", + "refresh_token", + "assertion", + ].freeze + + def test_initialize_stores_credentials_as_client_information + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("cc-secret", info["client_secret"]) + assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + end + + def test_authorization_flow_is_client_credentials + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + assert_equal(:client_credentials, provider.authorization_flow) + end + + def test_initialize_accepts_client_secret_post + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "client_secret_post", + ) + + assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"]) + end + + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", http_client_customizer: customizer) + + assert_same(customizer, provider.http_client_customizer) + assert_nil(ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret").http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials + storage = InMemoryStorage.new + + error = assert_raises(ArgumentError) do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", storage: storage, http_client_customizer: "recorder") + end + + assert_equal("http_client_customizer must respond to call (got String).", error.message) + assert_nil(storage.client_information) + end + + def test_initialize_rejects_missing_client_id + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: value, client_secret: "cc-secret") + end + end + end + + def test_initialize_rejects_missing_client_secret + # The client_credentials grant is for confidential clients, so a credential is mandatory. + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: value) + end + end + end + + def test_initialize_rejects_none_auth_method + # An unauthenticated client_credentials request is meaningless. + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "none", + ) + end + end + + def test_token_helpers_delegate_to_storage + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + provider.save_tokens("access_token" => "cc-token") + + assert_equal("cc-token", provider.access_token) + provider.clear_tokens! + assert_nil(provider.tokens) + end + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_es256_key + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + end + + def private_key_jwt_provider(key: generate_es256_key) + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: key, + signing_algorithm: "ES256", + ) + end + + def test_initialize_accepts_private_key_jwt + provider = private_key_jwt_provider + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("private_key_jwt", info["token_endpoint_auth_method"]) + end + + def test_initialize_private_key_jwt_does_not_persist_the_key_or_a_secret + # The PEM must never reach a (potentially persistent) storage backend. + provider = private_key_jwt_provider + + info = provider.client_information + refute(info.key?("client_secret")) + refute(info.values.any? { |value| value.to_s.include?("PRIVATE KEY") }) + end + + def test_initialize_private_key_jwt_requires_private_key + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_requires_signing_algorithm + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + ) + end + end + + def test_initialize_private_key_jwt_rejects_client_secret + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_fails_fast_on_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: OpenSSL::PKey::RSA.new(2048), + signing_algorithm: "ES256", + ) + end + end + + def test_client_assertion_returns_signed_jwt_for_the_audience + provider = private_key_jwt_provider + + assertion = provider.client_assertion(audience: "https://auth.example.com") + payload_segment = assertion.split(".")[1] + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + end + + def test_token_request_params_is_nil_by_default + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + assert_nil(provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) + + params["audience"] << "/changed" + params["organization"] = "org_123" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + assert_predicate(provider.token_request_params["audience"], :frozen?) + end + + def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself + RESERVED_TOKEN_REQUEST_PARAMS.each do |key| + error = assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{key.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) + end + + assert_includes(error.message, key.inspect) + end + end + + def test_initialize_rejects_token_request_params_that_are_not_a_hash_of_strings + ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }].each do |params| + assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{params.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) + end + end + end + + def test_initialize_rejects_token_request_params_that_compare_keys_by_identity + # Two equal keys are two entries in such a Hash and would silently collapse into one when copied. + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_request_params: { "audience" => "x" }.compare_by_identity, + ) + end + + assert_match(/identity/, error.message) + end + + def test_initialize_copies_keys_that_hash_would_share_with_the_caller + # `Hash` copies and freezes only keys whose class is exactly `String`; a subclass key would otherwise + # stay shared with the caller, and a later `replace` would turn it into a reserved name. + key = Class.new(String).new("audience") + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) + + key.replace("grant_type") + + assert_equal({ "audience" => "x" }, provider.token_request_params) + refute_same(key, provider.token_request_params.keys.first) + assert_predicate(provider.token_request_params.keys.first, :frozen?) + end + + def test_initialize_keeps_empty_token_request_params_as_a_frozen_empty_hash + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: {}) + + assert_equal({}, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_writes_no_client_information_when_token_request_params_are_rejected + storage = InMemoryStorage.new + + assert_raises(Flow::InvalidTokenRequestParamsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + storage: storage, + token_request_params: { "grant_type" => "password" }, + ) + end + + assert_nil(storage.client_information) + end + end + end + end +end diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb new file mode 100644 index 0000000..ed49b47 --- /dev/null +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class CrossAppAccessProviderTest < Minitest::Test + def build_provider(assertion_provider: ->(**) { "id-jag" }) + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: assertion_provider, + ) + end + + def test_initialize_stores_credentials_with_basic_auth_method + provider = build_provider + + info = provider.client_information + assert_equal("xaa-client", info["client_id"]) + assert_equal("xaa-secret", info["client_secret"]) + assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + end + + def test_authorization_flow_is_jwt_bearer + assert_equal(:jwt_bearer, build_provider.authorization_flow) + end + + def test_jwt_bearer_assertion_passes_audience_and_resource_through + received = nil + provider = build_provider( + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + assertion = provider.jwt_bearer_assertion( + audience: "https://auth.example.com", + resource: "https://srv.example.com/mcp", + ) + + assert_equal("id-jag-assertion", assertion) + assert_equal( + { audience: "https://auth.example.com", resource: "https://srv.example.com/mcp" }, + received, + ) + end + + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + http_client_customizer: customizer, + ) + + assert_same(customizer, provider.http_client_customizer) + assert_nil(build_provider.http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials + storage = InMemoryStorage.new + + error = assert_raises(ArgumentError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + storage: storage, + http_client_customizer: "recorder", + ) + end + + assert_equal("http_client_customizer must respond to call (got String).", error.message) + assert_nil(storage.client_information) + end + + def test_initialize_rejects_missing_client_id + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: " ", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + ) + end + end + + def test_initialize_rejects_missing_client_secret + # SEP-990 authenticates the jwt-bearer grant with client_secret_basic. + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: nil, + assertion_provider: ->(**) { "id-jag" }, + ) + end + end + + def test_initialize_rejects_non_callable_assertion_provider + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: "not callable", + ) + end + end + + def test_token_helpers_delegate_to_storage + provider = build_provider + provider.save_tokens("access_token" => "xaa-token") + + assert_equal("xaa-token", provider.access_token) + provider.clear_tokens! + assert_nil(provider.tokens) + end + + def test_token_request_params_is_nil_by_default + assert_nil(build_provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + token_request_params: params, + ) + + params["audience"] << "/changed" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_writes_no_client_information_when_token_request_params_are_rejected + storage = InMemoryStorage.new + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + storage: storage, + token_request_params: { "assertion" => "x" }, + ) + end + + assert_includes(error.message, '"assertion"') + assert_nil(storage.client_information) + end + end + end + end +end diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb new file mode 100644 index 0000000..d58c7f2 --- /dev/null +++ b/test/mcp/client/oauth/discovery_test.rb @@ -0,0 +1,727 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Client + module OAuth + class DiscoveryTest < Minitest::Test + def test_parse_www_authenticate_with_quoted_resource_metadata + header = 'Bearer error="invalid_token", error_description="Missing", ' \ + 'resource_metadata="https://srv.example.com/.well-known/oauth-protected-resource/mcp"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("Missing", params["error_description"]) + assert_equal("https://srv.example.com/.well-known/oauth-protected-resource/mcp", params["resource_metadata"]) + end + + def test_parse_www_authenticate_returns_empty_for_other_schemes + assert_empty(Discovery.parse_www_authenticate('Basic realm="x"')) + end + + def test_parse_www_authenticate_returns_empty_for_nil + assert_empty(Discovery.parse_www_authenticate(nil)) + end + + def test_parse_www_authenticate_finds_bearer_after_other_scheme + header = 'Basic realm="x", Bearer error="invalid_token", resource_metadata="https://srv/x"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_stops_at_following_scheme + header = 'Bearer error="invalid_token", resource_metadata="https://srv/x", DPoP algs="ES256"' + params = Discovery.parse_www_authenticate(header) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + refute(params.key?("algs")) + end + + def test_parse_www_authenticate_returns_empty_when_bearer_absent + assert_empty(Discovery.parse_www_authenticate('DPoP algs="ES256"')) + end + + def test_parse_www_authenticate_handles_bare_bearer + assert_empty(Discovery.parse_www_authenticate("Bearer")) + end + + def test_parse_www_authenticate_handles_tab_separator + params = Discovery.parse_www_authenticate(%(Bearer\terror="invalid_token",\tresource_metadata="https://srv/x")) + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_tolerates_trailing_comma + params = Discovery.parse_www_authenticate('Bearer error="invalid_token", resource_metadata="https://srv/x",') + + assert_equal("invalid_token", params["error"]) + assert_equal("https://srv/x", params["resource_metadata"]) + end + + def test_parse_www_authenticate_handles_quoted_value_with_comma_and_spaces + params = Discovery.parse_www_authenticate('Bearer error_description="missing, expired, or revoked token"') + + assert_equal("missing, expired, or revoked token", params["error_description"]) + end + + def test_parse_www_authenticate_unescapes_quoted_pair + # Per RFC 7230 Section 3.2.6, a backslash inside a quoted-string escapes + # the next character. The parser must surface the unescaped form. + header = 'Bearer error_description="value with \"quoted\" word and a back\\\\slash"' + params = Discovery.parse_www_authenticate(header) + + assert_equal('value with "quoted" word and a back\\slash', params["error_description"]) + end + + def test_protected_resource_metadata_urls_uses_explicit_url_first + urls = Discovery.protected_resource_metadata_urls( + server_url: "https://api.example.com/mcp", + resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ) + + assert_equal("https://api.example.com/.well-known/oauth-protected-resource/mcp", urls.first) + assert_includes(urls, "https://api.example.com/.well-known/oauth-protected-resource") + end + + def test_protected_resource_metadata_urls_falls_back_to_path_then_root + urls = Discovery.protected_resource_metadata_urls(server_url: "https://api.example.com/mcp") + + assert_equal( + [ + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + "https://api.example.com/.well-known/oauth-protected-resource", + ], + urls, + ) + end + + def test_authorization_server_metadata_urls_for_root_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + + assert_equal( + [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ], + urls, + ) + end + + def test_authorization_server_metadata_urls_for_path_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + assert_includes(urls, "https://auth.example.com/.well-known/oauth-authorization-server/tenant1") + assert_includes(urls, "https://auth.example.com/.well-known/openid-configuration/tenant1") + assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration") + end + + # Per SEP-2351, MCP explicitly uses the RFC 8414 default `oauth-authorization-server` well-known URI suffix + # and defines no application-specific suffix. The first probed candidate for a root issuer must be exactly + # that default suffix. + def test_authorization_server_metadata_urls_probe_rfc8414_default_suffix_first + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + + assert_equal("https://auth.example.com/.well-known/oauth-authorization-server", urls.first) + end + + def test_authorization_server_metadata_urls_use_only_registered_well_known_suffixes + root_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com") + path_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + (root_urls + path_urls).each do |url| + suffix = url[%r{/\.well-known/([^/]+)}, 1] + + assert_includes( + ["oauth-authorization-server", "openid-configuration"], suffix, "unexpected well-known suffix in #{url}" + ) + end + end + + def test_authorization_server_metadata_urls_put_path_inserted_oauth_candidate_first_for_path_issuer + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1") + + assert_equal("https://auth.example.com/.well-known/oauth-authorization-server/tenant1", urls.first) + end + + def test_authorization_server_metadata_urls_treat_trailing_slash_issuer_as_root + urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/") + + assert_equal( + ["https://auth.example.com/.well-known/oauth-authorization-server", "https://auth.example.com/.well-known/openid-configuration"], + urls, + ) + end + + def test_infer_application_type_returns_native_for_loopback_http_redirect_uris + uris = ["http://localhost:0/callback", "http://127.0.0.1:8080/cb", "http://[::1]/cb"] + + assert_equal("native", Discovery.infer_application_type(uris)) + end + + def test_infer_application_type_returns_native_for_custom_scheme_redirect_uris + assert_equal("native", Discovery.infer_application_type(["com.example.app:/oauth/callback"])) + end + + def test_infer_application_type_returns_web_for_https_redirect_uris + assert_equal("web", Discovery.infer_application_type(["https://app.example.com/callback"])) + end + + def test_infer_application_type_returns_web_when_any_redirect_uri_is_not_native + uris = ["http://localhost:0/callback", "https://app.example.com/callback"] + + assert_equal("web", Discovery.infer_application_type(uris)) + end + + def test_infer_application_type_returns_web_for_localhost_lookalike_host + assert_equal("web", Discovery.infer_application_type(["http://localhost.example.com/cb"])) + end + + def test_infer_application_type_returns_web_for_nil_or_empty_redirect_uris + assert_equal("web", Discovery.infer_application_type(nil)) + assert_equal("web", Discovery.infer_application_type([])) + end + + def test_infer_application_type_returns_web_for_unparseable_redirect_uri + assert_equal("web", Discovery.infer_application_type(["http://[invalid"])) + end + + def test_canonicalize_url_normalizes_scheme_host_port_and_path + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("HTTPS://Srv.Example.COM:443/mcp#frag"), + ) + assert_equal( + "http://srv.example.com", + Discovery.canonicalize_url("http://Srv.Example.COM:80/"), + ) + assert_equal( + "https://srv.example.com:8443/mcp", + Discovery.canonicalize_url("https://srv.example.com:8443/mcp"), + ) + end + + def test_canonicalize_url_resolves_dot_segments + # Critical for audience binding: an attacker-supplied URL like + # `/api/../mcp` must collapse to `/mcp` so it cannot pass a PRM check + # whose `resource` is the more privileged `/api` path. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/../mcp"), + ) + assert_equal( + "https://srv.example.com/api/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/./mcp"), + ) + assert_equal( + "https://srv.example.com/bar", + Discovery.canonicalize_url("https://srv.example.com/foo/../../bar"), + ) + assert_equal( + "https://srv.example.com", + Discovery.canonicalize_url("https://srv.example.com/api/.."), + ) + end + + def test_canonicalize_url_resolves_percent_encoded_dot_segments + # `%2e` and `%2E` are percent-encoded forms of `.` (RFC 3986 Section 6.2.2.2) + # and must be decoded *before* dot-segment resolution. Otherwise, + # an attacker-supplied URL like `/api/%2e%2e/mcp` would slip past + # `remove_dot_segments` and escape the PRM `resource=/api` audience + # binding. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2E%2E/mcp"), + ) + assert_equal( + "https://srv.example.com/api/mcp", + Discovery.canonicalize_url("https://srv.example.com/api/%2e/mcp"), + ) + end + + def test_canonicalize_url_drops_userinfo + # The canonicalized URL is sent on the wire as the RFC 8707 `resource` + # claim and is surfaced in error messages, so credentials in + # the authority must never round-trip: dropping `user:pass@` keeps them + # from leaking to the authorization server or to log destinations. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://user:pass@Srv.Example.COM:443/api/../mcp"), + ) + end + + def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling + # Reported next to a failed request, the URL must still match the server's access log, so the host + # and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding. + assert_equal( + "https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson", + Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"), + ) + end + + def test_redact_url_does_not_echo_a_url_it_cannot_parse + assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc")) + end + + def test_canonicalize_url_normalizes_query_to_match_faraday + # Faraday rewrites `env.url` before sending a request: it sorts + # parameters by name, uppercases percent-encoded hex, and drops + # a bare `?` with no parameters. The canonical form must apply + # the same transformation so the URL guard does not raise a false + # positive on every valid request with a query string. + assert_equal( + "https://srv.example.com/mcp?a=1&b=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?b=2&a=1"), + ) + assert_equal( + "https://srv.example.com/mcp?x=%2F", + Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?"), + ) + end + + def test_canonicalize_url_collapses_same_name_query_parameters + # Faraday's internal query encoder uses a Hash, so repeated keys + # collapse to the last value. The canonical form must apply + # the same collapse, otherwise the URL guard false-positives on URLs + # like `?a=1&a=2` (Faraday rewrites to `?a=2`). + assert_equal( + "https://srv.example.com/mcp?a=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&a=2"), + ) + assert_equal( + "https://srv.example.com/mcp?a=3&b=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b=2&a=3"), + ) + assert_equal( + "https://srv.example.com/mcp?x=%2F", + Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f&x=%2F"), + ) + end + + def test_canonicalize_url_preserves_valueless_vs_empty_value_distinction + # Faraday preserves the difference between `?key` (no `=`) and + # `?key=` (empty value) exactly as the caller passed it. A naive + # `URI.decode_www_form` round-trip would collapse both to `?key=` + # and false-positive the URL guard / RFC 8707 `resource` check for + # any URL that uses the value-less form. + assert_equal( + "https://srv.example.com/mcp?tenant", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant"), + ) + assert_equal( + "https://srv.example.com/mcp?tenant=", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant="), + ) + assert_equal( + "https://srv.example.com/mcp?a=1&b", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b"), + ) + # Collapse with last-write-wins also keeps the `=` form of + # the surviving segment: `flag=1&flag` -> last is `flag` (no `=`), so + # the canonical form is `?flag`, matching Faraday. + assert_equal( + "https://srv.example.com/mcp?flag", + Discovery.canonicalize_url("https://srv.example.com/mcp?flag=1&flag"), + ) + end + + def test_canonicalize_url_preserves_array_form_repeated_keys + # Keys ending in `[]` are Faraday's array notation: the encoder + # preserves every occurrence in input order instead of collapsing + # them. The canonical form must do the same; otherwise a hijacked + # middleware that drops one entry from `?tenant[]=victim&tenant[]=...` + # would slip past the URL guard, and a legitimate + # `?roles[]=a&roles[]=b` URL would false-positive on the first + # request. + assert_equal( + "https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2"), + ) + assert_equal( + "https://srv.example.com/mcp?tenant%5B%5D=evil&tenant%5B%5D=victim", + Discovery.canonicalize_url("https://srv.example.com/mcp?tenant[]=evil&tenant[]=victim"), + ) + assert_equal( + "https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2&b=3", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2&b=3"), + ) + # Nested keys like `a[b]` are not array notation. They collapse with + # last-write-wins, matching Faraday's `NestedParamsEncoder`. + assert_equal( + "https://srv.example.com/mcp?a%5Bb%5D=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a[b]=1&a[b]=2"), + ) + end + + def test_canonicalize_url_drops_empty_name_and_blank_query_segments + # Faraday's encoder treats pairs with an empty name and blank `&&` + # separators as no parameter at all. The canonical form must do + # the same; otherwise URLs like `?=v` or `?&&a=1&&` produce a snapshot + # that disagrees with the Faraday-rewritten effective URL and + # the request-time guard false-positives. + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?=v"), + ) + assert_equal( + "https://srv.example.com/mcp?a=1", + Discovery.canonicalize_url("https://srv.example.com/mcp?&&a=1&&"), + ) + assert_equal( + "https://srv.example.com/mcp?a=2", + Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&&a=2"), + ) + assert_equal( + "https://srv.example.com/mcp?a=1", + Discovery.canonicalize_url("https://srv.example.com/mcp?=&=&a=1"), + ) + assert_equal( + "https://srv.example.com/mcp", + Discovery.canonicalize_url("https://srv.example.com/mcp?&"), + ) + end + + def test_resource_covers_blocks_dot_segment_bypass_after_canonicalization + server = Discovery.canonicalize_url("https://srv.example.com/api/../mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/api") + + refute( + Discovery.resource_covers?(prm: prm, server: server), + "PRM `resource=/api` must not cover `/api/../mcp` after canonicalization", + ) + end + + def test_secure_url_accepts_https + assert(Discovery.secure_url?("https://srv.example.com/mcp")) + assert(Discovery.secure_url?("HTTPS://Srv.Example.COM/mcp")) + end + + def test_secure_url_accepts_loopback_http + assert(Discovery.secure_url?("http://localhost/x")) + assert(Discovery.secure_url?("http://localhost:8080/x")) + assert(Discovery.secure_url?("http://127.0.0.1/x")) + assert(Discovery.secure_url?("http://127.42.0.5/x")) + assert(Discovery.secure_url?("http://127.255.255.255/x")) + assert(Discovery.secure_url?("http://[::1]/x")) + end + + def test_secure_url_rejects_non_loopback_http + refute(Discovery.secure_url?("http://srv.example.com/mcp")) + refute(Discovery.secure_url?("http://192.168.1.1/x")) + refute(Discovery.secure_url?("http://10.0.0.1/x")) + end + + def test_secure_url_rejects_hostname_tricks_that_resemble_loopback + # Naive `start_with?("127.")` would let these slip through; IPAddr-based + # checks correctly classify them as non-loopback hostnames. + refute(Discovery.secure_url?("http://127.attacker.com/x")) + refute(Discovery.secure_url?("http://127.0.0.1.evil.com/x")) + refute(Discovery.secure_url?("http://127./x")) + # `localhost` MUST be matched exactly - `foo.localhost` is just a regular + # hostname that happens to share the suffix. + refute(Discovery.secure_url?("http://foo.localhost/x")) + end + + def test_secure_url_rejects_non_http_schemes + refute(Discovery.secure_url?("ftp://srv.example.com/mcp")) + refute(Discovery.secure_url?("file:///etc/passwd")) + refute(Discovery.secure_url?("javascript:alert(1)")) + refute(Discovery.secure_url?("")) + refute(Discovery.secure_url?(nil)) + end + + def test_secure_url_rejects_malformed_or_hostless_urls + refute(Discovery.secure_url?("https:/missing-host")) + refute(Discovery.secure_url?("https:///missing-host")) + refute(Discovery.secure_url?("https://")) + refute(Discovery.secure_url?("not a url")) + end + + def test_same_origin_accepts_matching_scheme_host_and_port + assert(Discovery.same_origin?("https://srv.example.com/.well-known/x", "https://srv.example.com/mcp")) + assert(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com:8443/b")) + assert(Discovery.same_origin?("http://localhost:9292/a", "http://localhost:9292/mcp")) + assert(Discovery.same_origin?("https://srv.example.com/a?q=1#f", "https://srv.example.com/mcp")) + end + + def test_same_origin_ignores_case_in_scheme_and_host + assert(Discovery.same_origin?("HTTPS://SRV.Example.COM/a", "https://srv.example.com/mcp")) + end + + def test_same_origin_treats_an_explicit_default_port_as_the_implicit_one + assert(Discovery.same_origin?("https://srv.example.com:443/a", "https://srv.example.com/mcp")) + assert(Discovery.same_origin?("http://srv.example.com:80/a", "http://srv.example.com/mcp")) + end + + def test_same_origin_rejects_differing_components + refute(Discovery.same_origin?("https://attacker.example.com/a", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://169.254.169.254/latest/", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("http://srv.example.com/a", "https://srv.example.com/mcp")) + # A subdomain is a different origin, so a hijacked challenge cannot walk + # sideways into a sibling host that shares the registrable domain. + refute(Discovery.same_origin?("https://evil.srv.example.com/a", "https://srv.example.com/mcp")) + end + + def test_same_origin_rejects_malformed_or_hostless_urls + refute(Discovery.same_origin?("not a url", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("https://srv.example.com/a", "not a url")) + refute(Discovery.same_origin?("https://", "https://srv.example.com/mcp")) + refute(Discovery.same_origin?(nil, "https://srv.example.com/mcp")) + refute(Discovery.same_origin?("/relative/path", "https://srv.example.com/mcp")) + end + + def test_private_network_host_recognizes_reserved_ipv4_ranges + assert(Discovery.private_network_host?("0.0.0.0")) + assert(Discovery.private_network_host?("10.0.0.5")) + assert(Discovery.private_network_host?("100.64.0.1")) + assert(Discovery.private_network_host?("127.0.0.1")) + # The cloud metadata address this check exists for. + assert(Discovery.private_network_host?("169.254.169.254")) + assert(Discovery.private_network_host?("172.16.0.1")) + assert(Discovery.private_network_host?("172.31.255.255")) + assert(Discovery.private_network_host?("192.168.1.1")) + end + + def test_private_network_host_recognizes_reserved_ipv6_ranges + assert(Discovery.private_network_host?("::")) + assert(Discovery.private_network_host?("::1")) + assert(Discovery.private_network_host?("[::1]")) + assert(Discovery.private_network_host?("fd00::1")) + assert(Discovery.private_network_host?("fe80::1")) + assert(Discovery.private_network_host?("[fe80::1]")) + end + + def test_private_network_host_unwraps_ipv4_mapped_ipv6 + # `::ffff:169.254.169.254` reaches the same interface as the IPv4 spelling, + # so the mapped form must not be a way around the ranges above. + assert(Discovery.private_network_host?("::ffff:169.254.169.254")) + assert(Discovery.private_network_host?("[::ffff:10.0.0.5]")) + assert(Discovery.private_network_host?("::ffff:127.0.0.1")) + refute(Discovery.private_network_host?("::ffff:93.184.216.34")) + end + + def test_private_network_host_refuses_non_canonical_ipv4_spellings + # `inet_aton` accepts forms `IPAddr` rejects, and the resolver reaches the same + # interfaces through them: `2130706433` and `127.1` are 127.0.0.1, and + # `0xa9fea9fe` is the cloud metadata address. Checking only the dotted-decimal + # spelling would refuse `169.254.169.254` and admit every alias of it. + assert(Discovery.private_network_host?("2130706433")) + assert(Discovery.private_network_host?("0x7f000001")) + assert(Discovery.private_network_host?("0xa9fea9fe")) + assert(Discovery.private_network_host?("127.1")) + assert(Discovery.private_network_host?("127.0.1")) + assert(Discovery.private_network_host?("0x7f.0.0.1")) + assert(Discovery.private_network_host?("0251.0376.0251.0376")) + end + + def test_private_network_host_refuses_numeric_spellings_whose_value_is_resolver_dependent + # `010.0.0.1` is 8.0.0.1 read as octal and 10.0.0.1 read as decimal, and platforms + # disagree on which it is. Both readings are refused rather than one being chosen, + # since the reading not taken would otherwise be a way through. + assert(Discovery.private_network_host?("010.0.0.1")) + assert(Discovery.private_network_host?("0177.0.0.1")) + + # The same applies to numeric spellings that land outside the blocked ranges, or + # nowhere at all: nothing is addressed this way on purpose. + assert(Discovery.private_network_host?("0x8080808")) + assert(Discovery.private_network_host?("256.1.1.1")) + end + + def test_private_network_host_recognizes_localhost_by_name_only + assert(Discovery.private_network_host?("localhost")) + assert(Discovery.private_network_host?("LOCALHOST")) + refute(Discovery.private_network_host?("foo.localhost")) + refute(Discovery.private_network_host?("localhost.attacker.com")) + end + + def test_private_network_host_rejects_public_addresses_and_names + refute(Discovery.private_network_host?("93.184.216.34")) + refute(Discovery.private_network_host?("8.8.8.8")) + refute(Discovery.private_network_host?("172.32.0.1")) + refute(Discovery.private_network_host?("2606:4700::1111")) + refute(Discovery.private_network_host?("srv.example.com")) + refute(Discovery.private_network_host?(nil)) + refute(Discovery.private_network_host?("")) + end + + def test_private_network_host_does_not_resolve_names + # Documents the deliberate limit of the predicate: an internal host that is + # named rather than addressed reads as public here. `same_origin?` is what + # keeps the `resource_metadata` URL from reaching one. + refute(Discovery.private_network_host?("vault.corp.internal")) + end + + def test_client_id_metadata_document_url_accepts_https_with_path + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com:8443/cm")) + end + + def test_client_id_metadata_document_url_accepts_boundary_forms + # An uppercase scheme is `https` after case folding (URIs are scheme-insensitive, RFC 3986 Section 3.1). + assert(Discovery.client_id_metadata_document_url?("HTTPS://app.example.com/cm")) + + # A trailing slash and consecutive slashes are non-root, non-dot paths. + # RFC 3986 `remove_dot_segments` does not collapse them, so they remain + # stable `client_id` strings rather than aliasing another URL. + assert(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/")) + assert(Discovery.client_id_metadata_document_url?("https://app.example.com//cm")) + + # The default https port is equivalent to omitting it; either form is a valid origin. + assert(Discovery.client_id_metadata_document_url?("https://app.example.com:443/cm")) + + # IPv6 literal hosts and Punycode (IDN) hosts are ordinary hosts. + assert(Discovery.client_id_metadata_document_url?("https://[2001:db8::1]/cm")) + assert(Discovery.client_id_metadata_document_url?("https://[::1]/cm")) + assert(Discovery.client_id_metadata_document_url?("https://xn--e1afmkfd.example.com/cm")) + end + + def test_client_id_metadata_document_url_rejects_non_https_schemes + refute(Discovery.client_id_metadata_document_url?("http://localhost/cm")) + refute(Discovery.client_id_metadata_document_url?("http://app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("ftp://app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("file:///cm")) + end + + def test_client_id_metadata_document_url_rejects_root_and_empty_path + refute(Discovery.client_id_metadata_document_url?("https://app.example.com")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/")) + end + + def test_client_id_metadata_document_url_rejects_fragment_query_userinfo + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm#frag")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm?x=1")) + refute(Discovery.client_id_metadata_document_url?("https://user:pass@app.example.com/cm")) + refute(Discovery.client_id_metadata_document_url?("https://user@app.example.com/cm")) + end + + def test_client_id_metadata_document_url_rejects_dot_segments + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/./cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/a/../cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/.")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/..")) + # Percent-encoded `.` would otherwise let `%2E` and `.` produce + # different `client_id` values for the same document. + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2E/cm")) + refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2e/cm")) + end + + def test_client_id_metadata_document_url_rejects_nil_or_empty_or_malformed + refute(Discovery.client_id_metadata_document_url?(nil)) + refute(Discovery.client_id_metadata_document_url?("")) + refute(Discovery.client_id_metadata_document_url?("not a url")) + refute(Discovery.client_id_metadata_document_url?("https://")) + end + + def test_resource_covers_blocks_percent_encoded_dot_segment_bypass + server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/api") + + refute( + Discovery.resource_covers?(prm: prm, server: server), + "PRM `resource=/api` must not cover `/api/%2e%2e/mcp` after canonicalization", + ) + end + + def test_resource_covers_blocks_cross_tenant_query_bypass + # An attacker-controlled PRM that advertises a different tenant's + # resource URI MUST NOT be treated as covering the server URL. If it + # were, the client would mint a token for `tenant=evil` while + # actually talking to `tenant=victim`, defeating audience binding. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm_evil = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil") + + refute( + Discovery.resource_covers?(prm: prm_evil, server: server), + "PRM `resource=?tenant=evil` must not cover server `?tenant=victim`", + ) + end + + def test_resource_covers_allows_matching_query + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + + assert(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_allows_prm_without_query_to_cover_server_with_query + # A query-less PRM acts as a generic identifier over the origin + + # path prefix and is allowed to cover server URLs that scope by query. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=1") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp") + + assert(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_blocks_prm_with_query_when_server_has_none + # PRM that names a specific tenant cannot cover an unscoped server + # URL; otherwise a hijacked PRM could re-bind the token to a tenant + # the user did not select. + server = Discovery.canonicalize_url("https://srv.example.com/mcp") + prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil") + + refute(Discovery.resource_covers?(prm: prm, server: server)) + end + + def test_resource_covers_does_not_treat_empty_query_as_wildcard + # `https://srv.example.com/mcp?` parses with `URI#query == ""`, which + # is distinct from "no query at all" (`URI#query == nil`). A naive + # `prm_query.nil? || prm_query.empty?` would let an attacker-supplied + # PRM with a trailing `?` slip past the cross-tenant audience check. + server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim") + prm_empty_query = "https://srv.example.com/mcp?" + + refute( + Discovery.resource_covers?(prm: prm_empty_query, server: server), + "PRM with a literal empty query (`?`) must not act as a wildcard cover", + ) + end + + def test_resource_covers_returns_true_for_same_origin_prefix + assert(Discovery.resource_covers?( + prm: "https://srv.example.com", + server: "https://srv.example.com/mcp", + )) + assert(Discovery.resource_covers?( + prm: "https://srv.example.com/mcp", + server: "https://srv.example.com/mcp", + )) + assert(Discovery.resource_covers?( + prm: "https://srv.example.com/api", + server: "https://srv.example.com/api/v2/mcp", + )) + end + + def test_resource_covers_returns_false_for_different_origin_or_path_mismatch + refute(Discovery.resource_covers?( + prm: "https://evil.example.com/mcp", + server: "https://srv.example.com/mcp", + )) + refute(Discovery.resource_covers?( + prm: "https://srv.example.com:8443/mcp", + server: "https://srv.example.com/mcp", + )) + # Path "api" must not be considered a prefix of "/apiv2". + refute(Discovery.resource_covers?( + prm: "https://srv.example.com/api", + server: "https://srv.example.com/apiv2", + )) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb new file mode 100644 index 0000000..c13b49b --- /dev/null +++ b/test/mcp/client/oauth/flow_test.rb @@ -0,0 +1,4453 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "json" +require "webmock/minitest" +require "faraday" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class FlowTest < Minitest::Test + def setup + WebMock.enable! + @server_url = "https://srv.example.com/mcp" + @prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp" + @auth_base = "https://auth.example.com" + @as_metadata_url = "#{@auth_base}/.well-known/oauth-authorization-server" + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client", client_name: "ruby-sdk-test"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return do |req| + form = URI.decode_www_form(req.body).to_h + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "test-token-from-flow", + token_type: "Bearer", + expires_in: 3600, + grant_type_received: form["grant_type"], + code_verifier_received: form["code_verifier"], + ), + } + end + end + + def teardown + WebMock.reset! + end + + # A plain authorization-code provider for the destination-check tests. + # They all expect to fail before any redirect happens, so the handlers stay inert; + # a test that reached them would be asserting the wrong thing. + def ssrf_test_provider + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + end + + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil, http_client_customizer: nil) + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: token_endpoint_auth_method, + token_request_params: token_request_params, + http_client_customizer: http_client_customizer, + ) + end + + # An authorization-code provider that drives the code exchange and refresh with the given parameters. + def provider_with_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) + Provider.new(**authorization_code_provider_arguments(redirect_handler, callback_handler), token_request_params: params) + end + + # Overriding the reader bypasses the constructor's validation, leaving the flow's own check to be exercised. + def provider_returning_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) + provider_class = Class.new(Provider) do + define_method(:token_request_params) { params } + end + + provider_class.new(**authorization_code_provider_arguments(redirect_handler, callback_handler)) + end + + def authorization_code_provider_arguments(redirect_handler, callback_handler) + { + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: redirect_handler, + callback_handler: callback_handler, + } + end + + # See `provider_returning_token_request_params`. + def client_credentials_provider_returning(token_request_params:) + provider_class = Class.new(ClientCredentialsProvider) do + define_method(:token_request_params) { token_request_params } + end + + provider_class.new(client_id: "cc-client", client_secret: "cc-secret") + end + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); + # fall back to the pre-3.0 API on older Rubies. + def generate_es256_key + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + end + + # Records `[method, url]` for every request that passes through, standing in for the tracing middleware + # an application adds through `http_client_customizer`. + class RecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << [env.method, env.url.to_s] + @app.call(env) + end + end + + # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind: + # the flow asked for one URL and the adapter is handed another. + class URLRewritingMiddleware + def initialize(app, from:, to:) + @app = app + @from = from + @to = to + end + + def call(env) + env.url = URI(@to) if env.url.to_s == @from + @app.call(env) + end + end + + # Replaces the per-request context the way instrumentation middleware may; the guard must not depend on it. + class ContextReplacingMiddleware + def initialize(app) + @app = app + end + + def call(env) + env.request.context = { tag: "instrumented" } + @app.call(env) + end + end + + # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever + # an earlier middleware recorded on the original. + class EnvRebuildingMiddleware + def initialize(app) + @app = app + end + + def call(env) + @app.call(Faraday::Env.from(env.to_h)) + end + end + + # Follows a `3xx` the way `faraday-follow_redirects` does: duplicates the environment, points it at the `Location`, + # and re-enters the stack from its own position. + class RedirectFollowingMiddleware + def initialize(app) + @app = app + end + + def call(env) + response = @app.call(env) + location = response.headers["Location"] + return response unless location && (300..399).cover?(response.status) + + redirected = env.dup + redirected.url = URI(location) + redirected.response = nil + @app.call(redirected) + end + end + + # Replaces the request options wholesale, the way middleware that resets timeouts or contexts might. + class RequestOptionsReplacingMiddleware + def initialize(app) + @app = app + end + + def call(env) + env.request = Faraday::RequestOptions.new + @app.call(env) + end + end + + # Re-enters the stack once with the same environment after a `5xx`, the way retry middleware does. + class RetryingMiddleware + def initialize(app) + @app = app + end + + def call(env) + response = @app.call(env) + return response unless response.status >= 500 + + env.response = nil + @app.call(env) + end + end + + # Records the application's `trace_id` from the per-request context, as a tracing middleware would. + class ContextRecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << env.request.context&.dig(:trace_id) + @app.call(env) + end + end + + # Runs the full authorization flow and returns the `scope` query parameter + # sent on the authorization request. The caller stubs the AS metadata; + # this helper supplies a provider whose `grant_types` and optional pre-set + # `scope` drive the SEP-2207 offline_access decision. + def capture_authorization_scope(grant_types:, provider_scope: nil) + captured_scope = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: grant_types, + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + query = URI.decode_www_form(url.query).to_h + captured_scope = query["scope"] + state_holder[:state] = query.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + scope: provider_scope, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + captured_scope + end + + def test_run_uses_client_credentials_grant_for_client_credentials_provider + provider = client_credentials_provider + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + # No authorization-code machinery: /authorize is never contacted + # (no stub exists for it, so a request would raise), and DCR is not + # run because credentials are pre-registered. + assert_not_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + expected_basic = "Basic " + Base64.strict_encode64("cc-client:cc-secret") + form["grant_type"] == "client_credentials" && + !form.key?("code") && + !form.key?("code_verifier") && + req.headers["Authorization"] == expected_basic + end + end + + def test_run_sends_every_authorization_server_request_through_the_provider_customizer + log = [] + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal([[:get, @prm_url], [:get, @as_metadata_url], [:post, "#{@auth_base}/token"]], log) + + # The SDK's defaults are applied before the customizer runs. + assert_requested(:get, @prm_url, headers: { "Accept" => "application/json" }) + end + + def test_run_refuses_a_customized_connection_that_sends_a_request_off_the_origin + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_equal(<<~MESSAGE, error.message) + Request to \"#{@prm_url}\" would be sent to \"https://other.example.com/prm.json\", on a different origin; \ + middleware that follows redirects or rewrites URLs is refused. + MESSAGE + + # The guard sits before the adapter, so the request never leaves. + assert_not_requested(:get, "https://other.example.com/prm.json") + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_keeps_a_same_origin_rewrite + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: moved_url) }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_context + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(ContextReplacingMiddleware) + faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") + }, + ) + + assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_ahead_of_the_stamp + # `builder.insert(0, ...)` is where middleware that wants to be outermost puts itself, so a follower can + # land ahead of the SDK's stamp and re-enter the stack; the record of the first URL must survive that. + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_ahead_of_the_stamp + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_refuses_a_request_whose_environment_was_rebuilt + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_equal(<<~MESSAGE, error.message) + Request to \"#{@prm_url}\" carries no record of the URL the flow asked for; \ + middleware that rebuilds the request environment is refused. + MESSAGE + assert_not_requested(:get, @prm_url) + end + + def test_run_refuses_a_rewritten_token_request + stub_request(:post, "https://other.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "stolen", token_type: "Bearer"), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") + }, + ) + + assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://other.example.com/token") + assert_not_requested(:post, "#{@auth_base}/token") + assert_nil(provider.access_token) + end + + def test_run_keeps_the_application_request_context_for_every_request + trace_ids = [] + + result = run_authorization_flow( + http_client_customizer: ->(faraday) { + faraday.options.context = { trace_id: "incident-123" } + faraday.use(ContextRecordingMiddleware, trace_ids) + }, + ) + + assert_equal(:authorized, result) + # Protected Resource Metadata and authorization server metadata (GET), registration (JSON POST), + # and the token exchange (form POST) all carry it. + assert_equal(["incident-123"] * 4, trace_ids) + end + + def test_run_keeps_the_application_request_context_on_a_factory_connection + trace_ids = [] + factory = -> { + Faraday.new do |faraday| + faraday.options.context = { trace_id: "incident-123" } + faraday.use(ContextRecordingMiddleware, trace_ids) + end + } + + result = Flow.new(provider: client_credentials_provider, http_client_factory: factory).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal(["incident-123"] * 3, trace_ids) + end + + def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_options + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider( + http_client_customizer: ->(faraday) { + faraday.use(RequestOptionsReplacingMiddleware) + faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") + }, + ) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # The mismatch, not a lost record: replacing the options must leave the record in place. + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_after_the_stamp + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) + stub_request(:get, "https://other.example.com/prm.json").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) + + error = assert_raises(Flow::DestinationMismatchError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # The mismatch, not a lost record: the duplicate the follower re-enters with shares the record. + assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) + assert_not_requested(:get, "https://other.example.com/prm.json") + end + + def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_after_the_stamp + moved_url = "https://srv.example.com/prm-moved.json" + stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) + stub_request(:get, moved_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, moved_url) + end + + def test_run_keeps_a_retry_by_middleware_placed_ahead_of_the_stamp + # The same environment re-enters the stack with the same URL, so the first record still matches. + stub_request(:get, @prm_url).to_return( + { status: 503 }, + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + }, + ) + provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.builder.insert(0, RetryingMiddleware) }) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:get, @prm_url, times: 2) + end + + def test_build_http_client_records_each_request_on_its_own + # A caller using the SDK-built connection directly may address different origins from one request + # to the next; each request gets its own record. + stub_request(:get, "https://a.example.com/one").to_return(status: 200, body: "one") + stub_request(:get, "https://b.example.com/two").to_return(status: 200, body: "two") + connection = Flow.build_http_client + + assert_equal("one", connection.get("https://a.example.com/one").body) + assert_equal("two", connection.get("https://b.example.com/two").body) + end + + def test_run_prefers_an_explicit_http_client_factory_over_the_customizer + provider = client_credentials_provider(http_client_customizer: ->(_faraday) { raise "the customizer must not run" }) + + result = Flow.new(provider: provider, http_client_factory: -> { Faraday.new }).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body + # `client_secret_post` puts the credentials in the form body rather + # than an HTTP Basic header (RFC 6749 Section 2.3.1). + provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_post") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "client_credentials" && + form["client_id"] == "cc-client" && + form["client_secret"] == "cc-secret" && + req.headers["Authorization"].nil? + end + end + + def test_run_client_credentials_with_client_secret_basic_omits_client_id_from_body + # Explicit Basic authentication must keep both credentials in the header + # to avoid servers treating body credentials as a second auth method. + provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") && + !form.key?("client_id") && + !form.key?("client_secret") + end + end + + def test_run_client_credentials_raises_clean_error_when_client_information_missing + # The constructor always stores credentials, but if a custom storage loses them + # the grant must fail with a domain error rather than a `NoMethodError` from + # the authorization-code registration helper. + provider = client_credentials_provider + provider.storage.save_client_information(nil) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_credentials grant/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_client_credentials_succeeds_with_matching_stored_issuer + provider = client_credentials_provider + provider.storage.save_client_information( + provider.storage.client_information.merge("issuer" => @auth_base), + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_client_credentials_raises_for_mismatched_stored_issuer + # Per SEP-2352, static machine-to-machine credentials bound to another authorization server must surface + # an error instead of being replayed; re-registration is not an option for pre-registered credentials. + provider = client_credentials_provider + provider.storage.save_client_information( + provider.storage.client_information.merge("issuer" => "https://old-as.example.com"), + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_client_credentials_requests_scope_from_prm_scopes_supported + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write"], + ), + ) + + provider = client_credentials_provider + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["scope"] == "mcp:read mcp:write" + end + end + + def test_run_client_credentials_with_private_key_jwt_sends_signed_assertion + # SEP-1046 / RFC 7523 Section 2.2: the token request authenticates + # with a signed JWT assertion; `client_id` and any shared secret are + # absent from the body, and the assertion's audience is the AS issuer. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + token_endpoint: "#{@auth_base}/token", + grant_types_supported: ["client_credentials"], + token_endpoint_auth_methods_supported: ["private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + ), + ) + + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + payload_segment = form["client_assertion"].to_s.split(".")[1].to_s + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + form["grant_type"] == "client_credentials" && + form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + !form.key?("client_id") && + !form.key?("client_secret") && + req.headers["Authorization"].nil? && + claims["iss"] == "cc-client" && + claims["sub"] == "cc-client" && + claims["aud"] == @auth_base && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_client_credentials_sends_token_request_params_with_client_secret_basic + provider = client_credentials_provider(token_request_params: { "audience" => "https://api.example.com" }) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "client_credentials" && + form["resource"] == "https://srv.example.com/mcp" && + form["audience"] == "https://api.example.com" && + !form.key?("client_id") && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") + end + end + + def test_run_client_credentials_sends_token_request_params_returned_by_a_provider_method + provider = client_credentials_provider_returning(token_request_params: { "audience" => "https://api.example.com" }) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["audience"] == "https://api.example.com" + end + end + + def test_run_client_credentials_sends_the_same_request_for_empty_token_request_params + provider = client_credentials_provider(token_request_params: {}) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).map(&:first) == ["grant_type", "resource"] + end + end + + def test_run_client_credentials_sends_token_request_params_with_client_secret_post + provider = client_credentials_provider( + token_endpoint_auth_method: "client_secret_post", + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["audience"] == "https://api.example.com" && + form["client_id"] == "cc-client" && + form["client_secret"] == "cc-secret" + end + end + + def test_run_client_credentials_sends_token_request_params_with_private_key_jwt + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["audience"] == "https://api.example.com" && + form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + !form["client_assertion"].to_s.empty? + end + end + + def test_run_sends_token_request_params_on_the_authorization_code_exchange + state_value = nil + provider = provider_with_token_request_params( + { "audience" => "https://api.example.com" }, + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" && + form["audience"] == "https://api.example.com" + end + end + + def test_refresh_sends_token_request_params + provider = provider_with_token_request_params({ "audience" => "https://api.example.com" }) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + form["audience"] == "https://api.example.com" + end + end + + def test_run_sends_token_request_params_on_the_jwt_bearer_grant + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + token_request_params: { "audience" => "https://api.example.com" }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["audience"] == "https://api.example.com" + end + end + + def test_run_refuses_token_request_params_that_name_a_reserved_parameter + provider = client_credentials_provider_returning(token_request_params: { "grant_type" => "password" }) + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"grant_type"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_refuses_token_request_params_that_are_not_a_hash_of_strings + shapes = ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }, { "audience" => "x" }.compare_by_identity] + shapes.each do |params| + provider = client_credentials_provider_returning(token_request_params: params) + + assert_raises(Flow::InvalidTokenRequestParamsError, "should refuse #{params.inspect}") do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_refuses_token_request_params_that_name_a_reserved_parameter + # Not an `AuthorizationError`: the transport treats a failed refresh as a reason + # to run the interactive flow, which would fail the same way afterwards. + provider = provider_returning_token_request_params({ "refresh_token" => "other" }) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"refresh_token"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_refuses_token_request_params_on_the_authorization_code_exchange + state_value = nil + provider = provider_returning_token_request_params( + { "code" => "other" }, + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/"code"/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider + # SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at + # the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved. + received = nil + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + # The assertion audience is the validated AS issuer; the resource is the canonical MCP server URL. + assert_equal({ audience: @auth_base, resource: "https://srv.example.com/mcp" }, received) + assert_not_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + expected_basic = "Basic " + Base64.strict_encode64("xaa-client:xaa-secret") + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["resource"] == "https://srv.example.com/mcp" && + !form.key?("code") && + !form.key?("code_verifier") && + req.headers["Authorization"] == expected_basic + end + end + + def test_run_jwt_bearer_raises_when_assertion_provider_returns_nothing + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) {}, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned no ID-JAG/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_uses_the_stored_credentials_of_a_cross_app_access_provider + # RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden; + # when the authorization server issued one, it is exchanged with the stored client secret + # and no new ID-JAG assertion is minted. + assertion_calls = 0 + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { + assertion_calls += 1 + "id-jag-assertion" + }, + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_equal("test-token-from-flow", provider.access_token) + assert_equal(@auth_base, provider.tokens["issuer"]) + assert_equal(0, assertion_calls) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + !form.key?("assertion") && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("xaa-client:xaa-secret") + end + end + + def test_refresh_uses_the_stored_credentials_of_a_client_credentials_provider + # RFC 6749 Section 4.4.3 says a refresh token SHOULD NOT be issued for this grant, + # so one in storage is unusual, but it must be exchanged rather than crash the refresh. + provider = client_credentials_provider + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + assert_equal(@auth_base, provider.tokens["issuer"]) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "refresh_token" && + form["refresh_token"] == "saved-rt" && + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") + end + end + + def test_refresh_refuses_an_authorization_server_that_did_not_issue_a_cross_app_access_token + # The token-level issuer binding is what protects these providers: their stored `client_information` carries no `issuer`, + # so `ensure_refreshable_client_information!` has nothing to compare. + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => "https://old-as.example.com") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issued by a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_refuses_tokens_without_a_recorded_issuer_for_the_client_credentials_and_jwt_bearer_providers + # These providers had no refresh before their tokens recorded the issuer, so nothing older needs + # tolerating; without the record a refresh would go to whatever server discovery names, unasked. + cross_app = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + [cross_app, client_credentials_provider].each do |provider| + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError, "should refuse #{provider.class}") do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/record no issuer/, error.message) + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + assert_not_requested(:get, @prm_url) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_raises_cleanly_for_a_provider_with_neither_client_information_nor_a_cimd_url + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + provider.storage.save_client_information(nil) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/no client_information/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_registers_through_dcr_for_a_provider_without_a_cimd_url_reader + # Registration reads the CIMD URL through the same duck-typed helper as refresh, + # so a provider that lacks the reader goes to Dynamic Client Registration. + provider_class = Class.new(Provider) { undef_method :client_id_metadata_document_url } + state_value = nil + provider = provider_class.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_requested(:post, "#{@auth_base}/register") + end + + def test_run_uses_authorization_code_grant_for_default_provider + # A standard `Provider` declares `authorization_flow == :authorization_code`, + # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code", "client_credentials"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "authorization_code" + end + end + + # Runs the full authorization flow with a minimal provider so tests can assert on + # the Dynamic Client Registration request body. The default loopback redirect URI + # exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises + # the `"web"` inference. + def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}, http_client_customizer: nil) + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: [redirect_uri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }.merge(client_metadata_extra), + redirect_uri: redirect_uri, + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + http_client_customizer: http_client_customizer, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # Runs the authorization-code flow with an `authorization_request_validator` that records what it + # was handed and answers `approve`. + private def run_flow_with_validator(approve:, recorder: []) + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + recorder << :redirected + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + authorization_request_validator: ->(request) { + recorder << request + approve + }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + def test_run_hands_the_authorization_server_and_scopes_to_the_validator + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: [@auth_base], + scopes_supported: ["Mail.Read", "Files.ReadWrite.All"], + ), + ) + + recorder = [] + run_flow_with_validator(approve: true, recorder: recorder) + + request = recorder.first + + assert_equal(@auth_base, request.authorization_server) + assert_equal(["Mail.Read", "Files.ReadWrite.All"], request.scopes) + assert_equal(@server_url, request.server_url) + assert_equal("https://srv.example.com/mcp", request.resource) + end + + def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines + recorder = [] + + error = assert_raises(Flow::AuthorizationRefusedError) do + run_flow_with_validator(approve: false, recorder: recorder) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + + # Refused before registration, so nothing of this client is left at the authorization server + # the host just rejected, and no browser was opened. + assert_not_requested(:post, "#{@auth_base}/register") + refute_includes(recorder, :redirected) + end + + def test_run_client_credentials_consults_the_validator + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + authorization_request_validator: ->(_request) { false }, + ) + + error = assert_raises(Flow::AuthorizationRefusedError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_jwt_bearer_consults_the_validator + minted = false + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + minted = true + "id-jag-assertion" + }, + authorization_request_validator: ->(_request) { false }, + ) + + error = assert_raises(Flow::AuthorizationRefusedError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/refused by `authorization_request_validator`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + + # Refused before the identity provider was asked for an assertion carrying this authorization server as its audience. + refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server") + end + + def test_run_registers_native_application_type_for_loopback_redirect_uri + run_authorization_flow + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "native" + end + end + + def test_run_registers_web_application_type_for_https_redirect_uri + run_authorization_flow(redirect_uri: "https://app.example.com/callback") + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "web" + end + end + + def test_run_does_not_override_explicit_application_type + run_authorization_flow(client_metadata_extra: { application_type: "web" }) + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "web" + end + end + + def test_run_does_not_override_explicit_string_keyed_application_type + run_authorization_flow( + redirect_uri: "https://app.example.com/callback", + client_metadata_extra: { "application_type" => "native" }, + ) + + assert_requested(:post, "#{@auth_base}/register") do |req| + JSON.parse(req.body)["application_type"] == "native" + end + end + + def test_run_completes_full_authorization_flow + captured_authorization_url = nil + state_value = nil + + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_value = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_value] }, + ) + + flow = Flow.new(provider: provider) + result = flow.run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("code", query["response_type"]) + assert_equal("test-client", query["client_id"]) + assert_equal("S256", query["code_challenge_method"]) + assert_equal("http://localhost:0/callback", query["redirect_uri"]) + assert_equal("https://srv.example.com/mcp", query["resource"]) + + assert_requested(:get, @prm_url) + assert_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" && + !form["code_verifier"].to_s.empty? && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared + # SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access` + # when the AS advertises it, so it can obtain a refresh token. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + assert_includes(captured.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared + # The AS advertises offline_access, but the client did not opt into refresh tokens, + # so the scope is not requested. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_server_does_not_advertise_it + # SEP-2207 forbids requesting offline_access when the AS does not list it, + # even if the client declared the refresh_token grant type. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "mcp:read"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it + # SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope + # (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope. + # SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's + # defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access` + # even though the AS has not opted in. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + refute_includes(captured.to_s.split, "offline_access") + assert_includes(captured.to_s.split, "mcp:basic") + end + + def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it + # When stripping leaves an empty scope, no `scope` parameter is sent. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "offline_access", + ) + + assert_nil(captured) + end + + def test_run_does_not_duplicate_offline_access_already_in_scope + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + assert_equal(1, captured.split.count("offline_access")) + end + + def test_run_raises_on_state_mismatch + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "wrong-state"] }, + ) + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_run_raises_when_pkce_unsupported + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["plain"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/S256/, error.message) + end + + def test_run_raises_when_authorization_endpoint_is_missing + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + # NB: no `authorization_endpoint`. + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_endpoint/i, error.message) + end + + def test_run_raises_when_authorization_endpoint_is_malformed_uri + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "ht!tp:bad uri", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_endpoint/i, error.message) + end + + def test_run_raises_when_prm_resource_is_malformed_uri + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "ht!tp:bad uri", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/PRM `resource`|not a valid URI/i, error.message) + end + + def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object + # Valid JSON but the wrong shape counts as nothing usable being published, so it selects the legacy 2025-03-26 path + # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing a domain error rather than + # a raw `TypeError` from indexing the array. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "[]", + ) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return(status: 404) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/Dynamic client registration failed/i, error.message) + assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + end + + def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back + # A request that never reached the server says nothing about whether it publishes PRM, so once + # the other candidates answer 404 the flow stops instead of moving to the legacy authorization base; + # the TypeScript and Python SDKs propagate the error the same way. + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_match(/Faraday::ConnectionFailed/, error.message) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/register") + end + + def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error + # The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line + # the error reaches, so the query is dropped, while the path keeps the spelling that was requested + # (`%2E` is not decoded) so the line can be matched against the server's access log. + challenge_url = "https://srv.example.com/prm%2Ejson?token=abc" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET") + refute_includes(error.message, "token=abc") + end + + def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error + # The challenge URL is the server's to choose, so its length is bounded before it lands in the message. + challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json" + stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) + stub_prm_not_found + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed") + refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3)) + end + + def test_run_bounds_the_transport_error_message_in_the_discovery_error + # A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats + # the status line and the request URL), so it is bounded like the parser's message. + stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000)) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET") + refute_includes(error.message, "x" * 126) + end + + def test_run_bounds_the_parser_message_in_the_discovery_error + # json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body + # could otherwise put up to the response cap into the message once per candidate. + stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "{") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 503) + JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'") + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots. + assert_includes(error.message, <<~MESSAGE.chomp) + returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \ + GET https://srv.example.com/.well-known/oauth-protected-resource returned 503. + MESSAGE + + refute_includes(error.message, "{" * 105) + end + + def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back + # A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404; + # the Python SDK refuses the legacy path the same way. + [503, 429].each do |status| + stub_request(:get, @prm_url).to_return(status: status) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned #{status}/, error.message) + end + + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/register") + end + + def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable + # An unusable document from a later candidate must not turn an earlier `503` into "nothing published". + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_return(status: 503) + stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + end + + assert_match(/returned 503/, error.message) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + end + + def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document + # The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped. + challenge_url = "https://srv.example.com/prm.json" + stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") + + result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) + + assert_equal(:authorized, result) + assert_requested(:get, @prm_url) + end + + def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error + # The OAuth document being unreachable must not stop the OpenID one from being tried. + stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired")) + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + result = run_authorization_flow + + assert_equal(:authorized, result) + assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration") + end + + def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document + # An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried. + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + ["{", "[]"].each do |body| + stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body) + + assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}") + end + + assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2) + end + + def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back + stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired")) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back + stub_request(:get, @prm_url).to_return(status: 503) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::MetadataUnreachableError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/returned 503/, error.message) + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + # Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert + # which endpoint was used. + def build_legacy_discovery_provider(holder) + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + holder[:authorization_url] = url + holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", holder[:state]] }, + ) + end + + def stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + end + + # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin with the given `issuer` + # and the OAuth endpoints under a path prefix. + def stub_legacy_metadata_with_prefixed_endpoints(issuer:, iss_supported: false) + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: issuer, + authorization_endpoint: "https://srv.example.com/oauth/authorize", + token_endpoint: "https://srv.example.com/oauth/token", + registration_endpoint: "https://srv.example.com/oauth/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + authorization_response_iss_parameter_supported: iss_supported, + ), + ) + stub_request(:post, "https://srv.example.com/oauth/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/oauth/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + end + + def test_run_falls_back_to_server_origin_metadata_without_prm + # The document names the origin, so the RFC 8414 Section 3.3 check passes and its prefixed endpoints are used. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com") + + holder = {} + provider = build_legacy_discovery_provider(holder) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("legacy-token", provider.access_token) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_equal("/oauth/authorize", holder[:authorization_url].path) + assert_requested(:post, "https://srv.example.com/oauth/register") + assert_requested(:post, "https://srv.example.com/oauth/token") + end + + def test_run_accepts_legacy_metadata_naming_the_origin_with_a_trailing_slash + # A root issuer rendered as `https://host/` names the same server; the TypeScript and Python SDKs accept it too, + # and the document's spelling is what the RFC 9207 `iss` and the recorded issuer carry. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/", iss_supported: true) + + holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", holder[:state], "https://srv.example.com/"] }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("https://srv.example.com/", provider.tokens["issuer"]) + end + + def test_run_refuses_legacy_metadata_whose_issuer_is_not_the_origin + # RFC 8414 Section 3.3 applies on the legacy path as well: the 2025-03-26 spec places the metadata + # at the origin, so an issuer under a path prefix is a mismatch, as it is for the TypeScript and Python SDKs. + stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/oauth") + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_nil(holder[:authorization_url]) + assert_not_requested(:post, "https://srv.example.com/oauth/register") + assert_not_requested(:post, "https://srv.example.com/oauth/token") + end + + def test_run_falls_back_to_default_endpoints_without_any_metadata + # Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata, + # the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256. + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("/authorize", holder[:authorization_url].path) + query = URI.decode_www_form(holder[:authorization_url].query).to_h + assert_equal("S256", query["code_challenge_method"]) + refute_empty(query["code_challenge"].to_s) + assert_requested(:post, "https://srv.example.com/register") + assert_requested(:post, "https://srv.example.com/token") do |req| + URI.decode_www_form(req.body).to_h["code_verifier"].to_s != "" + end + end + + def test_run_client_credentials_without_prm_uses_the_legacy_authorization_base + # A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes, + # so the provider's own scope is used and the tokens are bound to the origin. + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://srv.example.com", + token_endpoint: "https://srv.example.com/token", + token_endpoint_auth_methods_supported: ["client_secret_basic"], + ), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read") + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("legacy-token", provider.access_token) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "client_credentials" && + form["scope"] == "mcp:read" && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + received = nil + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + received = { audience: audience, resource: resource } + "id-jag-assertion" + }, + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + # The assertion is minted for the legacy authorization base, which is the only identity such a server has. + assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && + form["assertion"] == "id-jag-assertion" && + form["resource"] == "https://srv.example.com/mcp" && + !form.key?("scope") + end + end + + def test_run_legacy_fallback_rejects_insecure_authorization_base + # The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not + # become the authorization base URL. + stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404) + stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource").to_return(status: 404) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: "http://internal.example.com/mcp") + end + + assert_match(/legacy authorization base URL/, error.message) + end + + def test_run_keeps_strict_issuer_validation_when_prm_is_present + # The legacy issuer-check relaxation must not leak into the modern path: with PRM present, + # a mismatched issuer still aborts. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://evil.example.com", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + ), + ) + + holder = {} + provider = build_legacy_discovery_provider(holder) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/`issuer` does not match/, error.message) + end + + # Legacy metadata served at the MCP server origin that claims the identity of another authorization server, + # with every endpoint at the origin itself. The claim fails the RFC 8414 check, so none of these endpoints is reached. + def stub_legacy_metadata_claiming(issuer) + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: issuer, + authorization_endpoint: "https://srv.example.com/authorize", + token_endpoint: "https://srv.example.com/token", + registration_endpoint: "https://srv.example.com/register", + code_challenge_methods_supported: ["S256"], + ), + ) + stub_request(:post, "https://srv.example.com/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "legacy-client"), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + end + + def test_run_refuses_legacy_metadata_claiming_another_authorization_server_before_the_validator + stub_legacy_metadata_claiming(@auth_base) + recorder = [] + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { recorder << :redirected }, + callback_handler: -> { ["test-auth-code", "state"] }, + authorization_request_validator: ->(request) { + recorder << request + true + }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_empty(recorder) + assert_not_requested(:post, "https://srv.example.com/register") + assert_not_requested(:post, "https://srv.example.com/token") + end + + def test_refresh_refuses_legacy_metadata_claiming_the_issuer_that_minted_the_tokens + # The tokens came from https://auth.example.com; a PRM-less server claiming that issuer at its own origin + # must not receive them, so the refresh token stays for the server that issued it. + stub_legacy_metadata_claiming(@auth_base) + provider = build_legacy_discovery_provider({}) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url) + end + + assert_match(/`issuer` does not match/, error.message) + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_run_does_not_present_client_information_bound_elsewhere_when_legacy_metadata_claims_that_issuer + # Client information issued by https://auth.example.com is bound to it (SEP-2352); a PRM-less server claiming + # that issuer is refused before the credentials could be presented anywhere. + stub_legacy_metadata_claiming(@auth_base) + holder = {} + provider = build_legacy_discovery_provider(holder) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret", "issuer" => @auth_base) + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url) + end + + assert_nil(holder[:authorization_url]) + assert_not_requested(:post, "https://srv.example.com/register") + assert_not_requested(:post, "https://srv.example.com/token") + assert_equal("conf-secret", provider.client_information["client_secret"]) + assert_equal(@auth_base, provider.client_information["issuer"]) + end + + def test_run_raises_when_prm_authorization_servers_is_not_an_array + # `authorization_servers` MUST be an Array per RFC 9728. A misbehaving + # PRM that returns a String would otherwise reach `.first` and raise + # raw `NoMethodError` out of the SDK. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: "https://auth.example.com", + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/authorization_servers/i, error.message) + end + + def test_run_refuses_an_authorization_server_metadata_body_over_the_cap + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + def test_run_refuses_a_protected_resource_metadata_body_over_the_cap + # An oversized document is refused outright rather than treated as unpublished, so the flow stops + # without the legacy fallback. The padded document is valid JSON naming an authorization server, + # so contacting that server is exactly what would happen if the body had been read: + # the assertion below fails if the cap stops working. + stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404) + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + padding: "a" * (4 * 1024 * 1024), + ), + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + assert_not_requested(:get, @as_metadata_url) + assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") + end + + def test_run_refuses_a_dynamic_client_registration_body_over_the_cap + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + def test_run_refuses_a_token_endpoint_body_over_the_cap + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: over_cap_body, + ) + + error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } + + assert_match(/exceeds \d+ bytes/, error.message) + end + + # One byte past the cap, so the three request wrappers are each proven to install the streaming callback. + # A wrapper that forgot it would buffer this whole body and parse it. + def over_cap_body + "a" * (4 * 1024 * 1024 + 1) + end + + def test_run_raises_when_token_response_is_not_a_json_object + # The token endpoint MUST return a JSON object per RFC 6749 §5.1. + # A non-object body would otherwise be persisted into the provider + # and raise raw exceptions the next time `provider.access_token` + # is read. + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "[]", + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/Token endpoint response is not a JSON object/i, error.message) + end + + def test_run_treats_whitespace_only_stored_client_id_as_unregistered + # `" "` (whitespace only) is just as meaningless as `""`. + # The check on stored client_information must trigger DCR rather than + # passing the blank value through to the token endpoint. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "registered-after-whitespace"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => " ") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("registered-after-whitespace", provider.client_information.fetch("client_id")) + end + + def test_run_raises_when_as_metadata_is_not_a_json_object + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "null", + ) + stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/not a JSON object/i, error.message) + end + + def test_run_raises_when_dcr_response_has_blank_client_id + # A blank `client_id` is just as invalid as a missing one: peer SDKs + # (TS / Python) require a non-empty string, and treating `""` as + # truthy would let a misbehaving AS register a "client" the SDK can + # never authenticate as. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "", client_name: "blank-id"), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/client_id/i, error.message) + end + + def test_run_treats_blank_stored_client_id_as_unregistered + # Pre-stored `client_information` with a blank `client_id` must + # trigger DCR rather than skipping registration; otherwise + # the token endpoint receives `client_id=""` which the AS will reject. + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "registered-after-blank"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("registered-after-blank", provider.client_information.fetch("client_id")) + end + + def test_run_raises_when_dcr_response_lacks_client_id + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_name: "test-but-no-id"), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/client_id/i, error.message) + end + + # Builds a provider whose callback returns the given `iss` shape: + # `:omitted` keeps the legacy 2-element `[code, state]` form, any other value (including nil) returns + # the 3-element `[code, state, iss]` form that opts into SEP-2468 / RFC 9207 issuer validation. + def build_iss_validation_provider(callback_iss: :omitted) + state_holder = {} + callback_handler = if callback_iss == :omitted + -> { ["test-auth-code", state_holder[:state]] } + else + -> { ["test-auth-code", state_holder[:state], callback_iss] } + end + + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: callback_handler, + ) + end + + # Re-stubs the AS metadata with `authorization_response_iss_parameter_supported: true`. + def stub_as_metadata_with_iss_support + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + authorization_response_iss_parameter_supported: true, + ), + ) + end + + def test_run_completes_when_authorization_response_iss_matches_issuer + provider = build_iss_validation_provider(callback_iss: @auth_base) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + end + + def test_run_aborts_before_code_exchange_when_authorization_response_iss_mismatches + provider = build_iss_validation_provider(callback_iss: "https://evil.example.com") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + # Per RFC 9207, the client MUST NOT send the authorization code to a token endpoint after an `iss` mismatch. + assert_match(/`iss` does not match/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_aborts_when_iss_is_missing_but_as_advertises_iss_support + stub_as_metadata_with_iss_support + provider = build_iss_validation_provider(callback_iss: nil) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/carried no `iss`/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_completes_with_legacy_two_element_callback_even_when_as_advertises_iss_support + stub_as_metadata_with_iss_support + provider = build_iss_validation_provider + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_completes_when_iss_is_missing_and_as_does_not_advertise_iss_support + provider = build_iss_validation_provider(callback_iss: nil) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_validates_present_iss_even_when_as_does_not_advertise_iss_support + provider = build_iss_validation_provider(callback_iss: "https://evil.example.com") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_skips_dcr_when_client_information_already_stored + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + def test_run_accepts_symbol_keys_in_preregistered_client_information + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information(client_id: "preregistered-symbol") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + # Builds a provider that completes the non-interactive authorization + # flow against the stubs from `setup`, for the SEP-2352 issuer-binding + # tests. + def build_issuer_binding_provider + state_holder = {} + Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + end + + def test_run_skips_dcr_when_stored_issuer_matches + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "preregistered-client", "issuer" => @auth_base) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("test-token-from-flow", provider.access_token) + assert_not_requested(:post, "#{@auth_base}/register") + end + + def test_run_reregisters_when_authorization_server_changes + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "old-as-token") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("test-client", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + + def test_run_clears_stale_tokens_when_authorization_server_changes + # Abort the flow right after the discard (DCR fails) to observe that tokens minted by + # the old authorization server were wiped before any re-registration attempt. + stub_request(:post, "#{@auth_base}/register").to_return(status: 500, body: "") + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "old-as-token", "refresh_token" => "old-as-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_nil(provider.tokens) + assert_nil(provider.client_information) + end + + def test_run_binds_legacy_client_information_to_issuer_on_first_use + # Credentials persisted before issuer binding existed (or supplied as pre-registered credentials) + # are reused and stamped with the current issuer rather than discarded. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal(@auth_base, provider.client_information["issuer"]) + end + + def test_run_binds_symbol_keyed_client_information_to_issuer_on_first_use + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information(client_id: "preregistered-symbol") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal("test-token-from-flow", provider.access_token) + assert_equal(@auth_base, provider.client_information["issuer"]) + end + + def test_run_reuses_portable_cimd_client_id_across_authorization_servers + # Per SEP-2352, a CIMD `client_id` (an HTTPS URL) is portable across authorization servers: + # reuse it and re-bind the issuer instead of discarding the registration. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + provider = build_issuer_binding_provider + provider.save_client_information( + "client_id" => "https://app.example.com/client-metadata.json", + "issuer" => "https://other-as.example.com", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("https://app.example.com/client-metadata.json", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + + def test_run_persists_issuer_with_dcr_client_information + provider = build_issuer_binding_provider + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + info = provider.client_information + assert_equal("test-client", info["client_id"]) + assert_equal(@auth_base, info["issuer"]) + end + + def test_run_skips_dcr_when_as_supports_cimd_and_provider_has_cimd_url + # When the AS advertises Client ID Metadata Document support and the provider was configured with a CIMD URL, + # the SDK uses the URL as the OAuth `client_id` and does not call /register. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + captured_authorization_url = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_not_requested(:post, "#{@auth_base}/register") + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://app.example.com/client-metadata.json", query["client_id"]) + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["client_id"] == "https://app.example.com/client-metadata.json" + end + # The CIMD URL is NOT persisted to storage: AS metadata is re-read on every flow, + # so a later AS that no longer advertises CIMD will not be sent a stale CIMD `client_id`. + assert_nil(provider.client_information) + end + + def test_run_falls_back_to_dcr_when_provider_has_cimd_url_but_as_does_not_advertise_support + # The provider may carry a CIMD URL across multiple servers; if a particular AS does not advertise CIMD support, + # the SDK must still register via DCR rather than send an unsupported `client_id`. + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", provider.client_information["client_id"]) + end + + def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_false + # A misconfigured AS that ships `"client_id_metadata_document_supported": "false"` + # is truthy in Ruby. The SDK must NOT route through CIMD on truthy non-boolean values; + # only a JSON `boolean true` opts in. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "false", + ), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", provider.client_information["client_id"]) + end + + def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_true + # Mirror of the previous test for the other truthy non-boolean value: + # `"true"` is also a string, not the JSON boolean the spec requires. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "true", + ), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + end + + def test_run_does_not_persist_cimd_client_id_so_later_flow_recovers_when_as_drops_support + # Regression for the storage side-effect: if the SDK persisted the CIMD URL on the first flow, + # a second flow against an AS that no longer advertises CIMD support would still send the URL as + # the `client_id` and skip DCR. Re-running with the same provider must honour the updated AS metadata. + shared_storage = InMemoryStorage.new + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + storage: shared_storage, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + # First flow: AS supports CIMD, so DCR is skipped. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + assert_nil(shared_storage.client_information, "CIMD client_id must not be persisted") + + # Second flow: AS no longer advertises CIMD. The SDK must register via DCR rather than reuse a stale CIMD URL. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/register") + assert_equal("test-client", shared_storage.client_information["client_id"]) + end + + def test_run_skips_cimd_when_pre_registered_client_information_exists + # Pre-registered `client_information` wins over CIMD: the user explicitly chose a DCR-style identity, + # so neither DCR nor the CIMD URL should overwrite it. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + captured_authorization_url = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_client_information("client_id" => "preregistered-client") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("preregistered-client", query["client_id"]) + end + + def test_run_uses_basic_auth_when_token_endpoint_auth_method_is_client_secret_basic + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information( + "client_id" => "pre-registered-client", + "client_secret" => "pre-registered-secret", + "token_endpoint_auth_method" => "client_secret_basic", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + expected = "Basic " + Base64.strict_encode64("pre-registered-client:pre-registered-secret") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + req.headers["Authorization"] == expected && + !form.key?("client_id") && + !form.key?("client_secret") + end + end + + def test_run_form_urlencodes_basic_auth_credentials_with_special_characters + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + # Per RFC 6749 Section 2.3.1, credentials must be `application/x-www-form-urlencoded` + # before being joined with ":" and base64-encoded. A `:` in the secret would + # otherwise be ambiguous with the username/password separator. + provider.save_client_information( + "client_id" => "client:with colon", + "client_secret" => "secret:with:colons and spaces", + "token_endpoint_auth_method" => "client_secret_basic", + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + encoded_id = URI.encode_www_form_component("client:with colon") + encoded_secret = URI.encode_www_form_component("secret:with:colons and spaces") + expected = "Basic " + Base64.strict_encode64("#{encoded_id}:#{encoded_secret}") + assert_requested(:post, "#{@auth_base}/token") do |req| + req.headers["Authorization"] == expected + end + end + + def test_run_rejects_non_https_resource_metadata_url + # `WWW-Authenticate` may carry a `resource_metadata=` URL we have never + # seen before; if it isn't HTTPS-or-loopback, an attacker could lure us + # into fetching a doctored PRM document over plain HTTP. + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: "http://attacker.example.com/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message) + end + + def test_run_does_not_fetch_a_resource_metadata_url_off_the_server_origin + # A server that answers 401 chooses the `resource_metadata` URL, and discovery puts it first + # in the candidate list. Left unchecked, a single response header aims the client's first OAuth request + # at any host it can route to, including the cloud metadata address. The assertion that matters is not + # the raise but `assert_not_requested`: nothing downstream can un-send a request. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + assert_match(/resource_metadata/i, error.message) + assert_match(/origin/i, error.message) + end + + def test_run_does_not_fetch_an_https_resource_metadata_url_on_a_foreign_public_origin + # The scheme check alone passes this one: it is HTTPS, just not the server's. + metadata_url = "https://attacker.example.com/.well-known/oauth-protected-resource" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + end + + def test_refresh_does_not_fetch_a_resource_metadata_url_off_the_server_origin + # `refresh!` reads the same header on a 401 and must gate it identically; + # otherwise the token-refresh path stays reachable after `run!` is closed. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + provider = ssrf_test_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: metadata_url, + ) + end + + assert_not_requested(:get, metadata_url) + end + + def test_run_does_not_contact_an_authorization_server_on_a_private_address + # One hop deeper than the `resource_metadata` URL: the server serves its own PRM from its own origin, + # then names a private address as its authorization server. Same-origin cannot see this one, + # so the range check has to. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: ["https://169.254.169.254"], + ), + ) + stub_request(:get, %r{\Ahttps://169\.254\.169\.254/}).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, %r{\Ahttps://169\.254\.169\.254/}) + assert_match(/private network/i, error.message) + end + + def test_run_does_not_contact_an_authorization_server_spelled_as_a_packed_integer + # `0xa9fea9fe` is 169.254.169.254 written for `inet_aton`. The connection resolves it to + # the same address, so the previous test's protection has to survive the rewrite. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: ["https://0xa9fea9fe"], + ), + ) + stub_request(:get, %r{\Ahttps://0xa9fea9fe/}).to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:get, %r{\Ahttps://0xa9fea9fe/}) + assert_match(/private network/i, error.message) + end + + def test_run_does_not_post_to_a_token_endpoint_on_a_private_address + # The token endpoint receives the client credentials and the authorization code, + # so an AS document that relocates it inside the client's network is a credential disclosure, + # not just a probe. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "https://10.0.0.5/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "https://10.0.0.5/token").to_return(status: 200, body: "{}") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://10.0.0.5/token") + assert_match(/private network/i, error.message) + end + + def test_run_does_not_post_to_a_registration_endpoint_on_a_private_address + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "https://192.168.1.1/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "https://192.168.1.1/register").to_return(status: 201, body: "{}") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_not_requested(:post, "https://192.168.1.1/register") + end + + def test_run_allows_a_private_authorization_server_when_the_mcp_server_is_also_private + # The shape the conformance harness runs and the shape local development takes: + # the MCP server and the authorization server sit on two loopback ports. + # Once the caller has pointed the client at a private address, the authorization server + # living there too is not an escalation, and refusing it would break every local setup. + server_url = "http://localhost:9292/mcp" + prm_url = "http://localhost:9292/.well-known/oauth-protected-resource/mcp" + auth_base = "http://localhost:9393" + + stub_request(:get, prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: server_url, authorization_servers: [auth_base]), + ) + stub_request(:get, "#{auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: auth_base, + authorization_endpoint: "#{auth_base}/authorize", + token_endpoint: "#{auth_base}/token", + registration_endpoint: "#{auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "#{auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "local-client"), + ) + stub_request(:post, "#{auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "local-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + result = Flow.new(provider: provider).run!(server_url: server_url, resource_metadata_url: prm_url) + + assert_equal(:authorized, result) + assert_equal("local-token", provider.access_token) + end + + def test_run_rejects_when_as_metadata_issuer_does_not_match + # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal the discovery URL. + # A metadata document advertising a different issuer than the one PRM pointed at is treated + # as a confused-deputy attempt. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://other-auth.example.com", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash + # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent after normalization". + # `https://auth.example.com` and `https://auth.example.com/` are different strings and MUST NOT match. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "#{@auth_base}/", + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_issuer_differs_by_case + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base.upcase, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_when_as_metadata_lacks_issuer + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issuer/i, error.message) + end + + def test_run_rejects_non_https_authorization_server + # Communication Security: an `http://` authorization server URL (other + # than loopback) MUST be refused. Returning a non-HTTPS auth server + # from a hijacked PRM would let the attacker steer the client at + # an endpoint they control to harvest tokens. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: ["http://auth.example.com"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/HTTPS|Communication Security/i, error.message) + end + + def test_run_rejects_non_https_token_endpoint + # Even when PRM and AS metadata discovery happen over HTTPS, the AS + # metadata document itself can advertise an `http://` token endpoint. + # That endpoint is where the bearer token lands, so it MUST be rejected. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "http://insecure.example.com/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/HTTPS|Communication Security|token_endpoint/i, error.message) + end + + def test_refresh_swaps_refresh_token_for_new_access_token + # Refresh should NOT hit /register (no DCR) and SHOULD post + # grant_type=refresh_token with the saved refresh_token. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + # New response did not include a refresh_token, so the old one is preserved (RFC 6749 Section 6). + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_with_client_secret_basic_omits_client_id_from_body + # Stored credentials without an auth method default to Basic. Refresh + # must also omit the body `client_id` when using that fallback. + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + req.headers["Authorization"] == "Basic " + Base64.strict_encode64("conf-client:conf-secret") && + !form.key?("client_id") && + !form.key?("client_secret") + end + end + + def test_run_records_the_issuer_that_minted_the_tokens + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(@auth_base, provider.tokens["issuer"]) + end + + def test_refresh_refuses_an_authorization_server_that_did_not_issue_the_tokens + # The stored tokens came from a different authorization server, so this refresh token must not reach the one named now. + stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("must not reach the token endpoint.")) + + provider = refresh_only_provider + provider.save_tokens( + "access_token" => "stale-at", + "refresh_token" => "saved-rt", + "issuer" => "https://issued-by.example.com", + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/issued by a different authorization server/, error.message) + + # Left in place: it is still good at the server that issued it, and the caller answers this by running the full authorization flow. + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_refresh_proceeds_for_tokens_stored_before_the_issuer_was_recorded + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), + ) + + provider = refresh_only_provider + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + + # Recorded on the way out, so the binding takes effect from here on. + assert_equal(@auth_base, provider.tokens["issuer"]) + end + + def test_refresh_succeeds_when_stored_issuer_matches + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client", "issuer" => @auth_base) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + end + + def test_refresh_raises_when_stored_issuer_differs + # Per SEP-2352, another authorization server's credentials must not be replayed at this token endpoint; + # the caller falls back to the full flow, which discards the stale registration and re-registers. + stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("Token endpoint should not be called.")) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_uses_cimd_url_as_client_id_when_provider_has_cimd_and_as_supports_it + # Regression: the CIMD branch in `ensure_client_registered` does not persist `client_information`, + # so a refresh call that requires stored client info would always fail after a CIMD-only flow. + # `refresh!` must reconstruct the CIMD `client_id` from live AS metadata + the provider URL on every call. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including( + "grant_type" => "refresh_token", + "refresh_token" => "saved-rt", + "client_id" => "https://app.example.com/client-metadata.json", + ), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + result = Flow.new(provider: provider).refresh!( + server_url: @server_url, + resource_metadata_url: @prm_url, + ) + + assert_equal(:refreshed, result) + assert_equal("fresh-at", provider.access_token) + assert_nil(provider.client_information, "refresh must not persist a CIMD client_information entry") + end + + def test_refresh_raises_when_provider_has_cimd_url_but_as_no_longer_advertises_support + # If the AS later drops CIMD support and the provider has neither a stored `client_information` nor + # an alternative identity, refresh has no way to authenticate. Fail loudly rather than send a CIMD + # `client_id` the AS no longer recognises. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_id_metadata_document_supported/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_prefers_stored_client_information_over_cimd_url + # Even when both are available, an explicitly stored `client_information` wins. + # CIMD MUST NOT silently override it. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + ), + ) + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including( + "grant_type" => "refresh_token", + "client_id" => "preregistered-client", + ), + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_client_information("client_id" => "preregistered-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal("fresh-at", provider.access_token) + end + + def test_refresh_raises_when_cimd_support_advertised_as_string_true + # Mirror of the `run!` strict-boolean tests on the refresh path: a truthy non-boolean value + # such as the string `"true"` must NOT be treated as CIMD support. With no stored `client_information` + # and no genuine CIMD support, refresh has no identity to authenticate with. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: "true", + ), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_id_metadata_document_supported/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_refresh_raises_when_no_refresh_token + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "only-at") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_when_token_endpoint_rejects_refresh + stub_request(:post, "#{@auth_base}/token").to_return(status: 401, body: "") + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_invalid_grant_error_when_token_endpoint_says_invalid_grant + stub_request(:post, "#{@auth_base}/token").to_return( + status: 400, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(error: "invalid_grant", error_description: "refresh token expired"), + ) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") + + error = assert_raises(Flow::InvalidGrantError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("refresh token expired", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: refresh token expired", error.message) + end + + def test_token_exchange_preserves_oauth_diagnostics + stub_request(:post, "#{@auth_base}/token").to_return( + status: 400, + body: JSON.generate( + error: "invalid_request", + error_description: "Client must not use multiple authentication methods", + access_token: "do-not-log-access-token", + refresh_token: "do-not-log-refresh-token", + client_secret: "do-not-log-client-secret", + error_uri: "https://auth.example.com/error?secret=do-not-log", + ), + ) + + error = assert_raises(Flow::AuthorizationError) do + capture_authorization_scope(grant_types: ["authorization_code"]) + end + + assert_equal(400, error.http_status) + assert_equal("invalid_request", error.error) + assert_equal("Client must not use multiple authentication methods", error.error_description) + assert_equal( + "Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods", + error.message, + ) + end + + def test_refresh_preserves_oauth_diagnostics + error = refresh_token_endpoint_error( + JSON.generate(error: "invalid_client", error_description: "Client authentication failed"), + status: 401, + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(401, error.http_status) + assert_equal("invalid_client", error.error) + assert_equal("Client authentication failed", error.error_description) + assert_equal("Token endpoint returned status 401. invalid_client: Client authentication failed", error.message) + end + + def test_token_endpoint_errors_fall_back_for_malformed_bodies + ["", "secret", "{broken", "null", "[]", '"secret"', "42"].each do |body| + error = refresh_token_endpoint_error(body) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(400, error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_ignore_non_string_and_empty_fields + [nil, 42, true, [], { secret: "hidden" }, "", " \r\n\t"].each do |value| + error = refresh_token_endpoint_error(JSON.generate(error: value, error_description: value)) + + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_preserve_optional_fields_independently + error = refresh_token_endpoint_error(JSON.generate(error: "provider_extension")) + assert_equal("provider_extension", error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400. provider_extension", error.message) + + error = refresh_token_endpoint_error(JSON.generate(error_description: "Details without a code")) + assert_nil(error.error) + assert_equal("Details without a code", error.error_description) + assert_equal("Token endpoint returned status 400. Details without a code", error.message) + end + + def test_token_endpoint_errors_sanitize_without_changing_grant_classification + error = refresh_token_endpoint_error( + JSON.generate(error: "invalid_grant\n", error_description: "expired\r\n\t\e\u0000\"\\\u2028token"), + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal("invalid_grant", error.error) + assert_equal("expired token", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: expired token", error.message) + end + + def test_invalid_grant_with_invalid_utf8_description_preserves_classification + error = refresh_token_endpoint_error( + "{\"error\":\"invalid_grant\",\"error_description\":\"bad \xFF byte\"}".b, + ) + + assert_instance_of(Flow::InvalidGrantError, error) + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("bad byte", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) + end + + def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields + error = refresh_token_endpoint_error( + "{\"error\":\"invalid_grant\xFF\",\"error_description\":\"bad \xFF byte\"}".b, + ) + + assert_instance_of(Flow::AuthorizationError, error) + assert_equal(400, error.http_status) + assert_equal("invalid_grant", error.error) + assert_equal("bad byte", error.error_description) + assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) + end + + def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises + Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text") + + { "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass| + error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details")) + + assert_instance_of(klass, error) + assert_equal(400, error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + assert_equal("Token endpoint returned status 400.", error.message) + end + end + + def test_token_endpoint_errors_bound_diagnostic_lengths + error = refresh_token_endpoint_error(JSON.generate(error: "e" * 200, error_description: "d" * 1000)) + + assert_equal("#{"e" * 125}...", error.error) + assert_equal("#{"d" * 509}...", error.error_description) + assert_equal("Token endpoint returned status 400. #{error.error}: #{error.error_description}", error.message) + end + + def test_other_authorization_errors_have_no_token_endpoint_diagnostics + error = Flow::AuthorizationError.new("Discovery failed") + + assert_equal("Discovery failed", error.message) + assert_nil(error.http_status) + assert_nil(error.error) + assert_nil(error.error_description) + end + + def refresh_token_endpoint_error(body, status: 400) + stub_request(:post, "#{@auth_base}/token").to_return(status: status, body: body) + provider = ssrf_test_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") + + assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + end + + def test_refresh_raises_generic_authorization_error_on_5xx + stub_request(:post, "#{@auth_base}/token").to_return(status: 503, body: "") + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-at", "refresh_token" => "live-rt") + + err = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + end + refute_kind_of( + Flow::InvalidGrantError, + err, + "5xx must not be classified as invalid_grant; transient failures must preserve the refresh token", + ) + end + + def test_resolve_scope_prefers_prm_scopes_supported_over_provider_scope + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write"], + ), + ) + + captured = nil + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + scope: "provider-supplied-scope", + redirect_handler: ->(url) { captured = url }, + callback_handler: -> { + [ + "code-1", + URI.decode_www_form(captured.query).to_h["state"], + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: @prm_url, + scope: nil, + ) + + authorized_params = URI.decode_www_form(captured.query).to_h + assert_equal( + "mcp:read mcp:write", + authorized_params["scope"], + "PRM scopes_supported must win over provider.scope when challenge scope is absent", + ) + end + + def test_resolve_scope_falls_back_to_provider_scope_when_prm_omits_scopes_supported + captured = nil + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + scope: "provider-fallback", + redirect_handler: ->(url) { captured = url }, + callback_handler: -> { + [ + "code-1", + URI.decode_www_form(captured.query).to_h["state"], + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: @prm_url, + scope: nil, + ) + + authorized_params = URI.decode_www_form(captured.query).to_h + assert_equal( + "provider-fallback", + authorized_params["scope"], + "provider.scope is the last-resort fallback when neither challenge nor PRM supplies one", + ) + end + + def test_token_endpoint_defaults_to_client_secret_basic_for_confidential_clients + captured_authorization = nil + captured_form = nil + stub_request(:post, "#{@auth_base}/token").to_return do |req| + captured_authorization = req.headers["Authorization"] + captured_form = URI.decode_www_form(req.body).to_h + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "tok", token_type: "Bearer"), + } + end + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + # Confidential pre-registered client without `token_endpoint_auth_method`. + provider.save_client_information( + "client_id" => "preregistered", + "client_secret" => "s3cret", + ) + provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt") + + Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + expected = Base64.strict_encode64("preregistered:s3cret") + assert_equal( + "Basic #{expected}", + captured_authorization, + "default to client_secret_basic per RFC 6749 §2.3.1 and TS/Python SDK convention", + ) + refute( + captured_form.key?("client_secret"), + "client_secret must not be duplicated in the form when sent via Basic auth", + ) + end + + def test_run_blocks_percent_encoded_dot_segment_bypass_in_server_url + # Same audience-binding bypass as the literal `..` case, but using + # the percent-encoded form `%2e%2e`. RFC 3986 Section 6.2.2.2 normalization must + # decode `%2e`/`%2E` before resolving dot-segments. + stub_request(:get, "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/api", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/api/%2e%2e/evil", + resource_metadata_url: "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_blocks_dot_segment_bypass_in_server_url + # An attacker-supplied server URL like "/api/../evil" must not be + # treated as covered by a PRM whose resource is the more privileged + # "/api". Canonicalization resolves the dot-segments so the bypass + # is detected. + stub_request(:get, "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource") + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/api", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/api/../evil", + resource_metadata_url: "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource", + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_strips_userinfo_from_resource_parameter_when_server_url_carries_credentials + # `Discovery.canonicalize_url` drops `user:pass@`, so the RFC 8707 + # `resource` parameter sent on both the authorization request and + # the token exchange must contain only the origin + path: shipping + # the raw URL would hand the user's credentials to the authorization + # server. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + captured_url = nil + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_url = url }, + callback_handler: -> { + [ + "test-auth-code", + URI.decode_www_form(captured_url.query).to_h.fetch("state"), + ] + }, + ) + + Flow.new(provider: provider).run!( + server_url: "https://hunter2:t0psecret@srv.example.com/mcp", + resource_metadata_url: @prm_url, + ) + + query = URI.decode_www_form(captured_url.query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + refute_includes(query["resource"], "hunter2") + refute_includes(query["resource"], "t0psecret") + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_resource_mismatch_error_does_not_leak_userinfo + # When PRM advertises a `resource` that does not cover the MCP server + # URL, the mismatch error message MUST cite the canonicalized URLs so + # that `user:pass@` cannot reach logs, stack traces, or exception + # reporters. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://hunter2:t0psecret@evil.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://hunter2:t0psecret@srv.example.com/mcp", + resource_metadata_url: @prm_url, + ) + end + + refute_includes(error.message, "hunter2") + refute_includes(error.message, "t0psecret") + end + + def test_run_blocks_cross_tenant_query_bypass + # A hijacked PRM that advertises a different tenant's `resource` + # MUST cause an audience-binding failure rather than allowing + # the client to mint a token bound to the attacker's tenant for + # the victim's MCP server. + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp?tenant=evil", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!( + server_url: "https://srv.example.com/mcp?tenant=victim", + resource_metadata_url: @prm_url, + ) + end + + assert_match(/resource/i, error.message) + end + + def test_run_raises_on_resource_mismatch + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://evil.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/resource/i, error.message) + end + + def test_run_sends_server_url_as_resource_when_prm_omits_it + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:url] = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + query = URI.decode_www_form(state_holder[:url].query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp" + end + end + + private + + def refresh_only_provider + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + provider.save_client_information("client_id" => "test-client") + + provider + end + end + end + end +end diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb new file mode 100644 index 0000000..dcbe302 --- /dev/null +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -0,0 +1,1946 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "webmock/minitest" +require "faraday" +require "mcp/client/http" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class HTTPOAuthTest < Minitest::Test + # Records `[method, url]` for every request that passes through, standing in for the tracing + # middleware an application adds through `http_client_customizer`. + class RecordingMiddleware + def initialize(app, log) + @app = app + @log = log + end + + def call(env) + @log << [env.method, env.url.to_s] + @app.call(env) + end + end + + # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind. + class URLRewritingMiddleware + def initialize(app, from:, to:) + @app = app + @from = from + @to = to + end + + def call(env) + env.url = URI(@to) if env.url.to_s == @from + @app.call(env) + end + end + + # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever + # an earlier middleware recorded on the original. + class EnvRebuildingMiddleware + def initialize(app) + @app = app + end + + def call(env) + @app.call(Faraday::Env.from(env.to_h)) + end + end + + def setup + WebMock.enable! + @mcp_url = "https://srv.example.com/mcp" + @prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp" + @auth_base = "https://auth.example.com" + end + + def teardown + WebMock.reset! + end + + def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token + # First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"].nil? } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer test-token-after-flow" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("test-token-after-flow", provider.access_token) + end + + def test_send_request_runs_the_oauth_flow_through_the_provider_customizer + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"].nil? + }.to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer test-token-after-flow" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600), + ) + + log = [] + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal( + [ + [:get, @prm_url], + [:get, "#{@auth_base}/.well-known/oauth-authorization-server"], + [:post, "#{@auth_base}/register"], + [:post, "#{@auth_base}/token"], + ], + log, + ) + end + + def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin + # End to end over the transport, which is where the header is actually parsed: + # a server that answers 401 must not be able to name an unrelated host in + # `resource_metadata` and have the client fetch it. The request never leaving + # is the point; the 401 surfacing to the caller is secondary. + metadata_url = "https://169.254.169.254/latest/meta-data/iam/" + + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"), + }, + body: "", + ) + stub_request(:get, metadata_url).to_return(status: 200, body: "{}") + + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(StandardError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_not_requested(:get, metadata_url) + end + + def test_send_request_runs_step_up_flow_on_403_insufficient_scope + # First call with the initial token: 403 carrying an `insufficient_scope` + # Bearer challenge with the escalated scope. The transport must re-run + # a full authorization (NOT a refresh) with the escalated scope and + # retry the original request. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer escalated-token" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: { name: "x" } }, + ) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("escalated-token", provider.access_token) + + # The fresh authorization-code grant ran (refresh path was bypassed). + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "authorization_code" && + form["code"] == "test-auth-code" + end + + # And the authorization request the SDK handed to the redirect + # handler carried the escalated scope from the challenge. + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + assert_equal("mcp:basic mcp:write", authorization_query["scope"]) + end + + def test_send_request_step_up_unions_existing_scope_with_challenge_scope + # MCP step-up: the re-authorization request must ask for the union + # of the previously granted scope and the newly demanded scope. + # Otherwise the new token would lack permissions the caller had + # before, triggering another step-up the next time they are used. + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + # The provider already holds a token granted for `mcp:read`. + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + requested = authorization_query["scope"].split + assert_includes(requested, "mcp:read", "must preserve previously granted scope") + assert_includes(requested, "mcp:write", "must add the newly demanded scope") + assert_equal(requested.uniq.length, requested.length, "must not duplicate scopes") + end + + def test_send_request_does_not_retry_plain_403 + # A 403 without an `insufficient_scope` Bearer challenge is a hard + # forbidden, not a step-up signal. Surface it as a `RequestHandlerError` + # rather than tripping the OAuth flow. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, body: "", + ) + + provider = build_step_up_provider + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + end + assert_equal(:forbidden, error.error_type) + assert_not_requested(:get, "#{@auth_base}/.well-known/oauth-authorization-server") + end + + def test_send_request_does_not_loop_on_repeated_insufficient_scope + # The server keeps demanding more scope after every authorization. + # The transport retries the step-up flow at most once per + # `send_request`, then surfaces the 403 to the caller. + stub_request(:post, @mcp_url).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:everything", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + end + assert_equal(:forbidden, error.error_type) + end + + def test_send_request_step_up_bypasses_refresh_token + # The provider has a refresh_token, but step-up MUST run a fresh + # authorization request with the escalated scope, not exchange the + # refresh_token. Refreshing would mint a new access token with the + # same scopes as the current one, which already failed. + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer initial-token" }, + ).to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer escalated-token" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server + provider = build_step_up_provider + provider.save_tokens( + "access_token" => "initial-token", + "refresh_token" => "saved-rt", + "token_type" => "Bearer", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # The token endpoint was only called for the fresh + # `authorization_code` grant, never for `refresh_token`. + assert_not_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token" + end + end + + def test_send_request_step_up_appends_offline_access_when_advertised + # Integration of step-up with the SEP-2207 offline_access logic: + # the re-authorization request unions the existing and challenge scopes, + # then -- because the AS advertises `offline_access` and the client + # declared the `refresh_token` grant -- `offline_access` is appended + # so the escalated token can itself be refreshed later. + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server( + extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write", "offline_access"] }, + ) + provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"]) + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split + assert_includes(requested, "mcp:read", "must preserve previously granted scope") + assert_includes(requested, "mcp:write", "must add the challenged scope") + assert_includes(requested, "offline_access", "must append offline_access when advertised") + end + + def test_send_request_step_up_strips_offline_access_when_not_advertised + # Mirror of the previous test: if the AS does NOT advertise + # `offline_access`, the step-up request must not carry it even when + # the existing token's scope happened to include it + # (SEP-2207 MUST-NOT, enforced by `normalize_offline_access_scope`). + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + # AS advertises only resource scopes, no offline_access. + stub_step_up_authorization_server( + extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write"] }, + ) + provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"]) + provider.save_tokens( + "access_token" => "initial-token", + "token_type" => "Bearer", + "scope" => "mcp:read offline_access", + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split + assert_includes(requested, "mcp:read") + assert_includes(requested, "mcp:write") + refute_includes(requested, "offline_access", "must strip offline_access when the AS does not advertise it") + end + + def test_send_request_step_up_uses_cimd_client_id + # Integration of step-up with Client ID Metadata Documents: + # when the provider is CIMD-configured and the AS advertises CIMD support, + # the step-up re-authorization uses the CIMD URL as `client_id` and skips + # Dynamic Client Registration, just like the initial flow. + cimd_url = "https://app.example.com/client-metadata.json" + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer initial-token" }) + .to_return( + status: 403, + headers: { + "WWW-Authenticate" => %(Bearer error="insufficient_scope", ) + + %(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer escalated-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_step_up_authorization_server( + extra_as_metadata: { client_id_metadata_document_supported: true }, + ) + provider = build_step_up_provider(client_id_metadata_document_url: cimd_url) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + transport.send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # DCR was skipped and the CIMD URL was used as the client_id on + # the escalated authorization request. + assert_not_requested(:post, "#{@auth_base}/register") + authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h + assert_equal(cimd_url, authorization_query["client_id"]) + end + + def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set + # A bearer token sent to `http://attacker.example.com/mcp` would leak + # over the wire. When OAuth is on, the transport URL must clear + # the same Communication Security bar as every other OAuth-related URL. + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + MCP::Client::HTTP.new(url: "http://attacker.example.com/mcp", oauth: provider) + end + end + + def test_initialize_allows_loopback_http_mcp_url_when_oauth_is_set + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + ) + + # Loopback HTTP is permitted for local development / conformance. + MCP::Client::HTTP.new(url: "http://localhost:9292/mcp", oauth: provider) + MCP::Client::HTTP.new(url: "http://127.0.0.1:9292/mcp", oauth: provider) + end + + def test_initialize_allows_non_secure_mcp_url_when_oauth_is_nil + # Without OAuth, no bearer tokens are sent, so the SDK leaves + # the scheme decision to the caller. + MCP::Client::HTTP.new(url: "http://internal.example.com/mcp") + end + + def test_refresh_path_preserves_query_string_in_resource_claim + # The refresh flow MUST send the same canonical `resource` claim as + # the original authorization request -- otherwise the AS rejects + # the refresh as a different audience. Verify by giving the transport + # a query-bearing MCP URL and a saved refresh token, then asserting + # the refresh POST body carries the full URL. + tenant_mcp_url = "https://srv.example.com/mcp?tenant=1" + + stub_request(:post, tenant_mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer refreshed-tenant-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, tenant_mcp_url) + .with(headers: { "Authorization" => "Bearer refreshed-tenant-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-tenant-token", token_type: "Bearer", expires_in: 3600), + ) + + provider = build_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "valid-rt") + + transport = HTTP.new(url: tenant_mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + + # The refresh POST body's `resource` MUST keep the query string so it + # matches the audience the original token was minted for. + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "refresh_token" && + form["resource"] == "https://srv.example.com/mcp?tenant=1" + end + end + + def test_send_request_refreshes_when_refresh_token_is_available + # On 401 with a saved refresh_token, the transport should swap it for + # a fresh access token rather than re-running the full interactive + # Authorization Code flow. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer refreshed-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer refreshed-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # DCR and /authorize must not be invoked when refresh succeeds. + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:get, "#{@auth_base}/authorize").to_raise(StandardError.new("authorize should not be called.")) + + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600), + ) + + provider = build_provider + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("refreshed-token", provider.access_token) + end + + def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token + # Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader. + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"] != "Bearer refreshed-token" + }.to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer refreshed-token" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + token_endpoint: "#{@auth_base}/token", + grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + ), + ) + + stub_request(:post, "#{@auth_base}/token").with( + body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt") + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600), + ) + + assertion_calls = 0 + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { + assertion_calls += 1 + "id-jag-assertion" + }, + ) + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("refreshed-token", provider.access_token) + assert_equal(0, assertion_calls) + end + + def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing + # A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized: + # the refresh attempt must raise rather than fall through to the interactive flow, + # which would fail the same way after the user signed in. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + redirected = false + provider_class = Class.new(Provider) do + define_method(:token_request_params) { { "grant_type" => "password" } } + end + provider = provider_class.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { redirected = true }, + callback_handler: -> { ["code", "state"] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(Flow::InvalidTokenRequestParamsError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + refute(redirected) + assert_not_requested(:post, "#{@auth_base}/token") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_send_request_surfaces_a_refused_refresh_without_falling_back + # A customizer middleware that would carry the refresh request off the origin is refused before + # the request leaves, and that refusal is not one of the failures that start the interactive flow + # or discard the stored tokens. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + stub_request(:post, "https://other.example.com/token").to_raise(StandardError.new("the rewritten request must not leave.")) + + provider = build_provider( + http_client_customizer: ->(faraday) { + faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") + }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_not_requested(:post, "https://other.example.com/token") + assert_not_requested(:post, "#{@auth_base}/token") + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal("saved-rt", provider.tokens["refresh_token"]) + end + + def test_send_request_surfaces_a_refresh_whose_environment_was_rebuilt_without_falling_back + # The missing-record refusal takes the same route as the origin mismatch: out of `send_request`, + # with no interactive fallback and the stored tokens intact. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + stub_request(:get, @prm_url).to_raise(StandardError.new("the rebuilt request must not leave.")) + stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) + + interactive_flow_started = false + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) { interactive_flow_started = true }, + callback_handler: -> { ["code", "state"] }, + http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + error = assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + assert_match(/carries no record of the URL the flow asked for/, error.message) + refute(interactive_flow_started, "the refusal must not fall back to the interactive flow") + assert_not_requested(:get, @prm_url) + assert_not_requested(:post, "#{@auth_base}/token") + assert_not_requested(:post, "#{@auth_base}/register") + assert_equal({ "access_token" => "stale-token", "refresh_token" => "saved-rt" }, provider.tokens) + end + + def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure + # A 5xx (or any non-`invalid_grant`) from the token endpoint indicates + # a transient AS outage, NOT that the refresh token is dead. + # The transport must fall through to the full Authorization Code flow + # (we cannot serve the MCP request without a fresh access token), but + # the stored refresh_token must remain so subsequent attempts on + # the next outage-free request can succeed without interactive reauth. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer brand-new-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer brand-new-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # Refresh request: transient 503. Auth code exchange: succeeds with + # a NEW refresh token (so we can prove the live RT survived rather than + # being silently overwritten by the success path). + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return(status: 503, body: "") + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "authorization_code")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer"), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + # The transient 503 must not wipe the refresh token; the auth-code + # success path overwrites tokens, but we asserted the prior refresh + # token survived the refresh-attempt failure by NOT seeing the full + # flow trigger `clear_tokens!`. To prove this, inspect that + # the final stored refresh_token is whatever the auth-code response + # provided (omitted here), not nil from a forced clear. + stored = provider.tokens || {} + assert_equal("brand-new-token", stored["access_token"] || stored[:access_token]) + end + + def test_send_request_falls_back_to_full_flow_when_refresh_fails + # If the refresh request itself is rejected (refresh token revoked), + # the transport should clear stale tokens and fall through to + # the full Authorization Code flow. + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer brand-new-token" } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer brand-new-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + # First /token call (refresh) fails; second call (authorization_code) succeeds. + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return(status: 401, body: "") + stub_request(:post, "#{@auth_base}/token") + .with(body: hash_including("grant_type" => "authorization_code")) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + provider.save_client_information("client_id" => "test-client") + provider.save_tokens("access_token" => "stale", "refresh_token" => "revoked-rt") + + transport = HTTP.new(url: @mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + assert_equal("brand-new-token", provider.access_token) + end + + def test_oauth_flow_preserves_query_string_in_resource_claim + # Query parameters on the MCP URL (e.g. `?tenant=...`) must be carried + # into the RFC 8707 `resource` claim sent on the authorization and + # token requests, matching how the TS and Python SDKs build + # the canonical resource URL. The URL guard's snapshot is allowed to drop + # the query because Faraday does the same at request time, but + # the OAuth-side snapshot must retain it. + tenant_mcp_url = "https://srv.example.com/mcp?tenant=1" + + stub_request(:post, tenant_mcp_url) + .with { |req| req.headers["Authorization"].nil? } + .to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:post, tenant_mcp_url) + .with(headers: { "Authorization" => "Bearer tenant-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + # PRM is allowed to omit `resource`; the client falls back to its + # canonical MCP URL (query and all). + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "tenant-token", token_type: "Bearer", expires_in: 3600), + ) + + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_authorization_url = url }, + callback_handler: -> { + query = URI.decode_www_form(captured_authorization_url.query).to_h + ["test-auth-code", query.fetch("state")] + }, + ) + + transport = HTTP.new(url: tenant_mcp_url, oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + + # The authorization URL's `resource` parameter MUST keep the query. + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://srv.example.com/mcp?tenant=1", query["resource"]) + + # The token endpoint POST body's `resource` MUST also keep the query. + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp?tenant=1" + end + end + + def test_oauth_flow_uses_validated_url_after_url_mutation + # Even if an attacker mutates `@url` after construction (e.g. + # `instance_variable_set(:@url, "https://attacker.example.com/mcp")`), + # the OAuth discovery / authorization code flow MUST continue to use + # the URL snapshotted at initialize time. Otherwise PRM, AS metadata, + # and the `resource` claim sent to the authorization server would + # all point at the attacker-controlled host. + attacker_prm = "https://attacker.example.com/.well-known/oauth-protected-resource/mcp" + attacker_root_prm = "https://attacker.example.com/.well-known/oauth-protected-resource" + stub_request(:get, attacker_prm).to_raise(StandardError.new("PRM must not target attacker host.")) + stub_request(:get, attacker_root_prm).to_raise(StandardError.new("PRM must not target attacker host.")) + + stub_request(:post, @mcp_url) + .with { |req| req.headers["Authorization"] != "Bearer post-mutation-token" } + .to_return( + status: 401, + # No `resource_metadata` to force PRM derivation from + # the server_url -- this is exactly the path where a mutated + # `@url` could redirect discovery without the snapshot. + headers: { "WWW-Authenticate" => 'Bearer error="invalid_token"' }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "post-mutation-token", token_type: "Bearer", expires_in: 3600), + ) + + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { captured_authorization_url = url }, + callback_handler: -> { + query = URI.decode_www_form(captured_authorization_url.query).to_h + ["test-auth-code", query.fetch("state")] + }, + ) + + stub_request(:post, @mcp_url) + .with(headers: { "Authorization" => "Bearer post-mutation-token" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + # Force the lazy Faraday connection to be built against the validated + # URL before the mutation. After this, `@client.url_prefix` is + # locked to `srv.example.com` regardless of what `@url` later points + # at, which is what the URL guard middleware reads. + transport.send(:client) + transport.instance_variable_set(:@url, "https://attacker.example.com/mcp") + + # The first 401 must drive `run_oauth_flow!` against the validated + # URL, not the mutated `@url`. The OAuth flow succeeds, the retry + # carries the new bearer token, and Faraday (still pointed at + # the safe URL via its frozen `url_prefix`) returns the stubbed 200. + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + # Discovery must have aimed at the validated host. The attacker-host + # PRM stubs would have raised otherwise. + assert_requested(:get, @prm_url) + refute_requested(:get, attacker_prm) + refute_requested(:get, attacker_root_prm) + # The `resource` claim sent on the authorization URL is built from + # the validated URL, not the attacker-host mutation. + query = URI.decode_www_form(captured_authorization_url.query).to_h + assert_equal("https://srv.example.com/mcp", query["resource"]) + refute_includes(query["resource"], "attacker.example.com") + end + + def test_send_request_rejects_url_mutation_after_initialize + # The constructor secure-URL check alone is not enough: `@url` is + # a plain instance variable, so a caller can `instance_variable_set` it + # to a non-secure URL after the transport is built. The send-time + # re-check must catch this and refuse to send the bearer token. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) + transport.instance_variable_set(:@url, "http://attacker.example.com/mcp") + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_faraday_customizer_url_swap + # An `&block` Faraday customizer can rewrite `url_prefix` to point at + # a plaintext attacker host; the constructor URL check would miss this. + # The send-time effective-URL check must reject it. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.url_prefix = "http://attacker.example.com/mcp" + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_https_to_different_host_via_customizer + # An `https://` URL alone is not enough - the URL must also match + # the one validated at initialize time. A customizer that swaps to + # a *different* https host (e.g. `https://attacker.example.com/mcp`) + # would otherwise pass a naive "still https?" check and leak + # the bearer token to the attacker. + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.url_prefix = "https://attacker.example.com/mcp" + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_middleware_env_url_query_rewrite + # A Faraday middleware that swaps only the query (`tenant=victim` -> + # `tenant=evil`) must be caught by the URL guard. Otherwise + # an attacker who can inject middleware can ship the bearer token to + # a different tenant of the same origin/path while the OAuth-side + # audience binding stays correct. + rewrite_query_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url.query = "tenant=evil" + @app.call(env) + end + end + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + transport = MCP::Client::HTTP.new( + url: "https://srv.example.com/mcp?tenant=victim", + oauth: provider, + ) do |faraday| + faraday.use(rewrite_query_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_rejects_middleware_env_url_rewrite + # A Faraday middleware can mutate `env.url` at request time, which + # `Faraday::Connection#url_prefix` cannot detect. The identity guard + # must run as a middleware *after* the customizer so it sees + # the rewritten env.url before it reaches the adapter. + rewrite_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url = URI("https://attacker.example.com/mcp") + @app.call(env) + end + end + + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday| + faraday.use(rewrite_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_accepts_validated_url_with_userinfo + # Faraday hoists `user:pass@` out of the URL into an `Authorization: + # Basic` header, so `env.url` at request time does not carry + # the userinfo. The identity guard must drop userinfo on both sides to + # avoid a false-positive `InsecureURLError` on this valid URL. + stub_request(:post, "https://srv.example.com/mcp") + .with(headers: { "Authorization" => /Basic|Bearer/ }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://user:pass@srv.example.com/mcp", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_insecure_url_error_does_not_leak_userinfo + # The error message MUST surface only the canonicalized (origin + path) + # form so that `user:pass@` cannot leak into logs, stack traces, or + # exception reporters. + provider = build_provider + error = assert_raises(MCP::Client::HTTP::InsecureURLError) do + MCP::Client::HTTP.new(url: "http://hunter2:t0psecret@attacker.example.com/mcp", oauth: provider) + end + + refute_includes(error.message, "hunter2") + refute_includes(error.message, "t0psecret") + end + + def test_send_request_accepts_validated_url_with_query_string + # A `?tenant=1` style query on the user-supplied URL must not trip + # the identity guard. `Faraday::Connection#url_prefix.to_s` drops + # the query, so naive string-compare would raise a false-positive + # `InsecureURLError` on the very first request. + stub_request(:post, "https://srv.example.com/mcp?tenant=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant=1", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_unsorted_query_parameters + # Faraday sorts query parameters by name before sending the request, + # so the snapshot and the effective URL must compare as equal even + # when the user-supplied URL listed `b` first and `a` second. + stub_request(:post, "https://srv.example.com/mcp?b=2&a=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?b=2&a=1", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_lowercase_percent_encoded_hex + # Faraday uppercases percent-encoded hex digits (`%2f` -> `%2F`), + # so the snapshot and the effective URL must compare as equal even + # when the user-supplied URL used lowercase hex. + stub_request(:post, "https://srv.example.com/mcp?x=%2f").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?x=%2f", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_valueless_query_key + # `?tenant` (no `=`) and `?tenant=` (empty value) are distinct in + # Faraday's encoding. The snapshot must preserve that distinction + # so the URL guard does not false-positive when the user-supplied + # URL omits the `=`. + stub_request(:post, "https://srv.example.com/mcp?tenant").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_array_form_query_parameters + # Faraday preserves array-form (`?roles[]=a&roles[]=b`) duplicates + # rather than collapsing them. The snapshot must do the same so + # the URL guard does not raise a false positive. + stub_request(:post, "https://srv.example.com/mcp?roles[]=a&roles[]=b").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?roles[]=a&roles[]=b", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_rejects_array_form_query_entry_drop_via_middleware + # An attacker middleware that strips one value from a multi-value + # array-form query (`?tenant[]=victim&tenant[]=victim2` -> + # `?tenant[]=victim`) must be caught by the URL guard. If + # the snapshot collapsed duplicates, both forms would compare as equal + # and the bearer token would leak to a partially-rewritten audience. + drop_first_value_middleware = Class.new do + def initialize(app) + @app = app + end + + def call(env) + env.url.query = "tenant%5B%5D=victim" + @app.call(env) + end + end + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + transport = MCP::Client::HTTP.new( + url: "https://srv.example.com/mcp?tenant[]=victim&tenant[]=victim2", + oauth: provider, + ) do |faraday| + faraday.use(drop_first_value_middleware) + end + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_accepts_url_with_blank_query_segments + # Faraday's query encoder drops empty-name pairs and blank `&&` + # separators (`?&&a=1&&` -> `?a=1`). The snapshot must do the same + # so the URL guard does not raise a false positive. + stub_request(:post, "https://srv.example.com/mcp?a=1").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?&&a=1&&", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_repeated_query_parameters + # Faraday's query encoder collapses repeated same-name parameters + # to the last value (`?a=1&a=2` -> `?a=2`). The snapshot must apply + # the same collapse so the URL guard does not raise a false + # positive. + stub_request(:post, "https://srv.example.com/mcp?a=2").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?a=1&a=2", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_accepts_url_with_empty_query_marker + # Faraday drops a bare trailing `?` with no parameters, so + # the snapshot and the effective URL must compare as equal even when + # the user-supplied URL kept the `?` marker. + stub_request(:post, "https://srv.example.com/mcp").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + provider = build_provider + provider.save_tokens("access_token" => "valid-token") + + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?", oauth: provider) + response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + + assert_equal({ "ok" => true }, response["result"]) + end + + def test_send_request_rejects_https_to_different_host_via_url_mutation + # Same identity-binding requirement when the swap goes through + # `instance_variable_set(:@url, ...)`: the new URL is HTTPS, but it + # is not the URL that was validated at initialize time. + stub_request(:post, "https://attacker.example.com/mcp").to_return(status: 200, body: "") + provider = build_provider + transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) + transport.instance_variable_set(:@url, "https://attacker.example.com/mcp") + transport.instance_variable_set(:@client, nil) # force Faraday rebuild against mutated @url + + assert_raises(MCP::Client::HTTP::InsecureURLError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_raises_unauthorized_when_no_oauth_provider + stub_request(:post, @mcp_url).to_return(status: 401, body: "") + transport = HTTP.new(url: @mcp_url) + + error = assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + assert_equal(:unauthorized, error.error_type) + end + + def test_send_request_does_not_loop_when_oauth_flow_fails + stub_request(:post, @mcp_url).to_return(status: 401, body: "") + stub_request(:get, @prm_url).to_return(status: 404, body: "") + stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return( + status: 404, + body: "", + ) + + # With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once. + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/register").to_return(status: 404) + + provider = Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { [nil, nil] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(Flow::AuthorizationError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + end + + def test_send_request_does_not_re_run_oauth_flow_on_repeated_401 + # Server keeps returning 401 even after a successful token exchange. + # The retry guard MUST limit OAuth flow execution to one attempt per + # `send_request` so we surface the failure instead of looping. + stub_request(:post, @mcp_url).to_return( + status: 401, + headers: { + "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), + }, + body: "", + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "still-bad-token", token_type: "Bearer", expires_in: 3600), + ) + + state_holder = {} + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + transport = HTTP.new(url: @mcp_url, oauth: provider) + + assert_raises(MCP::Client::RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) + end + + # Token endpoint should be hit at most once across the failing call, + # proving the retry guard prevents an infinite re-authorization loop. + assert_requested(:post, "#{@auth_base}/token", times: 1) + end + + def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens + # The refusal in `refresh!` is only half the answer; this is the other half. + # A renamed authorization server makes the refresh fail, the transport falls through to a full authorization, + # and that is where the embedding application is asked about the new one. + stub_request(:post, @mcp_url).with { |req| + req.headers["Authorization"] != "Bearer fresh-at" + }.to_return( + status: 401, + headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, + body: "", + ) + stub_request(:post, @mcp_url).with( + headers: { "Authorization" => "Bearer fresh-at" } + ).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + ) + + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: @mcp_url, authorization_servers: [@auth_base]), + ) + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ), + ) + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "reauthorized-client"), + ) + stub_request(:post, "#{@auth_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), + ) + + seen = [] + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + authorization_request_validator: ->(request) { + seen << request.authorization_server + true + }, + ) + + # Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is + # what stops it rather than a missing client identity. + provider.save_client_information("client_id" => "test-client") + provider.save_tokens( + "access_token" => "stale-at", + "refresh_token" => "saved-rt", + "issuer" => "https://issued-by.example.com", + ) + + HTTP.new(url: @mcp_url, oauth: provider).send_request( + request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, + ) + + # The refresh token was never presented to the authorization server now being named. + assert_not_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token" + end + assert_equal([@auth_base], seen) + assert_equal(@auth_base, provider.tokens["issuer"]) + end + + private + + # Stubs PRM, AS metadata, /register, and /token so a full authorization + # flow runs. The /token stub echoes `requested_scope` so callers can + # assert the escalated scope was sent. + # `extra_as_metadata` lets a test advertise additional AS capabilities + # (e.g. `scopes_supported` for the offline_access interaction, or + # `client_id_metadata_document_supported` for the CIMD interaction) + # without duplicating the whole stub. + def stub_step_up_authorization_server(extra_as_metadata: {}) + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: "https://srv.example.com/mcp", + authorization_servers: [@auth_base], + ), + ) + + stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate({ + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }.merge(extra_as_metadata)), + ) + + stub_request(:post, "#{@auth_base}/register").to_return( + status: 201, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(client_id: "test-client"), + ) + + stub_request(:post, "#{@auth_base}/token").to_return do |request| + form = URI.decode_www_form(request.body).to_h + + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "escalated-token", + token_type: "Bearer", + expires_in: 3600, + requested_scope: form["scope"], + grant_type_received: form["grant_type"], + ), + } + end + end + + def build_step_up_provider(grant_types: ["authorization_code"], client_id_metadata_document_url: nil) + state_holder = {} + captured_authorization_url = nil + provider = Provider.new( + client_metadata: { + client_name: "ruby-sdk-test", + redirect_uris: ["http://localhost:0/callback"], + grant_types: grant_types, + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + captured_authorization_url = url + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + client_id_metadata_document_url: client_id_metadata_document_url, + ) + provider.save_tokens("access_token" => "initial-token", "token_type" => "Bearer") + + # `captured_authorization_url` is a closure variable that the redirect handler writes at flow time; + # expose its current value via a method on the provider so tests can assert on what was sent to /authorize. + provider.define_singleton_method(:last_authorization_url) { captured_authorization_url } + provider + end + + def build_provider(http_client_customizer: nil) + Provider.new( + client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + http_client_customizer: http_client_customizer, + ) + end + end + end + end +end diff --git a/test/mcp/client/oauth/id_jag_token_exchange_test.rb b/test/mcp/client/oauth/id_jag_token_exchange_test.rb new file mode 100644 index 0000000..23d152c --- /dev/null +++ b/test/mcp/client/oauth/id_jag_token_exchange_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" +require "faraday" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class IDJAGTokenExchangeTest < Minitest::Test + def setup + WebMock.enable! + @token_endpoint = "https://idp.example.com/token" + end + + def teardown + WebMock.reset! + end + + def request_exchange + IDJAGTokenExchange.request( + token_endpoint: @token_endpoint, + id_token: "idp-id-token", + client_id: "idp-client", + audience: "https://auth.example.com", + resource: "https://srv.example.com/mcp", + ) + end + + def test_request_sends_rfc8693_token_exchange_parameters + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "id-jag-assertion", + issued_token_type: "urn:ietf:params:oauth:token-type:id-jag", + token_type: "N_A", + ), + ) + + assertion = request_exchange + + assert_equal("id-jag-assertion", assertion) + assert_requested(:post, @token_endpoint) do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" && + form["subject_token"] == "idp-id-token" && + form["subject_token_type"] == "urn:ietf:params:oauth:token-type:id_token" && + form["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" && + form["audience"] == "https://auth.example.com" && + form["resource"] == "https://srv.example.com/mcp" && + form["client_id"] == "idp-client" + end + end + + def test_request_rejects_response_without_id_jag_token_type + # A plain access token is not an ID-JAG; presenting it as a + # jwt-bearer assertion would fail confusingly downstream. + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "ordinary-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + ), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/did not issue an ID-JAG/, error.message) + end + + def test_request_rejects_missing_access_token + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(issued_token_type: "urn:ietf:params:oauth:token-type:id-jag"), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/missing `access_token`/, error.message) + end + + def test_request_raises_on_non_2xx_response + stub_request(:post, @token_endpoint).to_return(status: 400, body: JSON.generate(error: "invalid_grant")) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/status 400/, error.message) + end + + def test_request_raises_on_unparseable_response + stub_request(:post, @token_endpoint).to_return(status: 200, body: "not json") + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/Failed to parse/, error.message) + end + + def test_request_raises_on_non_object_json_response + stub_request(:post, @token_endpoint).to_return(status: 200, body: "[]") + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/not a JSON object/, error.message) + end + + def test_request_refuses_a_response_body_over_the_cap + stub_request(:post, @token_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "a" * (4 * 1024 * 1024 + 1), + ) + + error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } + assert_match(/exceeds \d+ bytes/, error.message) + end + end + end + end +end diff --git a/test/mcp/client/oauth/jwt_client_assertion_test.rb b/test/mcp/client/oauth/jwt_client_assertion_test.rb new file mode 100644 index 0000000..412f966 --- /dev/null +++ b/test/mcp/client/oauth/jwt_client_assertion_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class JWTClientAssertionTest < Minitest::Test + ES256_COMPONENT_BYTES = 32 + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_ec_key(curve) + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate(curve) + else + OpenSSL::PKey::EC.new(curve).tap(&:generate_key) + end + end + + def es256_key + @es256_key ||= generate_ec_key("prime256v1") + end + + def rs256_key + @rs256_key ||= OpenSSL::PKey::RSA.new(2048) + end + + def generate(private_key: es256_key, signing_algorithm: "ES256", **kwargs) + JWTClientAssertion.generate( + client_id: "cc-client", + audience: "https://auth.example.com", + private_key: private_key, + signing_algorithm: signing_algorithm, + **kwargs, + ) + end + + def decode_segment(segment) + JSON.parse(Base64.urlsafe_decode64(segment + "=" * (-segment.length % 4))) + end + + # JWS ES256 signatures are the raw 64-byte `r || s` concatenation; + # OpenSSL verifies the ASN.1 DER form, so convert back for verification. + def ecdsa_raw_to_der(raw) + r = OpenSSL::BN.new(raw[0, ES256_COMPONENT_BYTES], 2) + s = OpenSSL::BN.new(raw[ES256_COMPONENT_BYTES, ES256_COMPONENT_BYTES], 2) + OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der + end + + def test_generate_builds_compact_jws_with_rfc7523_claims + before = Time.now.to_i + assertion = generate + after = Time.now.to_i + + header_segment, payload_segment, _signature_segment = assertion.split(".") + header = decode_segment(header_segment) + claims = decode_segment(payload_segment) + + assert_equal({ "alg" => "ES256", "typ" => "JWT" }, header) + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + assert_includes(before..after, claims["iat"]) + assert_equal(claims["iat"] + JWTClientAssertion::DEFAULT_LIFETIME, claims["exp"]) + refute_empty(claims["jti"]) + end + + def test_generate_uses_unpadded_base64url_segments + assertion = generate + + segments = assertion.split(".") + assert_equal(3, segments.size) + segments.each do |segment| + refute_match(%r{[+/=]}, segment, "JWS segments must be unpadded base64url") + end + end + + def test_generate_es256_signature_verifies_with_the_public_key + assertion = generate + + header_segment, payload_segment, signature_segment = assertion.split(".") + raw_signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal(ES256_COMPONENT_BYTES * 2, raw_signature.bytesize) + assert( + es256_key.verify( + OpenSSL::Digest.new("SHA256"), + ecdsa_raw_to_der(raw_signature), + "#{header_segment}.#{payload_segment}", + ), + "ES256 signature must verify against the signing input", + ) + end + + def test_generate_rs256_signature_verifies_with_the_public_key + assertion = generate(private_key: rs256_key, signing_algorithm: "RS256") + + header_segment, payload_segment, signature_segment = assertion.split(".") + signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal("RS256", decode_segment(header_segment)["alg"]) + assert(rs256_key.verify(OpenSSL::Digest.new("SHA256"), signature, "#{header_segment}.#{payload_segment}")) + end + + def test_generate_accepts_pem_encoded_private_key + assertion = generate(private_key: es256_key.to_pem) + + refute_empty(assertion) + end + + def test_generate_honors_lifetime_override + assertion = generate(lifetime: 60) + + claims = decode_segment(assertion.split(".")[1]) + assert_equal(claims["iat"] + 60, claims["exp"]) + end + + def test_generate_uses_a_unique_jti_per_assertion + jtis = Array.new(2) { decode_segment(generate.split(".")[1])["jti"] } + + refute_equal(jtis[0], jtis[1]) + end + + def test_generate_rejects_unsupported_algorithm + assert_raises(JWTClientAssertion::UnsupportedAlgorithmError) do + generate(signing_algorithm: "HS256") + end + end + + def test_generate_rejects_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key, signing_algorithm: "ES256") + end + + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: es256_key, signing_algorithm: "RS256") + end + end + + def test_generate_rejects_ec_key_on_the_wrong_curve + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: generate_ec_key("secp384r1"), signing_algorithm: "ES256") + end + end + + def test_generate_rejects_unparseable_pem + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: "not a pem") + end + end + + def test_generate_rejects_public_only_key + # `RSA#public_key` is available on every supported openssl version, + # unlike `public_to_pem` (openssl 3.0+), so the public-only check is + # exercised with an RSA key. + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key.public_key, signing_algorithm: "RS256") + end + end + end + end + end +end diff --git a/test/mcp/client/oauth/pkce_test.rb b/test/mcp/client/oauth/pkce_test.rb new file mode 100644 index 0000000..49a628b --- /dev/null +++ b/test/mcp/client/oauth/pkce_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "digest" +require "mcp/client/oauth/pkce" + +module MCP + class Client + module OAuth + class PKCETest < Minitest::Test + def test_generate_returns_verifier_and_s256_challenge + result = PKCE.generate + + assert_kind_of(String, result[:code_verifier]) + assert_operator(result[:code_verifier].length, :>=, 43) + assert_operator(result[:code_verifier].length, :<=, 128) + assert_equal("S256", result[:code_challenge_method]) + + expected = Base64.urlsafe_encode64(Digest::SHA256.digest(result[:code_verifier]), padding: false) + assert_equal(expected, result[:code_challenge]) + end + + def test_generate_produces_distinct_values + a = PKCE.generate + b = PKCE.generate + + refute_equal(a[:code_verifier], b[:code_verifier]) + refute_equal(a[:code_challenge], b[:code_challenge]) + end + end + end + end +end diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb new file mode 100644 index 0000000..7425131 --- /dev/null +++ b/test/mcp/client/oauth/provider_test.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class ProviderTest < Minitest::Test + # Builds a complete Provider argument hash whose `client_metadata` + # registers the same `redirect_uri` that is being asserted on. + # The registered-URI check in `Provider#initialize` requires the two to + # match, so accept-path tests need them paired. + def args_for(redirect_uri) + { + client_metadata: { + redirect_uris: [redirect_uri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: redirect_uri, + redirect_handler: ->(_url) {}, + callback_handler: -> { ["code", "state"] }, + } + end + + def test_initialize_accepts_https_redirect_uri + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_equal("https://app.example.com/callback", provider.redirect_uri) + end + + def test_initialize_accepts_loopback_http_redirect_uri + ["http://localhost/callback", "http://127.0.0.1:3000/cb", "http://[::1]/cb"].each do |uri| + provider = Provider.new(**args_for(uri)) + assert_equal(uri, provider.redirect_uri) + end + end + + def test_initialize_defaults_http_client_customizer_to_nil + assert_nil(Provider.new(**args_for("https://app.example.com/callback")).http_client_customizer) + end + + def test_initialize_keeps_the_http_client_customizer + customizer = ->(_faraday) {} + provider = Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: customizer) + + assert_same(customizer, provider.http_client_customizer) + end + + def test_initialize_rejects_a_non_callable_http_client_customizer + # A connection object in place of a callable is the likely mistake. + error = assert_raises(ArgumentError) do + Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: Object.new) + end + + assert_equal("http_client_customizer must respond to call (got Object).", error.message) + end + + def test_initialize_rejects_non_loopback_http_redirect_uri + # Communication Security: a non-loopback `http://` redirect URI would + # let an attacker steal the authorization code from a network sniffer, + # so the provider must refuse to construct. + assert_raises(Provider::InsecureRedirectURIError) do + Provider.new(**args_for("http://app.example.com/callback")) + end + end + + def test_initialize_rejects_non_http_scheme_redirect_uri + assert_raises(Provider::InsecureRedirectURIError) do + Provider.new(**args_for("ftp://app.example.com/callback")) + end + end + + def test_initialize_rejects_hostname_tricks_that_resemble_loopback + ["http://127.attacker.com/cb", "http://127.0.0.1.evil.com/cb", "http://foo.localhost/cb"].each do |uri| + assert_raises(Provider::InsecureRedirectURIError, "should reject #{uri}") do + Provider.new(**args_for(uri)) + end + end + end + + def test_initialize_rejects_redirect_uri_not_listed_in_client_metadata + # Per the OAuth Dynamic Client Registration response, the AS will + # bind the client_id to a specific set of redirect_uris and reject any + # authorization request that uses something else. Detect that mismatch + # at Provider construction so the failure surfaces immediately. + args = args_for("http://localhost:0/callback") + args[:redirect_uri] = "http://localhost:0/different" + + assert_raises(Provider::UnregisteredRedirectURIError) do + Provider.new(**args) + end + end + + def test_initialize_accepts_redirect_uri_with_string_keys_in_client_metadata + # `client_metadata` is hand-built by callers, so accept either symbol + # or string keys for `redirect_uris`. + args = args_for("http://localhost:0/callback") + args[:client_metadata] = { "redirect_uris" => ["http://localhost:0/callback"] } + + provider = Provider.new(**args) + assert_equal("http://localhost:0/callback", provider.redirect_uri) + end + + def test_initialize_defaults_client_id_metadata_document_url_to_nil + provider = Provider.new(**args_for("http://localhost:0/callback")) + + assert_nil(provider.client_id_metadata_document_url) + end + + def test_initialize_accepts_https_client_id_metadata_document_url + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/client-metadata.json", + ) + + provider = Provider.new(**args) + assert_equal( + "https://app.example.com/client-metadata.json", + provider.client_id_metadata_document_url, + ) + end + + def test_initialize_rejects_http_client_id_metadata_document_url + # The loopback `http` carve-out used for discovery does not extend to the CIMD URL: + # the URL is sent to the AS as the OAuth `client_id` and travels off-loopback, + # so MCP requires `https`. + ["http://localhost:8080/client-metadata.json", "http://app.example.com/cm"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_non_https_scheme_client_id_metadata_document_url + ["ftp://app.example.com/cm", "file:///cm", "foo://bar/cm"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_client_id_metadata_document_url_without_path + # The CIMD document is a discrete resource, not the origin. + # A bare origin or root-path URL would let two different deployments at + # the same origin shadow each other under one `client_id`. + ["https://app.example.com", "https://app.example.com/"].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_fragment + # A fragment is never sent to the server; embedding one in the `client_id` value + # would make the OAuth `client_id` and the URL the AS dereferences point at different artifacts. + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/cm#frag", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_query + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://app.example.com/cm?x=1", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_userinfo + # Embedding credentials in the `client_id` leaks them to every AS + # log line and to the conformance audit trail. + args = args_for("http://localhost:0/callback").merge( + client_id_metadata_document_url: "https://user:pass@app.example.com/cm", + ) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError) do + Provider.new(**args) + end + end + + def test_initialize_rejects_client_id_metadata_document_url_with_dot_segments + # `https://x/a/./b` and `https://x/a/b` are RFC 3986 equivalent, + # so accepting both would let one client publish under two `client_id` values. + # Reject `.`/`..` (including percent-encoded `.`). + [ + "https://app.example.com/./cm", + "https://app.example.com/a/../cm", + "https://app.example.com/%2E/cm", + ].each do |uri| + args = args_for("http://localhost:0/callback").merge(client_id_metadata_document_url: uri) + assert_raises(Provider::InvalidClientIDMetadataDocumentURLError, "should reject #{uri}") do + Provider.new(**args) + end + end + end + + def test_authorization_flow_is_authorization_code + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_equal(:authorization_code, provider.authorization_flow) + end + + def test_token_request_params_is_nil_by_default + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_nil(provider.token_request_params) + end + + def test_initialize_keeps_a_frozen_copy_of_token_request_params + params = { "audience" => +"https://api.example.com" } + provider = Provider.new(**args_for("https://app.example.com/callback"), token_request_params: params) + + params["audience"] << "/changed" + + assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) + assert_predicate(provider.token_request_params, :frozen?) + end + + def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself + error = assert_raises(Flow::InvalidTokenRequestParamsError) do + Provider.new(**args_for("https://app.example.com/callback"), token_request_params: { "code" => "x" }) + end + + assert_includes(error.message, '"code"') + end + end + end + end +end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb new file mode 100644 index 0000000..08e8d17 --- /dev/null +++ b/test/mcp/client/stdio_test.rb @@ -0,0 +1,1957 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "mcp/client" +require "mcp/client/stdio" +require "mcp/client/tool" + +module MCP + class Client + class StdioTest < Minitest::Test + include DeprecationWarningTestHelper + + def test_send_request_raises_when_connect_not_called + Open3.expects(:popen3).never + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + error = assert_raises(RuntimeError) do + transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + end + + assert_equal("MCP::Client#connect must be called before sending requests.", error.message) + end + + def test_send_request_starts_process_and_returns_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + # Simulate server responses: initialize response, then tools/list response + server_thread = Thread.new do + # Read and respond to initialize request + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + init_response = { + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + } + stdout_write.puts(JSON.generate(init_response)) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read and respond to tools/list request + tools_line = stdin_read.gets + tools_request = JSON.parse(tools_line) + tools_response = { + jsonrpc: "2.0", + id: tools_request["id"], + result: { tools: [{ name: "test_tool", description: "A test tool", inputSchema: {} }] }, + } + stdout_write.puts(JSON.generate(tools_response)) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_equal(1, response.dig("result", "tools").size) + assert_equal("test_tool", response.dig("result", "tools", 0, "name")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + stderr_read.close + end + + def test_send_request_answers_a_server_ping_inline + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Ping the client while it awaits the response; the receiver "MUST respond promptly". + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-1", pong["id"]) + assert_equal({}, pong["result"]) + refute(pong.key?("error")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # JSON-RPC id spaces are per sender, so a server ping may reuse the id the client + # is awaiting; it must still be answered rather than treated as the response. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + pong = JSON.parse(pong_line) + assert_equal("test-id", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Break the write path: the pong cannot be delivered, and that must not fail the request. + stdin_read.close + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close unless stdin_read.closed? + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_ping_with_a_malformed_id_is_not_answered + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" })) + stdout_write.flush + + # The single pong that arrives must answer the well-formed ping, not the malformed one. + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-2", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_skips_notifications + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + # Handle initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request + stdin_read.gets + + # Send a notification before the response + notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" } + stdout_write.puts(JSON.generate(notification)) + stdout_write.flush + + # Then send the actual response + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: "test-id", + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_skips_non_object_frames + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request + stdin_read.gets + + # Non-object frames are skipped like any other non-matching frame. + stdout_write.puts("[[]]") + stdout_write.puts("null") + stdout_write.flush + + # Then send the actual response + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: "test-id", + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_raises_error_when_process_exits + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + + dead_thread = mock("wait_thread") + dead_thread.stubs(:alive?).returns(false) + dead_thread.stubs(:value).returns(nil) + + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, dead_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_equal("Server process has exited", error.message) + assert_equal(:internal_error, error.error_type) + ensure + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_raises_error_on_closed_stdout + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + # Handle initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request, then close stdout + stdin_read.gets + stdout_write.close + end + + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_equal("Server process closed stdout unexpectedly", error.message) + assert_equal(:internal_error, error.error_type) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_close_resets_state + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + + assert(transport.instance_variable_get(:@started)) + + transport.close + + refute(transport.instance_variable_get(:@started)) + refute(transport.instance_variable_get(:@initialized)) + ensure + stdin_read.close + begin + stdin_write.close + rescue + nil + end + begin + stdout_read.close + rescue + nil + end + stdout_write.close + begin + stderr_read.close + rescue + nil + end + stderr_write.close + end + + def test_multiple_send_requests_do_not_reinitialize + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received_methods = [] + + server_thread = Thread.new do + # First call: initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + received_methods << init_request["method"] + + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + notification_line = stdin_read.gets + received_methods << JSON.parse(notification_line)["method"] + + # First request: tools/list + first_line = stdin_read.gets + first_request = JSON.parse(first_line) + received_methods << first_request["method"] + + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: first_request["id"], + result: { tools: [] }, + })) + stdout_write.flush + + # Second request: tools/list (no re-initialization) + second_line = stdin_read.gets + second_request = JSON.parse(second_line) + received_methods << second_request["method"] + + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: second_request["id"], + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) + transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) + + assert_equal( + ["initialize", "notifications/initialized", "tools/list", "tools/list"], + received_methods, + ) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_env_is_passed_to_process + transport = Stdio.new(command: "ruby", args: ["server.rb"], env: { "FOO" => "bar" }) + + Open3.expects(:popen3).with({ "FOO" => "bar" }, "ruby", "server.rb").returns( + [StringIO.new, StringIO.new, StringIO.new, mock_wait_thread], + ) + + transport.start + end + + def test_send_request_raises_error_on_invalid_json + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + # Handle initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request, then send invalid JSON + stdin_read.gets + stdout_write.puts("not valid json") + stdout_write.flush + end + + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_equal("Failed to parse server response", error.message) + assert_equal(:internal_error, error.error_type) + assert_instance_of(JSON::ParserError, error.original_error) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_close_kills_process_on_timeout + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + hanging_thread = mock("wait_thread") + hanging_thread.stubs(:alive?).returns(true) + hanging_thread.stubs(:pid).returns(99999) + hanging_thread.stubs(:value).raises(Timeout::Error) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, hanging_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + + Process.expects(:kill).with("TERM", 99999).once + Process.expects(:kill).with("KILL", 99999).once + + transport.close + ensure + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_read_response_raises_error_on_timeout + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.01) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + # Handle initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read tools/list request but don't respond (simulate timeout) + stdin_read.gets + end + + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_equal("Timed out waiting for server response", error.message) + assert_equal(:internal_error, error.error_type) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_raises_error_when_stdin_is_closed + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + # Handle initialize handshake + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification + stdin_read.gets + + # Read and respond to first request + line = stdin_read.gets + request = JSON.parse(line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: request["id"], + result: {}, + })) + stdout_write.flush + end + + transport.connect + # Complete a successful request before breaking the pipe. + transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) + server_thread.join + + # Now close stdin to simulate broken pipe + stdin_write.close + + error = assert_raises(RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + end + + assert_equal("Failed to write to server process", error.message) + assert_equal(:internal_error, error.error_type) + ensure + stdin_read.close + begin + stdin_write.close + rescue + nil + end + stdout_read.close + stdout_write.close + end + + def test_close_is_noop_when_not_started + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + # Should not raise + transport.close + end + + def test_start_raises_error_when_already_started + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + + error = assert_raises(RuntimeError) do + transport.start + end + + assert_equal("MCP::Client::Stdio already started", error.message) + ensure + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_start_raises_error_for_invalid_command + Open3.stubs(:popen3).raises(Errno::ENOENT.new("No such file or directory - nonexistent_command")) + + transport = Stdio.new(command: "nonexistent_command") + + error = assert_raises(RequestHandlerError) do + transport.start + end + + assert_match(/Failed to spawn server process/, error.message) + assert_equal(:internal_error, error.error_type) + assert_instance_of(Errno::ENOENT, error.original_error) + end + + def test_connect_performs_initialize_handshake_explicitly + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + received_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + + notification_line = stdin_read.gets + received_methods << JSON.parse(notification_line)["method"] + end + + result = transport.connect + + server_thread.join + + assert_equal(["initialize", "notifications/initialized"], received_methods) + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal({ "tools" => {} }, result["capabilities"]) + assert_equal({ "name" => "test-server", "version" => "1.0.0" }, result["serverInfo"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_does_not_warn_after_explicit_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + + stdin_read.gets + + ping_line = stdin_read.gets + ping_request = JSON.parse(ping_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: ping_request["id"], + result: {}, + )) + stdout_write.flush + end + + assert_silent do + transport.connect + end + + response = nil + assert_silent do + response = transport.send_request(request: { jsonrpc: "2.0", id: "ping-id", method: "ping" }) + end + + assert_equal("ping-id", response["id"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_caches_server_info + transport, server_thread, pipes = stub_successful_connect + + transport.connect + + assert_equal("2025-11-25", transport.server_info["protocolVersion"]) + assert_equal({ "tools" => {} }, transport.server_info["capabilities"]) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_is_idempotent + transport, server_thread, pipes = stub_successful_connect + + first_result = transport.connect + second_result = transport.connect + + assert_same(first_result, second_result) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_accepts_custom_parameters + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_init_params = nil + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_init_params = init_request["params"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-03-26" }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect( + client_info: { name: "my-app", version: "9.9" }, + protocol_version: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + ) + + assert_equal("2025-03-26", sent_init_params["protocolVersion"]) + assert_equal({ "name" => "my-app", "version" => "9.9" }, sent_init_params["clientInfo"]) + assert_equal({ "roots" => { "listChanged" => true } }, sent_init_params["capabilities"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_adopts_a_counter_offered_protocol_version + # A server may answer `initialize` with a version other than the one offered, and the connection + # then speaks the answer rather than the offer. This server counter-offers a handshake version + # to clients asking `initialize` for a modern one, which only resolves anything because + # clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + offered = nil + + server_thread = Thread.new do + init_request = JSON.parse(stdin_read.gets) + offered = init_request.dig("params", "protocolVersion") + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: counter_offered }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, transport.protocol_version) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition (SEP-2575 era model), + # and the TypeScript and Python clients refuse a modern counter-offer the same way. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2026-07-28" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_includes(error.message, "2026-07-28") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + # Validated before the child process is spawned: a pure argument error must not leave + # an orphaned server process behind. + Open3.expects(:popen3).never + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + error = assert_raises(ArgumentError) do + transport.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") + end + + def test_connect_raises_on_jsonrpc_error_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + error: { code: -32602, message: "boom" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "boom") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_missing_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_non_hash_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: [], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_clears_state_when_initialized_notification_fails + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + # Close stdin_read first so the next write to @stdin (the notification) raises EPIPE. + stdin_read.close + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + [stdin_read, stdin_write, stdout_read, stdout_write].each do |io| + io.close unless io.closed? + end + end + + def test_connect_can_be_retried_after_failed_handshake + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + # First handshake: server returns an unsupported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + + # Second handshake: server returns a supported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + stdin_read.gets + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + + result = transport.connect + + server_thread.join + + assert_predicate(transport, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_unsupported_protocol_version + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "unsupported protocol version") + assert_includes(error.message, "1999-01-01") + assert_equal(["initialize"], sent_methods) + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connected_is_false_before_first_send_request + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + refute_predicate(transport, :connected?) + end + + def test_connected_is_true_after_explicit_connect + transport, server_thread, pipes = stub_successful_connect + + refute_predicate(transport, :connected?) + + transport.connect + + assert_predicate(transport, :connected?) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connected_is_false_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + + assert_predicate(transport, :connected?) + + transport.close + + refute_predicate(transport, :connected?) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + + def test_server_info_is_nil_before_connect + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + assert_nil(transport.server_info) + end + + def test_server_info_is_cleared_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + transport.instance_variable_set(:@server_info, { "protocolVersion" => "2025-11-25" }) + + transport.close + + assert_nil(transport.server_info) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + + def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + def test_send_request_raises_when_response_frame_exceeds_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification. + stdin_read.gets + + # Read tools/list request, then stream an unbounded frame with no newline. + stdin_read.gets + stdout_write.write("A" * 2000) + stdout_write.flush + end + + transport.connect + + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_match(/exceeds 1024 bytes without a newline/, error.message) + assert_equal(:internal_error, error.error_type) + # The stream is desynced (leftover bytes in the pipe), so the transport is closed + # rather than left resumable on a corrupt stream. + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_accepts_response_within_custom_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + stdin_read.gets + + tools_line = stdin_read.gets + tools_request = JSON.parse(tools_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: tools_request["id"], + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_equal({ "tools" => [] }, response["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_notification_writes_json_line_without_waiting_for_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "abc-123", reason: "user cancel" }, + } + + # Server: respond to initialize, then read the notification line. + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test", version: "1.0.0" } }, + })) + stdout_write.flush + + stdin_read.gets # skip initialized notification + + stdin_read.gets # read the cancellation notification line we are testing + end + + result = transport.send_notification(notification: notification) + + assert_nil(result, "send_notification must return nil (no response expected)") + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_concurrent_write_message_does_not_interleave_lines + # Regression: cancellation can call `send_notification` from a thread while `send_request` is mid-flight + # on another thread. The two writes must serialize so JSON-RPC lines do not interleave on stdin. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + # Server: respond to initialize, then accept multiple lines. + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "t", version: "1.0.0" } }, + })) + stdout_write.flush + stdin_read.gets # initialized + end + + # Force initialization synchronously. + notification = { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "warmup" }, + } + transport.send_notification(notification: notification) + server_thread.join + + # Now hammer send_notification from many threads concurrently. Each line is one JSON object terminated by `\n`; + # if writes interleave, the received text would not be one valid JSON object per line. + threads = 8.times.map do |i| + Thread.new do + 10.times do |j| + transport.send_notification( + notification: { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "t#{i}-#{j}" }, + }, + ) + end + end + end + threads.each(&:join) + + # Drain everything written to stdin and verify each line parses as JSON. + stdin_write.close + lines = [] + until stdin_read.eof? + line = stdin_read.gets + break if line.nil? + + lines << line + end + lines.each do |line| + assert(JSON.parse(line.strip), "interleaved write produced unparseable line: #{line.inspect}") + end + assert_operator(lines.size, :>=, 80, "expected at least 80 notification lines") + ensure + stdout_read.close + stdout_write.close + begin + stderr_read.close + rescue + nil + end + end + + private + + def stub_successful_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + stdin_read.gets + end + + [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] + end + + def test_send_request_allows_server_discover_before_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + response = { + jsonrpc: "2.0", + id: discover_request["id"], + result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" }, + } + stdout_write.puts(JSON.generate(response)) + stdout_write.flush + end + + # No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery. + response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" }) + + assert_equal(["2026-07-28"], response.dig("result", "supportedVersions")) + refute_predicate(transport, :connected?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_modern_probes_discover_and_stamps_requests + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received = [] + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + received << discover_request + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + result: { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + }, + })) + stdout_write.flush + + tools_request = JSON.parse(stdin_read.gets) + received << tools_request + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } })) + stdout_write.flush + end + + result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" }) + response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" }) + + assert_equal(["2026-07-28"], result["supportedVersions"]) + assert_predicate(transport, :modern?) + assert_predicate(transport, :connected?) + assert_equal("2026-07-28", transport.protocol_version) + assert_equal("t1", response["id"]) + + # No `initialize` was sent; both frames carry the SEP-2575 envelope. + assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] }) + triple = received[1].dig("params", "_meta") + assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"]) + assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"]) + assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"]) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + result: { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + }, + })) + stdout_write.flush + end + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(transport, :modern?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_auto_falls_back_to_the_legacy_handshake + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + error: { code: -32601, message: "Method not found" }, + })) + stdout_write.flush + + init_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + }, + })) + stdout_write.flush + + # `notifications/initialized` + stdin_read.gets + end + + result = transport.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal("2025-11-25", transport.protocol_version) + refute_predicate(transport, :modern?) + assert_predicate(transport, :connected?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.stubs(:start) + + observed_timeout = nil + transport.define_singleton_method(:probe_discover) do + observed_timeout = @read_timeout + raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error) + end + transport.define_singleton_method(:connect_legacy) do |**| + { "protocolVersion" => "2025-11-25" } + end + + result = transport.connect(mode: :auto) + + assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout) + assert_nil(transport.instance_variable_get(:@read_timeout)) + assert_equal("2025-11-25", result["protocolVersion"]) + end + + def test_connect_auto_falls_back_when_the_server_never_answers_the_probe + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + # A configured `read_timeout` also bounds the probe; the server below reads + # the `server/discover` request and never responds, which must count as + # legacy evidence instead of blocking `connect` forever. + transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05) + + server_thread = Thread.new do + # Silently swallow the probe. + stdin_read.gets + + init_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + }, + })) + stdout_write.flush + + # `notifications/initialized` + stdin_read.gets + end + + result = transport.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + refute_predicate(transport, :modern?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + + def mock_wait_thread + thread = mock("wait_thread") + thread.stubs(:alive?).returns(true) + thread.stubs(:value).returns(nil) + thread + end + end + end +end diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb index 88e82e9..f2025ab 100644 --- a/test/mcp/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -33,6 +33,24 @@ module MCP assert_nil(@tool.output_schema) end + def test_annotations_returns_nil_when_not_provided + assert_nil(@tool.annotations) + end + + def test_annotations_returns_annotations_when_provided + tool_with_annotations = Tool.new( + name: "test_tool_with_annotations", + description: "A test tool with annotations", + input_schema: { "type" => "object" }, + annotations: { "readOnlyHint" => true, "title" => "Test Tool" }, + ) + + assert_equal( + { "readOnlyHint" => true, "title" => "Test Tool" }, + tool_with_annotations.annotations, + ) + end + def test_output_schema_returns_output_schema_when_provided tool_with_output = Tool.new( name: "test_tool_with_output", @@ -53,12 +71,14 @@ module MCP description: "A tool with all parameters", input_schema: { "type" => "object" }, output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, + annotations: { "readOnlyHint" => true }, ) assert_equal("full_tool", tool.name) assert_equal("A tool with all parameters", tool.description) assert_equal({ "type" => "object" }, tool.input_schema) assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema) + assert_equal({ "readOnlyHint" => true }, tool.annotations) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 308fbe1..bbd0652 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -5,12 +5,342 @@ require "securerandom" module MCP class ClientTest < Minitest::Test + def test_connect_delegates_to_transport_and_forwards_keyword_args + transport = mock + init_result = { + "protocolVersion" => "2025-11-25", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "test-server", "version" => "1.0" }, + } + transport.expects(:connect).with( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ).returns(init_result).once + + result = Client.new(transport: transport).connect( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ) + + assert_equal(init_result, result) + end + + def test_connect_passes_nil_defaults_to_transport + transport = mock + transport.expects(:connect) + .with(client_info: nil, protocol_version: nil, capabilities: {}) + .returns({ "protocolVersion" => "2025-11-25" }).once + + Client.new(transport: transport).connect + end + + def test_connect_is_noop_when_transport_does_not_respond_to_connect + transport = mock + transport.stubs(:respond_to?).with(:connect).returns(false) + transport.stubs(:respond_to?).with(:server_info).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.connect) + assert_nil(client.server_info) + end + + def test_on_elicitation_registers_handler_on_transport + transport = mock + handler = proc { { action: "accept", content: {} } } + transport.expects(:on_server_request).with("elicitation/create") + + Client.new(transport: transport).on_elicitation(&handler) + end + + def test_on_elicitation_is_accepted_when_transport_does_not_support_server_requests + # The handler still answers embedded `elicitation/create` requests of SEP-2322 `input_required` results, + # which need no server-to-client route. Refusing to register it would lock stdio clients, + # and every modern-lifecycle connection, out of the only way the spec leaves for asking. + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + + Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } + end + + def test_on_sampling_registers_handler_on_transport + transport = mock + handler = proc { { role: "assistant", content: { type: "text", text: "hi" } } } + transport.expects(:on_server_request).with("sampling/createMessage") + + Client.new(transport: transport).on_sampling(&handler) + end + + def test_on_sampling_is_accepted_when_transport_does_not_support_server_requests + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + + Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } + end + + def test_on_roots_registers_handler_on_transport + transport = mock + handler = proc { { roots: [{ uri: "file:///project", name: "Project" }] } } + transport.expects(:on_server_request).with("roots/list") + + Client.new(transport: transport).on_roots(&handler) + end + + # A transport that declares `mode:` on `connect`, recording what it received. + # The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from + # "Client forwarded mode: :legacy". + class ModeAwareTransport + attr_reader :received + + def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded) + @received = { + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, + mode: mode, + } + { "supportedVersions" => ["2026-07-28"] } + end + end + + def test_connect_defaults_to_auto_on_transports_declaring_mode + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect + + assert_equal(:auto, transport.received[:mode]) + end + + def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode + # Custom transports whose `connect` does not declare `mode:` keep receiving + # the historical call shape, so the `:auto` default cannot break them. + transport = mock + transport.expects(:connect).with( + client_info: nil, protocol_version: nil, capabilities: {}, + ).returns({ "protocolVersion" => "2025-11-25" }).once + + Client.new(transport: transport).connect + end + + def test_connect_routes_explicit_legacy_through_the_legacy_call_shape + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(mode: :legacy) + + assert_equal(:not_forwarded, transport.received[:mode]) + end + + def test_connect_pins_legacy_for_an_explicit_stable_protocol_version + # An explicitly requested legacy-generation version must never be overridden + # by the default negotiation adopting the modern lifecycle. + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(protocol_version: "2025-11-25") + + assert_equal(:not_forwarded, transport.received[:mode]) + assert_equal("2025-11-25", transport.received[:protocol_version]) + end + + def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version + transport = ModeAwareTransport.new + + Client.new(transport: transport).connect(protocol_version: "2026-07-28") + + assert_equal(:auto, transport.received[:mode]) + end + + def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode + transport = mock + transport.expects(:connect).never + + error = assert_raises(ArgumentError) do + Client.new(transport: transport).connect(mode: :modern) + end + + assert_includes(error.message, "does not support mode:") + end + + def test_connect_rejects_unknown_modes + transport = ModeAwareTransport.new + + assert_raises(ArgumentError) do + Client.new(transport: transport).connect(mode: :bogus) + end + end + + def test_discover_returns_a_discover_result + transport = mock + transport.expects(:send_request).with do |args| + args[:request][:method] == "server/discover" + end.returns({ + "result" => { + "supportedVersions" => ["2026-07-28"], + "capabilities" => { "tools" => {} }, + "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } }, + "instructions" => "Optional instructions", + "ttlMs" => 3_600_000, + "cacheScope" => "public", + }, + }).once + + result = Client.new(transport: transport).discover + + assert_equal(["2026-07-28"], result.supported_versions) + assert_equal({ "tools" => {} }, result.capabilities) + assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info) + assert_equal("Optional instructions", result.instructions) + assert_equal(3_600_000, result.ttl_ms) + assert_equal("public", result.cache_scope) + end + + def test_discover_maps_a_top_level_server_info_as_a_fallback + # Servers built against the frozen SEP text still stamp `serverInfo` at the top level; + # the finalized spec moved it into the result `_meta`. + transport = mock + transport.expects(:send_request).returns({ + "result" => { + "supportedVersions" => ["2026-07-28"], + "serverInfo" => { "name" => "sep-era-server", "version" => "1.0" }, + }, + }).once + + result = Client.new(transport: transport).discover + + assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info) + end + + def test_discover_raises_validation_error_on_missing_result + transport = mock + transport.expects(:send_request).returns({}).once + + assert_raises(Client::ValidationError) do + Client.new(transport: transport).discover + end + end + + def test_discover_raises_server_error_on_jsonrpc_error + transport = mock + transport.expects(:send_request).returns({ + "error" => { "code" => -32601, "message" => "Method not found" }, + }).once + + error = assert_raises(Client::ServerError) do + Client.new(transport: transport).discover + end + + assert_equal(-32601, error.code) + end + + def test_era_independent_readers_on_a_legacy_result + transport = mock + transport.stubs(:server_info).returns({ + "protocolVersion" => "2025-11-25", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "legacy-server", "version" => "1.0" }, + "instructions" => "Be helpful", + }) + + client = Client.new(transport: transport) + + assert_equal("2025-11-25", client.protocol_version) + assert_equal({ "tools" => {} }, client.server_capabilities) + assert_equal("Be helpful", client.instructions) + assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation) + end + + def test_era_independent_readers_on_a_modern_result + # A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes + # from the transport. `serverInfo` sits in the result `_meta` per the final spec. + transport = mock + transport.stubs(:protocol_version).returns("2026-07-28") + transport.stubs(:server_info).returns({ + "supportedVersions" => ["2026-07-28"], + "capabilities" => { "tools" => {} }, + "instructions" => "Be helpful", + "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } }, + "ttlMs" => 0, + "cacheScope" => "private", + }) + + client = Client.new(transport: transport) + + assert_equal("2026-07-28", client.protocol_version) + assert_equal({ "tools" => {} }, client.server_capabilities) + assert_equal("Be helpful", client.instructions) + assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation) + end + + def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence + transport = mock + transport.stubs(:server_info).returns({ + "supportedVersions" => ["2026-07-28"], + "serverInfo" => { "name" => "top-level", "version" => "1.0" }, + }) + + assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation) + + anonymous = mock + anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] }) + + assert_nil(Client.new(transport: anonymous).server_implementation) + end + + def test_era_independent_readers_return_nil_before_connect + transport = mock + transport.stubs(:respond_to?).with(:server_info).returns(false) + transport.stubs(:respond_to?).with(:protocol_version).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.protocol_version) + assert_nil(client.server_capabilities) + assert_nil(client.instructions) + assert_nil(client.server_implementation) + end + + def test_connected_delegates_to_transport_when_supported + transport = mock + transport.expects(:connected?).returns(true) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_connected_returns_true_when_transport_does_not_respond_to_connected + transport = mock + transport.stubs(:respond_to?).with(:connected?).returns(false) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_server_info_delegates_to_transport_when_supported + transport = mock + init_result = { "protocolVersion" => "2025-11-25" } + transport.expects(:server_info).returns(init_result) + + assert_equal(init_result, Client.new(transport: transport).server_info) + end + + def test_server_info_returns_nil_when_transport_does_not_expose_it + transport = mock + transport.stubs(:respond_to?).with(:server_info).returns(false) + + assert_nil(Client.new(transport: transport).server_info) + end + def test_tools_sends_request_to_transport_and_returns_tools_array transport = mock mock_response = { "result" => { "tools" => [ - { "name" => "tool1", "description" => "tool1", "inputSchema" => {} }, + { + "name" => "tool1", + "description" => "tool1", + "inputSchema" => {}, + "outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } }, + "annotations" => { "readOnlyHint" => true, "title" => "Tool One" }, + }, { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, ], }, @@ -18,7 +348,7 @@ module MCP # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| - args in { request: { method: "tools/list", jsonrpc: "2.0" } } + args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0" end.returns(mock_response).once client = Client.new(transport: transport) @@ -26,7 +356,11 @@ module MCP assert_equal(2, tools.size) assert_equal("tool1", tools.first.name) + assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema) + assert_equal({ "readOnlyHint" => true, "title" => "Tool One" }, tools.first.annotations) assert_equal("tool2", tools.last.name) + assert_nil(tools.last.output_schema) + assert_nil(tools.last.annotations) end def test_tools_returns_empty_array_when_no_tools @@ -35,13 +369,69 @@ module MCP # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| - args in { request: { method: "tools/list", jsonrpc: "2.0" } } + args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0" end.returns(mock_response).once client = Client.new(transport: transport) tools = client.tools - assert_empty(tools) + assert_equal([], tools) + end + + def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection + # SEP-2243: on the modern lifecycle a tool definition violating the `x-mcp-header` + # constraints MUST be excluded from `tools/list` results, with a warning naming the tool. + transport = mock + transport.stubs(:modern?).returns(true) + mock_response = { + "result" => { + "tools" => [ + { "name" => "valid_tool", "inputSchema" => { "type" => "object" } }, + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ], + }, + } + transport.expects(:send_request).returns(mock_response).once + client = Client.new(transport: transport) + + # `rake` runs the suite with `-W0`, which silences `Kernel#warn`; restore warnings + # locally so the SHOULD-level warning is observable. + original_verbose = $VERBOSE + $VERBOSE = false + tools = nil + _out, warning = capture_io { tools = client.tools } + $VERBOSE = original_verbose + + assert_equal(["valid_tool"], tools.map(&:name)) + assert_includes(warning, "invalid_tool") + end + + def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection + transport = mock + transport.stubs(:modern?).returns(false) + mock_response = { + "result" => { + "tools" => [ + { + "name" => "invalid_tool", + "inputSchema" => { + "type" => "object", + "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, + }, + }, + ], + }, + } + transport.expects(:send_request).returns(mock_response).once + client = Client.new(transport: transport) + + assert_equal(["invalid_tool"], client.tools.map(&:name)) end def test_call_tool_sends_request_to_transport_and_returns_content @@ -54,16 +444,10 @@ module MCP # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| - args in { - request: { - method: "tools/call", - jsonrpc: "2.0", - params: { - name: "tool1", - arguments: arguments, - }, - }, - } + args.dig(:request, :method) == "tools/call" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :name) == "tool1" && + args.dig(:request, :params, :arguments) == arguments end.returns(mock_response).once client = Client.new(transport: transport) @@ -73,6 +457,293 @@ module MCP assert_equal([{ type: "text", text: "Hello, world!" }], content) end + def test_call_tool_by_name + transport = mock + arguments = { foo: "bar" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params, :name) == "tool1" && + args.dig(:request, :params, :arguments) == arguments + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.call_tool(name: "tool1", arguments: arguments) + content = result.dig("result", "content") + + assert_equal([{ type: "text", text: "Hello, world!" }], content) + end + + def test_call_tool_raises_input_required_error_when_result_type_is_input_required + # Per SEP-2322, a result with `resultType: "input_required"` is not a final result; + # surface it instead of returning it as a normal result. + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { + "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } }, + }, + "requestState" => "opaque-state", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::InputRequiredError) do + client.call_tool(tool: tool, arguments: {}) + end + + assert_equal( + { "1" => { "method" => "elicitation/create", "params" => { "mode" => "form", "message" => "Name?" } } }, + error.input_requests, + ) + assert_equal("opaque-state", error.request_state) + assert_equal(mock_response["result"], error.result) + end + + def test_call_tool_returns_normally_when_result_type_is_complete + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { + "resultType" => "complete", + "content" => [{ "type" => "text", "text" => "done" }], + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.call_tool(tool: tool, arguments: {}) + + assert_equal("done", result.dig("result", "content", 0, "text")) + end + + def test_call_tool_returns_normally_when_result_type_is_absent + # Regression guard: stable-protocol servers never send `resultType`. + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { "content" => [{ "type" => "text", "text" => "done" }] }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.call_tool(tool: tool, arguments: {}) + + assert_equal("done", result.dig("result", "content", 0, "text")) + end + + def test_list_tools_raises_input_required_error_for_input_required_results + # The recognition lives in the shared request path, so every client method is covered, not only tools/call. + transport = mock + mock_response = { + "result" => { "resultType" => "input_required", "inputRequests" => {} }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::InputRequiredError) { client.list_tools } + end + + def test_call_tool_drives_the_input_required_loop_with_a_configured_handler + transport = mock + sent_requests = [] + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { + "region" => { "method" => "elicitation/create", "params" => { "message" => "Which region?" } }, + }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "type" => "text", "text" => "done" }] } } + transport.expects(:send_request).twice.with do |args| + sent_requests << args[:request] + true + end.returns(input_required_response, final_response) + + handled_params = nil + client = Client.new(transport: transport) + client.on_elicitation do |params| + handled_params = params + { action: "accept", content: { value: "us-east-1" } } + end + + response = client.call_tool(name: "my_tool", arguments: { city: "Tokyo" }) + + assert_equal("done", response.dig("result", "content", 0, "text")) + assert_equal({ "message" => "Which region?" }, handled_params) + + first_leg, retry_leg = sent_requests + # The ORIGINAL params are preserved; the answers ride under the same keys with + # the byte-exact `requestState` echo, on a fresh JSON-RPC id. + assert_equal("my_tool", retry_leg.dig(:params, :name)) + assert_equal({ city: "Tokyo" }, retry_leg.dig(:params, :arguments)) + assert_equal( + { "region" => { action: "accept", content: { value: "us-east-1" } } }, + retry_leg.dig(:params, :inputResponses), + ) + assert_equal("state-1", retry_leg.dig(:params, :requestState)) + refute_equal(first_leg[:id], retry_leg[:id]) + end + + def test_input_required_loop_backs_off_on_request_state_only_legs + transport = mock + state_only_response = { + "result" => { "resultType" => "input_required", "requestState" => "busy-state" }, + } + final_response = { "result" => { "content" => [] } } + transport.stubs(:send_request).returns(state_only_response, state_only_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| {} } + slept = [] + client.stubs(:sleep).with { |seconds| slept << seconds } + + response = client.call_tool(name: "my_tool") + + assert_equal(final_response, response) + + # Exponential from 50ms, matching the Python SDK. + assert_equal([0.05, 0.1], slept) + end + + def test_input_required_loop_raises_after_max_rounds + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => {} } }, + "requestState" => "state-1", + }, + } + transport.stubs(:send_request).returns(input_required_response) + + client = Client.new(transport: transport, input_required_max_rounds: 2) + client.on_elicitation { |_params| { action: "decline" } } + + error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } + + assert_includes(error.message, "after 2 rounds") + assert_equal("state-1", error.request_state) + end + + def test_input_required_loop_falls_back_to_the_error_for_unhandled_request_kinds + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "llm" => { "method" => "sampling/createMessage", "params" => {} } }, + "requestState" => "state-1", + }, + } + transport.expects(:send_request).returns(input_required_response).once + + client = Client.new(transport: transport) + client.on_elicitation { |_params| {} } + + error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } + + assert_includes(error.message, "sampling/createMessage") + assert_equal("state-1", error.request_state) + end + + def test_input_required_loop_runs_on_a_transport_without_server_request_support + # The modern lifecycle forbids server-to-client requests, so an `input_required` result is the only + # way a server can ask. A transport with no `on_server_request` must still drive the loop. + transport = mock + transport.stubs(:respond_to?).with(:on_server_request).returns(false) + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "Region?" } } }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "text" => "done" }] } } + transport.expects(:send_request).twice.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| { action: "accept", content: { value: "us-east-1" } } } + + response = client.call_tool(name: "my_tool") + + assert_equal("done", response.dig("result", "content", 0, "text")) + end + + def test_input_required_loop_uses_a_roots_handler_registered_with_on_roots + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "r" => { "method" => "roots/list" } }, + "requestState" => "state-1", + }, + } + final_response = { "result" => { "content" => [{ "text" => "done" }] } } + transport.expects(:on_server_request).with("roots/list") + transport.expects(:send_request).twice.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_roots { |_params| { roots: [{ uri: "file:///project", name: "Project" }] } } + + response = client.call_tool(name: "my_tool") + + assert_equal("done", response.dig("result", "content", 0, "text")) + end + + def test_call_tool_sends_manual_retry_fields + transport = mock + transport.expects(:send_request).with do |args| + params = args[:request][:params] + params[:inputResponses] == { "region" => { action: "accept" } } && params[:requestState] == "state-1" && params[:name] == "my_tool" + end.returns({ "result" => { "content" => [] } }).once + + client = Client.new(transport: transport) + client.call_tool(name: "my_tool", input_responses: { "region" => { action: "accept" } }, request_state: "state-1") + end + + def test_get_prompt_drives_the_input_required_loop + transport = mock + input_required_response = { + "result" => { + "resultType" => "input_required", + "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "?" } } }, + "requestState" => "prompt-state", + }, + } + final_response = { "result" => { "messages" => [] } } + sent_requests = [] + transport.expects(:send_request).twice.with do |args| + sent_requests << args[:request] + true + end.returns(input_required_response, final_response) + + client = Client.new(transport: transport) + client.on_elicitation { |_params| { action: "accept", content: {} } } + + result = client.get_prompt(name: "my_prompt") + + assert_equal({ "messages" => [] }, result) + assert_equal("prompt-state", sent_requests[1].dig(:params, :requestState)) + end + + def test_call_tool_raises_when_no_name_or_tool + client = Client.new(transport: mock) + + error = assert_raises(ArgumentError) { client.call_tool(arguments: { foo: "bar" }) } + assert_equal("Either `name:` or `tool:` must be provided.", error.message) + end + def test_resources_sends_request_to_transport_and_returns_resources_array transport = mock mock_response = { @@ -86,7 +757,7 @@ module MCP # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| - args in { request: { method: "resources/list", jsonrpc: "2.0" } } + args.dig(:request, :method) == "resources/list" && args.dig(:request, :jsonrpc) == "2.0" end.returns(mock_response).once client = Client.new(transport: transport) @@ -108,7 +779,7 @@ module MCP client = Client.new(transport: transport) resources = client.resources - assert_empty(resources) + assert_equal([], resources) end def test_read_resource_sends_request_to_transport_and_returns_contents @@ -124,15 +795,9 @@ module MCP # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| - args in { - request: { - method: "resources/read", - jsonrpc: "2.0", - params: { - uri: uri, - }, - }, - } + args.dig(:request, :method) == "resources/read" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :uri) == uri end.returns(mock_response).once client = Client.new(transport: transport) @@ -154,7 +819,1319 @@ module MCP client = Client.new(transport: transport) contents = client.read_resource(uri: uri) - assert_empty(contents) + assert_equal([], contents) + end + + def test_resource_templates_sends_request_to_transport_and_returns_resource_templates_array + transport = mock + mock_response = { + "result" => { + "resourceTemplates" => [ + { "name" => "template1", "uriTemplate" => "file:///path/{filename}", "description" => "First template" }, + { "name" => "template2", "uriTemplate" => "http://example.com/{id}", "description" => "Second template" }, + ], + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "resources/templates/list" && args.dig(:request, :jsonrpc) == "2.0" + end.returns(mock_response).once + + client = Client.new(transport: transport) + resource_templates = client.resource_templates + + assert_equal(2, resource_templates.size) + assert_equal("template1", resource_templates.first["name"]) + assert_equal("file:///path/{filename}", resource_templates.first["uriTemplate"]) + assert_equal("template2", resource_templates.last["name"]) + assert_equal("http://example.com/{id}", resource_templates.last["uriTemplate"]) + end + + def test_resource_templates_returns_empty_array_when_no_resource_templates + transport = mock + mock_response = { "result" => { "resourceTemplates" => [] } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + resource_templates = client.resource_templates + + assert_equal([], resource_templates) + end + + def test_prompts_sends_request_to_transport_and_returns_prompts_array + transport = mock + mock_response = { + "result" => { + "prompts" => [ + { + "name" => "prompt_1", + "description" => "First prompt", + "arguments" => [ + { + "name" => "code_1", + "description" => "The code_1 to review", + "required" => true, + }, + ], + }, + { + "name" => "prompt_2", + "description" => "Second prompt", + "arguments" => [ + { + "name" => "code_2", + "description" => "The code_2 to review", + "required" => true, + }, + ], + }, + ], + }, + } + + # Only checking for the essential parts of the request + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "prompts/list" && args.dig(:request, :jsonrpc) == "2.0" + end.returns(mock_response).once + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + assert_equal("prompt_1", prompts.first["name"]) + assert_equal("First prompt", prompts.first["description"]) + assert_equal("code_1", prompts.first["arguments"].first["name"]) + assert_equal("The code_1 to review", prompts.first["arguments"].first["description"]) + assert(prompts.first["arguments"].first["required"]) + + assert_equal("prompt_2", prompts.last["name"]) + assert_equal("Second prompt", prompts.last["description"]) + assert_equal("code_2", prompts.last["arguments"].first["name"]) + assert_equal("The code_2 to review", prompts.last["arguments"].first["description"]) + assert(prompts.last["arguments"].first["required"]) + end + + def test_prompts_returns_empty_array_when_no_prompts + transport = mock + mock_response = { "result" => { "prompts" => [] } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal([], prompts) + end + + def test_get_prompt_sends_request_to_transport_and_returns_contents + transport = mock + name = "first_prompt" + mock_response = { + "result" => { + "description" => "First prompt", + "messages" => [ + { + "role" => "user", + "content" => { + "text" => "First prompt content", + "type" => "text", + }, + }, + ], + }, + } + + # Only checking for the essential parts of the request + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "prompts/get" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :name) == name + end.returns(mock_response).once + + client = Client.new(transport: transport) + contents = client.get_prompt(name: name) + + assert_equal("First prompt", contents["description"]) + assert_equal("user", contents["messages"].first["role"]) + assert_equal("First prompt content", contents["messages"].first["content"]["text"]) + end + + def test_get_prompt_returns_empty_hash_when_no_contents + transport = mock + name = "nonexistent_prompt" + mock_response = { "result" => {} } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + contents = client.get_prompt(name: name) + + assert_equal({}, contents) + end + + def test_get_prompt_returns_empty_hash + transport = mock + name = "nonexistent_prompt" + mock_response = {} + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + contents = client.get_prompt(name: name) + + assert_equal({}, contents) + end + + def test_call_tool_includes_meta_progress_token_when_provided + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + arguments = { foo: "bar" } + progress_token = "my-progress-token" + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/call" && + args.dig(:request, :params, :_meta, :progressToken) == progress_token && + args.dig(:request, :params, :name) == "tool1" && + args.dig(:request, :params, :arguments) == arguments + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token) + end + + def test_call_tool_sends_trace_context_meta_entries + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { + MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + MCP::TraceContext::TRACESTATE_META_KEY => "vendor=value", + MCP::TraceContext::BAGGAGE_META_KEY => "userId=alice", + } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + sent_meta = args.dig(:request, :params, :_meta) + sent_meta["traceparent"] == meta["traceparent"] && + sent_meta["tracestate"] == meta["tracestate"] && + sent_meta["baggage"] == meta["baggage"] + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, meta: meta) + end + + def test_call_tool_merges_meta_with_progress_token_taking_precedence + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { "traceparent" => "00-trace-span-01", "progressToken" => "from-meta" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + sent_meta = args.dig(:request, :params, :_meta) + sent_meta["traceparent"] == "00-trace-span-01" && + sent_meta[:progressToken] == "explicit-token" && + !sent_meta.key?("progressToken") + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, progress_token: "explicit-token", meta: meta) + end + + def test_call_tool_omits_meta_when_empty_meta_hash_given + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params).key?(:_meta) == false + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, meta: {}) + end + + def test_call_tool_does_not_mutate_caller_meta + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + meta = { "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: {}, progress_token: "t", meta: meta) + + assert_equal({ "traceparent" => "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" }, meta) + end + + def test_read_resource_sends_meta_when_provided + transport = mock + traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" + mock_response = { "result" => { "contents" => [] } } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "resources/read" && + args.dig(:request, :params, :uri) == "file:///foo" && + args.dig(:request, :params, :_meta, :traceparent) == traceparent + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.read_resource(uri: "file:///foo", meta: { traceparent: traceparent }) + end + + def test_request_methods_send_meta_when_provided + # Per SEP-414, trace context should flow on every request, so the `meta:` keyword + # is available on all client request methods. + traceparent = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01" + meta = { traceparent: traceparent } + + [ + ["tools/list", { "tools" => [] }, ->(client) { client.list_tools(meta: meta) }], + ["resources/list", { "resources" => [] }, ->(client) { client.list_resources(meta: meta) }], + ["resources/templates/list", { "resourceTemplates" => [] }, ->(client) { client.list_resource_templates(meta: meta) }], + ["prompts/list", { "prompts" => [] }, ->(client) { client.list_prompts(meta: meta) }], + ["prompts/get", {}, ->(client) { client.get_prompt(name: "p", meta: meta) }], + [ + "completion/complete", + { "completion" => { "values" => [] } }, + ->(client) { + client.complete(ref: { type: "ref/prompt", name: "p" }, argument: { name: "a", value: "v" }, meta: meta) + }, + ], + ["ping", {}, ->(client) { client.ping(meta: meta) }], + ].each do |method, result, invoke| + transport = mock + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == method && + args.dig(:request, :params, :_meta, :traceparent) == traceparent + end.returns({ "result" => result }).once + + invoke.call(Client.new(transport: transport)) + end + end + + def test_request_methods_omit_meta_when_not_provided + # Wire-format regression: without `meta:`, list requests keep sending no `params` at all. + transport = mock + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" && !args[:request].key?(:params) + end.returns({ "result" => { "tools" => [] } }).once + + Client.new(transport: transport).list_tools + end + + def test_call_tool_omits_meta_when_no_progress_token + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + arguments = { foo: "bar" } + mock_response = { + "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/call" && + args.dig(:request, :params, :name) == "tool1" && + args.dig(:request, :params).key?(:_meta) == false + end.returns(mock_response).once + + client = Client.new(transport: transport) + client.call_tool(tool: tool, arguments: arguments) + end + + def test_tools_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.tools } + assert_equal(-32_601, error.code) + assert_equal("Method not found", error.message) + end + + def test_resources_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.resources } + assert_equal(-32_602, error.code) + end + + def test_read_resource_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") } + end + + def test_read_resource_surfaces_resource_not_found_code_and_data + # Per SEP-2164, servers report unknown resource URIs with -32602 and + # the URI in the error `data`; the client must expose both unmodified. + transport = mock + mock_response = { + "error" => { + "code" => -32_602, + "message" => "Resource not found: file:///missing.txt", + "data" => { "uri" => "file:///missing.txt" }, + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing.txt") } + + assert_equal(-32_602, error.code) + assert_equal({ "uri" => "file:///missing.txt" }, error.data) + end + + def test_get_prompt_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.get_prompt(name: "missing") } + end + + def test_prompts_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.prompts } + end + + def test_resource_templates_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + assert_raises(Client::ServerError) { client.resource_templates } + end + + def test_call_tool_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") } + assert_equal(-32_602, error.code) + end + + def test_server_error_includes_data_field + transport = mock + mock_response = { + "error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.tools } + assert_equal("extra details", error.data) + end + + def test_complete_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) } + assert_equal(-32_602, error.code) + end + + def test_complete_sends_request_and_returns_completion_result + transport = mock + mock_response = { + "result" => { + "completion" => { + "values" => ["python", "pytorch"], + "hasMore" => false, + }, + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "completion/complete" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } && + args.dig(:request, :params, :argument) == { name: "language", value: "py" } && + !args.dig(:request, :params).key?(:context) + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "language", value: "py" }, + ) + + assert_equal(["python", "pytorch"], result["values"]) + refute(result["hasMore"]) + end + + def test_complete_includes_context_when_provided + transport = mock + mock_response = { + "result" => { + "completion" => { + "values" => ["flask"], + "hasMore" => false, + }, + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params, :context) == { arguments: { language: "python" } } + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "framework", value: "fla" }, + context: { arguments: { language: "python" } }, + ) + + assert_equal(["flask"], result["values"]) + end + + def test_complete_returns_default_when_result_is_missing + transport = mock + mock_response = { "result" => {} } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.complete( + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + ) + + assert_equal([], result["values"]) + refute(result["hasMore"]) + end + + def test_ping_sends_request_and_returns_empty_hash + transport = mock + mock_response = { "result" => {} } + + transport.expects(:send_request).with do |args| + req = args[:request] + req[:method] == "ping" && + req[:jsonrpc] == "2.0" && + !req.key?(:params) + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.ping + + assert_equal({}, result) + end + + def test_ping_raises_server_error_on_error_response + transport = mock + mock_response = { "error" => { "code" => -32_603, "message" => "Internal error" } } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.ping } + assert_equal(-32_603, error.code) + assert_equal("Internal error", error.message) + end + + def test_ping_raises_validation_error_when_result_is_missing + transport = mock + mock_response = {} + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_raises_validation_error_when_result_is_wrong_type + transport = mock + mock_response = { "result" => "ok" } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ValidationError) { client.ping } + assert_equal("Response validation failed: missing or invalid `result`", error.message) + end + + def test_ping_propagates_transport_errors + transport = mock + transport_error = StandardError.new("read timeout") + transport.expects(:send_request).raises(transport_error).once + + client = Client.new(transport: transport) + error = assert_raises(StandardError) { client.ping } + assert_equal("read timeout", error.message) + end + + def test_tools_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + call_count = 0 + transport.expects(:send_request).twice.with do |args| + call_count += 1 + req = args[:request] + if call_count == 1 + req[:method] == "tools/list" && req[:params].nil? + else + req[:method] == "tools/list" && req[:params] == { cursor: "cursor1" } + end + end.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + assert_equal("tool1", tools[0].name) + assert_equal("tool2", tools[1].name) + end + + def test_list_tools_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools + + assert_equal(1, result.tools.size) + assert_equal("tool1", result.tools[0].name) + assert_equal("cursor1", result.next_cursor) + end + + def test_list_results_expose_ttl_ms_and_cache_scope + # SEP-2549 cache hints are surfaced on every paginated result struct. + [ + ["tools/list", "tools", :list_tools], + ["prompts/list", "prompts", :list_prompts], + ["resources/list", "resources", :list_resources], + ["resources/templates/list", "resourceTemplates", :list_resource_templates], + ].each do |method, items_key, client_method| + transport = mock + mock_response = { + "result" => { items_key => [], "ttlMs" => 5000, "cacheScope" => "private" }, + } + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == method + end.returns(mock_response).once + + result = Client.new(transport: transport).public_send(client_method) + + assert_equal(5000, result.ttl_ms, "#{method} missing ttl_ms") + assert_equal("private", result.cache_scope, "#{method} missing cache_scope") + end + end + + def test_list_results_have_nil_cache_hints_when_server_omits_them + transport = mock + mock_response = { "result" => { "tools" => [] } } + transport.expects(:send_request).returns(mock_response).once + + result = Client.new(transport: transport).list_tools + + assert_nil(result.ttl_ms) + assert_nil(result.cache_scope) + end + + def test_list_tools_with_cursor_param + transport = mock + + mock_response = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :params) == { cursor: "cursor1" } + end.returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_tools(cursor: "cursor1") + + assert_equal(1, result.tools.size) + assert_equal("tool2", result.tools[0].name) + assert_nil(result.next_cursor) + end + + def test_resources_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resources" => [{ "uri" => "https://b.invalid", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + assert_equal("a", resources[0]["name"]) + assert_equal("b", resources[1]["name"]) + end + + def test_list_resources_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resources + + assert_equal(1, result.resources.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_resource_templates_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://b.invalid/{id}", "name" => "b" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + assert_equal("a", templates[0]["name"]) + assert_equal("b", templates[1]["name"]) + end + + def test_list_resource_templates_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_resource_templates + + assert_equal(1, result.resource_templates.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_prompts_auto_paginates_across_multiple_pages + transport = mock + + page1_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + page2_response = { + "result" => { + "prompts" => [{ "name" => "prompt_b", "description" => "B" }], + }, + } + + transport.expects(:send_request).twice.returns(page1_response).then.returns(page2_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + assert_equal("prompt_a", prompts[0]["name"]) + assert_equal("prompt_b", prompts[1]["name"]) + end + + def test_tools_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "stuck_cursor", + }, + } + + # If the server keeps returning the same cursor, the client must not loop forever. + # Expect at most 2 calls: the initial request (cursor=nil) and one retry (cursor="stuck_cursor") + # that detects the repeat and breaks out. + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(2, tools.size) + end + + def test_tools_raises_when_the_server_offers_more_pages_than_max_pages + transport = mock + + pages = (1..3).map do |n| + { + "result" => { + "tools" => [{ "name" => "tool#{n}", "description" => "tool#{n}", "inputSchema" => {} }], + "nextCursor" => "cursor#{n}", + }, + } + end + + # A fresh cursor on every response defeats the repeat guard, so only `max_pages` stops the walk. + transport.expects(:send_request).times(3).returns(*pages) + + client = Client.new(transport: transport, max_pages: 3) + + error = assert_raises(Client::PaginationLimitError) { client.tools } + assert_equal( + "Server returned more than 3 pages; pass a larger `max_pages:` to `MCP::Client.new` if this is expected.", + error.message, + ) + end + + def test_tools_returns_every_page_when_the_server_stops_exactly_at_max_pages + transport = mock + + page1 = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "cursor1", + }, + } + page2 = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + }, + } + + transport.expects(:send_request).twice.returns(page1, page2) + + client = Client.new(transport: transport, max_pages: 2) + tools = client.tools + + assert_equal(["tool1", "tool2"], tools.map(&:name)) + end + + def test_raises_argument_error_when_max_pages_is_not_positive + error = assert_raises(ArgumentError) { Client.new(transport: mock, max_pages: 0) } + + assert_equal("max_pages must be a positive Integer", error.message) + end + + def test_tools_breaks_when_server_cycles_between_cursors + transport = mock + + page_a = { + "result" => { + "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + page_b = { + "result" => { + "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], + "nextCursor" => "B", + }, + } + # Server cycles A -> B -> A. Client must detect the revisited cursor and break. + page_a_again = { + "result" => { + "tools" => [{ "name" => "tool3", "description" => "tool3", "inputSchema" => {} }], + "nextCursor" => "A", + }, + } + + transport.expects(:send_request).times(3).returns(page_a, page_b, page_a_again) + + client = Client.new(transport: transport) + tools = client.tools + + assert_equal(3, tools.size) + assert_equal(["tool1", "tool2", "tool3"], tools.map(&:name)) + end + + def test_resources_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resources" => [{ "uri" => "https://a.invalid", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + resources = client.resources + + assert_equal(2, resources.size) + end + + def test_resource_templates_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "resourceTemplates" => [{ "uriTemplate" => "https://a.invalid/{id}", "name" => "a" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + templates = client.resource_templates + + assert_equal(2, templates.size) + end + + def test_prompts_breaks_when_server_returns_same_cursor_repeatedly + transport = mock + + stuck_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "stuck_cursor", + }, + } + + transport.expects(:send_request).twice.returns(stuck_response) + + client = Client.new(transport: transport) + prompts = client.prompts + + assert_equal(2, prompts.size) + end + + def test_list_prompts_returns_single_page_with_cursor + transport = mock + + mock_response = { + "result" => { + "prompts" => [{ "name" => "prompt_a", "description" => "A" }], + "nextCursor" => "cursor1", + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + result = client.list_prompts + + assert_equal(1, result.prompts.size) + assert_equal("cursor1", result.next_cursor) + end + + def test_cancellation_without_reason_omits_reason_in_notification + # The token-driven cancel path must omit `reason` from the `notifications/cancelled` params + # when the token is cancelled without one. + sent = Queue.new + cancellation = MCP::Cancellation.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + cancellation.cancel # no reason + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + sent.push(notification) + nil + end + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + notification = sent.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + refute(notification[:params].key?(:reason)) + end + + def test_call_tool_with_cancellation_returns_response_when_no_cancel + transport = mock + transport.expects(:send_request).returns({ "result" => { "content" => [{ "type" => "text", "text" => "ok" }] } }).once + transport.stubs(:send_notification) # required for `cancellation:` capability check + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + assert_equal("ok", response.dig("result", "content", 0, "text")) + end + + def test_call_tool_with_cancellation_already_cancelled_raises_without_sending + transport = mock + transport.expects(:send_request).never + transport.expects(:send_notification).never + + cancellation = MCP::Cancellation.new + cancellation.cancel(reason: "pre-cancelled") + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + end + + def test_call_tool_with_cancellation_mid_flight_raises_and_sends_notification + cancellation = MCP::Cancellation.new + notification_queue = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + # Cancel while the request is "in flight". + cancellation.cancel(reason: "user abort") + sleep(0.05) + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + notification_queue.push(notification) + nil + end + + client = Client.new(transport: transport) + + error = assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + assert_equal("user abort", error.reason) + + # Cancel notification is dispatched fire-and-forget on a separate thread; + # block here until it reaches the transport. + notification = notification_queue.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + assert_equal("user abort", notification[:params][:reason]) + end + + def test_loop_methods_accept_cancellation_keyword + # Regression: `tools` / `resources` / `resource_templates` / `prompts` are the high-level pagination loops. + # They must accept `cancellation:` so the README's "all request methods accept the cancellation: keyword" + # claim holds. The token is propagated to each underlying `list_*` page. + cancellation = MCP::Cancellation.new + + transport = mock + transport.stubs(:send_request).returns({ "result" => { "tools" => [] } }) + transport.stubs(:send_notification) # required for `cancellation:` capability check + + client = Client.new(transport: transport) + + [ + -> { client.tools(cancellation: cancellation) }, + -> { client.resources(cancellation: cancellation) }, + -> { client.resource_templates(cancellation: cancellation) }, + -> { client.prompts(cancellation: cancellation) }, + ].each do |loop_call| + transport.stubs(:send_request).returns({ "result" => {} }) + loop_call.call + end + end + + def test_cancel_callback_does_not_deadlock_when_transport_holds_a_mutex + # Regression: when the cancellation callback fires on the same thread that is currently inside + # `transport.send_request` (and therefore holds a transport-level mutex), a synchronous `send_notification` + # from the callback would deadlock by re-entering the same mutex. The fix wakes the calling thread first + # and dispatches the notification on a separate thread. + transport_mutex = Mutex.new + cancellation = MCP::Cancellation.new + + # Custom transport class so we can model a mutex held during send_request. + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + transport_mutex.synchronize do + # Trigger cancel while we hold the mutex. The callback must NOT try to synchronously acquire + # the same mutex via send_notification. + cancellation.cancel(reason: "mid-flight") + # Hold the mutex briefly to give the callback time to (incorrectly) try to re-enter. + sleep(0.05) + { "result" => {} } + end + end + + # send_notification also needs the mutex - this is the path that would deadlock under synchronous dispatch. + send_notification_called = Queue.new + transport.define_singleton_method(:send_notification) do |notification:| + transport_mutex.synchronize do + send_notification_called.push(notification) + end + end + + client = Client.new(transport: transport) + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + + # The notification must reach the transport eventually (after the mutex is released by the now-finished send_request). + notification = send_notification_called.pop + assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method]) + assert_equal("mid-flight", notification[:params][:reason]) + end + + def test_late_cancel_after_response_does_not_send_stray_notification + # Regression: between the worker thread pushing `:response` and the main thread leaving `dispatch_with_cancellation`, + # a cancellation firing in that gap must not emit a stray `notifications/cancelled` for the already-completed request. + # The completion-mutex gate makes the worker and the on_cancel callback fight for a single slot; whichever sets + # `completed` first wins, the loser bails. + cancellation = MCP::Cancellation.new + notification_count = 0 + notification_count_mutex = Mutex.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**, &on_sent| + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |**| + notification_count_mutex.synchronize { notification_count += 1 } + nil + end + + client = Client.new(transport: transport) + response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + assert_equal({}, response["result"]) + + # Cancel after the call returned. The on_cancel hook was deregistered in the `ensure` block; + # even if a callback raced with the response handoff, `completed` would already be true and + # `queue.push :cancelled` plus the cancel-dispatch thread would not run. + cancellation.cancel(reason: "late") + + sleep(0.05) # let any erroneous cancel-dispatch thread complete. + + count = notification_count_mutex.synchronize { notification_count } + assert_equal(0, count, "no stray notifications/cancelled for an already-completed request") + end + + def test_call_tool_with_cancellation_raises_NoMethodError_when_transport_lacks_send_notification + # Regression: a transport that only implements `send_request` cannot deliver `notifications/cancelled`. + # We must surface this upfront with a clear `NoMethodError` rather than silently swallow + # the failure inside the cancel-dispatch thread - otherwise the user sees `CancelledError` but + # the server never receives the notification. + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + { "result" => {} } + end + # NOTE: no send_notification. + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + error = assert_raises(NoMethodError) do + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + end + + assert_match(/send_notification/, error.message) + end + + def test_cancel_notification_is_dispatched_after_request_write_via_on_sent_block + # Regression for the wire-order race: a transport that yields from `send_request` after writing + # the request must see `:request` recorded before `:cancel` even when the cancel signal arrives concurrently. + recorded = [] + recorded_mutex = Mutex.new + cancellation = MCP::Cancellation.new + cancel_complete = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |request:, &on_sent| + # Trigger cancel BEFORE the simulated wire-write so the cancel-dispatch thread starts racing immediately. + # The `on_sent` block is yielded only after we have recorded the request - so any cancel-write that obeys + # the block must land afterwards. + cancellation.cancel(reason: "race") + sleep(0.02) # give cancel-dispatch thread time to spawn and start waiting + recorded_mutex.synchronize { recorded << [:request, request[:id]] } + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] } + cancel_complete.push(:done) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-A") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + cancel_complete.pop # wait for the fire-and-forget cancel dispatch + + # Wire-order must be: request first, then cancel. + assert_equal([[:request, "req-A"], [:cancel, "req-A"]], recorded) + end + + def test_cancel_notification_waits_for_delayed_on_sent_signal + # Regression: the cancel-dispatch thread must wait for `&on_sent` no matter how long it takes - + # it cannot fall back to wall-clock-based timeout. An earlier 100 ms fixed-duration fallback + # could release the cancel thread before the worker reached its send-boundary at all, allowing + # the cancel to be issued without any prior request commitment - which the spec only covers under + # the receiver's MAY-ignore-unknown-id clause. The wait is now bounded by worker termination + # (via `ensure -> signal_sent.call`), not by elapsed time. + recorded = [] + recorded_mutex = Mutex.new + cancellation = MCP::Cancellation.new + cancel_complete = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |request:, &on_sent| + cancellation.cancel(reason: "race") + # Delay well beyond any plausible fixed-duration fallback (200 ms). + # If cancel-dispatch fires on a timer rather than waiting for on_sent, + # the recorded order will be [:cancel, :request] and the assertion below will fail. + sleep(0.2) + recorded_mutex.synchronize { recorded << [:request, request[:id]] } + on_sent&.call + { "result" => {} } + end + transport.define_singleton_method(:send_notification) do |notification:| + recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] } + cancel_complete.push(:done) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-D") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + cancel_complete.pop + + assert_equal([[:request, "req-D"], [:cancel, "req-D"]], recorded) + end + + def test_cancel_notification_dispatches_when_worker_raises_before_on_sent + # Regression: if the worker raises before `&on_sent` is called - which can happen if a transport rejects + # a malformed request synchronously - the cancel-dispatch thread must still terminate AND deliver the notification. + # The worker's `ensure` block invokes `signal_sent.call`, which broadcasts to the condition variable and unblocks + # the `until request_sent` wait. Without the ensure-driven unblock, the dispatch thread would leak. + cancellation = MCP::Cancellation.new + send_notification_called = Queue.new + + transport = Object.new + transport.define_singleton_method(:send_request) do |**| + # Trigger cancel before raising so the cancel hook fires while the worker is mid-flight, + # then bail out without ever calling on_sent. + cancellation.cancel(reason: "race") + raise StandardError, "transport boom" + end + transport.define_singleton_method(:send_notification) do |notification:| + send_notification_called.push(notification) + nil + end + + client = Client.new(transport: transport) + client.stubs(:generate_request_id).returns("req-E") + + assert_raises(MCP::CancelledError) do + client.call_tool(name: "slow", arguments: {}, cancellation: cancellation) + end + + # The cancel-dispatch thread should still send the notification because the worker's `ensure` triggers `signal_sent.call`. + notification = send_notification_called.pop + assert_equal("req-E", notification[:params][:requestId]) + end + + def test_call_tool_with_cancellation_normal_completion_does_not_send_notification + transport = mock + transport.expects(:send_request).returns({ "result" => {} }).once + transport.expects(:send_notification).never + + cancellation = MCP::Cancellation.new + client = Client.new(transport: transport) + + client.call_tool(name: "noop", arguments: {}, cancellation: cancellation) + + # Late cancel after normal completion: hook should already be deregistered. + cancellation.cancel(reason: "late") end end end diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index cd2777d..058bc19 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -12,8 +12,9 @@ module MCP exception = StandardError.new("test error") server_context = { test: "context" } - # Should not raise any errors - config.exception_reporter.call(exception, server_context) + assert_nothing_raised do + config.exception_reporter.call(exception, server_context) + end end test "allows setting a custom exception reporter" do @@ -36,8 +37,30 @@ module MCP end test "initializes with default protocol version" do + # The unset pin reads as the version the handshake settles on, which is also the only kind of + # value the writer accepts: reading back a version that cannot be set again would break code + # that round-trips the setting. config = Configuration.new - assert_equal Configuration::DEFAULT_PROTOCOL_VERSION, config.protocol_version + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version + refute_predicate config, :protocol_version? + assert_nothing_raised { Configuration.new(protocol_version: config.protocol_version) } + end + + test "uses the default protocol version when protocol_version is set to nil" do + config = Configuration.new(protocol_version: nil) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version + end + + test "raises ArgumentError when setting the draft protocol version" do + exception = assert_raises(ArgumentError) do + # DRAFT-2025-v3 is the latest draft protocol version: + # https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15 + Configuration.new(protocol_version: "DRAFT-2025-v3") + end + + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "raises ArgumentError when protocol_version is not a supported protocol version" do @@ -46,7 +69,30 @@ module MCP custom_version = "2025-03-27" config.protocol_version = custom_version end - assert_equal("protocol_version must be 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + end + + test "exposes the SEP-2575 modern protocol versions" do + assert_equal "2026-07-28", Configuration::LATEST_MODERN_PROTOCOL_VERSION + assert_equal ["2026-07-28"], Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS + assert Configuration.modern_protocol_version?("2026-07-28") + refute Configuration.modern_protocol_version?("2025-11-25") + end + + test "rejects a modern version as the protocol version pin" do + # The pin scopes the `initialize` handshake; a modern version has no handshake to pin + # (its version rides every request in `_meta`), so accepting it would configure nothing. + # The message names that reason instead of listing the accepted values, because this is + # the error an upgrade from a release that accepted the value lands on. + exception = assert_raises(ArgumentError) do + Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) + end + + assert_includes(exception.message, "is a modern protocol version and cannot be pinned here") + assert_includes(exception.message, "remove the setting") + + config = Configuration.new + assert_raises(ArgumentError) { config.protocol_version = "2026-07-28" } end test "raises ArgumentError when protocol_version is not a boolean value" do @@ -57,6 +103,14 @@ module MCP assert_equal("validate_tool_call_arguments must be a boolean", exception.message) end + test "raises ArgumentError when validate_tool_call_results is not a boolean value" do + config = Configuration.new + exception = assert_raises(ArgumentError) do + config.validate_tool_call_results = "true" + end + assert_equal("validate_tool_call_results must be a boolean", exception.message) + end + test "merges protocol version from other configuration" do config1 = Configuration.new(protocol_version: "2025-03-26") config2 = Configuration.new(protocol_version: "2025-06-18") @@ -106,6 +160,116 @@ module MCP refute merged.validate_tool_call_arguments end + test "defaults instrument_server_context to false" do + config = Configuration.new + + refute_predicate config, :instrument_server_context? + end + + test "accepts instrument_server_context in the constructor" do + config = Configuration.new(instrument_server_context: true) + + assert_predicate config, :instrument_server_context? + end + + test "raises ArgumentError when instrument_server_context is not a boolean value" do + config = Configuration.new + + exception = assert_raises(ArgumentError) do + config.instrument_server_context = "true" + end + assert_equal("instrument_server_context must be a boolean", exception.message) + end + + test "merge carries instrument_server_context from the other configuration" do + base = Configuration.new(instrument_server_context: true) + other = Configuration.new(instrument_server_context: false) + + refute_predicate base.merge(other), :instrument_server_context? + assert_predicate other.merge(base), :instrument_server_context? + end + + test "defaults validate_tool_call_results to false" do + config = Configuration.new + refute config.validate_tool_call_results + end + + test "can set validate_tool_call_results to true" do + config = Configuration.new(validate_tool_call_results: true) + assert config.validate_tool_call_results + end + + test "validate_tool_call_results? returns false when not set" do + config = Configuration.new + refute config.validate_tool_call_results? + end + + test "validate_tool_call_results? returns true when set" do + config = Configuration.new(validate_tool_call_results: true) + assert config.validate_tool_call_results? + end + + test "merge preserves validate_tool_call_results from other config" do + config1 = Configuration.new(validate_tool_call_results: true) + config2 = Configuration.new + merged = config1.merge(config2) + refute merged.validate_tool_call_results? + end + + test "merge preserves validate_tool_call_results from self when other set" do + config1 = Configuration.new(validate_tool_call_results: true) + config2 = Configuration.new + merged = config2.merge(config1) + assert merged.validate_tool_call_results + end + + test "initializes with a default pass-through around_request" do + config = Configuration.new + called = false + config.around_request.call({}) { called = true } + assert called + end + + test "allows setting a custom around_request" do + config = Configuration.new + call_log = [] + config.around_request = ->(_data, &request_handler) { + call_log << :before + request_handler.call + call_log << :after + } + + config.around_request.call({}) { call_log << :execute } + assert_equal([:before, :execute, :after], call_log) + end + + test "around_request? returns false by default" do + config = Configuration.new + refute config.around_request? + end + + test "around_request? returns true when set" do + config = Configuration.new + config.around_request = ->(_data, &request_handler) { request_handler.call } + assert config.around_request? + end + + test "merge preserves around_request from other config" do + custom = ->(_data, &request_handler) { request_handler.call } + config1 = Configuration.new + config2 = Configuration.new(around_request: custom) + merged = config1.merge(config2) + assert_equal custom, merged.around_request + end + + test "merge preserves around_request from self when other not set" do + custom = ->(_data, &request_handler) { request_handler.call } + config1 = Configuration.new(around_request: custom) + config2 = Configuration.new + merged = config1.merge(config2) + assert_equal custom, merged.around_request + end + test "raises ArgumentError when protocol_version is not a supported value" do exception = assert_raises(ArgumentError) do Configuration.new(protocol_version: "1999-12-31") @@ -119,5 +283,12 @@ module MCP end assert_equal("validate_tool_call_arguments must be a boolean", exception.message) end + + test "raises ArgumentError when validate_tool_call_results is not a boolean" do + exception = assert_raises(ArgumentError) do + Configuration.new(validate_tool_call_results: "true") + end + assert_equal("validate_tool_call_results must be a boolean", exception.message) + end end end diff --git a/test/mcp/content_test.rb b/test/mcp/content_test.rb new file mode 100644 index 0000000..fc2103e --- /dev/null +++ b/test/mcp/content_test.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + module Content + class ImageTest < ActiveSupport::TestCase + test "#to_h returns mimeType (camelCase) per MCP spec" do + image = Image.new("base64data", "image/png") + result = image.to_h + + assert_equal "image/png", result[:mimeType] + refute result.key?(:mime_type), "Expected camelCase mimeType, got snake_case mime_type" + assert_equal "image", result[:type] + assert_equal "base64data", result[:data] + end + + test "#to_h with annotations" do + image = Image.new("base64data", "image/png", annotations: { role: "thumbnail" }) + result = image.to_h + + assert_equal({ role: "thumbnail" }, result[:annotations]) + end + + test "#to_h without annotations omits the key" do + image = Image.new("base64data", "image/png") + result = image.to_h + + refute result.key?(:annotations) + end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + image = Image.new("base64data", "image/png", meta: meta) + + assert_equal meta, image.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + image = Image.new("base64data", "image/png") + + refute image.to_h.key?(:_meta) + end + end + + class AudioTest < ActiveSupport::TestCase + test "#to_h returns correct format per MCP spec" do + audio = Audio.new("base64data", "audio/wav") + result = audio.to_h + + assert_equal "audio", result[:type] + assert_equal "base64data", result[:data] + assert_equal "audio/wav", result[:mimeType] + end + + test "#to_h with annotations" do + audio = Audio.new("base64data", "audio/wav", annotations: { role: "recording" }) + result = audio.to_h + + assert_equal({ role: "recording" }, result[:annotations]) + end + + test "#to_h without annotations omits the key" do + audio = Audio.new("base64data", "audio/wav") + result = audio.to_h + + refute result.key?(:annotations) + end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + audio = Audio.new("base64data", "audio/wav", meta: meta) + + assert_equal meta, audio.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + audio = Audio.new("base64data", "audio/wav") + + refute audio.to_h.key?(:_meta) + end + end + + class EmbeddedResourceTest < ActiveSupport::TestCase + test "#to_h returns correct format per MCP spec" do + resource = Object.new + def resource.to_h + { uri: "test://example", mimeType: "text/plain", text: "content" } + end + + embedded = EmbeddedResource.new(resource) + result = embedded.to_h + + assert_equal "resource", result[:type] + assert_equal({ uri: "test://example", mimeType: "text/plain", text: "content" }, result[:resource]) + end + + test "#to_h with annotations" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + embedded = EmbeddedResource.new(resource, annotations: { role: "data" }) + result = embedded.to_h + + assert_equal({ role: "data" }, result[:annotations]) + end + + test "#to_h without annotations omits the key" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + embedded = EmbeddedResource.new(resource) + result = embedded.to_h + + refute result.key?(:annotations) + end + + test "#to_h with meta" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + embedded = EmbeddedResource.new(resource, meta: meta) + + assert_equal meta, embedded.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + resource = Object.new + def resource.to_h + { uri: "test://x" } + end + + embedded = EmbeddedResource.new(resource) + + refute embedded.to_h.key?(:_meta) + end + end + + class TextTest < ActiveSupport::TestCase + test "#to_h returns correct format per MCP spec" do + text = Text.new("hello") + result = text.to_h + + assert_equal "text", result[:type] + assert_equal "hello", result[:text] + end + + test "#to_h with meta" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + text = Text.new("hello", meta: meta) + + assert_equal meta, text.to_h[:_meta] + end + + test "#to_h without meta omits the key" do + text = Text.new("hello") + + refute text.to_h.key?(:_meta) + end + end + end +end diff --git a/test/mcp/elicitation/enum_schema_test.rb b/test/mcp/elicitation/enum_schema_test.rb new file mode 100644 index 0000000..19a4246 --- /dev/null +++ b/test/mcp/elicitation/enum_schema_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + module Elicitation + class EnumSchemaTest < ActiveSupport::TestCase + test "untitled_single_select returns string + enum schema" do + schema = EnumSchema.untitled_single_select(values: ["red", "green", "blue"]) + + assert_equal({ type: "string", enum: ["red", "green", "blue"] }, schema.to_h) + end + + test "untitled_single_select preserves default value" do + schema = EnumSchema.untitled_single_select(values: ["red", "green"], default: "red") + + assert_equal "red", schema.to_h[:default] + end + + test "titled_single_select returns string + oneOf schema" do + schema = EnumSchema.titled_single_select( + options: [ + { value: "small", title: "Small" }, + { value: "large", title: "Large" }, + ], + ) + + assert_equal( + { + type: "string", + oneOf: [ + { const: "small", title: "Small" }, + { const: "large", title: "Large" }, + ], + }, + schema.to_h, + ) + end + + test "untitled_multi_select returns array + items.enum schema" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b", "c"]) + + assert_equal( + { type: "array", items: { type: "string", enum: ["a", "b", "c"] } }, + schema.to_h, + ) + end + + test "untitled_multi_select preserves default array" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: ["a"]) + + assert_equal ["a"], schema.to_h[:default] + end + + test "titled_multi_select returns array + items.anyOf schema" do + schema = EnumSchema.titled_multi_select( + options: [ + { value: "us", title: "United States" }, + { value: "jp", title: "Japan" }, + ], + ) + + assert_equal( + { + type: "array", + items: { + anyOf: [ + { const: "us", title: "United States" }, + { const: "jp", title: "Japan" }, + ], + }, + }, + schema.to_h, + ) + end + + test "legacy_titled returns enum + enumNames" do + schema = EnumSchema.legacy_titled( + values: ["s", "m", "l"], + value_titles: ["Small", "Medium", "Large"], + ) + + assert_equal( + { + type: "string", + enum: ["s", "m", "l"], + enumNames: ["Small", "Medium", "Large"], + }, + schema.to_h, + ) + end + + test "title and description are included when provided" do + schema = EnumSchema.untitled_single_select( + values: ["a"], + title: "Color", + description: "Pick a color", + ) + + assert_equal "Color", schema.to_h[:title] + assert_equal "Pick a color", schema.to_h[:description] + end + + test "raises ArgumentError when values array is empty" do + assert_raises(ArgumentError) do + EnumSchema.untitled_single_select(values: []) + end + end + + test "raises ArgumentError when titled options miss :value" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ title: "missing value" }]) + end + end + + test "raises ArgumentError when titled options miss :title" do + assert_raises(ArgumentError) do + EnumSchema.titled_single_select(options: [{ value: "v" }]) + end + end + + test "raises ArgumentError when legacy value_titles length differs" do + assert_raises(ArgumentError) do + EnumSchema.legacy_titled(values: ["a", "b"], value_titles: ["A"]) + end + end + + test "preserves an empty-string default value" do + # Regression for `unless @default.nil?`: empty-but-non-nil defaults must still survive `to_h` + # (the guard distinguishes nil from "absent vs supplied", not truthy vs falsey). + schema = EnumSchema.untitled_single_select(values: ["", "yes", "no"], default: "") + + assert_equal "", schema.to_h[:default] + end + + test "preserves an empty-array default for multi-select" do + schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: []) + + assert_equal [], schema.to_h[:default] + end + end + end +end diff --git a/test/mcp/error_codes_test.rb b/test/mcp/error_codes_test.rb new file mode 100644 index 0000000..80826b8 --- /dev/null +++ b/test/mcp/error_codes_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ErrorCodesTest < ActiveSupport::TestCase + test "exposes the SEP-2575 stateless lifecycle error codes" do + # The exact values are wire vocabulary shared with other SDKs. + assert_equal(-32020, ErrorCodes::HEADER_MISMATCH) + assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY) + assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION) + end + end +end diff --git a/test/mcp/icon_test.rb b/test/mcp/icon_test.rb new file mode 100644 index 0000000..f2fbc9a --- /dev/null +++ b/test/mcp/icon_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class IconTest < ActiveSupport::TestCase + def test_initialization + icon = Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light") + + assert_equal("image/png", icon.mime_type) + assert_equal(["48x48", "96x96"], icon.sizes) + assert_equal("https://example.com", icon.src) + assert_equal("light", icon.theme) + + assert_equal({ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }, icon.to_h) + end + + def test_initialization_with_only_src + icon = Icon.new(src: "https://example.com/icon.png") + + assert_nil(icon.mime_type) + assert_nil(icon.sizes) + assert_equal("https://example.com/icon.png", icon.src) + assert_nil(icon.theme) + + assert_equal({ src: "https://example.com/icon.png" }, icon.to_h) + end + + def test_src_is_required + exception = assert_raises(ArgumentError) do + Icon.new + end + assert_equal("missing keyword: :src", exception.message) + end + + def test_src_rejects_nil_and_an_empty_string + [nil, ""].each do |src| + exception = assert_raises(ArgumentError) do + Icon.new(src: src) + end + assert_equal("The value of src must be a non-empty String (got #{src.class}).", exception.message) + end + end + + def test_src_rejects_a_non_string + exception = assert_raises(ArgumentError) do + Icon.new(src: :icon) + end + assert_equal("The value of src must be a non-empty String (got Symbol).", exception.message) + end + + def test_sizes_accepts_an_array_of_strings + [["48x48", "96x96"], ["any"], []].each do |sizes| + icon = Icon.new(sizes: sizes, src: "https://example.com/icon.png") + + assert_equal(sizes, icon.to_h[:sizes]) + end + end + + # https://github.com/modelcontextprotocol/ruby-sdk/issues/562 + def test_sizes_rejects_a_string + exception = assert_raises(ArgumentError) do + Icon.new(mime_type: "image/png", sizes: "51x51", src: "https://example.com/icon.png") + end + assert_equal( + 'The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', + exception.message, + ) + end + + def test_sizes_rejects_an_array_holding_a_non_string + { + ["48x48", 96] => "Integer", + [nil] => "NilClass", + ["48x48", nil] => "NilClass", + [nil, 96] => "NilClass", + }.each do |sizes, offender| + exception = assert_raises(ArgumentError) do + Icon.new(sizes: sizes, src: "https://example.com/icon.png") + end + assert_equal( + "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (got #{offender} inside the Array).", + exception.message, + ) + end + end + + def test_src_accepts_any_scheme_and_sizes_accept_any_string + string_subclass = Class.new(String) + icon = Icon.new(sizes: ["unconventional", +"48x48", string_subclass.new("any")], src: "custom:icon") + + assert_equal({ sizes: ["unconventional", "48x48", "any"], src: "custom:icon" }, icon.to_h) + end + + def test_mime_type_rejects_a_non_string + exception = assert_raises(ArgumentError) do + Icon.new(mime_type: :png, src: "https://example.com/icon.png") + end + assert_equal("The value of mime_type must be a String (got Symbol).", exception.message) + end + + def test_valid_theme_for_light + assert_nothing_raised do + Icon.new(src: "https://example.com/icon.png", theme: "light") + end + end + + def test_valid_theme_for_dark + assert_nothing_raised do + Icon.new(src: "https://example.com/icon.png", theme: "dark") + end + end + + def test_invalid_theme + exception = assert_raises(ArgumentError) do + Icon.new(src: "https://example.com/icon.png", theme: "unexpected") + end + assert_equal('The value of theme must specify "light" or "dark".', exception.message) + end + + def test_theme_rejects_false + exception = assert_raises(ArgumentError) do + Icon.new(src: "https://example.com/icon.png", theme: false) + end + assert_equal('The value of theme must specify "light" or "dark".', exception.message) + end + end +end diff --git a/test/mcp/instrumentation_test.rb b/test/mcp/instrumentation_test.rb index e1ecf51..8fb06af 100644 --- a/test/mcp/instrumentation_test.rb +++ b/test/mcp/instrumentation_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "mcp/instrumentation" module MCP class InstrumentationTest < ActiveSupport::TestCase @@ -24,6 +25,92 @@ module MCP add_instrumentation_data(additional_data: "test") end end + + def instrumented_method_with_server_context(context) + instrument_call("instrumented_method_with_server_context", server_context: context) do + # nothing to do + end + end + + def instrumented_method_that_raises_with_error_set + instrument_call("instrumented_method_that_raises_with_error_set") do + add_instrumentation_data(error: :custom_error) + raise "block error" + end + end + end + + # Mirrors `MCP::Server`, which exposes the user-defined hash through + # `attr_accessor :server_context`. + class SubjectWithServerContext < Subject + attr_accessor :server_context + + def initialize(server_context) + super() + @server_context = server_context + end + end + + test "#instrument_call omits the user-defined server_context by default" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + + subject.instrumented_method + + refute_includes subject.instrumentation_data_received.keys, :server_context + end + + test "#instrument_call exposes the user-defined server_context when instrument_server_context is enabled" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) + end + + test "#instrument_call exposes the user-defined server_context to around_request" do + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + seen = nil + subject.configuration.around_request = ->(data, &handler) { + seen = data[:server_context] + handler.call + } + + subject.instrumented_method + + assert_equal({ user_id: 42 }, seen) + end + + test "#instrument_call reports the user-defined server_context as nil when the host does not set one" do + subject = SubjectWithServerContext.new(nil) + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + assert_includes subject.instrumentation_data_received.keys, :server_context + assert_nil subject.instrumentation_data_received[:server_context] + end + + test "#instrument_call does not confuse the reporter context with the user-defined server_context" do + # `instrument_call(server_context:)` is the exception-reporter context, a + # different value from the user-defined hash. Enabling the flag must + # surface the latter, not the former. + subject = SubjectWithServerContext.new({ user_id: 42 }) + subject.configuration.instrument_server_context = true + + subject.instrumented_method_with_server_context({ request: "reporter-context" }) + + assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) + end + + test "#instrument_call skips server_context for hosts without the reader even when enabled" do + subject = Subject.new + subject.configuration.instrument_server_context = true + + subject.instrumented_method + + refute_includes subject.instrumentation_data_received.keys, :server_context end test "#instrument_call adds the method name to the instrumentation data" do @@ -45,6 +132,329 @@ module MCP ) end + test "#instrument_call invokes around_request wrapping execution" do + call_log = [] + subject = Subject.new + subject.configuration.around_request = ->(_data, &request_handler) { + call_log << :before + request_handler.call + call_log << :after + } + + subject.instrumented_method + + assert_equal([:before, :after], call_log) + end + + test "#instrument_call around_request receives method before request_handler.call" do + received_method = nil + subject = Subject.new + subject.configuration.around_request = ->(data, &request_handler) { + received_method = data[:method] + request_handler.call + } + + subject.instrumented_method + + assert_equal("instrumented_method", received_method) + end + + test "#instrument_call around_request data is populated after request_handler.call" do + data_after_call = nil + subject = Subject.new + subject.configuration.around_request = ->(data, &request_handler) { + request_handler.call + data_after_call = data.dup + } + + subject.instrumented_method_with_additional_data + + assert_equal("instrumented_method_with_additional_data", data_after_call[:method]) + assert_equal("test", data_after_call[:additional_data]) + end + + test "#instrument_call default around_request is pass-through" do + subject = Subject.new + + subject.instrumented_method_with_additional_data + + assert_equal( + { method: "instrumented_method_with_additional_data", additional_data: "test" }, + subject.instrumentation_data_received.tap { |data| data.delete(:duration) }, + ) + end + + test "#instrument_call reports exception and sets error when around_request raises before request_handler.call" do + reported_exception = nil + reported_context = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(e, server_context) { + reported_exception = e + reported_context = server_context + } + subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom before" } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_equal("boom before", error.message) + assert_same(error, reported_exception) + assert_equal({}, reported_context) + assert_equal(:internal_error, subject.instrumentation_data_received[:error]) + assert(subject.instrumentation_data_received.key?(:duration)) + end + + test "#instrument_call reports exception and sets error when around_request raises after request_handler.call" do + reported_exception = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported_exception = e } + subject.configuration.around_request = ->(_data, &request_handler) { + request_handler.call + raise "boom after" + } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_equal("boom after", error.message) + assert_same(error, reported_exception) + assert_equal(:internal_error, subject.instrumentation_data_received[:error]) + end + + test "#instrument_call preserves block's custom error tag and reports the exception" do + report_count = 0 + subject = Subject.new + subject.configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } + + assert_raises(RuntimeError) { subject.instrumented_method_that_raises_with_error_set } + assert_equal(1, report_count) + assert_equal(:custom_error, subject.instrumentation_data_received[:error]) + end + + test "#instrument_call skips reporting when exception_already_reported returns true" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + error = StandardError.new("inner failure") + subject.define_singleton_method(:instrumented_with_skip) do + instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { error.equal?(e) }) do + raise error + end + end + + assert_raises(StandardError) { subject.instrumented_with_skip } + assert_equal([], reported) + end + + test "#instrument_call reports when exception_already_reported returns false for a new exception" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + target = StandardError.new("inner failure") + subject.configuration.around_request = ->(_data, &request_handler) do + request_handler.call + ensure + raise "ensure boom" + end + + subject.define_singleton_method(:instrumented_with_skip) do + instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { target.equal?(e) }) do + raise target + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_skip } + assert_equal(["ensure boom"], reported) + end + + test "#instrument_call falls back to reporting when exception_already_reported itself raises" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_broken_predicate) do + instrument_call( + "instrumented_with_broken_predicate", + exception_already_reported: ->(_e) { raise "predicate blew up" }, + ) do + raise "original failure" + end + end + + error = assert_raises(RuntimeError) { subject.instrumented_with_broken_predicate } + assert_equal("original failure", error.message) + assert_equal(["original failure"], reported) + end + + test "#instrument_call falls back to reporting when exception_already_reported raises a non-StandardError" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_system_exit_predicate) do + instrument_call( + "instrumented_with_system_exit_predicate", + exception_already_reported: ->(_e) { raise SystemExit, 9 }, + ) do + raise "original failure" + end + end + + error = assert_raises(RuntimeError) { subject.instrumented_with_system_exit_predicate } + assert_equal("original failure", error.message) + assert_equal(["original failure"], reported) + end + + test "#instrument_call normalizes non-boolean truthy return values from exception_already_reported" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_truthy_predicate) do + instrument_call( + "instrumented_with_truthy_predicate", + exception_already_reported: ->(_e) { "non-boolean truthy" }, + ) do + raise "original failure" + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_truthy_predicate } + assert_equal([], reported) + end + + test "#instrument_call reports when exception_already_reported returns nil" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + subject.define_singleton_method(:instrumented_with_nil_predicate) do + instrument_call( + "instrumented_with_nil_predicate", + exception_already_reported: ->(_e) { nil }, + ) do + raise "original failure" + end + end + + assert_raises(RuntimeError) { subject.instrumented_with_nil_predicate } + assert_equal(["original failure"], reported) + end + + test "#instrument_call does not carry reported state across invocations when the same exception is reused" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + shared_error = RuntimeError.new("reused") + subject.configuration.around_request = ->(_data, &_request_handler) { raise shared_error } + + 2.times do + assert_raises(RuntimeError) { subject.instrumented_method } + end + assert_equal(["reused", "reused"], reported) + end + + test "#instrument_call reports frozen exceptions without mutating them" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } + + frozen_error = RuntimeError.new("frozen").freeze + subject.configuration.around_request = ->(_data, &_request_handler) { raise frozen_error } + + error = assert_raises(RuntimeError) { subject.instrumented_method } + assert_same(frozen_error, error) + assert_equal([frozen_error], reported) + assert_predicate(frozen_error, :frozen?) + end + + test "#instrument_call nested calls sharing an exception_already_reported predicate report only once" do + reported = [] + subject = Subject.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + reported_exception = nil + predicate = ->(e) { reported_exception.equal?(e) } + + subject.define_singleton_method(:outer) do + instrument_call("outer", exception_already_reported: predicate) { inner } + end + + subject.define_singleton_method(:inner) do + instrument_call("inner", exception_already_reported: predicate) do + raise "nested boom" + rescue => e + configuration.exception_reporter.call(e, {}) + reported_exception = e + raise + end + end + + assert_raises(RuntimeError) { subject.outer } + assert_equal(["nested boom"], reported) + end + + test "#instrument_call keeps reported state isolated across concurrent threads on a shared subject" do + subject = Subject.new + reported = Queue.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + threads = 10.times.map do |i| + Thread.new do + subject.instrument_call("threaded_#{i}") { raise "thread #{i}" } + rescue RuntimeError + # Swallow the intentional raise so the thread finishes cleanly. + # The test verifies side effects via the shared `reported` queue. + end + end + threads.each(&:join) + + messages = [] + messages << reported.pop until reported.empty? + + assert_equal(10.times.map { |i| "thread #{i}" }, messages.sort) + end + + test "#instrument_call reports each invocation when the same exception instance is raised from multiple threads" do + subject = Subject.new + reported = Queue.new + subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } + + shared_error = RuntimeError.new("shared across threads") + start_gate = Queue.new + + threads = 10.times.map do + Thread.new do + start_gate.pop + begin + subject.instrument_call("shared_error_method") { raise shared_error } + rescue RuntimeError + # Swallow the intentional raise so the thread finishes cleanly. + # The test verifies side effects via the shared `reported` queue. + end + end + end + 10.times { start_gate << :go } + threads.each(&:join) + + collected = [] + collected << reported.pop until reported.empty? + + assert_equal(10, collected.size) + assert(collected.all? { |e| e.equal?(shared_error) }) + end + + test "#instrument_call forwards server_context to exception_reporter on around_request failure" do + reported_context = nil + subject = Subject.new + subject.configuration.exception_reporter = ->(_e, server_context) { reported_context = server_context } + subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom" } + + assert_raises(RuntimeError) do + subject.instrumented_method_with_server_context(request: { id: 42 }) + end + assert_equal({ request: { id: 42 } }, reported_context) + end + test "#instrument_call resets the instrumentation data between calls" do subject = Subject.new diff --git a/test/mcp/logging_message_notification_test.rb b/test/mcp/logging_message_notification_test.rb new file mode 100644 index 0000000..7eb7515 --- /dev/null +++ b/test/mcp/logging_message_notification_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class LoggingMessageNotificationTest < ActiveSupport::TestCase + test "valid_level? returns true for valid levels" do + LoggingMessageNotification::LOG_LEVEL_SEVERITY.keys.each do |level| + logging_message_notification = LoggingMessageNotification.new(level: level) + assert logging_message_notification.valid_level?, "#{level} should be valid" + end + end + + test "valid_level? returns false for invalid levels" do + invalid_levels = ["invalid", 1, "", nil, :fatal] + invalid_levels.each do |level| + logging_message_notification = LoggingMessageNotification.new(level: level) + refute logging_message_notification.valid_level?, "#{level} should be invalid" + end + end + + test "should_notify? returns true when notification level is higher priority than threshold level or equals to it" do + logging_message_notification = LoggingMessageNotification.new(level: "warning") + assert logging_message_notification.should_notify?("warning") + assert logging_message_notification.should_notify?("error") + assert logging_message_notification.should_notify?("critical") + assert logging_message_notification.should_notify?("alert") + assert logging_message_notification.should_notify?("emergency") + end + + test "should_notify? returns false when notification level is lower priority than threshold level" do + logging_message_notification = LoggingMessageNotification.new(level: "warning") + refute logging_message_notification.should_notify?("notice") + refute logging_message_notification.should_notify?("info") + refute logging_message_notification.should_notify?("debug") + end + + test "should_notify? returns false for invalid log levels" do + logging_message_notification = LoggingMessageNotification.new(level: "warning") + refute logging_message_notification.should_notify?("invalid") + refute logging_message_notification.should_notify?(1) + refute logging_message_notification.should_notify?("") + refute logging_message_notification.should_notify?(nil) + refute logging_message_notification.should_notify?(:fatal) + end + end +end diff --git a/test/mcp/methods_test.rb b/test/mcp/methods_test.rb index 2022a2a..c6ae11a 100644 --- a/test/mcp/methods_test.rb +++ b/test/mcp/methods_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "mcp/methods" module MCP class MethodsTest < ActiveSupport::TestCase @@ -23,6 +24,8 @@ module MCP # Server methods and notifications ensure_capability_does_not_raise_for Methods::INITIALIZE + # `server/discover` (SEP-2575) must respond regardless of declared capabilities. + ensure_capability_does_not_raise_for Methods::SERVER_DISCOVER ensure_capability_raises_error_for Methods::PROMPTS_LIST, required_capability_name: "prompts" ensure_capability_raises_error_for Methods::PROMPTS_GET, required_capability_name: "prompts" @@ -67,16 +70,16 @@ module MCP ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_INITIALIZED ensure_capability_raises_error_for Methods::ROOTS_LIST, required_capability_name: "roots" - ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED, required_capability_name: "roots" - ensure_capability_raises_error_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED, - required_capability_name: "roots.listChanged", - capabilities: { roots: {} } + ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED ensure_capability_raises_error_for Methods::SAMPLING_CREATE_MESSAGE, required_capability_name: "sampling" + ensure_capability_raises_error_for Methods::ELICITATION_CREATE, required_capability_name: "elicitation" + # Methods and notifications of both server and client ensure_capability_does_not_raise_for Methods::PING ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_PROGRESS ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_CANCELLED + ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ELICITATION_COMPLETE end end diff --git a/test/mcp/progress_test.rb b/test/mcp/progress_test.rb new file mode 100644 index 0000000..e9552ba --- /dev/null +++ b/test/mcp/progress_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ProgressTest < ActiveSupport::TestCase + class MockTransport < Transport + attr_reader :notifications + + def initialize(server) + super + @notifications = [] + end + + def send_notification(method, params = nil) + @notifications << { method: method, params: params } + true + end + + def send_response(response); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + @server = Server.new(name: "test_server") + @transport = MockTransport.new(@server) + @session = ServerSession.new(server: @server, transport: @transport) + end + + test "#report is a no-op when progress_token is nil" do + progress = Progress.new(notification_target: @session, progress_token: nil) + progress.report(50, total: 100, message: "halfway") + + assert_equal 0, @transport.notifications.size + end + + test "#report is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: "token-1") + progress.report(50, total: 100, message: "halfway") + + assert_equal 0, @transport.notifications.size + end + + test "#report sends notification when progress_token is present" do + progress = Progress.new(notification_target: @session, progress_token: "token-1") + progress.report(50, total: 100, message: "halfway") + + assert_equal 1, @transport.notifications.size + notification = @transport.notifications.first + assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] + assert_equal "token-1", notification[:params]["progressToken"] + assert_equal 50, notification[:params]["progress"] + assert_equal 100, notification[:params]["total"] + assert_equal "halfway", notification[:params]["message"] + end + + test "#report omits total and message when not provided" do + progress = Progress.new(notification_target: @session, progress_token: "token-1") + progress.report(50) + + assert_equal 1, @transport.notifications.size + notification = @transport.notifications.first + assert_equal "token-1", notification[:params]["progressToken"] + assert_equal 50, notification[:params]["progress"] + refute notification[:params].key?("total") + refute notification[:params].key?("message") + end + end +end diff --git a/test/mcp/prompt/result_test.rb b/test/mcp/prompt/result_test.rb new file mode 100644 index 0000000..62f161e --- /dev/null +++ b/test/mcp/prompt/result_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Prompt + class ResultTest < ActiveSupport::TestCase + test "#to_h returns description and messages" do + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + ) + + hash = result.to_h + + assert_equal "a prompt", hash[:description] + assert_equal 1, hash[:messages].size + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + meta: meta, + ) + + assert_equal meta, result.to_h[:_meta] + end + + test "#to_h omits _meta when nil" do + result = Prompt::Result.new( + description: "a prompt", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))], + ) + + refute result.to_h.key?(:_meta) + end + end + end +end diff --git a/test/mcp/prompt_test.rb b/test/mcp/prompt_test.rb index 41f3857..a6fe18a 100644 --- a/test/mcp/prompt_test.rb +++ b/test/mcp/prompt_test.rb @@ -6,6 +6,7 @@ module MCP class PromptTest < ActiveSupport::TestCase class TestPrompt < Prompt description "Test prompt" + icons [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }] arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] @@ -43,6 +44,7 @@ module MCP class MockPrompt < Prompt prompt_name "my_mock_prompt" description "a mock prompt for testing" + icons [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }] arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] @@ -64,6 +66,7 @@ module MCP assert_equal "my_mock_prompt", prompt.name_value assert_equal "a mock prompt for testing", prompt.description + assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons) assert_equal "test_argument", prompt.arguments.first.name assert_equal "Test argument", prompt.arguments.first.description assert prompt.arguments.first.required @@ -112,6 +115,7 @@ module MCP name: "mock_prompt", title: "Mock Prompt", description: "a mock prompt for testing", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], arguments: [ Prompt::Argument.new( name: "test_argument", @@ -128,13 +132,14 @@ module MCP description: "Hello, world!", messages: [ Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content:), + Prompt::Message.new(role: "assistant", content: content), ], ) end assert_equal "mock_prompt", prompt.name_value assert_equal "a mock prompt for testing", prompt.description + assert_equal([{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], prompt.icons) assert_equal "test_argument", prompt.arguments.first.name assert_equal "Test argument title", prompt.arguments.first.title assert_equal "This is a test argument description", prompt.arguments.first.description @@ -177,7 +182,7 @@ module MCP assert_equal expected, FullPrompt.to_h end - test "#to_h handles nil arguments value" do + test "#to_h omits arguments key when arguments are not declared" do class NoArgumentsPrompt < Prompt description "No arguments prompt" end @@ -190,5 +195,69 @@ module MCP assert_equal expected, prompt.to_h end + + test "#validate_arguments! does not raise when arguments are not declared" do + prompt_class = Class.new(Prompt) do + prompt_name "no_args_prompt" + description "A prompt with no arguments" + # NOTE: no `arguments` declaration at all + end + + assert_nothing_raised do + prompt_class.validate_arguments!({}) + end + end + + test "#validate_arguments! handles nil args" do + prompt_class = Class.new(Prompt) do + prompt_name "no_args_prompt" + description "A prompt with no arguments" + end + + assert_nothing_raised do + prompt_class.validate_arguments!(nil) + end + end + + test "#validate_arguments! does not raise when arguments is explicitly set to nil" do + prompt_class = Class.new(Prompt) do + prompt_name "nil_args_prompt" + description "A prompt with nil arguments" + arguments nil + end + + assert_nothing_raised do + prompt_class.validate_arguments!({}) + end + end + + test "#to_h omits arguments key when arguments is empty" do + prompt = Prompt.define( + name: "no_args_prompt", + description: "a prompt without arguments", + arguments: [], + ) + + refute prompt.to_h.key?(:arguments) + end + + test "#to_h does not have `:icons` key when icons is empty" do + prompt = Prompt.define( + name: "prompt_without_icons", + description: "a prompt without icons", + ) + + refute prompt.to_h.key?(:icons) + end + + test "#to_h does not have `:icons` key when icons is nil" do + prompt = Prompt.define( + name: "prompt_without_icons", + description: "a prompt without icons", + icons: nil, + ) + + refute prompt.to_h.key?(:icons) + end end end diff --git a/test/mcp/protocol_deprecations_test.rb b/test/mcp/protocol_deprecations_test.rb new file mode 100644 index 0000000..d7fbbd9 --- /dev/null +++ b/test/mcp/protocol_deprecations_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ProtocolDeprecationsTest < ActiveSupport::TestCase + test "roots, sampling, and logging are deprecated in 2026-07-28 and later" do + refute ProtocolDeprecations.deprecated_roots_sampling_logging?(nil) + refute ProtocolDeprecations.deprecated_roots_sampling_logging?("2025-11-25") + assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2026-07-28") + assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2027-01-01") + end + end +end diff --git a/test/mcp/request_envelope_test.rb b/test/mcp/request_envelope_test.rb new file mode 100644 index 0000000..e7261be --- /dev/null +++ b/test/mcp/request_envelope_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class RequestEnvelopeTest < ActiveSupport::TestCase + test "exposes the reserved SEP-2575 meta key names" do + # The exact strings are wire vocabulary shared with other SDKs. + assert_equal "io.modelcontextprotocol/protocolVersion", RequestEnvelope::PROTOCOL_VERSION_META_KEY + assert_equal "io.modelcontextprotocol/clientInfo", RequestEnvelope::CLIENT_INFO_META_KEY + assert_equal "io.modelcontextprotocol/clientCapabilities", RequestEnvelope::CLIENT_CAPABILITIES_META_KEY + assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY + end + + test ".modern? returns true when the protocolVersion claim key is present" do + assert RequestEnvelope.modern?(modern_params) + assert RequestEnvelope.modern?({ _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }) + end + + test ".modern? returns true for string keys" do + params = { + "_meta" => { + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientInfo" => { "name" => "c", "version" => "1" }, + "io.modelcontextprotocol/clientCapabilities" => {}, + }, + } + + assert RequestEnvelope.modern?(params) + end + + test ".modern? stays true for a claimed but incomplete envelope" do + # A claimed envelope must be validated by `parse!` (`-32602`), never silently served as legacy, + # matching the TypeScript and Python classifiers. + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") + + assert RequestEnvelope.modern?(params) + end + + test ".modern? returns false for legacy _meta entries such as progressToken" do + refute RequestEnvelope.modern?({ name: "echo", _meta: { progressToken: "token" } }) + end + + test ".modern? returns false without params or _meta" do + refute RequestEnvelope.modern?(nil) + refute RequestEnvelope.modern?({}) + refute RequestEnvelope.modern?({ name: "echo" }) + refute RequestEnvelope.modern?({ _meta: nil }) + end + + test ".parse! returns a frozen envelope with the triple and optional log level" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/logLevel"] = "warning" + + envelope = RequestEnvelope.parse!(params) + + assert_predicate envelope, :frozen? + assert_equal "2026-07-28", envelope.protocol_version + assert_equal({ name: "test_client", version: "1.0.0" }, envelope.client_info) + assert_equal({ elicitation: {} }, envelope.client_capabilities) + assert_equal "warning", envelope.log_level + end + + test ".parse! leaves log_level nil when absent" do + assert_nil RequestEnvelope.parse!(modern_params).log_level + end + + test ".parse! raises UnsupportedProtocolVersionError with the SEP-2575 data shape" do + params = modern_params(version: "2025-11-25") + + error = assert_raises(Server::UnsupportedProtocolVersionError) do + RequestEnvelope.parse!(params) + end + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, error.error_code + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] + assert_equal "2025-11-25", error.error_data[:requested] + end + + test ".parse! accepts an envelope without the optional clientInfo" do + # `clientInfo` became optional after the SEP was finalized (spec PR #3002); + # a conformant client configured not to identify itself must not be rejected. + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientInfo") + + envelope = RequestEnvelope.parse!(params) + + assert_nil envelope.client_info + assert_equal "2026-07-28", envelope.protocol_version + assert_equal({ elicitation: {} }, envelope.client_capabilities) + end + + test ".parse! raises Invalid params naming the missing key when clientCapabilities is absent" do + params = modern_params + params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_params, error.error_type + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code + assert_includes error.message, "io.modelcontextprotocol/clientCapabilities" + refute_includes error.message, "io.modelcontextprotocol/protocolVersion" + end + + test ".parse! raises Invalid params when a required field has the wrong type" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash" + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_params, error.error_type + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code + end + + test ".parse! raises Invalid params when the optional clientInfo has the wrong type" do + params = modern_params + params[:_meta][:"io.modelcontextprotocol/clientInfo"] = "not-a-hash" + + error = assert_raises(Server::RequestHandlerError) do + RequestEnvelope.parse!(params) + end + + assert_equal :invalid_params, error.error_type + assert_includes error.message, "io.modelcontextprotocol/clientInfo" + end + + private + + def modern_params(version: "2026-07-28") + { + name: "echo", + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "test_client", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, + }, + } + end + end +end diff --git a/test/mcp/resource/contents_test.rb b/test/mcp/resource/contents_test.rb new file mode 100644 index 0000000..f89f8f2 --- /dev/null +++ b/test/mcp/resource/contents_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Resource + class ContentsTest < ActiveSupport::TestCase + test "Contents#to_h returns hash with mimeType" do + contents = Resource::Contents.new( + uri: "test://example", + mime_type: "text/plain", + ) + + result = contents.to_h + + assert_equal "test://example", result[:uri] + assert_equal "text/plain", result[:mimeType] + refute result.key?(:mime_type), "Should use camelCase 'mimeType' not snake_case 'mime_type'" + end + + test "Contents#to_h omits mimeType when nil" do + contents = Resource::Contents.new(uri: "test://example") + + result = contents.to_h + + assert_equal({ uri: "test://example" }, result) + refute result.key?(:mimeType) + end + + test "TextContents#to_h returns hash with text and mimeType" do + text_contents = Resource::TextContents.new( + uri: "test://text", + mime_type: "text/plain", + text: "Hello, world!", + ) + + result = text_contents.to_h + + assert_equal "test://text", result[:uri] + assert_equal "text/plain", result[:mimeType] + assert_equal "Hello, world!", result[:text] + refute result.key?(:mime_type), "Should use camelCase 'mimeType' not snake_case 'mime_type'" + end + + test "BlobContents#to_h returns hash with blob and mimeType" do + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + ) + + result = blob_contents.to_h + + assert_equal "test://binary", result[:uri] + assert_equal "image/png", result[:mimeType] + assert_equal "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", result[:blob] + refute result.key?(:data), "Should use 'blob' not 'data' per MCP specification" + refute result.key?(:mime_type), "Should use camelCase 'mimeType' not snake_case 'mime_type'" + end + + test "BlobContents#to_h omits mimeType when nil" do + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: nil, + data: "base64data", + ) + + result = blob_contents.to_h + + assert_equal({ uri: "test://binary", blob: "base64data" }, result) + refute result.key?(:mimeType) + end + + test "Contents#to_h omits _meta when nil" do + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain") + + refute contents.to_h.key?(:_meta) + end + + test "Contents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain", meta: meta) + + assert_equal meta, contents.to_h[:_meta] + end + + test "TextContents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + text_contents = Resource::TextContents.new( + uri: "test://text", + mime_type: "text/plain", + text: "Hello", + meta: meta, + ) + + result = text_contents.to_h + + assert_equal meta, result[:_meta] + assert_equal "Hello", result[:text] + end + + test "TextContents#to_h omits _meta when nil" do + text_contents = Resource::TextContents.new( + uri: "test://text", + mime_type: "text/plain", + text: "Hello", + ) + + refute text_contents.to_h.key?(:_meta) + end + + test "BlobContents#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: "image/png", + data: "base64data", + meta: meta, + ) + + result = blob_contents.to_h + + assert_equal meta, result[:_meta] + assert_equal "base64data", result[:blob] + end + + test "BlobContents#to_h omits _meta when nil" do + blob_contents = Resource::BlobContents.new( + uri: "test://binary", + mime_type: "image/png", + data: "base64data", + ) + + refute blob_contents.to_h.key?(:_meta) + end + + test "Contents#to_h preserves empty _meta hash" do + contents = Resource::Contents.new(uri: "test://example", mime_type: "text/plain", meta: {}) + + result = contents.to_h + + assert result.key?(:_meta) + assert_equal({}, result[:_meta]) + end + end + end +end diff --git a/test/mcp/resource/embedded_test.rb b/test/mcp/resource/embedded_test.rb new file mode 100644 index 0000000..2967de9 --- /dev/null +++ b/test/mcp/resource/embedded_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Resource + class EmbeddedTest < ActiveSupport::TestCase + test "initializes with resource" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + ) + embedded = Resource::Embedded.new(resource: resource) + + assert_equal resource, embedded.resource + end + + test "initializes with resource and annotations" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + ) + annotations = { audience: ["user"], priority: 1.0 } + embedded = Resource::Embedded.new(resource: resource, annotations: annotations) + + assert_equal resource, embedded.resource + assert_equal annotations, embedded.annotations + end + + test "initializes with annotations as nil when not provided" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + ) + embedded = Resource::Embedded.new(resource: resource) + + assert_nil embedded.annotations + end + + test "#to_h returns hash with resource data when annotations is nil" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + ) + embedded = Resource::Embedded.new(resource: resource) + + expected = { + resource: { + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + }, + } + + assert_equal expected, embedded.to_h + end + + test "#to_h returns hash with resource and annotations when both are present" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + ) + annotations = { audience: ["user"], priority: 1.0 } + embedded = Resource::Embedded.new(resource: resource, annotations: annotations) + + expected = { + resource: { + uri: "file:///test.txt", + name: "test_resource", + description: "a test resource", + }, + annotations: { audience: ["user"], priority: 1.0 }, + } + + assert_equal expected, embedded.to_h + end + + test "#to_h handles resource with all optional fields" do + resource = Resource.new( + uri: "file:///test.txt", + name: "test_resource", + title: "Test Resource", + description: "a test resource", + mime_type: "text/plain", + ) + embedded = Resource::Embedded.new(resource: resource) + + expected = { + resource: { + uri: "file:///test.txt", + name: "test_resource", + title: "Test Resource", + description: "a test resource", + mimeType: "text/plain", + }, + } + + assert_equal expected, embedded.to_h + end + end + end +end diff --git a/test/mcp/resource_template_test.rb b/test/mcp/resource_template_test.rb new file mode 100644 index 0000000..ffe742d --- /dev/null +++ b/test/mcp/resource_template_test.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ResourceTemplateTest < ActiveSupport::TestCase + test "#to_h does not have `:icons` key when icons is empty" do + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "resource_template_without_icons", + description: "a resource template without icons", + ) + + refute resource_template.to_h.key?(:icons) + end + + test "#to_h does not have `:icons` key when icons is nil" do + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "resource_template_without_icons", + description: "a resource template without icons", + icons: nil, + ) + + refute resource_template.to_h.key?(:icons) + end + + test "#to_h includes icons when present" do + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "resource_template_with_icons", + description: "a resource template with icons", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + ) + expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }] + + assert_equal expected_icons, resource_template.to_h[:icons] + end + + test "#to_h omits _meta when nil" do + resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_meta") + + refute resource_template.to_h.key?(:_meta) + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "template_with_meta", + meta: meta, + ) + + assert_equal meta, resource_template.to_h[:_meta] + end + + test "#to_h omits annotations when nil" do + resource_template = ResourceTemplate.new(uri_template: "file:///{path}", name: "template_without_annotations") + + refute resource_template.to_h.key?(:annotations) + end + + test "#to_h includes annotations when present" do + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: "2025-01-12T15:00:58Z") + resource_template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "template_with_annotations", + annotations: annotations, + ) + + expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } + assert_equal expected, resource_template.to_h[:annotations] + end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + title "User Profile" + description "profile for a user" + mime_type "application/json" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = ResourceTemplate.new( + uri_template: "users://{user_id}/profile", + name: "user_profile_template", + title: "User Profile", + description: "profile for a user", + mime_type: "application/json", + ) + + assert_equal instance.to_h, UserProfileTemplate.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_profile_template", UserProfileTemplate.name_value + end + + test "explicit resource_template_name wins over the class name" do + template_class = Class.new(UserProfileTemplate) do + resource_template_name "custom_template" + end + + assert_equal "custom_template", template_class.resource_template_name + assert_equal "custom_template", template_class.name_value + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserProfileTemplate) + + assert_nil subclass.uri_template + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.mime_type + end + + test ".contents raises NotImplementedError unless implemented" do + template_class = Class.new(ResourceTemplate) + + assert_raises(NotImplementedError) { template_class.contents(user_id: "42") } + end + + test ".match_uri extracts a single template variable" do + assert_equal({ user_id: "42" }, UserProfileTemplate.match_uri("users://42/profile")) + end + + test ".match_uri extracts multiple template variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "repos://{owner}/{repo}" + end + + assert_equal({ owner: "octo", repo: "sdk" }, template_class.match_uri("repos://octo/sdk")) + end + + test ".match_uri returns an empty hash for an exact match without variables" do + template_class = Class.new(ResourceTemplate) do + uri_template "config://app" + end + + assert_equal({}, template_class.match_uri("config://app")) + end + + test ".match_uri returns nil when the URI does not match" do + assert_nil UserProfileTemplate.match_uri("users://42/settings") + assert_nil UserProfileTemplate.match_uri("posts://42/profile") + end + + test ".match_uri variables do not cross path separators" do + assert_nil UserProfileTemplate.match_uri("users://42/extra/profile") + end + + test ".match_uri returns nil when uri_template is not set" do + template_class = Class.new(ResourceTemplate) + + assert_nil template_class.match_uri("users://42/profile") + end + + test ".match_uri escapes regex metacharacters in literal parts" do + template_class = Class.new(ResourceTemplate) do + uri_template "file:///data.v1/{name}" + end + + assert_equal({ name: "foo" }, template_class.match_uri("file:///data.v1/foo")) + assert_nil template_class.match_uri("file:///dataXv1/foo") + end + + test ".match_uri treats unsupported RFC 6570 operators as literals" do + template_class = Class.new(ResourceTemplate) do + uri_template "search://items{?query}" + end + + assert_nil template_class.match_uri("search://items?query=foo") + assert_equal({}, template_class.match_uri("search://items{?query}")) + end + + test "re-setting uri_template invalidates the compiled pattern" do + template_class = Class.new(ResourceTemplate) do + uri_template "old://{id}" + end + assert_equal({ id: "1" }, template_class.match_uri("old://1")) + + template_class.uri_template("new://{id}") + + assert_nil template_class.match_uri("old://1") + assert_equal({ id: "2" }, template_class.match_uri("new://2")) + end + + test ".define creates a template class whose block implements contents" do + template_class = ResourceTemplate.define( + uri_template: "items://{item_id}", + name: "item_template", + mime_type: "text/plain", + ) do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: mime_type, text: "item #{item_id}")] + end + + assert_equal "items://{item_id}", template_class.uri_template + assert_equal "item_template", template_class.name_value + + contents = template_class.contents(item_id: "7") + assert_equal 1, contents.size + assert_equal "item 7", contents.first.text + end + end +end diff --git a/test/mcp/resource_test.rb b/test/mcp/resource_test.rb new file mode 100644 index 0000000..3c8ff1b --- /dev/null +++ b/test/mcp/resource_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ResourceTest < ActiveSupport::TestCase + test "#to_h does not have `:icons` key when icons is empty" do + resource = Resource.new( + uri: "file:///test.txt", + name: "resource_without_icons", + description: "a resource without icons", + ) + + refute resource.to_h.key?(:icons) + end + + test "#to_h does not have `:icons` key when icons is nil" do + resource = Resource.new( + uri: "file:///test.txt", + name: "resource_without_icons", + description: "a resource without icons", + icons: nil, + ) + + refute resource.to_h.key?(:icons) + end + + test "#to_h includes icons when present" do + resource = Resource.new( + uri: "file:///test.txt", + name: "resource_with_icons", + description: "a resource with icons", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + ) + expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }] + + assert_equal expected_icons, resource.to_h[:icons] + end + + test "#to_h omits _meta when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_meta") + + refute resource.to_h.key?(:_meta) + end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_meta", meta: meta) + + assert_equal meta, resource.to_h[:_meta] + end + + test "#to_h omits size when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_size") + + refute resource.to_h.key?(:size) + end + + test "#to_h includes size when present" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_size", size: 12_345) + + assert_equal 12_345, resource.to_h[:size] + end + + test "#to_h includes size when zero" do + resource = Resource.new(uri: "file:///empty.txt", name: "empty_resource", size: 0) + + assert_equal 0, resource.to_h[:size] + end + + test "#to_h omits annotations when nil" do + resource = Resource.new(uri: "file:///test.txt", name: "resource_without_annotations") + + refute resource.to_h.key?(:annotations) + end + + test "#to_h includes annotations when present" do + annotations = Annotations.new(audience: ["user"], priority: 0.8, last_modified: "2025-01-12T15:00:58Z") + resource = Resource.new(uri: "file:///test.txt", name: "resource_with_annotations", annotations: annotations) + + expected = { audience: ["user"], priority: 0.8, lastModified: "2025-01-12T15:00:58Z" } + assert_equal expected, resource.to_h[:annotations] + end + + class UserGuideResource < Resource + uri "file:///docs/user_guide.md" + title "User Guide" + description "the user guide" + icons [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")] + mime_type "text/markdown" + annotations audience: ["user"], priority: 0.5 + size 2_048 + meta({ "example" => "value" }) + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "guide body")] + end + end + end + + test "class-based .to_h emits the same keys as the equivalent instance" do + instance = Resource.new( + uri: "file:///docs/user_guide.md", + name: "user_guide_resource", + title: "User Guide", + description: "the user guide", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + mime_type: "text/markdown", + annotations: Annotations.new(audience: ["user"], priority: 0.5), + size: 2_048, + meta: { "example" => "value" }, + ) + + assert_equal instance.to_h, UserGuideResource.to_h + end + + test "class-based name defaults to the snake_cased class name" do + assert_equal "user_guide_resource", UserGuideResource.name_value + end + + test "explicit resource_name wins over the class name" do + resource_class = Class.new(UserGuideResource) do + resource_name "custom_name" + end + + assert_equal "custom_name", resource_class.resource_name + assert_equal "custom_name", resource_class.name_value + end + + test "class-based .to_h omits keys that are not set" do + resource_class = Class.new(Resource) do + uri "file:///bare.txt" + resource_name "bare" + end + + assert_equal({ uri: "file:///bare.txt", name: "bare" }, resource_class.to_h) + end + + test "class-level annotations accepts an Annotations instance" do + annotations = Annotations.new(audience: ["assistant"]) + resource_class = Class.new(Resource) do + annotations annotations + end + + assert_same annotations, resource_class.annotations + end + + test "subclasses of a configured class start clean" do + subclass = Class.new(UserGuideResource) + + assert_nil subclass.uri + assert_nil subclass.title + assert_nil subclass.description + assert_nil subclass.icons + assert_nil subclass.mime_type + assert_nil subclass.annotations + assert_nil subclass.size + assert_nil subclass.meta + end + + test ".contents raises NotImplementedError unless implemented" do + resource_class = Class.new(Resource) + + assert_raises(NotImplementedError) { resource_class.contents } + end + + test ".define creates a resource class whose block implements contents" do + resource_class = Resource.define( + uri: "file:///defined.txt", + name: "defined_resource", + mime_type: "text/plain", + ) do + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "defined body")] + end + + assert_equal "file:///defined.txt", resource_class.uri + assert_equal "defined_resource", resource_class.name_value + + contents = resource_class.contents + assert_equal 1, contents.size + assert_equal "defined body", contents.first.text + end + + test ".define without a name omits :name from to_h" do + resource_class = Resource.define(uri: "file:///anonymous.txt") + + refute resource_class.to_h.key?(:name) + end + end +end diff --git a/test/mcp/server/capabilities_test.rb b/test/mcp/server/capabilities_test.rb new file mode 100644 index 0000000..93e7e5f --- /dev/null +++ b/test/mcp/server/capabilities_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class CapabilitiesTest < ActiveSupport::TestCase + test "to_h omits everything by default" do + assert_empty(Capabilities.new.to_h) + end + + test "constructor accepts a capabilities hash" do + capabilities = Capabilities.new( + completions: {}, + logging: {}, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + tools: { listChanged: true }, + ) + + assert_equal( + { + completions: {}, + logging: {}, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + tools: { listChanged: true }, + }, + capabilities.to_h, + ) + end + + test "constructor accepts extensions" do + capabilities = Capabilities.new( + tools: {}, + extensions: { "com.example/feature" => { enabled: true } }, + ) + + assert_equal({ "com.example/feature" => { enabled: true } }, capabilities.to_h[:extensions]) + end + + test "support_extensions merges repeated declarations" do + capabilities = Capabilities.new + capabilities.support_extensions("com.example/feature" => { enabled: true }) + capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) + + assert_equal( + { "com.example/feature" => { enabled: true }, "io.modelcontextprotocol/tasks" => {} }, + capabilities.to_h[:extensions], + ) + end + + test "support_extensions with no arguments declares an empty extensions object" do + capabilities = Capabilities.new + capabilities.support_extensions + + assert_equal({}, capabilities.to_h[:extensions]) + end + + test "support_extensions tolerates nil" do + capabilities = Capabilities.new(extensions: nil) + + assert_equal({}, capabilities.to_h[:extensions]) + end + + test "to_h omits extensions when never declared" do + refute(Capabilities.new(tools: {}).to_h.key?(:extensions)) + end + end + end +end diff --git a/test/mcp/server/input_required_result_test.rb b/test/mcp/server/input_required_result_test.rb new file mode 100644 index 0000000..ba8ce43 --- /dev/null +++ b/test/mcp/server/input_required_result_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class InputRequiredResultTest < ActiveSupport::TestCase + test "requires at least one of input_requests or request_state" do + assert_raises(ArgumentError) { InputRequiredResult.new } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: {}) } + assert_raises(ArgumentError) { InputRequiredResult.new(request_state: 42) } + end + + test "validates input_requests entries" do + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: []) } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: { region: "not-a-hash" }) } + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "tools/call" } }) + end + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: "x" } }) + end + end + + test "normalizes keys, tolerates string entry keys, and freezes" do + result = InputRequiredResult.new(input_requests: { + region: { "method" => "elicitation/create", "params" => { message: "Which region?" } }, + }) + + assert_predicate result, :frozen? + assert_equal ["region"], result.input_requests.keys + assert_equal "elicitation/create", result.input_requests["region"][:method] + assert_equal({ message: "Which region?" }, result.input_requests["region"][:params]) + end + + test "#to_h serializes the SEP-2322 wire shape" do + result = InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "opaque-state", + ) + + assert_equal( + { + resultType: "input_required", + inputRequests: { "region" => { method: "elicitation/create", params: { message: "Which region?" } } }, + requestState: "opaque-state", + }, + result.to_h, + ) + end + + test "#to_h omits absent fields" do + state_only = InputRequiredResult.new(request_state: "opaque-state") + + assert_equal({ resultType: "input_required", requestState: "opaque-state" }, state_only.to_h) + end + + test "#required_client_capabilities maps every embedded request kind" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + url: { method: "elicitation/create", params: { mode: "url", url: "https://example.com" } }, + plain_sampling: { method: "sampling/createMessage", params: { messages: [] } }, + tool_sampling: { method: "sampling/createMessage", params: { messages: [], tools: [] } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { + elicitation: { form: {}, url: {} }, + sampling: { tools: {} }, + roots: {}, + }, + result.required_client_capabilities, + ) + end + + test "#missing_client_capabilities returns only undeclared branches" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { elicitation: { form: {} }, roots: {} }, + result.missing_client_capabilities({}), + ) + assert_equal( + { roots: {} }, + result.missing_client_capabilities({ elicitation: { form: {} } }), + ) + assert_empty result.missing_client_capabilities({ "elicitation" => { "form" => {} }, "roots" => {} }) + end + + test "#missing_client_capabilities treats a bare elicitation declaration as implying form" do + # 2025 back-compat rule shared with the TypeScript and Python SDKs; an explicit + # url-only declaration does not imply form support. + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + }) + + assert_empty result.missing_client_capabilities({ elicitation: {} }) + assert_equal( + { elicitation: { form: {} } }, + result.missing_client_capabilities({ elicitation: { url: {} } }), + ) + end + end + end +end diff --git a/test/mcp/server/pagination_test.rb b/test/mcp/server/pagination_test.rb new file mode 100644 index 0000000..4885459 --- /dev/null +++ b/test/mcp/server/pagination_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class PaginationTest < ActiveSupport::TestCase + class PaginationHost + include Pagination + + # Expose private methods for testing. + public :paginate, :cursor_from + + # RequestHandlerError must be accessible. + RequestHandlerError = Server::RequestHandlerError + end + + setup do + @host = PaginationHost.new + @items = [{ name: "a" }, { name: "b" }, { name: "c" }, { name: "d" }, { name: "e" }] + @request = { method: "tools/list" } + end + + test "paginate returns all items when page_size is nil and no cursor" do + result = @host.paginate(@items, cursor: nil, page_size: nil, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate returns first page with nextCursor when page_size is set" do + result = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + + assert_equal [{ name: "a" }, { name: "b" }], result[:items] + assert_not_nil result[:next_cursor] + end + + test "paginate returns correct second page when cursor is provided" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }], second[:items] + assert_not_nil second[:next_cursor] + end + + test "paginate returns last page without nextCursor" do + first = @host.paginate(@items, cursor: nil, page_size: 2, request: @request) + second = @host.paginate(@items, cursor: first[:next_cursor], page_size: 2, request: @request) + third = @host.paginate(@items, cursor: second[:next_cursor], page_size: 2, request: @request) + + assert_equal [{ name: "e" }], third[:items] + assert_nil third[:next_cursor] + end + + test "paginate with page_size equal to items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 5, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with page_size larger than items count returns all without nextCursor" do + result = @host.paginate(@items, cursor: nil, page_size: 100, request: @request) + + assert_equal @items, result[:items] + assert_nil result[:next_cursor] + end + + test "paginate with empty items returns empty array" do + result = @host.paginate([], cursor: nil, page_size: 2, request: @request) + + assert_equal [], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError for non-numeric cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "not_a_number", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Integer cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 2, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for Float cursor (spec requires string)" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: 1.5, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for negative offset cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "-1", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate raises RequestHandlerError for out-of-range cursor" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: "100", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate returns all items from cursor offset when page_size is nil" do + result = @host.paginate(@items, cursor: "2", page_size: nil, request: @request) + + assert_equal [{ name: "c" }, { name: "d" }, { name: "e" }], result[:items] + assert_nil result[:next_cursor] + end + + test "paginate raises RequestHandlerError when cursor points exactly at items.size" do + error = assert_raises(RequestHandlerError) do + @host.paginate(@items, cursor: @items.size.to_s, page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate on empty items with any cursor raises RequestHandlerError" do + error = assert_raises(RequestHandlerError) do + @host.paginate([], cursor: "0", page_size: 2, request: @request) + end + assert_equal :invalid_params, error.error_type + end + + test "cursor_from returns nil for nil request" do + assert_nil @host.cursor_from(nil) + end + + test "cursor_from returns cursor for Hash request" do + assert_equal "abc", @host.cursor_from(cursor: "abc") + end + + test "cursor_from returns nil when Hash request has no cursor" do + assert_nil @host.cursor_from({}) + end + + test "cursor_from raises RequestHandlerError for non-Hash request" do + error = assert_raises(RequestHandlerError) do + @host.cursor_from([1, 2, 3]) + end + assert_equal :invalid_params, error.error_type + end + + test "paginate with page_size 1 iterates one item at a time" do + results = [] + cursor = nil + + loop do + page = @host.paginate(@items, cursor: cursor, page_size: 1, request: @request) + results.concat(page[:items]) + cursor = page[:next_cursor] + break unless cursor + end + + assert_equal @items, results + end + end + end +end diff --git a/test/mcp/server/request_state_security_test.rb b/test/mcp/server/request_state_security_test.rb new file mode 100644 index 0000000..29b7d79 --- /dev/null +++ b/test/mcp/server/request_state_security_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class RequestStateSecurityTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + + KEY = ("k" * 32).freeze + + test "validates constructor arguments" do + assert_raises(ArgumentError) { RequestStateSecurity.new(key: "short") } + assert_raises(ArgumentError) { RequestStateSecurity.new(key: KEY, ttl: 0) } + end + + test "seals and unseals a state bound to the originating request" do + security = RequestStateSecurity.new(key: KEY, audience: "my_server") + sealed = security.seal("plain-state", method: "tools/call", target: "my_tool", arguments_digest: "digest") + + assert sealed.start_with?("v1.") + refute_includes sealed, "plain-state" + assert_equal "plain-state", + security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") + end + + test "rejects expired tokens" do + security = RequestStateSecurity.new(key: KEY, ttl: 60) + sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") + + travel 120 do + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") + end + end + end + + test "rejects claims mismatches fail-closed" do + security = RequestStateSecurity.new(key: KEY, audience: "my_server") + sealed = security.seal("state", method: "tools/call", target: "my_tool", arguments_digest: "digest") + + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "prompts/get", target: "my_tool", arguments_digest: "digest") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "other_tool", arguments_digest: "digest") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "other") + end + + other_audience = RequestStateSecurity.new(key: KEY, audience: "other_server") + assert_raises(RequestStateSecurity::InvalidStateError) do + other_audience.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") + end + end + + test "rejects tampered and malformed tokens" do + security = RequestStateSecurity.new(key: KEY) + sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") + + tampered = sealed.dup + tampered[-1] = tampered[-1] == "A" ? "B" : "A" + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(tampered, method: "tools/call", target: "t", arguments_digest: "d") + end + + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal("not-a-token", method: "tools/call", target: "t", arguments_digest: "d") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal("v1.!!!", method: "tools/call", target: "t", arguments_digest: "d") + end + assert_raises(RequestStateSecurity::InvalidStateError) do + security.unseal(nil, method: "tools/call", target: "t", arguments_digest: "d") + end + end + + test "rejects tokens sealed under a different key" do + sealed = RequestStateSecurity.new(key: KEY) + .seal("state", method: "tools/call", target: "t", arguments_digest: "d") + other = RequestStateSecurity.new(key: "x" * 32) + + assert_raises(RequestStateSecurity::InvalidStateError) do + other.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") + end + end + end + end +end diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb index 6030e4b..f0d1963 100644 --- a/test/mcp/server/transports/stdio_notification_integration_test.rb +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -27,7 +27,7 @@ module MCP nil # Simulate end of input end - def set_encoding(encoding) # rubocop:disable Naming/AccessorMethodName + def set_encoding(encoding) # Mock implementation end @@ -61,7 +61,6 @@ module MCP resources: [], ) @transport = StdioTransport.new(@server) - @server.transport = @transport end teardown do @@ -79,8 +78,12 @@ module MCP # Test resources notification @server.notify_resources_list_changed + # Test log notification + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + # Check the notifications were sent - assert_equal 3, @mock_stdout.output.size + assert_equal 4, @mock_stdout.output.size # Parse and verify each notification notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) } @@ -96,6 +99,10 @@ module MCP assert_equal "2.0", notifications[2]["jsonrpc"] assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"] assert_nil notifications[2]["params"] + + assert_equal "2.0", notifications[3]["jsonrpc"] + assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3]["method"] + assert_equal({ "level" => "error", "data" => { "error" => "Connection Failed" } }, notifications[3]["params"]) end test "notifications include params when provided" do @@ -120,6 +127,7 @@ module MCP @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") end end @@ -204,7 +212,7 @@ module MCP # This test verifies the complete integration from server to transport # Start with no output - assert_empty @mock_stdout.output + assert_equal([], @mock_stdout.output) # Add a prompt and notify @server.define_prompt( @@ -240,6 +248,16 @@ module MCP assert_equal 2, @mock_stdout.output.size second_notification = JSON.parse(@mock_stdout.output.last) assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"] + + # Set log level and notify + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + # Manually trigger notification + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + assert_equal 3, @mock_stdout.output.size + third_notification = JSON.parse(@mock_stdout.output.last) + assert_equal Methods::NOTIFICATIONS_MESSAGE, third_notification["method"] + assert_equal({ "data" => { "error" => "Connection Failed" }, "level" => "error" }, third_notification["params"]) end end end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 48acf46..683e2ae 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -7,6 +7,7 @@ module MCP module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper setup do configuration = MCP::Configuration.new @@ -45,7 +46,7 @@ module MCP response = JSON.parse(output.string, symbolize_names: true) assert_equal("2.0", response[:jsonrpc]) assert_equal("123", response[:id]) - assert_empty(response[:result]) + assert_equal({}, response[:result]) refute(@transport.instance_variable_get(:@open)) ensure $stdin = original_stdin @@ -101,6 +102,111 @@ module MCP end end + test "open creates a ServerSession and processes requests through it" do + request = { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "stdio-client", version: "1.0" }, + }, + } + input = StringIO.new(JSON.generate(request) + "\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + # Verify a session was created. + session = @transport.instance_variable_get(:@session) + assert_instance_of(ServerSession, session) + + # Verify client info was stored on the session, not on the server. + assert_equal({ name: "stdio-client", version: "1.0" }, session.client) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "open handles a malformed JSON-RPC batch element and emits an error response" do + input = StringIO.new("[[]]\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + response = JSON.parse(output.string, symbolize_names: true) + assert_nil(response[:id]) + assert_equal(-32600, response[:error][:code]) + assert_equal("Invalid Request", response[:error][:message]) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "rejects duplicate initialize on the same stdio session with -32600" do + first = { + jsonrpc: "2.0", + method: "initialize", + id: "first", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "original", version: "1.0" }, + }, + } + second = { + jsonrpc: "2.0", + method: "initialize", + id: "second", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "intruder", version: "9.9" }, + }, + } + input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n") + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + @transport.open + + lines = output.string.lines + assert_equal(2, lines.length) + first_response = JSON.parse(lines[0], symbolize_names: true) + second_response = JSON.parse(lines[1], symbolize_names: true) + + assert_equal("first", first_response[:id]) + refute_nil(first_response[:result]) + + assert_equal("second", second_response[:id]) + assert_equal(-32600, second_response[:error][:code]) + assert_equal("Invalid Request", second_response[:error][:message]) + + session = @transport.instance_variable_get(:@session) + assert_equal({ name: "original", version: "1.0" }, session.client) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + test "handles invalid JSON requests" do invalid_json = "invalid json" output = StringIO.new @@ -119,6 +225,526 @@ module MCP $stdout = original_stdout end end + + test "send_request sends request to stdout and waits for response" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Send response from client in a thread. + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + response = { + jsonrpc: "2.0", + id: request[:id], + result: { content: "test response" }, + } + writer.puts(response.to_json) + writer.flush + end + + result = @transport.send_request("test/method", { param: "value" }) + + assert_equal({ content: "test response" }, result) + + # Verify request was sent. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + assert_equal("2.0", request[:jsonrpc]) + assert_equal("test/method", request[:method]) + assert_equal({ param: "value" }, request[:params]) + assert(request[:id]) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request raises on error response from client" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + error_response = { + jsonrpc: "2.0", + id: request[:id], + error: { code: -1, message: "User rejected sampling request" }, + } + writer.puts(error_response.to_json) + writer.flush + end + + error = assert_raises(StandardError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected sampling request", error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request does not double-report intentional raises via exception_reporter" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + reported_errors = [] + original_reporter = MCP.configuration.exception_reporter + + begin + MCP.configuration.exception_reporter = ->(e, ctx) { reported_errors << [e, ctx] } + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + error_response = { + jsonrpc: "2.0", + id: request[:id], + error: { code: -1, message: "rejected" }, + } + writer.puts(error_response.to_json) + writer.flush + end + + assert_raises(StandardError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_empty(reported_errors) + ensure + MCP.configuration.exception_reporter = original_reporter + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request processes interleaved requests via session" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Initialize a session so @session is set. + session = MCP::ServerSession.new(server: @server, transport: @transport) + @transport.instance_variable_set(:@session, session) + + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + request = JSON.parse(output.string.lines.first, symbolize_names: true) + + # Send an interleaved ping request before the response. + ping = { jsonrpc: "2.0", method: "ping", id: "ping-1" } + writer.puts(ping.to_json) + writer.flush + + sleep(0.05) # Wait for the ping to be processed. + + # Then send the actual response. + response = { + jsonrpc: "2.0", + id: request[:id], + result: { content: "done" }, + } + writer.puts(response.to_json) + writer.flush + end + + result = @transport.send_request("test/method", { param: "value" }) + + assert_equal({ content: "done" }, result) + + # Verify the interleaved ping was handled (response sent to output). + lines = output.string.lines + ping_response = lines.find { |l| l.include?("ping-1") } + assert(ping_response, "Interleaved ping request should have been handled") + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue + nil + end + begin + reader.close + rescue + nil + end + end + end + + test "send_request raises when transport is closed while waiting" do + reader, writer = IO.pipe + output = StringIO.new + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = reader + $stdout = output + @transport.instance_variable_set(:@open, true) + + # Close transport while waiting for response. + Thread.new do + sleep(0.05) # Wait for request to be written to `StringIO`. + @transport.instance_variable_set(:@open, false) + writer.close + end + + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_equal("Transport closed while waiting for response to sampling/createMessage request.", error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + begin + writer.close + rescue IOError + nil + end + begin + reader.close + rescue IOError + nil + end + end + end + + test "raises ArgumentError when max_line_bytes is not a positive Integer" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + StdioTransport.new(@server, max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + reported = [] + original_stdin = $stdin + original_stdout = $stdout + original_reporter = MCP.configuration.exception_reporter + + begin + $stdin = input + $stdout = output + MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] } + + transport.open + + refute(transport.instance_variable_get(:@open)) + assert_equal(1, reported.size) + error, _ctx = reported.first + assert_instance_of(RequestHandlerError, error) + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + MCP.configuration.exception_reporter = original_reporter + end + end + + test "open processes a valid final frame that ends at EOF without a trailing newline" do + request = { jsonrpc: "2.0", method: "ping", id: "eof" } + input = StringIO.new(JSON.generate(request)) # No trailing newline. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + @transport.open + + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("eof", response[:id]) + assert_equal({}, response[:result]) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "send_request raises when a response frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + transport.instance_variable_set(:@open, true) + + error = assert_raises(RequestHandlerError) do + transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "locks the legacy era on a successful initialize and rejects a later modern envelope" do + responses = run_transport_session([ + initialize_request(id: 1), + modern_tools_list_request(id: 2), + ]) + + refute responses[0].key?(:error) + assert_equal :legacy, session_era + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + end + + test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on + # the legacy lifecycle it selected, so a later modern envelope is still rejected. + responses = run_transport_session([ + initialize_request(id: 1, protocol_version: "2026-07-28"), + modern_tools_list_request(id: 2), + ]) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion) + assert_equal :legacy, session_era + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) + end + + test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do + responses = run_transport_session([ + { jsonrpc: "2.0", method: "server/discover", id: 1 }, + initialize_request(id: 2), + modern_tools_list_request(id: 3), + ]) + + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) + assert_equal :modern, session_era + # SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection + # answers it with Method not found. + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code) + refute responses[2].key?(:error) + end + + test "locks the modern era on a successful request carrying the modern envelope" do + responses = run_transport_session([modern_tools_list_request(id: 1)]) + + refute responses[0].key?(:error) + assert_equal :modern, session_era + end + + test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do + # The era locks only after a response succeeds, so this frame reaches the server unlocked. + # SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern. + responses = run_transport_session([ + { + jsonrpc: "2.0", + method: "resources/subscribe", + id: 1, + params: { + uri: "https://example.invalid/resource", + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }, + ]) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) + refute_equal :legacy, session_era + end + + test "does not lock an era when the era-distinctive request fails" do + # An unsupported envelope version fails with -32022, so the connection stays unlocked + # and a legacy initialize can still succeed afterwards. + responses = run_transport_session([ + modern_tools_list_request(id: 1, version: "2027-01-01"), + initialize_request(id: 2), + ]) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code) + refute responses[1].key?(:error) + assert_equal :legacy, session_era + end + + test "requires the modern envelope after a modern era lock" do + responses = run_transport_session([ + modern_tools_list_request(id: 1), + { jsonrpc: "2.0", method: "tools/list", id: 2 }, + ]) + + refute responses[0].key?(:error) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code) + end + + test "answers removed lifecycle methods with -32601 after a modern era lock" do + removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index| + { jsonrpc: "2.0", method: method, id: index + 2 } + end + responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests) + + refute responses[0].key?(:error) + responses[1..].each_with_index do |response, index| + method = Methods::MODERN_REMOVED_METHODS[index] + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + end + end + + test "subscriptions/listen is not served over stdio and answers -32601" do + # Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream. + listen = { + jsonrpc: "2.0", + method: "subscriptions/listen", + id: "listen-1", + params: { + notifications: { toolsListChanged: true }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + + responses = run_transport_session([listen]) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) + end + + test "#send_request raises on a modern-locked session" do + run_transport_session([modern_tools_list_request(id: 1)]) + + error = assert_raises(RuntimeError) do + @transport.send_request("roots/list") + end + assert_match(/modern lifecycle/, error.message) + end + + private + + def initialize_request(id:, protocol_version: "2025-11-25") + { + jsonrpc: "2.0", + method: "initialize", + id: id, + params: initialize_params( + protocolVersion: protocol_version, + clientInfo: { name: "legacy_client", version: "1.0" }, + ), + } + end + + # `tools/list` stands in as the era-distinctive modern request: `ping` cannot, + # because SEP-2575 removes it from the modern lifecycle (-32601 there). + def modern_tools_list_request(id:, version: "2026-07-28") + { + jsonrpc: "2.0", + method: "tools/list", + id: id, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + end + + # Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order. + def run_transport_session(frames) + input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n") + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + thread = Thread.new { @transport.open } + sleep(0.1) + @transport.close + thread.join + ensure + $stdin = original_stdin + $stdout = original_stdout + end + + output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) } + end + + def session_era + @transport.instance_variable_get(:@session).era + end end end end diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 716b167..e1a37a5 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -7,6 +7,8 @@ module MCP class Server module Transports class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase + include InitializeParamsTestHelper + setup do @server = Server.new( name: "test_server", @@ -15,7 +17,6 @@ module MCP resources: [], ) @transport = StreamableHTTPTransport.new(@server) - @server.transport = @transport end test "server notification methods send SSE notifications through HTTP transport" do @@ -24,10 +25,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -51,6 +52,12 @@ module MCP # Test resources notification @server.notify_resources_list_changed + # Set log level to error for log notification + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + # Test log notification + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + # Check the notifications were received io.rewind output = io.read @@ -58,6 +65,7 @@ module MCP assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}\"}" assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED}\"}" assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED}\"}" + assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_MESSAGE}\",\"params\":{\"data\":{\"error\":\"Connection Failed\"},\"level\":\"error\"}}\n\n" end test "notifications are broadcast to all connected sessions" do @@ -66,19 +74,19 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] init_request2 = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect both sessions with SSE io1 = StringIO.new @@ -121,10 +129,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -147,6 +155,7 @@ module MCP @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") end end @@ -156,10 +165,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -200,10 +209,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -232,11 +241,20 @@ module MCP private def create_rack_request(method, path, headers, body = nil) + default_accept = case method + when "POST" + { "HTTP_ACCEPT" => "application/json, text/event-stream" } + when "GET" + { "HTTP_ACCEPT" => "text/event-stream" } + else + {} + end + env = { "REQUEST_METHOD" => method, "PATH_INFO" => path, "rack.input" => StringIO.new(body.to_s), - }.merge(headers) + }.merge(default_accept, headers) Rack::Request.new(env) end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 5bf4411..bf032b2 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -7,6 +7,33 @@ module MCP class Server module Transports class StreamableHTTPTransportTest < ActiveSupport::TestCase + include InitializeParamsTestHelper + + # A stream that buffers writes and remains readable after close. + class TestStream + def initialize + @buffer = "".dup + @closed = false + end + + def write(data) + raise IOError, "closed stream" if @closed + + @buffer << data + end + + def flush + end + + def close + @closed = true + end + + def string + @buffer + end + end + setup do @server = Server.new( name: "test_server", @@ -14,7 +41,13 @@ module MCP prompts: [], resources: [], ) - @transport = StreamableHTTPTransport.new(@server) + # Disable the listen keepalive by default so opening a listen stream in a test does not + # spawn a background timer thread; the keepalive is exercised by its own dedicated tests. + @transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: nil) + end + + teardown do + @transport.close end test "handles POST request with valid JSON-RPC message" do @@ -23,10 +56,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Now make the ping request with the session ID request = create_rack_request( @@ -41,12 +74,87 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal "text/event-stream", response[1]["content-type"] - body = JSON.parse(response[2][0]) + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] - assert_empty(body["result"]) + assert_equal({}, body["result"]) + end + + test "response header names are lowercase as required by the Rack 3 spec" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + parse_error_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + "invalid json", + ) + + responses = { + initialize: init_response, + get_sse: @transport.handle_request(get_request), + delete: @transport.handle_request(delete_request), + parse_error: @transport.handle_request(parse_error_request), + } + + responses.each do |name, (_status, headers, _body)| + headers.each_key do |key| + assert_equal key.downcase, key, "#{name} response header `#{key}` must be lowercase" + end + end + end + + test "id-bearing notification message is rejected with Method not found" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "notifications/cancelled", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) + + assert_equal "123", body["id"] + refute body.key?("result") + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, body["error"]["code"] end test "handles POST request with invalid JSON" do @@ -59,10 +167,69 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Invalid JSON", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, body["error"]["code"] + assert_equal "Parse error: Invalid JSON", body["error"]["message"] + end + + test "POST request with JSON array body returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + [{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) + end + + test "POST request with non-object JSON body returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + "\"foo\"", + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) end test "handles POST request with initialize method" do @@ -70,18 +237,160 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "application/json", response[1]["Content-Type"] - assert response[1]["Mcp-Session-Id"] + assert_equal "application/json", response[1]["content-type"] + assert response[1]["mcp-session-id"] body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] - assert_equal "2025-06-18", body["result"]["protocolVersion"] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + end + + test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + assert session_id + + duplicate_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "initialize", id: "second", params: initialize_params }.to_json, + ) + duplicate_response = @transport.handle_request(duplicate_request) + + assert_equal 400, duplicate_response[0] + body = JSON.parse(duplicate_response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "second", body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/already initialized/i, body["error"]["message"]) + + # Original session should still be usable. + ping_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json, + ) + ping_response = @transport.handle_request(ping_request) + assert_equal 200, ping_response[0] + end + + test "rejects initialize with stale Mcp-Session-Id with 404" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "unknown-session" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Session not found", body["error"]["message"] + end + + test "rejects duplicate initialize against an idle-expired session with 404 and evicts it" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.05) + begin + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "first", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + assert(session_id) + + sleep(0.1) + + duplicate_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "initialize", id: "second", params: initialize_params }.to_json, + ) + duplicate_response = transport.handle_request(duplicate_request) + + assert_equal(404, duplicate_response[0]) + body = JSON.parse(duplicate_response[2][0]) + assert_equal("Session not found", body["error"]["message"]) + + refute(transport.send(:session_exists?, session_id), "expired session must be evicted") + ensure + transport.close + end + end + + test "evicts session and omits Mcp-Session-Id when initialize fails" do + # An `initialize` whose JSON-RPC envelope is rejected (e.g. wrong `jsonrpc` version) + # never reaches `Server#init`, so `mark_initialized!` is never called. The transport + # must drop the registered-but-uninitialized session to keep retries clean. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "1.0", method: "initialize", id: "broken" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + refute response[1].key?("mcp-session-id"), "no session id should leak from a failed init" + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal({}, @transport.instance_variable_get(:@sessions)) + end + + test "acks an initialize sent without an id and retains no session" do + # `initialize` framed as a notification (no id) produces no JSON-RPC response. + # The transport must not put nil in the Rack body or retain the orphaned session. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize" }.to_json, + ) + + response = @transport.handle_request(request) + + assert_equal 202, response[0] + refute response[1].key?("mcp-session-id"), "no session id should leak from an id-less init" + assert_equal [], response[2] + assert_equal({}, @transport.instance_variable_get(:@sessions)) + end + + test "rejects non-Hash JSON-RPC body with HTTP 400 and -32600" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + [{ jsonrpc: "2.0", method: "initialize", id: "batched", params: initialize_params }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_match(/single request object/i, body["error"]["message"]) end test "handles GET request with valid session ID" do @@ -90,10 +399,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then try to connect with GET request = create_rack_request( @@ -106,22 +415,21 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["Content-Type"] + assert_equal "text/event-stream", response[1]["content-type"] assert response[2].is_a?(Proc) # The body should be a Proc for streaming end - test "handles POST request when IOError raised" do - # Create and initialize a session + test "handles POST request as SSE even when GET SSE stream is closed" do init_request = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] - # Connect with SSE + # Connect with SSE then close it io = StringIO.new get_request = create_rack_request( "GET", @@ -130,13 +438,10 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(io) if response[2].is_a?(Proc) - - # Give the stream time to set up sleep(0.1) - - # Close the stream io.close + # POST request should still return SSE response via POST response stream request = create_rack_request( "POST", "/", @@ -147,30 +452,23 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, ) - # This should handle IOError and return the original response response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) - - # Verify session was cleaned up - assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + assert_equal "text/event-stream", response[1]["content-type"] end - test "handles POST request when Errno::EPIPE raised" do - # Create and initialize a session + test "handles POST request as SSE even when GET SSE stream has EPIPE" do init_request = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] - # Create a pipe to simulate EPIPE condition + # Connect GET SSE with a broken pipe reader, writer = IO.pipe - - # Connect with SSE using the writer end of the pipe get_request = create_rack_request( "GET", "/", @@ -178,13 +476,10 @@ module MCP ) response = @transport.handle_request(get_request) response[2].call(writer) if response[2].is_a?(Proc) - - # Give the stream time to set up sleep(0.1) - - # Close the reader end to break the pipe - this will cause EPIPE on write reader.close + # POST request should still return SSE response via POST response stream request = create_rack_request( "POST", "/", @@ -195,21 +490,56 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, ) - # This should handle Errno::EPIPE and return the original response response = @transport.handle_request(request) - assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) - - # Verify session was cleaned up - assert_not @transport.instance_variable_get(:@sessions).key?(session_id) - + assert_equal(200, response[0]) + assert_equal("text/event-stream", response[1]["content-type"]) + ensure begin writer.close - rescue + rescue StandardError nil end end + test "handles POST request as SSE even when GET SSE stream has ECONNRESET" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE with a mock that raises ECONNRESET + mock_stream = Object.new + mock_stream.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + mock_stream.define_singleton_method(:close) {} + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(mock_stream) if response[2].is_a?(Proc) + sleep(0.1) + + # POST request should still return SSE response via POST response stream + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "789" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + end + test "handles GET request with missing session ID" do request = create_rack_request( "GET", @@ -219,10 +549,198 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Missing session ID", body["error"]["message"] + end + + test "rejects POST request without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"]["message"] + end + + test "rejects notification without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"]["message"] + end + + test "rejects notification with an unknown session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => "does-not-exist" }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + + test "accepts a notification carrying a live session ID in stateful mode" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = @transport.handle_request(init_request)[1]["mcp-session-id"] + + notification = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = @transport.handle_request(notification) + assert_equal 202, response[0] + end + + test "rejects response without session ID in stateful mode" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", id: "1", result: {} }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"]["message"] + end + + test "allows POST request without session ID in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = stateless_transport.handle_request(request) + assert_equal 200, response[0] + end + + test "allows server/discover POST without session ID in stateful mode" do + # Per SEP-2575, `server/discover` is sessionless capability discovery and is exempt + # from the session requirement, like `initialize`. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json, + ) + + response = @transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + refute response[1].key?("mcp-session-id") + end + + test "allows server/discover POST without session ID in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json, + ) + + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + end + + test "rejects duplicate SSE connection with 409" do + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Simulate an active SSE stream by storing a stream object in the session + mock_stream = StringIO.new + @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream + + # Attempt a second GET request for the same session + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(get_request) + assert_equal 409, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"]["message"] + end + + test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Establish stream A + stream_a = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_a) + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] + + # Attempt to store stream B (simulating a racing request) + stream_b = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_b) + + # Stream A should still be the active stream + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] + + # Stream B should have been closed + assert stream_b.closed? end test "handles GET request with invalid session ID" do @@ -234,10 +752,29 @@ module MCP response = @transport.handle_request(request) assert_equal 404, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Session not found", body["error"] + assert_equal "Session not found", body["error"]["message"] + end + + test "handles POST request with invalid session ID" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "invalid_id", + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] end test "handles DELETE request with valid session ID" do @@ -246,10 +783,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then try to delete it request = create_rack_request( @@ -260,12 +797,90 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) assert body["success"] end + test "handles DELETE request with invalid session ID" do + request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => "invalid_id" }, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + + test "POST returns 404 after session is deleted" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + @transport.handle_request(delete_request) + + post_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = @transport.handle_request(post_request) + assert_equal 404, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + + test "DELETE returns 404 after session is already deleted" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + first_delete = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(first_delete) + assert_equal 200, response[0] + + second_delete = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(second_delete) + assert_equal 404, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + test "handles DELETE request with missing session ID" do request = create_rack_request( "DELETE", @@ -275,10 +890,10 @@ module MCP response = @transport.handle_request(request) assert_equal 400, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Missing session ID", body["error"] + assert_equal "Missing session ID", body["error"]["message"] end test "closes transport and cleans up session" do @@ -287,10 +902,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Then connect with GET io = StringIO.new @@ -315,29 +930,77 @@ module MCP @transport.close # Verify session was cleaned up + assert_equal({}, @transport.instance_variable_get(:@sessions)) + end + + test "cleanup_session_unsafe closes request_streams" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Simulate multiple request_streams being set on the session. + closed = [] + 2.times do |i| + mock_stream = Object.new + mock_stream.define_singleton_method(:close) { closed << i } + thread = Thread.new {} + thread.join + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] ||= {} + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams][thread] = mock_stream + end + + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + @transport.handle_request(delete_request) + + assert_equal [0, 1], closed.sort assert_empty @transport.instance_variable_get(:@sessions) end + test "broadcast notification skips sessions without GET SSE stream" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + @transport.handle_request(init_request) + + # No GET SSE stream connected, only request_streams. + # Pass **{} to prevent Ruby 2.7 from converting the Hash to keyword arguments. + result = @transport.send_notification("test/notify", { message: "hello" }, **{}) + + assert_equal 0, result + end + test "sends notification to correct session with multiple active sessions" do # Create first session init_request1 = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] # Create second session init_request2 = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect first session with GET io1 = StringIO.new @@ -385,15 +1048,16 @@ module MCP # Monkey-patch handle_json on the server to send a notification when called original_handle_json = @server.method(:handle_json) transport = @transport # Capture the transport in a local variable - @server.define_singleton_method(:handle_json) do |request| - result = original_handle_json.call(request) + @server.define_singleton_method(:handle_json) do |request, **kwargs| + result = original_handle_json.call(request, **kwargs) # Send notification while still in request context - broadcast to all sessions - transport.send_notification("test_notification", { session: "current" }) + transport.send_notification("test_notification", { session: "current" }, **{}) result end - # Handle request from session 1 - @transport.handle_request(request_as_session1) + # Handle request from session 1 (execute SSE proc) + response1 = @transport.handle_request(request_as_session1) + response1[2].call(StringIO.new) if response1[2].is_a?(Proc) # Make a request as session 2 request_as_session2 = create_rack_request( @@ -406,18 +1070,17 @@ module MCP { jsonrpc: "2.0", method: "ping", id: "890" }.to_json, ) - # Handle request from session 2 - @transport.handle_request(request_as_session2) + # Handle request from session 2 (execute SSE proc) + response2_post = @transport.handle_request(request_as_session2) + response2_post[2].call(StringIO.new) if response2_post[2].is_a?(Proc) - # Check that each session received one notification + # Broadcast notifications are sent to GET SSE streams (no related_request_id) io1.rewind output1 = io1.read - # Session 1 should have received two notifications (one from each request since we broadcast) assert_equal 2, output1.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count io2.rewind output2 = io2.read - # Session 2 should have received two notifications (one from each request since we broadcast) assert_equal 2, output2.scan(/data: {"jsonrpc":"2.0","method":"test_notification","params":{"session":"current"}}/).count end @@ -427,10 +1090,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -463,19 +1126,19 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response1 = @transport.handle_request(init_request1) - session_id1 = init_response1[1]["Mcp-Session-Id"] + session_id1 = init_response1[1]["mcp-session-id"] init_request2 = create_rack_request( "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "456" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json, ) init_response2 = @transport.handle_request(init_request2) - session_id2 = init_response2[1]["Mcp-Session-Id"] + session_id2 = init_response2[1]["mcp-session-id"] # Connect both sessions with SSE io1 = StringIO.new @@ -500,7 +1163,7 @@ module MCP sleep(0.1) # Broadcast notification to all sessions - sent_count = @transport.send_notification("broadcast", { message: "Hello everyone" }) + sent_count = @transport.send_notification("broadcast", { message: "Hello everyone" }, **{}) assert_equal 2, sent_count @@ -517,7 +1180,7 @@ module MCP end test "send_notification returns false for non-existent session" do - result = @transport.send_notification({ message: "test" }, session_id: "non_existent") + result = @transport.send_notification("test", { message: "test" }, session_id: "non_existent") refute result end @@ -527,10 +1190,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] # Connect with SSE io = StringIO.new @@ -549,7 +1212,7 @@ module MCP io.close # Try to send notification - result = @transport.send_notification({ message: "test" }, session_id: session_id) + result = @transport.send_notification("test", { message: "test" }, session_id: session_id) # Should return false and clean up the session refute result @@ -558,6 +1221,322 @@ module MCP assert_not @transport.instance_variable_get(:@sessions).key?(session_id) end + test "send_notification handles Errno::ECONNRESET gracefully" do + # Create and initialize a session. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Use a mock stream that raises Errno::ECONNRESET on write. + mock_stream = Object.new + mock_stream.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + mock_stream.define_singleton_method(:close) {} + + # Connect with SSE using the mock stream. + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(mock_stream) if response[2].is_a?(Proc) + + # Give the stream time to set up. + sleep(0.1) + + # Try to send notification - should handle ECONNRESET without raising. + result = @transport.send_notification("test", { message: "test" }, session_id: session_id) + + # Should return false and clean up the session. + refute result + + # Verify session was cleaned up. + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + end + + test "send_notification closes stream outside mutex on write error" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Use a mock stream that verifies mutex is NOT held during close. + mutex = @transport.instance_variable_get(:@mutex) + closed_outside_mutex = false + mock_stream = Object.new + mock_stream.define_singleton_method(:write) { |_data| raise Errno::EPIPE } + mock_stream.define_singleton_method(:close) do + if mutex.try_lock + closed_outside_mutex = true + mutex.unlock + end + end + + @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream + + result = @transport.send_notification("test", { message: "test" }, session_id: session_id) + + refute result + assert closed_outside_mutex, "Stream should be closed outside the mutex" + assert_not @transport.instance_variable_get(:@sessions).key?(session_id) + end + + test "send_notification on broken request_stream removes only that stream, not the session" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + sleep(0.1) + + # Simulate a broken request_stream. + broken_stream = Object.new + broken_stream.define_singleton_method(:write) { |_data| raise Errno::EPIPE } + broken_stream.define_singleton_method(:close) {} + related_id = "req-1" + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { related_id => broken_stream } + + result = @transport.send_notification("test", { msg: "hello" }, session_id: session_id, related_request_id: related_id) + + refute result + # Session should still exist. + assert @transport.instance_variable_get(:@sessions).key?(session_id) + # The broken request_stream should be removed. + refute @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams].key?(related_id) + # GET SSE stream should still be intact. + assert @transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] + end + + test "active_stream does not fall back to GET SSE when related_request_id is given but request_stream is missing" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + sleep(0.1) + + # Send notification with a related_request_id that has no matching request_stream. + result = @transport.send_notification( + "test/notify", + { message: "should not arrive" }, + session_id: session_id, + related_request_id: "nonexistent-request-id", + ) + + # Should return false because no matching request_stream exists. + refute result + + # Session should still exist (not cleaned up). + assert @transport.instance_variable_get(:@sessions).key?(session_id) + + # GET SSE stream should NOT have received the notification. + io.rewind + refute_includes io.read, "should not arrive" + end + + test "send_notification broadcast continues when one session raises Errno::ECONNRESET" do + # Create two sessions. + init_request1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + init_response1 = @transport.handle_request(init_request1) + session_id1 = init_response1[1]["mcp-session-id"] + + init_request2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "2", params: initialize_params }.to_json, + ) + init_response2 = @transport.handle_request(init_request2) + session_id2 = init_response2[1]["mcp-session-id"] + + # Session 1: mock stream that raises ECONNRESET. + broken_stream = Object.new + broken_stream.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + broken_stream.define_singleton_method(:close) {} + + get_request1 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id1 }, + ) + response1 = @transport.handle_request(get_request1) + response1[2].call(broken_stream) if response1[2].is_a?(Proc) + + # Session 2: healthy stream. + healthy_stream = StringIO.new + get_request2 = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id2 }, + ) + response2 = @transport.handle_request(get_request2) + response2[2].call(healthy_stream) if response2[2].is_a?(Proc) + + # Give the streams time to set up. + sleep(0.1) + + # Broadcast notification - should not abort despite ECONNRESET from session 1. + sent_count = @transport.send_notification("test", { message: "hello" }, **{}) + + # Session 2 should have received the notification. + assert_equal 1, sent_count + + healthy_stream.rewind + output = healthy_stream.read + assert_includes output, '"method":"test"' + + # Session 1 should have been cleaned up. + assert_not @transport.instance_variable_get(:@sessions).key?(session_id1) + + # Session 2 should still exist. + assert @transport.instance_variable_get(:@sessions).key?(session_id2) + end + + test "send_notification to a session writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + result = @transport.send_notification("test", { message: "hi" }, session_id: session_id) + + assert result + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "notification write must not hold @mutex" + end + + test "send_notification broadcast writes outside the mutex" do + session_id1 = initialize_test_session(id: "1") + session_id2 = initialize_test_session(id: "2") + writes1 = install_mutex_probe_stream(session_id1) + writes2 = install_mutex_probe_stream(session_id2) + + sent_count = @transport.send_notification("broadcast", { message: "hi" }, **{}) + + assert_equal 2, sent_count + assert_equal 0, writes1[:under_mutex], "broadcast write must not hold @mutex" + assert_equal 0, writes2[:under_mutex], "broadcast write must not hold @mutex" + end + + test "send_keepalive_ping writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + @transport.send(:send_keepalive_ping, session_id) + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex" + end + + test "send_request writes outside the mutex" do + session_id = initialize_test_session + writes = install_mutex_probe_stream(session_id) + + result_queue = Queue.new + thread = Thread.new do + result_queue.push(@transport.send_request("ping", nil, session_id: session_id)) + end + + # Wait until the request has been written to the probe stream. + Thread.pass until writes[:total].positive? + + request_id = @transport.instance_variable_get(:@pending_responses).keys.first + @transport.send(:handle_response, { jsonrpc: "2.0", id: request_id, result: {} }, session_id: session_id) + + result_queue.pop + thread.join + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "server-to-client request write must not hold @mutex" + end + + test "send_keepalive_ping handles Errno::ECONNRESET gracefully" do + # Create and initialize a session. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Use a mock stream that raises Errno::ECONNRESET on write. + mock_stream = Object.new + mock_stream.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + mock_stream.define_singleton_method(:close) {} + + # Connect with SSE using the mock stream. + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(mock_stream) if response[2].is_a?(Proc) + + # Give the stream time to set up. + sleep(0.1) + + # send_keepalive_ping is private; re-raises to exit the keepalive loop. + # Errno::ECONNRESET should be caught by the rescue clause (which reports + # the exception) before being re-raised. Verify that exception_reporter + # is called — this fails if ECONNRESET is not in the rescue list. + reported_errors = [] + original_reporter = MCP.configuration.exception_reporter + MCP.configuration.exception_reporter = ->(error, context) { reported_errors << [error, context] } + + begin + assert_raises(Errno::ECONNRESET) do + @transport.send(:send_keepalive_ping, session_id) + end + + assert_equal(1, reported_errors.size) + assert_instance_of(Errno::ECONNRESET, reported_errors.first[0]) + assert_equal("Stream closed", reported_errors.first[1][:error]) + ensure + MCP.configuration.exception_reporter = original_reporter + end + end + test "responds with 405 for unsupported methods" do request = create_rack_request( "PUT", @@ -567,10 +1546,1368 @@ module MCP response = @transport.handle_request(request) assert_equal 405, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Method not allowed", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Method not allowed", body["error"]["message"] + end + + test "POST request without Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { "HTTP_ACCEPT" => "application/json, text/event-stream" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"]["message"] + end + + test "POST request with wrong Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "text/plain", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + end + + test "POST request with Content-Type including charset succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json; charset=utf-8", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request without Accept header returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"]["message"] + end + + test "POST request with Accept header missing text/event-stream returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"]["message"] + end + + test "POST request with Accept header missing application/json returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"]["message"] + end + + test "POST request with valid Accept header succeeds" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with Accept header containing quality values succeeds" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json;q=0.9, text/event-stream;q=0.8", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with Accept: */* succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "*/*", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with mixed-case Accept header succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "Application/JSON, Text/Event-Stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "GET request with upper-case Accept header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { + "HTTP_ACCEPT" => "TEXT/EVENT-STREAM", + "HTTP_MCP_SESSION_ID" => session_id, + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + end + + test "GET request without Accept header returns 406" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"] + end + + test "GET request with Accept header missing text/event-stream returns 406" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ACCEPT" => "application/json", + }, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]["message"] + end + + test "GET request with valid Accept header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ACCEPT" => "text/event-stream", + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + end + + test "GET request with Accept: */* succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ACCEPT" => "*/*", + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + end + + test "POST initialize request ignores MCP-Protocol-Version header among legacy versions" do + # Per SEP-2575 header-primary routing, stable-only header values stay on the legacy path + # where `initialize` treats the body `params.protocolVersion` as authoritative. + # An unknown header value routes to the modern path (see the test below), and under + # the dual-era 2026-07-28 header a sessionless `initialize` stays legacy as well. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => "2024-11-05", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST initialize request with a non-legacy MCP-Protocol-Version header is rejected as modern traffic" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => "1900-01-01", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32022, body.dig("error", "code")) + assert_equal "1900-01-01", body.dig("error", "data", "requested") + end + + test "POST initialize request negotiates body protocolVersion when header is an older supported version" do + older_version = "2025-06-18" + assert_includes Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, older_version + refute_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, older_version + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => older_version, + }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + # The body's requested version drives negotiation (not the older header); + # the default modern request is counter-offered the latest handshake version. + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + end + + test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do + older_version = "2025-06-18" + assert_includes Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, older_version + refute_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, older_version + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION, + }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params(protocolVersion: older_version), + }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal older_version, body["result"]["protocolVersion"] + end + + test "POST request with unsupported MCP-Protocol-Version returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic and + # receives `-32022` with the supported list instead of the legacy `-32600` message. + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal(-32022, body["error"]["code"]) + assert_equal "1999-01-01", body.dig("error", "data", "requested") + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") + end + + test "POST request with malformed MCP-Protocol-Version returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "not-a-version", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal(-32022, body["error"]["code"]) + assert_equal "not-a-version", body.dig("error", "data", "requested") + end + + test "POST request with supported MCP-Protocol-Version succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION, + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request with the 2026-07-28 MCP-Protocol-Version header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, + ) + init_response = @transport.handle_request(init_request) + # The handshake never negotiates a modern version (SEP-2575 era model); + # the session proceeds on the counter-offered 2025-11-25. + assert_equal "2025-11-25", JSON.parse(init_response[2][0])["result"]["protocolVersion"] + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "POST request without MCP-Protocol-Version header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "missing MCP-Protocol-Version header falls back to default for validation" do + # The constant is frozen, so swap it out instead of stubbing `include?` on it. + original_versions = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS + MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) + MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, [].freeze) + + request = Rack::Request.new( + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + ) + + response = @transport.send(:validate_protocol_version_header, request) + assert_equal(400, response[0]) + + body = JSON.parse(response[2][0]) + assert_includes(body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION) + ensure + MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) + MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions) + end + + test "POST request with empty MCP-Protocol-Version header returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + + test "POST request with array body and unsupported MCP-Protocol-Version returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + [{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + end + + test "GET request with a non-legacy MCP-Protocol-Version returns 405" do + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, + # and the modern lifecycle removed the GET listening stream. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 405, response[0] + end + + test "GET request without MCP-Protocol-Version header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "DELETE request with a non-legacy MCP-Protocol-Version returns 405" do + # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, + # and the modern lifecycle has no DELETE (there is no session to terminate). + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 405, response[0] + end + + test "DELETE request with a non-legacy MCP-Protocol-Version returns 405 in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01" }, + ) + + response = stateless_transport.handle_request(request) + assert_equal 405, response[0] + end + + test "DELETE request with an unknown session and a non-legacy MCP-Protocol-Version returns 405" do + # Header-primary routing (SEP-2575) decides the era before any session validation. + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => "unknown-session-id", + "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01", + }, + ) + + response = @transport.handle_request(request) + assert_equal 405, response[0] + end + + test "stateless mode allows requests without session IDs, responding with no session ID" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = stateless_transport.handle_request(init_request) + assert_nil init_response[1]["mcp-session-id"] + end + + test "stateless mode responds without any session ID when session ID is present" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "unseen_session_id", + }, + { jsonrpc: "2.0", method: "ping", id: "123" }.to_json, + ) + + response = stateless_transport.handle_request(request) + assert_equal 200, response[0] + assert_equal( + { + "content-type" => "application/json", + }, + response[1], + ) + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "123", body["id"] + end + + test "stateless mode responds with 405 when SSE is requested" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + get_request = create_rack_request( + "GET", + "/", + { + "CONTENT_TYPE" => "application/json,text/event-stream", + }, + ) + response = stateless_transport.handle_request(get_request) + assert_equal 405, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Method not allowed", body["error"]["message"] + end + + test "stateless mode silently responds with success to session DELETE when session ID is not present" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + delete_request = create_rack_request( + "DELETE", + "/", + {}, + ) + response = stateless_transport.handle_request(delete_request) + assert_equal 200, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert body["success"] + end + + test "stateless mode silently responds with success to session DELETE when session ID is provided" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => "session_id" }, + ) + response = stateless_transport.handle_request(delete_request) + assert_equal 200, response[0] + assert_equal({ "content-type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert body["success"] + end + + test "stateless mode does not support server-sent events" do + # Notifications have no stream to ride in stateless mode; the transport reports non-delivery + # instead of raising so per-request session notify_* helpers degrade gracefully (SEP-2567). + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + result = stateless_transport.send_notification( + "test_notification", + { message: "Hello" }, + session_id: "some_session_id", + ) + + refute result + end + + test "stateless mode does not leak client info onto the shared server" do + # Each stateless POST runs against an ephemeral per-request session (SEP-2567); concurrent requests + # must never observe another client's identity through the shared Server instance. + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "client-a", version: "1.0" }, + }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + assert_nil @server.client_capabilities + assert_nil @server.instance_variable_get(:@client) + end + + test "stateless mode allows repeated initialize requests" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + 2.times do |i| + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: i + 1, + params: initialize_params(clientInfo: { name: "client-#{i}", version: "1.0" }), + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert body.key?("result"), "initialize ##{i + 1} should succeed, got #{body.inspect}" + refute response[1].key?("mcp-session-id") + end + end + + test "stateless mode skips progress notifications without raising" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(exception, _context) { reported << exception } + + server = Server.new(name: "stateless_progress_test", configuration: configuration) + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + stateless_transport = StreamableHTTPTransport.new(server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "tok" } }, + }.to_json, + ) + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + body = JSON.parse(response[2][0]) + assert_equal "ok", body.dig("result", "content", 0, "text") + assert_empty reported + end + + test "stateless mode responds with 202 when client sends a notification/initialized request" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "notifications/initialized" }.to_json, + ) + + response = stateless_transport.handle_request(request) + assert_equal 202, response[0] + assert_equal({}, response[1]) + + body = response[2][0] + assert_nil(body) + end + + test "POST request returns SSE response even with GET SSE connected" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect with GET SSE + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + sleep(0.1) + + # POST request should return SSE, not 202 + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + + post_io = StringIO.new + response[2].call(post_io) + body = JSON.parse(post_io.string.match(/^data: (.+)$/)[1]) + assert_equal "456", body["id"] + end + + test "JSON response mode returns application/json for POST requests" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + + body = JSON.parse(response[2][0]) + assert_equal("1", body["id"]) + ensure + transport.close + end + + test "JSON response mode accepts application/json only in Accept header" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "JSON response mode returns 406 when Accept header is missing" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(406, response[0]) + ensure + transport.close + end + + test "JSON response mode accepts wildcard Accept header" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "*/*", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "JSON response mode drops notifications during tool execution" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "should be dropped", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + + body = JSON.parse(response[2][0]) + assert_equal("call-1", body["id"]) + refute_includes(response[2][0], "should be dropped") + ensure + transport.close + end + + test "JSON response mode drops notifications even with GET SSE connected" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "should not leak", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Call tool that sends a notification. + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + + # Notification should NOT leak to GET SSE stream. + io.rewind + refute_includes(io.read, "should not leak") + ensure + transport.close + end + + test "JSON response mode raises on send_request (sampling)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request("sampling/createMessage", { messages: [] }, session_id: "s1") + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode raises on send_request (roots/list)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request("roots/list", nil, session_id: "s1") + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode raises on send_request (elicitation/create)" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + error = assert_raises(RuntimeError) do + transport.send_request( + "elicitation/create", + { mode: "form", message: "test", requestedSchema: {} }, + session_id: "s1", + ) + end + + assert_equal("JSON response mode does not support server-to-client requests.", error.message) + ensure + transport.close + end + + test "JSON response mode allows broadcast notifications via GET SSE" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + server.server_context = server + + server.define_tool(name: "notify_tool") do |server_context:| + server_context.notify_tools_list_changed + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Call tool that triggers a broadcast notification. + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "notify_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + + # Broadcast notification should arrive on GET SSE stream. + io.rewind + assert_includes(io.read, "notifications/tools/list_changed") + ensure + transport.close + end + + test "JSON response mode with stateless returns application/json without session ID" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) + server.transport = transport + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + assert_nil(response[1]["mcp-session-id"]) + + body = JSON.parse(response[2][0]) + assert_equal("1", body["id"]) + ensure + transport.close + end + + test "JSON response mode with stateless returns 405 on GET" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, stateless: true, enable_json_response: true) + server.transport = transport + + request = create_rack_request("GET", "/", {}) + + response = transport.handle_request(request) + assert_equal(405, response[0]) + ensure + transport.close + end + + test "JSON response mode delivers session-scoped standalone notifications via GET SSE" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + server.server_context = server + + server.define_tool(name: "touch_tool") do |server_context:| + server_context.notify_resources_updated(uri: "file:///example.txt") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = transport.handle_request(get_request) + get_response[2].call(io) if get_response[2].is_a?(Proc) + sleep(0.1) + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "touch_tool", arguments: {} }, + }.to_json, + ) + + response = transport.handle_request(tool_request) + assert_equal(200, response[0]) + assert_equal("application/json", response[1]["content-type"]) + + io.rewind + assert_includes(io.read, "notifications/resources/updated") + ensure + transport.close end test "handle post request with a standard error" do @@ -578,7 +2915,7 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "4567" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "4567", params: initialize_params }.to_json, ) @transport.define_singleton_method(:extract_session_id) do |_request| @@ -587,10 +2924,1088 @@ module MCP response = @transport.handle_request(request) assert_equal 500, response[0] - assert_equal({ "Content-Type" => "application/json" }, response[1]) + assert_equal({ "content-type" => "application/json" }, response[1]) body = JSON.parse(response[2][0]) - assert_equal "Internal server error", body["error"] + assert_equal "2.0", body["jsonrpc"] + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, body["error"]["code"] + assert_equal "Internal server error", body["error"]["message"] + end + + test "send_request raises error in stateless mode" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + + error = assert_raises(RuntimeError) do + stateless_transport.send_request("sampling/createMessage", { "messages" => [] }) + end + + assert_equal("Stateless mode does not support server-to-client requests.", error.message) + end + + test "send_request refuses to run at all in the modern lifecycle" do + # SEP-2575 removes server-initiated requests, so a modern handler reaching for elicitation or sampling + # is told why rather than being sent down a path that fails on an unrelated session lookup. + modern_session = ServerSession.new(server: @server, transport: @transport, session_id: "modern-1", era: :modern) + + error = assert_raises(RuntimeError) do + @transport.send_request("elicitation/create", { message: "hi" }, session_id: "modern-1", server_session: modern_session) + end + + assert_equal("Server-initiated requests are not available in the modern lifecycle (SEP-2575).", error.message) + end + + test "send_request raises error when session_id is not provided" do + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }) + end + + assert_equal("session_id is required for server-to-client requests.", error.message) + end + + test "send_request raises error when session is not found" do + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: "nonexistent") + end + + assert_equal("Session not found: nonexistent.", error.message) + end + + test "send_request raises error when no active SSE streams" do + # Create session but do NOT connect SSE. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + error = assert_raises(RuntimeError) do + @transport.send_request("sampling/createMessage", { "messages" => [] }, session_id: session_id) + end + + assert_equal("No active stream for sampling/createMessage request.", error.message) + end + + test "send_request sends via SSE and waits for response" do + # Create session. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + # Send request in background. + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [{ role: "user", content: { type: "text", text: "Hello" } }], maxTokens: 100 }, + session_id: session_id, + ) + result_queue.push(result) + end + + sleep(0.1) # Wait for the request to be sent. + + # Verify request was sent to stream. + io.rewind + output = io.read + assert_includes output, "sampling/createMessage" + + # Parse the sent request to get its ID. + data_lines = output.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Simulate client response. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: request_id, + result: { role: "assistant", content: { type: "text", text: "Hi there" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + # Get result. + result = result_queue.pop + assert_equal "assistant", result[:role] + assert_equal "Hi there", result[:content][:text] + end + + test "send_request times out when the client never answers" do + # The spec asks implementations to bound every sent request so an unanswered one cannot + # exhaust the sender's resources. + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) + session_id = open_sse_session(transport) + + error = assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + end + + assert_equal 0.05, error.timeout + assert_includes error.message, "sampling/createMessage" + + # Left uncaught in a handler, the originating request is answered with a timeout code rather than + # a generic internal error, so the peer that failed to answer can tell the two apart. + assert_equal(-32001, error.error_code) + ensure + transport.close + end + + test "send_request timeout is overridable per request" do + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 60) + session_id = open_sse_session(transport) + + error = assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("ping", nil, session_id: session_id, timeout: 0.05) + end + + assert_equal 0.05, error.timeout + ensure + transport.close + end + + test "send_request tells the peer it gave up when the timeout fires" do + # These requests only happen on 2025-11-25 and earlier connections, whose spec asks the sender + # to issue a cancellation notification for the request it stops waiting on. + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) + io = StringIO.new + session_id = open_sse_session(transport, io: io) + session = transport.instance_variable_get(:@sessions)[session_id][:server_session] + + assert_raises(MCP::Server::RequestTimeoutError) do + transport.send_request("ping", nil, session_id: session_id, server_session: session) + end + + events = io.string.lines.grep(/\Adata: /).map { |line| JSON.parse(line.sub("data: ", "")) } + cancellation = events.find { |event| event["method"] == "notifications/cancelled" } + refute_nil cancellation, "expected the peer to be told the request was abandoned" + assert_match(/Timed out after 0.05 seconds/, cancellation.dig("params", "reason")) + ensure + transport.close + end + + test "send_request returns the response when it arrives before the timeout" do + transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 30) + io = StringIO.new + session_id = open_sse_session(transport, io: io) + + result_queue = Queue.new + Thread.new do + result_queue.push(transport.send_request("ping", nil, session_id: session_id)) + end + wait_until { io.string.include?("ping") } + + request_id = JSON.parse(io.string.lines.grep(/\Adata: /).first.sub("data: ", ""))["id"] + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: request_id, result: { ok: true } }.to_json, + )) + + assert_equal({ ok: true }, result_queue.pop) + ensure + transport.close + end + + test "server_to_client_request_timeout rejects a non-positive value" do + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: -1) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: nil) } + end + + test "send_request with related_request_id rides the originating POST stream, not the GET stream" do + # Per SEP-2260, server-to-client requests associated with a client request are delivered on + # that request's POST SSE response stream rather than the standalone GET stream. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + get_io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(get_io) if response[2].is_a?(Proc) + sleep(0.1) + + # Register an in-flight POST request stream for "req-1". + post_io = StringIO.new + @transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { "req-1" => post_io } + + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "roots/list", + nil, + session_id: session_id, + related_request_id: "req-1", + ) + result_queue.push(result) + end + + sleep(0.1) + + post_io.rewind + post_output = post_io.read + assert_includes post_output, "roots/list" + + get_io.rewind + refute_includes get_io.read, "roots/list" + + # Respond so the background send_request completes. + data_lines = post_output.lines.select { |line| line.start_with?("data: ") } + request_id = JSON.parse(data_lines.first.sub("data: ", ""))["id"] + client_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: request_id, result: { roots: [] } }.to_json, + ) + @transport.handle_request(client_response) + + assert_equal({ roots: [] }, result_queue.pop) + end + + test "send_request ignores response from wrong session" do + # Create two sessions. + init_a = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-a", params: initialize_params }.to_json, + ) + resp_a = @transport.handle_request(init_a) + session_a = resp_a[1]["mcp-session-id"] + + init_b = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-b", params: initialize_params }.to_json, + ) + resp_b = @transport.handle_request(init_b) + session_b = resp_b[1]["mcp-session-id"] + + # Connect SSE for session A. + io_a = StringIO.new + get_a = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_a }) + response_a = @transport.handle_request(get_a) + response_a[2].call(io_a) if response_a[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + # Send sampling request targeting session A. + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [{ role: "user", content: { type: "text", text: "Hello" } }], maxTokens: 100 }, + session_id: session_a, + ) + result_queue.push(result) + end + + sleep(0.1) # Wait for the request to be sent. + + # Get the request ID from session A's stream. + io_a.rewind + data_lines = io_a.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Session B tries to respond (cross-session injection attempt). + cross_session_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_b }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "injected" } } }.to_json, + ) + @transport.handle_request(cross_session_response) + + # The request should still be pending (not resolved by wrong session). + assert_empty(result_queue, "Response from wrong session should be ignored") + + # Now send the correct response from session A. + correct_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_a }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "correct" } } }.to_json, + ) + @transport.handle_request(correct_response) + + result = result_queue.pop + assert_equal "correct", result[:content][:text] + end + + test "send_request with parent_cancellation unblocks with MCP::CancelledError when cancelled" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + cancellation = MCP::Cancellation.new(request_id: "parent-1") + server_session = @transport.instance_variable_get(:@sessions)[session_id][:server_session] + + result_queue = Queue.new + Thread.new do + @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + parent_cancellation: cancellation, + server_session: server_session, + ) + rescue => e + result_queue.push(e) + end + + sleep(0.1) + cancellation.cancel(reason: "parent cancelled") + + error = result_queue.pop + assert_kind_of MCP::CancelledError, error + end + + test "send_request deregisters on_cancel hook after completion so late parent cancel is a no-op" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + cancellation = MCP::Cancellation.new(request_id: "parent-x") + server_session = @transport.instance_variable_get(:@sessions)[session_id][:server_session] + + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + parent_cancellation: cancellation, + server_session: server_session, + ) + result_queue.push(result) + end + + sleep(0.1) + + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + nested_request_id = request_data["id"] + + # Client responds successfully; send_request completes normally. + client_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: nested_request_id, result: { role: "assistant", content: { type: "text", text: "ok" } } }.to_json, + ) + @transport.handle_request(client_response) + + result = result_queue.pop + assert_equal "ok", result[:content][:text] + + # Snapshot what was written to the SSE stream before late cancel. + io.rewind + before_cancel = io.read + + # Parent is cancelled after the nested request already completed. The hook must + # have been deregistered, so no `notifications/cancelled` should go to the peer + # for the already-completed nested request. + cancellation.cancel(reason: "late") + + sleep(0.05) # Give any stray callback time to fire (none should). + + io.rewind + after_cancel = io.read + + refute_includes after_cancel.sub(before_cancel, ""), "notifications/cancelled" + end + + test "cancel_pending_request is no-op when a real response is already queued (race)" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + io = StringIO.new + get_request = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) + + result_queue = Queue.new + Thread.new do + result = @transport.send_request( + "sampling/createMessage", + { messages: [] }, + session_id: session_id, + ) + result_queue.push(result) + end + + sleep(0.1) + + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Client responded first. + client_response = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", id: request_id, result: { role: "assistant", content: { type: "text", text: "ok" } } }.to_json, + ) + @transport.handle_request(client_response) + + # Cancel arrives after the response was already enqueued. Must not clobber the result. + @transport.cancel_pending_request(request_id, reason: "late cancel") + + result = result_queue.pop + assert_equal "ok", result[:content][:text] + end + + test "refuses a POST reusing an in-flight request id and keeps the original stream" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + gate = Queue.new + server.define_tool(name: "victim_tool") do |server_context:| + server_context.report_progress(1, message: "first-frame") + gate.pop + server_context.report_progress(2, message: "second-frame") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, server_session = start_session(transport) + + victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) + victim_stream = TestStream.new + victim_thread = Thread.new { victim[2].call(victim_stream) } + sleep(0.01) until server_session.lookup_in_flight("req-1") + + attacker = transport.handle_request(colliding_tool_call(session_id, "req-1")) + + assert_equal 409, attacker[0] + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + JSON.parse(attacker[2][0]).dig("error", "code"), + ) + + gate << :go + victim_thread.join + + # The registration survived the refusal, so the frame emitted after it still reaches + # the request that asked for it. + assert_includes victim_stream.string, "first-frame" + assert_includes victim_stream.string, "second-frame" + end + + test "a finishing request leaves a stream another request registered under the same id" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + gate = Queue.new + server.define_tool(name: "victim_tool") do |server_context:| + gate.pop + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, server_session = start_session(transport) + + victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) + victim_stream = TestStream.new + victim_thread = Thread.new { victim[2].call(victim_stream) } + sleep(0.01) until server_session.lookup_in_flight("req-1") + + # Stand in for a stream that won the registration in a race the pre-dispatch refusal normally prevents. + # Finishing the other request must not unregister it. + foreign_stream = TestStream.new + sessions = transport.instance_variable_get(:@sessions) + sessions[session_id][:post_request_streams]["req-1"] = foreign_stream + + gate << :go + victim_thread.join + + assert_same foreign_stream, sessions[session_id][:post_request_streams]["req-1"] + end + + test "a broken request-scoped stream drops only itself, even when it is not the registered one" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + session_id, = start_session(transport) + sessions = transport.instance_variable_get(:@sessions) + registered = TestStream.new + sessions[session_id][:post_request_streams] = { "req-1" => registered } + + transport.send(:drop_broken_stream, session_id, TestStream.new, "req-1") + + assert sessions.key?(session_id), "a request-scoped failure must not tear down the session" + assert_same registered, sessions[session_id][:post_request_streams]["req-1"] + end + + test "allows a request id to be reused once the earlier request has finished" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + server.transport = transport + + server.define_tool(name: "victim_tool") do |server_context:| + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session_id, = start_session(transport) + + 2.times do + response = transport.handle_request(colliding_tool_call(session_id, "req-1")) + + assert_equal 200, response[0] + stream = TestStream.new + response[2].call(stream) + assert_includes stream.string, "done" + end + end + + test "JSON response mode returns accepted when cancellation suppresses response" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server, enable_json_response: true) + server.transport = transport + + server.define_tool(name: "slow_tool") do |server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + + Tool::Response.new([{ type: "text", text: "done" }]) + end + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "init", + params: initialize_params, + }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + server_session = transport.instance_variable_get(:@sessions)[session_id][:server_session] + + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "slow_tool", arguments: {} }, + }.to_json, + ) + + response_queue = Queue.new + request_thread = Thread.new { response_queue.push(transport.handle_request(tool_request)) } + sleep(0.01) until server_session.lookup_in_flight("call-1") + + cancel_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + method: MCP::Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "call-1", reason: "user cancelled" }, + }.to_json, + ) + + cancel_response = transport.handle_request(cancel_request) + assert_equal(202, cancel_response[0]) + + response = response_queue.pop + assert_equal(202, response[0]) + assert_empty(response[1]) + assert_empty(response[2]) + ensure + request_thread.kill if request_thread.alive? + transport.close + end + + test "send_request raises on error response from client" do + # Create session. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + error_queue = Queue.new + Thread.new do + @transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + rescue => e + error_queue.push(e) + end + + sleep(0.1) # Wait for the request to be sent. + + # Get request ID from stream. + io.rewind + data_lines = io.read.lines.select { |line| line.start_with?("data: ") } + request_data = JSON.parse(data_lines.first.sub("data: ", "")) + request_id = request_data["id"] + + # Send error response. + error_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: request_id, + error: { code: -1, message: "User rejected" }, + }.to_json, + ) + @transport.handle_request(error_response) + + error = error_queue.pop + assert_kind_of StandardError, error + assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected", error.message) + end + + test "send_request unblocks when session is cleaned up" do + # Create session. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect SSE. + io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = @transport.handle_request(get_request) + response[2].call(io) if response[2].is_a?(Proc) + + sleep(0.1) # Give the stream time to set up. + + error_queue = Queue.new + Thread.new do + @transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) + rescue => e + error_queue.push(e) + end + + sleep(0.1) # Wait for the request to be sent. + + # Delete the session to trigger cleanup (simulates client disconnect). + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + @transport.handle_request(delete_request) + + error = error_queue.pop + assert_kind_of RuntimeError, error + assert_equal("SSE session closed while waiting for sampling/createMessage response.", error.message) + end + + test "send_request sends via POST response stream even with GET SSE connected" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Connect GET SSE. + get_io = StringIO.new + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + get_response = @transport.handle_request(get_request) + get_response[2].call(get_io) if get_response[2].is_a?(Proc) + sleep(0.1) + + # Set up sampling capability for the session. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .store_client_info(client: { name: "test" }, capabilities: { sampling: {} }) + + # Define a tool that calls create_sampling_message. + sampling_tool = MCP::Tool.define( + name: "sampling_tool", + input_schema: { properties: { prompt: { type: "string" } }, required: ["prompt"] }, + ) do |prompt:, server_context:| + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, + ) + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) + end + @server.tools[sampling_tool.name_value] = sampling_tool + + # Send tools/call via POST (GET SSE is connected). + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "sampling_tool", arguments: { prompt: "Hello" } }, + }.to_json, + ) + + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + response = @transport.handle_request(tool_request) + response[2].call(post_stream) + result_queue.push(:done) + end + + sleep(0.2) + + # Sampling request should be in POST response stream, not GET SSE. + output = post_stream.string + data_lines = output.lines.select { |line| line.start_with?("data: ") } + sampling_request = JSON.parse(data_lines.first.sub("data: ", "")) + assert_equal "sampling/createMessage", sampling_request["method"] + + # GET SSE should NOT have the sampling request. + get_io.rewind + refute_includes get_io.read, "sampling/createMessage" + + # Simulate client sending sampling result via POST. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: sampling_request["id"], + result: { role: "assistant", content: { type: "text", text: "Hi from LLM" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + result_queue.pop + + tool_response_lines = post_stream.string.lines.select { |line| line.start_with?("data: ") } + tool_response = JSON.parse(tool_response_lines.last.sub("data: ", "")) + assert_equal "tool-1", tool_response["id"] + assert_includes tool_response["result"]["content"].first["text"], "Hi from LLM" + end + + test "send_request sends via POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Set up sampling capability for the session. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .store_client_info(client: { name: "test" }, capabilities: { sampling: {} }) + + # Define a tool that calls create_sampling_message. + sampling_tool = MCP::Tool.define( + name: "sampling_tool", + input_schema: { properties: { prompt: { type: "string" } }, required: ["prompt"] }, + ) do |prompt:, server_context:| + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: prompt } }], + max_tokens: 100, + ) + MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }]) + end + @server.tools[sampling_tool.name_value] = sampling_tool + + # Send tools/call via POST (no GET SSE stream). + tool_request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "sampling_tool", arguments: { prompt: "Hello" } }, + }.to_json, + ) + + # Process in background since handle_request blocks until tool completes. + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + response = @transport.handle_request(tool_request) + response[2].call(post_stream) + result_queue.push(:done) + end + + sleep(0.2) # Wait for the tool to start and send sampling request. + + # Read the sampling request from the POST response stream. + output = post_stream.string + data_lines = output.lines.select { |line| line.start_with?("data: ") } + sampling_request = JSON.parse(data_lines.first.sub("data: ", "")) + assert_equal "sampling/createMessage", sampling_request["method"] + + # Simulate client sending sampling result via POST. + client_response = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: sampling_request["id"], + result: { role: "assistant", content: { type: "text", text: "Hi from LLM" } }, + }.to_json, + ) + @transport.handle_request(client_response) + + result_queue.pop # Wait for tool to complete. + + # Verify the tool result was written to the POST response stream. + tool_response_lines = post_stream.string.lines.select { |line| line.start_with?("data: ") } + tool_response = JSON.parse(tool_response_lines.last.sub("data: ", "")) + assert_equal "tool-1", tool_response["id"] + assert_includes tool_response["result"]["content"].first["text"], "Hi from LLM" + end + + test "send_notification uses POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Define a tool that sends a notification during execution. + notification_sent = Queue.new + slow_tool = MCP::Tool.define( + name: "slow_tool", + ) do |server_context:| + server_context.notify_log_message(data: "test log", level: "info") + notification_sent.push(true) + MCP::Tool::Response.new([{ type: "text", text: "done" }]) + end + @server.tools[slow_tool.name_value] = slow_tool + + # Configure logging so notifications are sent. + @transport.instance_variable_get(:@sessions)[session_id][:server_session] + .configure_logging(MCP::LoggingMessageNotification.new(level: "debug")) + + # Send tools/call via POST (no GET SSE stream). + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "slow_tool", arguments: {} }, + }.to_json, + ) + response = @transport.handle_request(request) + response[2].call(post_stream) + result_queue.push(:done) + end + + notification_sent.pop # Wait for tool to send notification. + result_queue.pop + + # Verify notification was written to the POST response stream. + assert_includes post_stream.string, "notifications/message" + assert_includes post_stream.string, "test log" + end + + test "progress notification uses POST response stream when no GET SSE stream" do + # Create session without connecting GET SSE. + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Define a tool that reports progress during execution. + progress_reported = Queue.new + progress_tool = MCP::Tool.define( + name: "progress_tool", + ) do |server_context:| + server_context.report_progress(50, total: 100, message: "halfway") + progress_reported.push(true) + MCP::Tool::Response.new([{ type: "text", text: "done" }]) + end + @server.tools[progress_tool.name_value] = progress_tool + + # Send tools/call via POST (no GET SSE stream) with a progress token. + post_stream = TestStream.new + result_queue = Queue.new + Thread.new do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { + jsonrpc: "2.0", + id: "tool-1", + method: "tools/call", + params: { name: "progress_tool", arguments: {}, _meta: { progressToken: "token-1" } }, + }.to_json, + ) + response = @transport.handle_request(request) + response[2].call(post_stream) + result_queue.push(:done) + end + + progress_reported.pop + result_queue.pop + + # Verify progress notification was written to the POST response stream. + assert_includes post_stream.string, "notifications/progress" + assert_includes post_stream.string, "token-1" end test "POST notifications/initialized returns 202 with no body" do @@ -599,10 +4014,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] notif_request = create_rack_request( "POST", @@ -616,8 +4031,462 @@ module MCP response = @transport.handle_request(notif_request) assert_equal 202, response[0] - assert_empty(response[1]) - assert_empty(response[2]) + assert_equal({}, response[1]) + assert_equal([], response[2]) + end + + test "expired session returns 404 on GET request" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + assert(session_id) + + # Session should now be expired (timeout is 0) + sleep(0.01) + + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = transport.handle_request(get_request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]["message"]) + ensure + transport.close + end + + test "expired session returns 404 on POST request" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Session should now be expired (timeout is 0) + sleep(0.01) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]["message"]) + ensure + transport.close + end + + test "session_idle_timeout: nil disables session expiry" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Make a request - session should still be valid + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "session within timeout period remains valid" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + response = transport.handle_request(request) + assert_equal(200, response[0]) + ensure + transport.close + end + + test "session activity resets the idle timeout" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.5) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Send requests every 0.2s to keep the session alive. + # Total elapsed time (~0.6s) exceeds timeout (0.5s), but each request + # resets the idle timer so the session remains valid. + 3.times do + sleep(0.2) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = transport.handle_request(request) + assert_equal(200, response[0]) + end + ensure + transport.close + end + + test "reaper thread cleans up expired sessions" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + assert(session_id) + + # Wait for session to expire + sleep(0.02) + + # Manually trigger reaper since the background thread runs on 60s interval + transport.send(:reap_expired_sessions) + + # Session should have been reaped + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + response = transport.handle_request(get_request) + assert_equal(404, response[0]) + ensure + transport.close + end + + test "reaper thread cleans up expired sessions and POST returns 404" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + transport.send(:reap_expired_sessions) + + # POST to a reaped session should also return 404 + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = transport.handle_request(request) + assert_equal(404, response[0]) + + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]["message"]) + ensure + transport.close + end + + test "reap_expired_sessions closes stream outside mutex" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Replace the stream with one that verifies mutex is NOT held during close. + mutex = transport.instance_variable_get(:@mutex) + closed_outside_mutex = false + mock_stream = Object.new + mock_stream.define_singleton_method(:close) do + # If stream.close runs outside the mutex, try_lock succeeds. + if mutex.try_lock + closed_outside_mutex = true + mutex.unlock + end + end + transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = mock_stream + + sleep(0.02) # Wait for session to expire. + + transport.send(:reap_expired_sessions) + + assert(closed_outside_mutex, "Stream should be closed outside the mutex") + assert_empty(transport.instance_variable_get(:@sessions)) + ensure + transport.close + end + + test "close stops the reaper thread" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600) + reaper_thread = transport.instance_variable_get(:@reaper_thread) + assert reaper_thread + assert reaper_thread.alive? + + transport.close + + sleep(0.01) + refute reaper_thread.alive? + assert_nil transport.instance_variable_get(:@reaper_thread) + end + + test "reaper thread is not started when session_idle_timeout is nil" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "applies a finite session_idle_timeout by default and starts the reaper" do + # Secure default: stateful mode expires idle sessions out of the box. + transport = StreamableHTTPTransport.new(@server) + assert_equal( + StreamableHTTPTransport::DEFAULT_SESSION_IDLE_TIMEOUT, + transport.instance_variable_get(:@session_idle_timeout), + ) + assert(transport.instance_variable_get(:@reaper_thread).alive?) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + # A freshly created session is well within the timeout, so normal use is unaffected. + session_id = transport.handle_request(init_request)[1]["mcp-session-id"] + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + + assert_equal(200, transport.handle_request(request)[0]) + ensure + transport.close + end + + test "session_idle_timeout: nil opts out of expiry" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil) + assert_nil(transport.instance_variable_get(:@session_idle_timeout)) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "stateless mode keeps no idle timeout despite the secure default" do + transport = StreamableHTTPTransport.new(@server, stateless: true) + assert_nil(transport.instance_variable_get(:@session_idle_timeout)) + assert_nil(transport.instance_variable_get(:@reaper_thread)) + ensure + transport.close + end + + test "rejects new initialize with 503 once max_sessions is reached" do + transport = StreamableHTTPTransport.new(@server, max_sessions: 2) + + 2.times do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + assert_equal(200, transport.handle_request(request)[0]) + end + + overflow = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + response = transport.handle_request(overflow) + + assert_equal(503, response[0]) + assert_equal(2, transport.instance_variable_get(:@sessions).size) + ensure + transport.close + end + + test "at the cap, reclaims an already-expired session before rejecting" do + # A very short idle timeout so the first session is expired by the time the cap would otherwise reject + # a new initialize; the expired slot is reclaimed. + transport = StreamableHTTPTransport.new(@server, max_sessions: 1, session_idle_timeout: 0.01) + init = lambda do + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + )) + end + + assert_equal(200, init.call[0]) + sleep(0.05) # let the first session's idle timer expire + second = init.call + + # The expired session was reclaimed in-line, so the second initialize succeeds (200) rather than + # being rejected with 503, and the store stays at the cap. + assert_equal(200, second[0]) + assert_equal(1, transport.instance_variable_get(:@sessions).size) + ensure + transport.close + end + + test "max_sessions: nil opts out of the session cap" do + transport = StreamableHTTPTransport.new(@server, max_sessions: nil) + assert_nil(transport.instance_variable_get(:@max_sessions)) + ensure + transport.close + end + + test "raises ArgumentError for an invalid max_sessions" do + [0, -1, 1.5, "10"].each do |invalid| + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_sessions: invalid) } + end + end + + test "raises ArgumentError when session_idle_timeout is zero" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, session_idle_timeout: 0) + end + assert_equal("session_idle_timeout must be a positive number.", error.message) + end + + test "raises ArgumentError when session_idle_timeout is negative" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, session_idle_timeout: -1) + end + assert_equal("session_idle_timeout must be a positive number.", error.message) + end + + test "raises ArgumentError when session_idle_timeout is used with stateless mode" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, stateless: true, session_idle_timeout: 3600) + end + assert_equal("session_idle_timeout is not supported in stateless mode.", error.message) + end + + test "expired session does not receive targeted notification" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + + result = transport.send_notification("test/notify", { message: "hello" }, session_id: session_id) + refute(result) + ensure + transport.close + end + + test "expired session is skipped during broadcast notification" do + transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01) + + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + init_response = transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + # Attach a mock stream to the session + stream = StringIO.new + transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = stream + + # Wait for the session to exceed the idle timeout (0.01s) + sleep(0.02) + + sent_count = transport.send_notification("test/notify", { message: "hello" }, **{}) + assert_equal(0, sent_count) + ensure + transport.close end test "handles POST request with body including JSON-RPC response object and returns with no body" do @@ -625,10 +4494,10 @@ module MCP "POST", "/", { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, ) init_response = @transport.handle_request(init_request) - session_id = init_response[1]["Mcp-Session-Id"] + session_id = init_response[1]["mcp-session-id"] request = create_rack_request( "POST", @@ -642,13 +4511,1936 @@ module MCP response = @transport.handle_request(request) assert_equal 202, response[0] - assert_empty(response[1]) - assert_empty(response[2]) + assert_equal({}, response[1]) + assert_equal([], response[2]) + end + + test "POST response without session ID returns 400" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", result: "success", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Missing session ID", body["error"]["message"] + end + + test "POST response with invalid session ID returns 404" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + @transport.handle_request(init_request) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "invalid-session-id", + }, + { jsonrpc: "2.0", result: "success", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 404, response[0] + body = JSON.parse(response[2][0]) + assert_equal "Session not found", body["error"]["message"] + end + + test "handle_regular_request returns 404 for unknown session_id" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => "nonexistent-session", + }, + { jsonrpc: "2.0", method: "ping", id: "456" }.to_json, + ) + response = @transport.handle_request(request) + assert_equal(404, response[0]) + body = JSON.parse(response[2][0]) + assert_equal("Session not found", body["error"]["message"]) + end + + test "session-scoped log notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + transport = StreamableHTTPTransport.new(server) + + server.define_tool(name: "log_tool") do |server_context:| + server_context.notify_log_message(data: "secret", level: "info") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["mcp-session-id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["mcp-session-id"] + + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1. + tool_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { name: "log_tool", arguments: {} }, + }.to_json, + ) + tool_response = transport.handle_request(tool_request) + post_io = StringIO.new + tool_response[2].call(post_io) + + # Session 1's POST response stream should contain the log notification. + assert_includes post_io.string, "secret" + + # GET SSE streams should NOT receive the log notification. + io1.rewind + refute_includes io1.read, "secret" + io2.rewind + refute_includes io2.read, "secret" + end + + test "session-scoped progress notification is sent only to the originating session" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + + server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100, message: "halfway") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server.server_context = server + + # Create two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["mcp-session-id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["mcp-session-id"] + + # Connect SSE for both sessions. + io1 = StringIO.new + get1 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session1 }) + response1 = transport.handle_request(get1) + response1[2].call(io1) if response1[2].is_a?(Proc) + + io2 = StringIO.new + get2 = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session2 }) + response2 = transport.handle_request(get2) + response2[2].call(io2) if response2[2].is_a?(Proc) + + sleep(0.1) + + # Call tool from session 1 with a progress token. + tool_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { + name: "progress_tool", + arguments: {}, + _meta: { progressToken: "token-1" }, + }, + }.to_json, + ) + tool_response = transport.handle_request(tool_request) + post_io = StringIO.new + tool_response[2].call(post_io) + + # Session 1's POST response stream should contain the progress notification. + assert_includes post_io.string, "halfway" + + # GET SSE streams should NOT receive the progress notification. + io1.rewind + refute_includes io1.read, "halfway" + io2.rewind + refute_includes io2.read, "halfway" + end + + test "each session stores its own client info independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + + # Initialize session 1 with client "alpha". + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: initialize_params(clientInfo: { name: "alpha", version: "1.0" }), + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["mcp-session-id"] + + # Initialize session 2 with client "beta". + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: initialize_params(clientInfo: { name: "beta", version: "2.0" }), + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["mcp-session-id"] + + # Each session should have its own client info. + sessions = transport.instance_variable_get(:@sessions) + assert_equal({ name: "alpha", version: "1.0" }, sessions[session1][:server_session].client) + assert_equal({ name: "beta", version: "2.0" }, sessions[session2][:server_session].client) + end + + test "each session stores its own logging level independently" do + server = Server.new(name: "test", tools: [], prompts: [], resources: []) + transport = StreamableHTTPTransport.new(server) + + # Initialize two sessions. + init1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "1", + params: initialize_params(clientInfo: { name: "a", version: "1.0" }), + }.to_json, + ) + session1 = transport.handle_request(init1)[1]["mcp-session-id"] + + init2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { + jsonrpc: "2.0", + method: "initialize", + id: "2", + params: initialize_params(clientInfo: { name: "b", version: "1.0" }), + }.to_json, + ) + session2 = transport.handle_request(init2)[1]["mcp-session-id"] + + # Session 1 sets log level to "error". + set_level1 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session1 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "3", + params: { level: "error" }, + }.to_json, + ) + response1 = transport.handle_request(set_level1) + response1[2].call(StringIO.new) + + # Session 2 sets log level to "debug". + set_level2 = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session2 }, + { + jsonrpc: "2.0", + method: "logging/setLevel", + id: "4", + params: { level: "debug" }, + }.to_json, + ) + response2 = transport.handle_request(set_level2) + response2[2].call(StringIO.new) + + # Session 1 (error level) should not notify for "info", but should for "error". + session1_logging = transport.instance_variable_get(:@sessions)[session1][:server_session].logging_message_notification + refute session1_logging.should_notify?("info") + assert session1_logging.should_notify?("error") + + # Session 2 (debug level) should notify for both "info" and "debug". + session2_logging = transport.instance_variable_get(:@sessions)[session2][:server_session].logging_message_notification + assert session2_logging.should_notify?("info") + assert session2_logging.should_notify?("debug") + end + + test "call(env) works as a Rack app for POST requests" do + env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init-1", params: initialize_params }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + + response = @transport.call(env) + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + + body = JSON.parse(response[2][0]) + assert_equal "2.0", body["jsonrpc"] + assert_equal "init-1", body["id"] + end + + test "call(env) returns 405 for unsupported HTTP methods" do + env = { + "REQUEST_METHOD" => "PUT", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + } + + response = @transport.call(env) + assert_equal 405, response[0] + end + + test "call(env) handles GET SSE stream request" do + init_env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + init_response = @transport.call(init_env) + session_id = init_response[1]["mcp-session-id"] + + get_env = { + "REQUEST_METHOD" => "GET", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + "HTTP_ACCEPT" => "text/event-stream", + "HTTP_MCP_SESSION_ID" => session_id, + } + + response = @transport.call(get_env) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + assert response[2].is_a?(Proc) + end + + test "call(env) handles DELETE session request" do + init_env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "rack.input" => StringIO.new({ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json), + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + } + init_response = @transport.call(init_env) + session_id = init_response[1]["mcp-session-id"] + + delete_env = { + "REQUEST_METHOD" => "DELETE", + "PATH_INFO" => "/", + "rack.input" => StringIO.new(""), + "HTTP_MCP_SESSION_ID" => session_id, + } + + response = @transport.call(delete_env) + assert_equal 200, response[0] + + body = JSON.parse(response[2][0]) + assert body["success"] + end + + test "SSE response headers are not frozen so Rack middleware can modify them" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init-frozen", params: initialize_params }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["mcp-session-id"] + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "tools/list", id: "sse-headers-1" }.to_json, + ) + + status, headers, = @transport.handle_request(request) + assert_equal 200, status + assert_equal "text/event-stream", headers["content-type"] + refute headers.frozen?, "SSE response headers should not be frozen" + end + + test "rejects a request body over max_request_bytes with 413 without reading it all" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # Content-Length present: rejected before reading. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "CONTENT_LENGTH" => oversized.bytesize.to_s }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, JSON.parse(response[2][0]).dig("error", "code") + end + + test "rejects an oversized body even when Content-Length is absent" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # No CONTENT_LENGTH: the bounded read must still catch it. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + end + + test "accepts a request body at the max_request_bytes boundary" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 4096) + body = { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json + assert_operator body.bytesize, :<=, 4096 + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + body, + ) + response = transport.handle_request(request) + + assert_equal 200, response[0] + end + + test "rejects deeply nested JSON beyond the nesting cap as a parse error" do + depth = StreamableHTTPTransport::MAX_JSON_NESTING + 5 + nested = ("[" * depth) + ("]" * depth) + body = %({"jsonrpc":"2.0","method":"initialize","id":"1","params":{"deep":#{nested}}}) + + request = create_rack_request("POST", "/", { "CONTENT_TYPE" => "application/json" }, body) + response = @transport.handle_request(request) + + assert_equal 400, response[0] + assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, JSON.parse(response[2][0]).dig("error", "code") + end + + test "raises ArgumentError for an invalid max_request_bytes" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_request_bytes: invalid) } + end + end + + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + status, headers, body = transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["content-type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"]) + ensure + transport&.close + end + + test "handle_request returns 403 for GET when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "GET", + "/", + { "HTTP_ORIGIN" => "https://evil.example.com" }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request returns 403 for DELETE when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_ORIGIN" => "https://evil.example.com", + "HTTP_MCP_SESSION_ID" => "any", + }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin is in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin header is absent (e.g. non-browser client)" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request rejects a cross-origin request by default (secure-by-default)" do + # No allowed_origins configured: a browser cross-origin Origin is still rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://anywhere.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 403, status + end + + test "handle_request allows a same-origin browser request" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "localhost:3000", + "HTTP_ORIGIN" => "http://localhost:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 200, status + end + + test "handle_request rejects a rebound Host by default" do + # DNS rebinding: a foreign Host re-pointed at the loopback server is rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, headers, body = @transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["content-type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"]) + end + + test "handle_request allows loopback Host values by default" do + ["127.0.0.1:8080", "localhost:3000", "[::1]:8080"].each do |host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => host, + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status, "expected #{host} to be allowed") + ensure + transport.close + end + end + + test "handle_request matches Host case-insensitively" do + # Host names are case-insensitive, so an upper-cased loopback Host is allowed. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "LOCALHOST:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request allows a non-loopback Host listed in allowed_hosts" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + + test "handle_request matches a host:port entry in allowed_hosts exactly" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com:8443"]) + + allowed = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + rejected = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + assert_equal(200, transport.handle_request(allowed).first) + assert_equal(403, transport.handle_request(rejected).first) + ensure + transport.close + end + + test "handle_request treats a default port as same-origin" do + # `Origin: http://localhost` (no port) is same-origin with `Host: localhost:80`. + [ + ["http://localhost", "localhost:80"], + ["https://localhost", "localhost:443"], + ].each do |origin, host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match") + ensure + transport.close + end + end + + test "handle_request rejects opaque and malformed Origins" do + ["null", "file:///etc/passwd", "http://localhost:3000/path"].each do |origin| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected") + ensure + transport.close + end + end + + test "handle_request enforces Host validation on GET requests" do + request = create_rack_request( + "GET", + "/", + { "HTTP_HOST" => "evil.example.com" }, + ) + + status, = @transport.handle_request(request) + assert_equal(403, status) + end + + test "handle_request allows a request with no Host header" do + # Non-browser clients may omit Host; the rebinding vector always carries one. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request skips DNS rebinding checks when protection is disabled" do + transport = StreamableHTTPTransport.new(@server, dns_rebinding_protection: false) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + + test "rejects a POST to a session whose Origin differs from initialize with 403" do + # Both origins are allow-listed so they pass DNS rebinding protection; the rejection + # below comes from the per-session Origin-consistency gate, not from the rebinding check. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com", "https://evil.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + + attacker = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(attacker) + + assert_equal(403, response[0]) + ensure + transport.close + end + + test "allows a POST with a matching Origin and one with no Origin" do + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + + same_origin = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + # A non-browser client that sends no Origin is not rejected by the built-in check. + no_origin = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "2" }.to_json, + ) + + assert_equal(200, transport.handle_request(same_origin)[0]) + assert_equal(200, transport.handle_request(no_origin)[0]) + ensure + transport.close + end + + test "a custom session_request_validator rejects and allows requests" do + allowed = { value: true } + validator = ->(_request, _session_id) { allowed[:value] } + transport = StreamableHTTPTransport.new(@server, enable_json_response: true, session_request_validator: validator) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + + ping = lambda do + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + end + + assert_equal(200, transport.handle_request(ping.call)[0]) + allowed[:value] = false + assert_equal(403, transport.handle_request(ping.call)[0]) + ensure + transport.close + end + + test "the validator also gates notification and response POSTs, not just regular requests" do + # A stolen session ID must not be able to POST `notifications/cancelled` (which would cancel a victim's in-flight request) + # or a client response by bypassing the ownership gate. `initialize` establishes the session and is exempt, + # so a single transport whose validator always rejects still initializes. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + session_request_validator: ->(_request, _session_id) { false }, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + refute_nil(session_id) + + headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id } + cancelled = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "1" } }.to_json, + ) + client_response = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", id: "1", result: {} }.to_json, + ) + + assert_equal(403, transport.handle_request(cancelled)[0]) + assert_equal(403, transport.handle_request(client_response)[0]) + ensure + transport.close + end + + test "session_request_validator receives the request and session id" do + seen = {} + validator = ->(request, session_id) { + seen[:origin] = request.env["HTTP_ORIGIN"] + seen[:session_id] = session_id + true + } + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + session_request_validator: validator, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + )) + + assert_equal(session_id, seen[:session_id]) + assert_equal("https://app.example.com", seen[:origin]) + ensure + transport.close + end + + test "session validation does not apply in stateless mode" do + rejecting = ->(_request, _session_id) { false } + transport = StreamableHTTPTransport.new(@server, stateless: true, enable_json_response: true, session_request_validator: rejecting) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(request) + + # Stateless mode keeps no sessions, so the validator is never consulted. + assert_equal(200, response[0]) + ensure + transport.close + end + + test "modern POST serves a single sessionless JSON exchange" do + @server.define_tool(name: "echo_tool") do + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "echo_tool", arguments: {} }), + headers: { "HTTP_MCP_METHOD" => "tools/call", "HTTP_MCP_NAME" => "echo_tool" }, + )) + + assert_equal 200, response[0] + refute response[1].key?("mcp-session-id") + body = JSON.parse(response[2][0]) + assert_equal "ok", body.dig("result", "content", 0, "text") + end + + test "modern POST with an unsupported header version returns 400 with -32022 and the supported list" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}, version: "2027-01-01"), + version: "2027-01-01", + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32022, body.dig("error", "code")) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") + assert_equal "2027-01-01", body.dig("error", "data", "requested") + end + + test "modern POST with a header/body version mismatch returns 400 with -32020" do + # Header names the supported modern version; the body envelope claims another. + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}, version: "2027-01-01"), + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST with an Mcp-Method header mismatch returns 400 with -32020" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + headers: { "HTTP_MCP_METHOD" => "tools/call" }, + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST without the Mcp-Method header returns 400 with -32020" do + # The header is required on every modern POST; a missing required standard header is + # a validation failure, matching the TypeScript SDK's enforcement. + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + method_header: nil, + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32020, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "Mcp-Method" + end + + test "modern POST without the Mcp-Name header on a name-bearing method returns 400 with -32020" do + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "some_tool", arguments: {} }), + name_header: nil, + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32020, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "Mcp-Name" + end + + test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do + # `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives + # wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`. + uri = "file:///日本語.txt" + encoded = "=?base64?#{[uri].pack("m0")}?=" + + matched = @transport.handle_request(modern_rack_request( + modern_body("resources/read", { uri: uri }), + headers: { "HTTP_MCP_NAME" => encoded }, + )) + mismatched = @transport.handle_request(modern_rack_request( + modern_body("resources/read", { uri: "file:///other.txt" }), + headers: { "HTTP_MCP_NAME" => encoded }, + )) + + assert_equal 200, matched[0] + assert_equal 400, mismatched[0] + assert_equal(-32020, JSON.parse(mismatched[2][0]).dig("error", "code")) + end + + test "modern POST rejects an Mcp-Session-Id header" do + response = @transport.handle_request(modern_rack_request( + modern_body("ping", {}), + headers: { "HTTP_MCP_SESSION_ID" => "some-session" }, + )) + + assert_equal 400, response[0] + assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern GET and DELETE are not allowed" do + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + ) + delete_request = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + ) + + assert_equal 405, @transport.handle_request(get_request)[0] + assert_equal 405, @transport.handle_request(delete_request)[0] + end + + test "modern POST maps an unknown method to 404 with -32601" do + response = @transport.handle_request(modern_rack_request(modern_body("no/such_method", {}))) + + assert_equal 404, response[0] + assert_equal(-32601, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST requires the _meta envelope" do + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do + body = { + jsonrpc: "2.0", + method: "tools/list", + id: 1, + params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "modern POST serves an envelope without the optional clientInfo" do + body = { + jsonrpc: "2.0", + method: "tools/list", + id: 1, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert JSON.parse(response[2][0]).key?("result") + end + + test "modern POST answers removed lifecycle methods with 404 and -32601" do + MCP::Methods::MODERN_REMOVED_METHODS.each do |method| + response = @transport.handle_request(modern_rack_request(modern_body(method, {}))) + + assert_equal 404, response[0], "expected 404 for #{method}" + body = JSON.parse(response[2][0]) + assert_equal(-32601, body.dig("error", "code"), "expected -32601 for #{method}") + assert_equal 1, body["id"], "expected the request id to be preserved for #{method}" + end + end + + test "modern POST maps a missing client capability to 400 with -32021" do + @server.define_tool(name: "guarded_tool") do |server_context:| + server_context.require_client_capability!(:elicitation, :form) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "guarded_tool", arguments: {} }), + headers: { "HTTP_MCP_NAME" => "guarded_tool" }, + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32021, body.dig("error", "code")) + assert_equal({ "elicitation" => { "form" => {} } }, body.dig("error", "data", "requiredCapabilities")) + end + + test "modern POST streams handler notifications ahead of the final response" do + @server.define_tool(name: "progress_tool") do |server_context:| + server_context.report_progress(50, total: 100) + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "progress_tool", + arguments: {}, + _meta: { + progressToken: "tok-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + frames = response[2][0].split("\n\n").reject(&:empty?).map do |chunk| + JSON.parse(chunk.sub(/\Aevent: message\ndata: /, "")) + end + assert_equal "notifications/progress", frames.first["method"] + assert_equal "tok-1", frames.first.dig("params", "progressToken") + assert frames.last.key?("result"), "expected the final frame to carry the result" + assert_equal 1, frames.last["id"] + end + + test "modern POST bounds the buffered notifications per request" do + # The sink holds notifications in memory until the handler returns, so a handler + # emitting in proportion to client input must not grow one request without bound. + overflow = 5 + delivery_results = [] + @server.define_tool(name: "flood_tool") do |server_context:| + (StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + overflow).times do |i| + delivery_results << server_context.notify_log_message(data: "message #{i}", level: "info") + end + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "flood_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "info", + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + frames = response[2][0].split("\n\n").reject(&:empty?) + assert_equal StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + 1, frames.size + assert_equal overflow, delivery_results.count { |result| result == false } + end + + test "modern POST without handler notifications keeps the single JSON exchange" do + response = @transport.handle_request(modern_rack_request(modern_body("tools/list", {}))) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + end + + test "modern POST suppresses log notifications without the envelope logLevel" do + # SEP-2575: log delivery is authorized per request via the `_meta` logLevel; + # the server-wide level does not apply to modern requests. + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + @server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "logging_tool", arguments: {} }), + )) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + refute_includes response[2][0], "notifications/message" + end + + test "modern POST delivers log notifications when the envelope carries logLevel" do + @server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "logging_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "info", + }, + }, + }.to_json + + response = @transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["content-type"] + assert_includes response[2][0], "notifications/message" + end + + test "modern POST treats an unrecognized envelope logLevel as absent" do + # Matching the Python SDK, an unrecognized level reads as absent: + # delivery stays off instead of erroring on every log call. + reported = [] + configuration = MCP::Configuration.new(exception_reporter: ->(e, _ctx) { reported << e }) + server = Server.new(name: "log_level_test", configuration: configuration) + transport = StreamableHTTPTransport.new(server, dns_rebinding_protection: false) + server.define_tool(name: "logging_tool") do |server_context:| + server_context.notify_log_message(data: "hi", level: "info") + Tool::Response.new([{ type: "text", text: "done" }]) + end + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "logging_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/logLevel": "bogus", + }, + }, + }.to_json + + response = transport.handle_request(modern_rack_request(body)) + + assert_equal 200, response[0] + assert_equal "application/json", response[1]["content-type"] + refute_includes response[2][0], "notifications/message" + assert_empty reported + end + + test "modern POST serves server/discover carrying the envelope" do + response = @transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + + assert_equal 200, response[0] + refute response[1].key?("mcp-session-id") + body = JSON.parse(response[2][0]) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + end + + test "modern POST rejects an envelope-less server/discover with 400 and -32602" do + # The 2026-07-28 conformance requirements validate the `_meta` envelope on `server/discover` like + # on every other modern request (SEP-2575). + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "legacy POST rejects a modern envelope body without the modern header as -32020" do + # Without header-primary routing evidence, the body-level triple would silently fall through + # the legacy path via the header default. + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + modern_body("ping", {}), + )) + + assert_equal 400, response[0] + assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "sessionless POST initialize with the dual-era header stays legacy and is counter-offered" do + # The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is + # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle + # and is counter-offered the latest handshake version (SEP-2575 era model). + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + refute_nil response[1]["mcp-session-id"] + assert_equal "2025-11-25", JSON.parse(response[2][0]).dig("result", "protocolVersion") + end + + test "session-bound POST with the dual-era header stays on the legacy path" do + # A deliberately lenient reading. The header names a version the handshake refused to + # negotiate, so it disagrees with the session; the session wins and the request is served + # at the negotiated revision, unstamped. Rejecting instead would read the spec's + # "invalid or unsupported MCP-Protocol-Version MUST be 400" as covering a version this + # server does in fact support (through the modern lifecycle), and would break a client + # over what is a SHOULD on its side: sending the negotiated version rather than its own. + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, + )) + + assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = sse_events(io).first + + # No `resultType`: the header claim does not move the session onto the modern wire. + refute(body["result"].key?("resultType")) + end + + test "session-bound DELETE with the dual-era header terminates the legacy session" do + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "DELETE", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + )) + + assert_equal 200, response[0] + end + + test "session-bound POST with the dual-era header carrying the modern envelope is rejected as -32600" do + # The session already negotiated the legacy lifecycle, and a connection can never change eras + # (mirroring the stdio era lock). + session_id = initialize_test_session + + response = @transport.handle_request(create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + }, + modern_body("ping", {}), + )) + + assert_equal 400, response[0] + body = JSON.parse(response[2][0]) + assert_equal(-32600, body.dig("error", "code")) + assert_includes body.dig("error", "message"), "legacy lifecycle" + end + + test "modern POST larger than max_request_bytes returns 413" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 64) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, + modern_body("ping", { filler: "A" * 200 }), + ) + + response = transport.handle_request(request) + assert_equal(413, response[0]) + ensure + transport.close + end + + test "subscriptions/listen opens an SSE stream and acknowledges the honored subset first" do + # `resourceSubscriptions` honoring requires the `resources.subscribe` capability flag, + # which the server defaults do not declare. + server = Server.new( + name: "listen_test", + capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + events = sse_events(io) + assert_equal(1, events.size) + ack = events[0] + assert_equal("notifications/subscriptions/acknowledged", ack["method"]) + assert_equal( + { "toolsListChanged" => true, "resourceSubscriptions" => ["file:///a.txt"] }, + ack.dig("params", "notifications"), + ) + assert_equal("listen-1", ack.dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) + ensure + transport.close + end + + test "SSE responses disable proxy buffering" do + # The spec asks servers to send `X-Accel-Buffering: no` on SSE streams so a reverse proxy + # delivers events as they are written; on a listen stream, buffering would also swallow + # the keepalive frames that detect a dropped peer. + listen = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + session_id = initialize_test_session + get_stream = @transport.handle_request(create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id })) + + assert_equal("no", listen[1]["x-accel-buffering"]) + assert_equal("no", get_stream[1]["x-accel-buffering"]) + end + + test "subscriptions/listen acknowledgement omits notification types the server does not support" do + server = Server.new(name: "listen_test", capabilities: { tools: { listChanged: true } }) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, promptsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + ack = sse_events(io)[0] + assert_equal({ "toolsListChanged" => true }, ack.dig("params", "notifications")) + ensure + transport.close + end + + test "subscriptions/listen requires a notifications filter object" do + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: {}), + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "subscriptions/listen requires the modern _meta envelope" do + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "subscriptions/listen", id: "listen-1", params: { notifications: {} } }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) + end + + test "subscriptions/listen delivers only opted-in notifications with the subscriptionId" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + @server.notify_tools_list_changed + # Not opted in: MUST NOT be delivered. + @server.notify_prompts_list_changed + + events = sse_events(io) + assert_equal 2, events.size + assert_equal "notifications/subscriptions/acknowledged", events[0]["method"] + assert_equal "notifications/tools/list_changed", events[1]["method"] + assert_equal "listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + end + + test "subscriptions/listen delivers resource updates only for subscribed URIs" do + server = Server.new( + name: "listen_test", + capabilities: { resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { resourceSubscriptions: ["file:///subscribed.txt"] }, + transport: transport, + ) + + # `**{}` keeps the params Hash positional on Ruby 2.7, matching the other `send_notification` tests. + transport.send_notification("notifications/resources/updated", { uri: "file:///subscribed.txt" }, **{}) + transport.send_notification("notifications/resources/updated", { uri: "file:///other.txt" }, **{}) + + events = sse_events(io) + assert_equal(2, events.size) + assert_equal("notifications/resources/updated", events[1]["method"]) + assert_equal("file:///subscribed.txt", events[1].dig("params", "uri")) + assert_equal("listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) + ensure + transport.close + end + + test "notifications are delivered only after the listen acknowledgement" do + # Inject an entry in the registered-but-not-yet-acknowledged state: the window between + # the registry insert and the acknowledgement write, which happens outside the lock. + io = StringIO.new + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { + stream: io, filter: { toolsListChanged: true }, active: false, write_mutex: Mutex.new + } + + @server.notify_tools_list_changed + + assert_empty sse_events(io) + + @transport.send(:activate_listen_subscription, "listen-1") + @server.notify_tools_list_changed + + assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] } + end + + test "a delivery racing the graceful teardown cannot write after the final result" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + entry = @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] + + @transport.close + + # Simulate an in-flight delivery that snapshotted the entry before teardown cleared + # the registry: the closed flag set under the write mutex makes it a no-op. + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = entry + @server.notify_tools_list_changed + + events = sse_events(io) + + assert_equal "complete", events.last.dig("result", "resultType") + refute(events.any? { |event| event["method"] == "notifications/tools/list_changed" }) + end + + test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do + first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }) + + @server.notify_tools_list_changed + + first_events = sse_events(first) + second_events = sse_events(second) + assert_equal "listen-1", first_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + assert_equal "listen-2", second_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") + end + + test "subscriptions/listen closes gracefully with a SubscriptionsListenResult on transport close" do + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + @transport.close + + events = sse_events(io) + result = events.last + assert_equal "listen-1", result["id"] + assert_equal "listen-1", result.dig("result", "_meta", "io.modelcontextprotocol/subscriptionId") + # `SubscriptionsListenResult` is a 2026-07-28 result, so it carries the REQUIRED `resultType`. + assert_equal "complete", result.dig("result", "resultType") + assert_predicate io, :closed? + + # The subscription is gone: further notifications are not delivered anywhere. + @server.notify_tools_list_changed + assert_equal events, sse_events(io) + end + + test "subscriptions/listen honoring reads the capability flags, not capability presence" do + # A server declaring `tools` without `listChanged: true` promises no + # list-changed delivery, so the acknowledgement omits the type. + server = Server.new(name: "listen_test", capabilities: { tools: {}, resources: { listChanged: true } }) + transport = StreamableHTTPTransport.new(server) + + io = open_listen_stream( + id: "listen-1", + notifications: { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, + transport: transport, + ) + + ack = sse_events(io)[0] + assert_equal({ "resourcesListChanged" => true }, ack.dig("params", "notifications")) + ensure + transport.close + end + + test "a buffered read of the listen stream body raises guidance instead of NoMethodError" do + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + error = assert_raises(RuntimeError) { response[2].first } + assert_includes error.message, "serve_subscriptions_listen: false" + + # The cited page is a published-URL contract, like the kwarg name above. + assert_includes error.message, "https://ruby.sdk.modelcontextprotocol.io/server/transports/" + + # Responding to `each` would make Rack classify the body as enumerable and break streaming. + refute_respond_to response[2], :each + end + + test "subscriptions/listen is refused as unimplemented when the transport does not serve it" do + transport = StreamableHTTPTransport.new(@server, serve_subscriptions_listen: false) + + status, headers, body = transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + + assert_equal 404, status + assert_equal "application/json", headers["content-type"] + + # The documented Rails controller pattern renders `body.first`, so the refusal must be an Array body, + # never the streaming Proc. + assert_kind_of Array, body + assert_equal(-32601, JSON.parse(body.first).dig("error", "code")) + ensure + transport.close + end + + test "discover stops advertising subscription capabilities when listen is not served" do + server = Server.new( + name: "listen_test", + capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, + ) + transport = StreamableHTTPTransport.new(server, serve_subscriptions_listen: false) + + response = transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + capabilities = JSON.parse(response[2].first).dig("result", "capabilities") + + assert_nil capabilities.dig("tools", "listChanged") + assert_nil capabilities.dig("resources", "listChanged") + assert_nil capabilities.dig("resources", "subscribe") + ensure + transport.close + end + + test "subscriptions/listen past the concurrent stream cap is rejected with 503" do + transport = StreamableHTTPTransport.new(@server, max_listen_subscriptions: 1) + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) + + response = transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-2", params: { notifications: { toolsListChanged: true } }), + )) + + assert_equal(503, response[0]) + body = JSON.parse(response[2][0]) + assert_equal("listen-2", body["id"]) + assert_includes(body.dig("error", "message"), "maximum concurrent subscriptions/listen streams") + ensure + transport.close + end + + test "subscriptions/listen rejects a duplicate subscription id by closing the new stream" do + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + duplicate = StringIO.new + response = @transport.handle_request(modern_rack_request( + modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), + )) + response[2].call(duplicate) + + assert_predicate duplicate, :closed? + end + + test "listen keepalive writes a comment frame outside the mutex" do + # Register a subscription backed by a mutex-probing stream, then invoke the ping directly. + mutex = @transport.instance_variable_get(:@mutex) + writes = { total: 0, under_mutex: 0 } + ping = nil + stream = Object.new + stream.define_singleton_method(:write) do |data| + writes[:total] += 1 + mutex.try_lock ? mutex.unlock : writes[:under_mutex] += 1 + ping = data + end + stream.define_singleton_method(:flush) {} + @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { stream: stream, filter: {}, write_mutex: Mutex.new } + + @transport.send(:send_listen_keepalive_ping, "listen-1") + + assert_equal 1, writes[:total] + assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex" + assert_match(/\A: ping /, ping) + end + + test "listen keepalive frees the slot and closes the stream when the peer is gone" do + transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0.01) + io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) + io.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 + until transport.instance_variable_get(:@listen_subscriptions).empty? + flunk("keepalive did not free the slot for a dead peer") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + + assert_predicate io, :closed? + ensure + transport.close + end + + test "listen keepalive is not started when the interval is nil" do + # The set of threads, not their count: `Thread.list` is process-wide, so a thread another test left running + # that finishes between the two samples moves the count in the direction this assertion does not care about. + # Only a thread that appears is evidence of a keepalive timer. + before = Thread.list + open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) + + assert_empty(Thread.list - before, "a nil interval must not spawn a keepalive thread") + end + + test "listen_keepalive_interval rejects a non-positive value" do + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0) } + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: -1) } end private + def initialize_test_session(id: "init") + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: id, params: initialize_params }.to_json, + ) + @transport.handle_request(init_request)[1]["mcp-session-id"] + end + + # Polls until the block is truthy; the work being awaited happens on another thread. + def wait_until(timeout: 5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until yield + flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep(0.01) + end + end + + # Initializes a session on `transport` and attaches a GET SSE stream to it, so + # server-to-client requests have somewhere to go. Returns the session id. + def open_sse_session(transport, io: StringIO.new) + session_id = transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ))[1]["mcp-session-id"] + + get_response = transport.handle_request(create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + )) + get_response[2].call(io) if get_response[2].is_a?(Proc) + wait_until { transport.instance_variable_get(:@sessions).dig(session_id, :get_sse_stream) } + + session_id + end + + # Installs a stream that records, on every write, whether `@mutex` was held at that moment. + # `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]` + # means a blocking write is being performed under the lock. + def install_mutex_probe_stream(session_id, related_request_id: nil) + mutex = @transport.instance_variable_get(:@mutex) + writes = { total: 0, under_mutex: 0 } + stream = Object.new + stream.define_singleton_method(:write) do |_data| + writes[:total] += 1 + if mutex.try_lock + mutex.unlock + else + writes[:under_mutex] += 1 + end + end + stream.define_singleton_method(:flush) {} + stream.define_singleton_method(:close) {} + + session = @transport.instance_variable_get(:@sessions)[session_id] + if related_request_id + (session[:post_request_streams] ||= {})[related_request_id] = stream + else + session[:get_sse_stream] = stream + end + + writes + end + + # Initializes `transport` and returns its session id together with the `ServerSession`, + # which the in-flight request id tests poll to know when a request has really started. + def start_session(transport) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(request)[1]["mcp-session-id"] + + [session_id, transport.instance_variable_get(:@sessions)[session_id][:server_session]] + end + + # A `tools/call` for `victim_tool` under a caller-chosen request id, with a progress token so + # the tool's `report_progress` has somewhere to go. + def colliding_tool_call(session_id, request_id) + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { + jsonrpc: "2.0", + id: request_id, + method: "tools/call", + params: { name: "victim_tool", arguments: {}, _meta: { progressToken: "tok" } }, + }.to_json, + ) + end + def create_rack_request(method, path, headers, body = nil) + default_accept = case method + when "POST" + { "HTTP_ACCEPT" => "application/json, text/event-stream" } + when "GET" + { "HTTP_ACCEPT" => "text/event-stream" } + else + {} + end + + env = { + "REQUEST_METHOD" => method, + "PATH_INFO" => path, + "rack.input" => StringIO.new(body.to_s), + }.merge(default_accept).merge(headers) + + Rack::Request.new(env) + end + + def create_rack_request_without_accept(method, path, headers, body = nil) env = { "REQUEST_METHOD" => method, "PATH_INFO" => path, @@ -657,6 +6449,86 @@ module MCP Rack::Request.new(env) end + + # Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header. + # Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST + # and `Mcp-Name` on a name-bearing method whose body carries a target name, so both default to + # the body's values. Pass `method_header: nil` / `name_header: nil` to omit one + # (for tests of the requirement itself) or a String to send a specific value. + def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body, + name_header: :from_body) + parsed = begin + JSON.parse(body) + rescue JSON::ParserError + nil + end + parsed = nil unless parsed.is_a?(Hash) + + derived_method = method_header == :from_body ? parsed&.dig("method") : method_header + derived_name = if name_header == :from_body + if StreamableHTTPTransport::NAME_BEARING_METHODS.include?(parsed&.dig("method")) + parsed.dig("params", "name") || parsed.dig("params", "uri") + end + else + name_header + end + + base_headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version } + base_headers["HTTP_MCP_METHOD"] = derived_method if derived_method + base_headers["HTTP_MCP_NAME"] = derived_name if derived_name + create_rack_request("POST", "/", base_headers.merge(headers), body) + end + + # Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope. + def modern_body(method, params, version: "2026-07-28", capabilities: {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + ), + }.to_json + end + + def modern_listen_body(id:, params:) + { + jsonrpc: "2.0", + method: "subscriptions/listen", + id: id, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + ), + }.to_json + end + + # Opens a `subscriptions/listen` stream on the modern path and returns the StringIO + # backing the SSE stream (already carrying the acknowledgement event). + def open_listen_stream(id:, notifications:, transport: @transport) + response = transport.handle_request(modern_rack_request( + modern_listen_body(id: id, params: { notifications: notifications }), + )) + + assert_equal(200, response[0]) + assert_equal("text/event-stream", response[1]["content-type"]) + + io = StringIO.new + response[2].call(io) + io + end + + # Parses every `data:` event written to an SSE StringIO. + def sse_events(io) + io.string.scan(/^data: (.+)$/).map { |match| JSON.parse(match[0]) } + end end end end diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb new file mode 100644 index 0000000..9fc28cf --- /dev/null +++ b/test/mcp/server_cancellation_test.rb @@ -0,0 +1,786 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerCancellationTest < ActiveSupport::TestCase + include InstrumentationTestHelper + include InitializeParamsTestHelper + + class MockTransport < Transport + attr_reader :requests, :notifications, :cancelled_request_ids + + def initialize(server) + super + @requests = [] + @notifications = [] + @cancelled_request_ids = [] + end + + def send_request(method, params = nil, **_kwargs) + @requests << { method: method, params: params } + {} + end + + def send_notification(method, params = nil, **_kwargs) + @notifications << { method: method, params: params } + true + end + + def cancel_pending_request(request_id, reason: nil) + @cancelled_request_ids << request_id + end + + def send_response(response); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new(name: "test_server", version: "1.0.0", configuration: configuration) + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + @session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "sess-1") + end + + test "cooperative cancellation via server_context suppresses response" do + observed_cancelled = false + + @server.define_tool(name: "slow") do |server_context:| + 20.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed_cancelled = server_context.cancelled? + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 42 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "user aborted" }, + ) + + response = call_thread.value + + assert observed_cancelled, "tool handler should observe cancellation" + assert_nil response, "cancelled request must not emit a JSON-RPC response" + end + + test "tool raising CancelledError via raise_if_cancelled! suppresses the JSON-RPC response" do + @server.define_tool(name: "raising") do |server_context:| + 50.times do + server_context.raise_if_cancelled! + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 99 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "raising", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "raised" }, + ) + + response = call_thread.value + + assert_nil response, "raise_if_cancelled! must result in no JSON-RPC response" + end + + test "CancelledError raised from nested server-to-client call suppresses response" do + @server.define_tool(name: "nesting") do |**| + # Simulate a transport-level CancelledError coming back from a nested + # send_request (e.g. sampling/createMessage after parent was cancelled). + raise MCP::CancelledError.new(request_id: "nested-xyz", reason: "parent aborted") + end + + response = @session.handle( + jsonrpc: "2.0", + id: 123, + method: Methods::TOOLS_CALL, + params: { name: "nesting", arguments: {} }, + ) + + assert_nil response, "CancelledError propagating from a nested call must suppress the JSON-RPC response" + end + + test "cancellation with unknown request id is silently ignored" do + response = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: "does-not-exist" }, + ) + + assert_nil response + end + + test "id-bearing notifications/cancelled is rejected with Method not found, not result: null" do + response = @session.handle( + jsonrpc: "2.0", + id: 1001, + method: Methods::NOTIFICATIONS_CANCELLED, + ) + + assert_equal 1001, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "id-bearing notifications/initialized is rejected with Method not found" do + response = @session.handle( + jsonrpc: "2.0", + id: 1002, + method: Methods::NOTIFICATIONS_INITIALIZED, + ) + + assert_equal 1002, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "id-bearing notifications/progress is rejected with Method not found" do + response = @session.handle( + jsonrpc: "2.0", + id: 1003, + method: Methods::NOTIFICATIONS_PROGRESS, + params: { progressToken: "tok", progress: 1 }, + ) + + assert_equal 1003, response[:id] + refute response.key?(:result) + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + end + + test "well-formed notifications/initialized still receives no response" do + response = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_INITIALIZED, + ) + + assert_nil response + end + + test "id-bearing notification mixed into a batch yields only its Method not found error" do + responses = @session.handle([ + { jsonrpc: "2.0", method: Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "x" } }, + { jsonrpc: "2.0", id: 2001, method: Methods::NOTIFICATIONS_CANCELLED }, + { jsonrpc: "2.0", id: 2002, method: Methods::PING }, + ]) + + cancelled_error = responses.find { |response| response[:id] == 2001 } + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, cancelled_error.dig(:error, :code) + + ping_response = responses.find { |response| response[:id] == 2002 } + assert_equal({}, ping_response[:result]) + + # The well-formed notification carries no id and contributes no response. + assert_equal 2, responses.size + end + + test "id-bearing custom notifications/* method is rejected with Method not found" do + called = false + @server.define_custom_method(method_name: "notifications/custom") { called = true } + + response = @session.handle( + jsonrpc: "2.0", + id: 3001, + method: "notifications/custom", + ) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code) + refute called + end + + test "well-formed custom notifications/* method is dispatched without a response" do + called = false + @server.define_custom_method(method_name: "notifications/custom") { called = true } + + response = @session.handle( + jsonrpc: "2.0", + method: "notifications/custom", + ) + + assert_nil response + assert called + end + + test "duplicate cancellation for the same in-flight request is idempotent" do + @server.define_tool(name: "slow_dup") do |server_context:| + 20.times do + break if server_context.cancelled? + + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 51 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow_dup", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + first = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "first" }, + ) + + second = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "second" }, + ) + + response = call_thread.value + + assert_nil first + assert_nil second, "duplicate cancel must not emit a response" + assert_nil response, "cancelled request must not emit a response" + end + + test "cancellation arriving after the request already completed is silently ignored" do + @server.define_tool(name: "quick") do + Tool::Response.new([{ type: "text", text: "done" }]) + end + + request_id = 61 + + response = @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "quick", arguments: {} }, + ) + + assert response, "the tool should have completed successfully before the cancel arrives" + assert_nil @session.lookup_in_flight(request_id), "completed request must be unregistered" + + late = @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "late" }, + ) + + assert_nil late, "late cancel for a completed request must be silently ignored" + end + + test "initialize request cannot be cancelled" do + init_params = { + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + clientInfo: { name: "test", version: "1.0" }, + capabilities: {}, + } + + response = @session.handle( + jsonrpc: "2.0", + id: "init-1", + method: Methods::INITIALIZE, + params: init_params, + ) + + assert response, "initialize returns a response" + assert response[:result][:protocolVersion] + # The initialize request is not registered in the in-flight map. + assert_nil @session.lookup_in_flight("init-1") + end + + test "ServerSession#cancel_request sends notification and cancels pending" do + @session.cancel_request(request_id: "req-9", reason: "timeout") + + notification = @mock_transport.notifications.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, notification[:method] + assert_equal "req-9", notification[:params][:requestId] + assert_equal "timeout", notification[:params][:reason] + + assert_includes @mock_transport.cancelled_request_ids, "req-9" + end + + test "register_in_flight refuses an id that is already in flight" do + first = @session.register_in_flight("req-1") + + assert first + assert_nil @session.register_in_flight("req-1"), "a second live request cannot share the id" + assert_same first, @session.lookup_in_flight("req-1"), "the first registration must survive" + end + + test "unregister_in_flight leaves a registration it does not own" do + owner = @session.register_in_flight("req-1") + @session.unregister_in_flight("req-1", cancellation: Cancellation.new(request_id: "req-1")) + + assert_same owner, @session.lookup_in_flight("req-1") + + @session.unregister_in_flight("req-1", cancellation: owner) + + assert_nil @session.lookup_in_flight("req-1") + end + + test "in_flight? reports whether an id is registered" do + refute @session.in_flight?("req-1") + + @session.register_in_flight("req-1") + + assert @session.in_flight?("req-1") + end + + test "a request reusing an in-flight id is answered with Invalid Request" do + @server.define_tool(name: "slow") do |server_context:| + sleep(0.2) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request = { + jsonrpc: "2.0", + id: "req-1", + method: Methods::TOOLS_CALL, + params: { name: "slow", arguments: {} }, + } + + in_flight = Thread.new { @session.handle(request) } + sleep(0.01) until @session.lookup_in_flight("req-1") + + duplicate = @session.handle(request) + + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + duplicate.dig(:error, :code) || duplicate.dig("error", "code"), + ) + in_flight.join + end + + test "a refused duplicate initialize leaves an in-flight registration under its reused id" do + @session.handle(jsonrpc: "2.0", id: "init", method: Methods::INITIALIZE, params: initialize_params) + + owner = @session.register_in_flight("req-1") + + # `initialize` bypasses the duplicate-id refusal (it is never in flight itself), so its + # rejection path is the one place a reused id reaches a handler while the id is still live. + duplicate = @session.handle(jsonrpc: "2.0", id: "req-1", method: Methods::INITIALIZE, params: initialize_params) + + assert_equal( + JsonRpcHandler::ErrorCode::INVALID_REQUEST, + duplicate.dig(:error, :code) || duplicate.dig("error", "code"), + ) + assert_same owner, @session.lookup_in_flight("req-1"), "the registration must survive the refused initialize" + end + + test "parent cancellation propagates to nested server-to-client requests" do + @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) + + parent_request_id = 101 + cancellation = @session.register_in_flight(parent_request_id) + + # Simulate a nested sampling/elicitation send via ServerSession. The transport + # is expected to receive `parent_cancellation:` and `server_session:` kwargs so + # it can install an `on_cancel` hook. Here we assert those kwargs are forwarded. + recorded = {} + (class << @mock_transport; self; end).define_method(:send_request) do |method, _params = nil, **kwargs| + recorded[:method] = method + recorded[:kwargs] = kwargs + {} + end + + @session.create_form_elicitation( + message: "ignored", + requested_schema: { type: "object", properties: {} }, + related_request_id: parent_request_id, + ) + + assert_equal Methods::ELICITATION_CREATE, recorded[:method] + assert_equal parent_request_id, recorded[:kwargs][:related_request_id] + assert_same cancellation, recorded[:kwargs][:parent_cancellation] + assert_same @session, recorded[:kwargs][:server_session] + end + + test "cancellation reason is recorded in instrumentation data" do + recorded_data = nil + @server.configuration.instrumentation_callback = ->(data) { recorded_data = data } + + @server.define_tool(name: "slow_with_reason") do |server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request_id = 77 + + call_thread = Thread.new do + @session.handle( + jsonrpc: "2.0", + id: request_id, + method: Methods::TOOLS_CALL, + params: { name: "slow_with_reason", arguments: {} }, + ) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "explicit reason string" }, + ) + + call_thread.value + + assert recorded_data + assert recorded_data[:cancelled] + assert_equal "explicit reason string", recorded_data[:cancellation_reason] + end + + # Helper for the non-tools cancellation regression tests below. Spawns a background + # request, waits for the in-flight entry, sends `notifications/cancelled`, and + # returns [response, thread_result_flag_yielded_by_block]. + def drive_cancellation(request_id:, method:, params:) + result_flag = { observed: false } + call_thread = Thread.new do + @session.handle(jsonrpc: "2.0", id: request_id, method: method, params: params) + end + + sleep(0.02) until @session.lookup_in_flight(request_id) + + yield(result_flag) if block_given? + + @session.handle( + jsonrpc: "2.0", + method: Methods::NOTIFICATIONS_CANCELLED, + params: { requestId: request_id, reason: "caller aborted" }, + ) + + [call_thread.value, result_flag] + end + + test "resources/read handler that opts in to server_context observes cancellation" do + observed = false + @server.resources_read_handler do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + [{ uri: "test://resource", text: "done" }] + end + + response, = drive_cancellation( + request_id: 201, + method: Methods::RESOURCES_READ, + params: { uri: "test://resource" }, + ) + + assert observed, "resources/read handler should observe cancellation via server_context" + assert_nil response, "cancelled resources/read must not emit a JSON-RPC response" + end + + test "completion/complete handler that opts in to server_context observes cancellation" do + observed = false + @server.capabilities[:completions] = {} + @server.completion_handler do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + { completion: { values: ["v"], hasMore: false } } + end + + # completion/complete requires a known ref; register a dummy prompt. + @server.define_prompt(name: "dummy") { MCP::Prompt::Result.new(messages: []) } + + response, = drive_cancellation( + request_id: 202, + method: Methods::COMPLETION_COMPLETE, + params: { ref: { type: "ref/prompt", name: "dummy" }, argument: { name: "arg", value: "" } }, + ) + + assert observed, "completion/complete handler should observe cancellation via server_context" + assert_nil response, "cancelled completion/complete must not emit a JSON-RPC response" + end + + test "prompts/get template that opts in to server_context observes cancellation" do + observed = false + prompt_class = Class.new(MCP::Prompt) do + prompt_name "slow_prompt" + + define_singleton_method(:template) do |_args, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + MCP::Prompt::Result.new(messages: []) + end + end + @server.prompts[prompt_class.name_value] = prompt_class + # Share `observed` between the closure above and the test scope. + @server.singleton_class.define_method(:_test_observed) { observed } + + response, = drive_cancellation( + request_id: 203, + method: Methods::PROMPTS_GET, + params: { name: "slow_prompt" }, + ) + + assert observed, "prompt template should observe cancellation via server_context" + assert_nil response, "cancelled prompts/get must not emit a JSON-RPC response" + end + + test "send_to_transport (notifications) works with a custom transport that only implements the abstract signature" do + # Regression: `ServerSession#send_to_transport` must not assume the + # transport's `send_notification` accepts the new kwargs + # (`session_id:` / `related_request_id:`). Custom transports that + # implement the abstract `Transport#send_notification(method, params = nil)` + # contract must keep working - this is exercised via `cancel_request` / + # `send_peer_cancellation` which the cancellation feature relies on. + minimal_transport = Class.new(Transport) do + attr_reader :recorded + + def initialize(server) + super + @recorded = [] + end + + def send_notification(method, params = nil) + @recorded << [method, params] + true + end + + def send_request(method, params = nil) + @recorded << [method, params] + {} + end + + def send_response(_); end + def open; end + def close; end + end.new(@server) + + session = ServerSession.new(server: @server, transport: minimal_transport) + + assert_nothing_raised do + session.cancel_request(request_id: "req-1", reason: "timeout") + end + + recorded = minimal_transport.recorded.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, recorded[0] + assert_equal "req-1", recorded[1][:requestId] + assert_equal "timeout", recorded[1][:reason] + end + + test "send_to_transport_request works with a custom transport that only implements the abstract signature" do + # Regression: `ServerSession#send_to_transport_request` must not assume + # the transport's `send_request` accepts the new kwargs + # (`session_id:` / `related_request_id:` / `parent_cancellation:` / + # `server_session:`). Custom transports that implement the abstract + # `Transport#send_request(method, params = nil)` contract must keep working. + minimal_transport = Class.new(Transport) do + attr_reader :recorded + + def initialize(server) + super + @recorded = [] + end + + def send_request(method, params = nil) + @recorded << [method, params] + {} # fake response + end + + def send_notification(method, params = nil) + @recorded << [method, params] + true + end + + def send_response(_); end + def open; end + def close; end + end.new(@server) + + session = ServerSession.new(server: @server, transport: minimal_transport) + session.instance_variable_set(:@client_capabilities, { sampling: {} }) + + assert_nothing_raised do + session.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "hi" } }], + max_tokens: 10, + ) + end + + recorded = minimal_transport.recorded.last + assert_equal Methods::SAMPLING_CREATE_MESSAGE, recorded[0] + refute_nil recorded[1] + end + + test "tool with positional `server_context` parameter is not auto-opted-in" do + # Regression: `accepts_server_context?` (used by tool/prompt dispatch) must + # require `server_context` as a *keyword* parameter. A tool whose `call` + # signature is `def self.call(arg, server_context)` (positional name collision) + # would previously have been opt-in, and `tool.call(**args, server_context: ctx)` + # would have blown up or passed the wrapped Hash to the wrong slot. + tool = Class.new(MCP::Tool) do + tool_name "positional_collision" + input_schema(properties: { a: { type: "string" } }, required: ["a"]) + + class << self + def call(a, server_context) + [a, server_context] + end + end + end + @server.tools[tool.name_value] = tool + + # Should dispatch without `server_context:` kwarg - the positional arg collision + # must NOT trigger opt-in. The tool receives only its declared positional (a). + response = @session.handle( + jsonrpc: "2.0", + id: 303, + method: Methods::TOOLS_CALL, + params: { name: "positional_collision", arguments: { a: "hello" } }, + ) + + # Because the tool signature isn't opt-in, `tool.call(**args)` is called + # (without `server_context:`). The method has `server_context` as a required + # positional, so missing it raises ArgumentError - caught as an internal error. + # The key assertion is that we do NOT pass `server_context:` kwarg that would + # flow into the positional slot as a Hash. + assert response.dig(:error), "dispatch should not silently inject server_context kwarg" + end + + test "handler with positional `server_context` parameter is not auto-opted-in" do + # Regression: `handler_declares_server_context?` must require `server_context` + # as a *keyword* parameter. A positional parameter that happens to be named + # `server_context` (rare but possible) would previously have been treated as + # opt-in, and the dispatch site would call `handler.call(params, server_context: ctx)` + # - the second positional argument would become the Hash `{server_context: ctx}`, + # which is never what the user intended. + received_args = nil + @server.define_custom_method(method_name: "custom/positional_name") do |params, server_context| + received_args = [params, server_context] + { ok: true } + end + + response = @session.handle( + jsonrpc: "2.0", + id: 302, + method: "custom/positional_name", + params: { hello: "world" }, + ) + + assert_equal [{ hello: "world" }, nil], + received_args, + "positional `server_context` must receive the single `params` argument, not an auto-wrapped context" + assert_equal({ ok: true }, response[:result]) + end + + test "handler with **kwargs-only signature is not auto-opted-in to server_context" do + # Regression: a block like `|**opts|` would have triggered opt-in under the + # looser `accepts_server_context?` check used by tools, and the dispatch site + # would then call `handler.call(params, server_context:)` - but a proc that + # only declares `**opts` cannot accept the positional `params` (lambdas raise + # ArgumentError, non-lambda procs silently drop it). The stricter + # `handler_declares_server_context?` check requires `server_context` as a + # named keyword to opt in, so a kwargs-only handler receives only `params`. + received_positional = nil + @server.define_custom_method(method_name: "custom/kwargs_only") do |params, **_opts| + received_positional = params + { ok: true } + end + + response = @session.handle( + jsonrpc: "2.0", + id: 301, + method: "custom/kwargs_only", + params: { hello: "world" }, + ) + + assert_equal({ hello: "world" }, received_positional) + assert_equal({ ok: true }, response[:result]) + end + + test "custom method handler that opts in to server_context observes cancellation" do + observed = false + @server.define_custom_method(method_name: "custom/slow") do |_params, server_context:| + 50.times do + break if server_context.cancelled? + + sleep(0.01) + end + observed = server_context.cancelled? + { ok: true } + end + + response, = drive_cancellation( + request_id: 204, + method: "custom/slow", + params: {}, + ) + + assert observed, "custom method handler should observe cancellation via server_context" + assert_nil response, "cancelled custom method must not emit a JSON-RPC response" + end + + test "send_peer_cancellation routes notification on parent stream via related_request_id" do + recorded_notifications = [] + (class << @mock_transport; self; end).define_method(:send_notification) do |method, params = nil, **kwargs| + recorded_notifications << { method: method, params: params, kwargs: kwargs } + true + end + + @session.send_peer_cancellation( + nested_request_id: "nested-1", + related_request_id: 42, + reason: "parent aborted", + ) + + notif = recorded_notifications.last + assert_equal Methods::NOTIFICATIONS_CANCELLED, notif[:method] + assert_equal "nested-1", notif[:params][:requestId] + assert_equal "parent aborted", notif[:params][:reason] + # Crucially, the cancel notification targets the parent's stream, not the GET SSE stream. + assert_equal 42, notif[:kwargs][:related_request_id] + end + end +end diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 03ad3e6..10089c5 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -4,6 +4,430 @@ require "test_helper" module MCP class ServerContextTest < ActiveSupport::TestCase + test "ServerContext delegates method calls to the underlying context" do + context = { user: "test_user" } + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + assert_equal "test_user", server_context[:user] + end + + test "ServerContext respond_to? returns true for methods on the underlying context" do + context = { user: "test_user" } + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + assert_respond_to server_context, :[] + assert_respond_to server_context, :keys + end + + test "ServerContext respond_to? returns false for methods not on the underlying context" do + context = { user: "test_user" } + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + refute_respond_to server_context, :nonexistent_method + end + + test "ServerContext raises NoMethodError for methods not on the underlying context" do + context = { user: "test_user" } + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + assert_raises(NoMethodError) { server_context.nonexistent_method } + end + + test "ServerContext#list_roots delegates to notification_target" do + notification_target = mock + notification_target.expects(:list_roots).with( + related_request_id: nil, + ).returns({ roots: [{ uri: "file:///project", name: "Project" }] }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.list_roots + + assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots] + end + + test "ServerContext#list_roots stamps the originating request id" do + # Per SEP-2260, server-to-client requests are associated with the originating client request automatically. + notification_target = mock + notification_target.expects(:list_roots).with(related_request_id: "req-1").returns({ roots: [] }) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new( + mock, + progress: progress, + notification_target: notification_target, + related_request_id: "req-1", + ) + + server_context.list_roots + end + + test "ServerContext#create_sampling_message stamps related_request_id non-overridably" do + # A caller-supplied `related_request_id:` must not detach the request from its originating client request (SEP-2260): + # the literal keyword after `**kwargs` wins. + notification_target = mock + notification_target.expects(:create_sampling_message).with( + messages: [], + max_tokens: 5, + related_request_id: "req-1", + ).returns({}) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new( + mock, + progress: progress, + notification_target: notification_target, + related_request_id: "req-1", + ) + + server_context.create_sampling_message(messages: [], max_tokens: 5, related_request_id: "attacker") + end + + test "ServerContext#create_form_elicitation stamps related_request_id non-overridably" do + notification_target = mock + notification_target.expects(:create_form_elicitation).with( + message: "hi", + requested_schema: {}, + related_request_id: "req-1", + ).returns({}) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new( + mock, + progress: progress, + notification_target: notification_target, + related_request_id: "req-1", + ) + + server_context.create_form_elicitation(message: "hi", requested_schema: {}, related_request_id: "attacker") + end + + test "ServerContext#create_url_elicitation stamps related_request_id non-overridably" do + notification_target = mock + notification_target.expects(:create_url_elicitation).with( + message: "hi", + url: "https://example.com", + elicitation_id: "el-1", + related_request_id: "req-1", + ).returns({}) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new( + mock, + progress: progress, + notification_target: notification_target, + related_request_id: "req-1", + ) + + server_context.create_url_elicitation( + message: "hi", + url: "https://example.com", + elicitation_id: "el-1", + related_request_id: "attacker", + ) + end + + test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do + notification_target = mock + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + assert_raises(NoMethodError) { server_context.list_roots } + end + + test "ServerContext#ping delegates to notification_target" do + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil).returns({}) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.ping + + assert_equal({}, result) + end + + test "ServerContext forwards a per-request timeout to the notification target" do + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil, timeout: 12).returns({}) + notification_target.expects(:list_roots).with(related_request_id: nil, timeout: 34).returns({ roots: [] }) + notification_target.expects(:create_form_elicitation).with( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + related_request_id: nil, + timeout: 56, + ).returns({ action: "accept" }) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) + + server_context.ping(timeout: 12) + server_context.list_roots(timeout: 34) + server_context.create_form_elicitation( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + timeout: 56, + ) + end + + test "ServerContext omits timeout entirely when the caller does not ask for one" do + # A notification target predating the keyword (a custom object standing in for a session) keeps working, + # and the transport applies its own default. + notification_target = mock + notification_target.expects(:ping).with(related_request_id: nil).returns({}) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) + + server_context.ping + end + + test "ServerContext#ping raises NoMethodError when notification_target does not respond" do + notification_target = mock + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + assert_raises(NoMethodError) { server_context.ping } + end + + test "ServerContext#create_sampling_message delegates to notification_target over context" do + notification_target = mock + notification_target.expects(:create_sampling_message).with( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + related_request_id: nil, + ).returns({ role: "assistant", content: { type: "text", text: "Hi" } }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal "Hi", result[:content][:text] + end + + test "ServerContext#create_sampling_message falls back to context when notification_target does not respond" do + notification_target = mock + context = mock + context.expects(:create_sampling_message).with( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + related_request_id: nil, + ).returns({ role: "assistant", content: { type: "text", text: "Fallback" } }) + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal "Fallback", result[:content][:text] + end + + test "ServerContext#create_form_elicitation delegates to notification_target" do + notification_target = mock + notification_target.expects(:create_form_elicitation).with( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + related_request_id: nil, + ).returns(action: "accept", content: { name: "test_user" }) + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_form_elicitation( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + + assert_equal "accept", result[:action] + end + + test "ServerContext#create_url_elicitation delegates to notification_target" do + notification_target = mock + notification_target.expects(:create_url_elicitation).with( + message: "Please authorize", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + related_request_id: nil, + ).returns(action: "accept") + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + result = server_context.create_url_elicitation( + message: "Please authorize", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + + assert_equal "accept", result[:action] + end + + test "ServerContext#notify_elicitation_complete delegates to notification_target" do + notification_target = mock + notification_target.expects(:notify_elicitation_complete).with(elicitation_id: "abc-123") + + context = mock + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: notification_target) + + server_context.notify_elicitation_complete(elicitation_id: "abc-123") + end + + test "ServerContext delegates to custom object context" do + context = Object.new + def context.custom_method + "custom_value" + end + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + assert_equal "custom_value", server_context.custom_method + end + + test "ServerContext forwards positional, keyword, and block arguments to the context" do + context = Object.new + def context.combine(prefix, suffix:, &block) + block.call("#{prefix}-#{suffix}") + end + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(context, progress: progress, notification_target: mock) + + result = server_context.combine("a", suffix: "b", &:upcase) + + assert_equal "A-B", result + end + + test "ServerContext#report_progress works with nil context" do + progress = mock + progress.expects(:report).with(50, total: 100, message: nil).once + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) + server_context.report_progress(50, total: 100) + end + + test "ServerContext#notify_log_message is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil) + + assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } + end + + test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do + # Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send + # any `notifications/message` for the request. + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: nil) + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext#notify_log_message drops messages below the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: "error") + + server_context.notify_log_message(data: "test", level: "info") + end + + test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).with( + data: "test", level: "error", logger: nil, related_request_id: nil, + ).once + server_context = build_modern_server_context(notification_target, log_level: "warning") + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext exposes envelope data and falls back to the session on legacy requests" do + notification_target = mock + notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" }) + notification_target.stubs(:client_capabilities).returns({ roots: {} }) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) + modern_context = build_modern_server_context(notification_target) + + refute_predicate legacy_context, :modern? + assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info) + assert_equal({ roots: {} }, legacy_context.client_capabilities) + assert_nil legacy_context.protocol_version + + assert_predicate modern_context, :modern? + assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info) + assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities) + assert_equal "2026-07-28", modern_context.protocol_version + end + + test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "c", version: "1" }, + client_capabilities: { "elicitation" => { "form" => {} } }, + ) + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope) + + assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) } + assert_raises(ArgumentError) { server_context.require_client_capability! } + error = assert_raises(Server::MissingRequiredClientCapabilityError) do + server_context.require_client_capability!(:sampling) + end + assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data) + end + + test "ServerContext#notify_resources_updated delegates to notification_target" do + notification_target = mock + notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once + + progress = Progress.new(notification_target: notification_target, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) + + server_context.notify_resources_updated(uri: "test://resource-1") + end + + test "ServerContext#notify_resources_updated is a no-op when notification_target is nil" do + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil) + + assert_nothing_raised { server_context.notify_resources_updated(uri: "test://resource-1") } + end + # Tool without server_context parameter class SimpleToolWithoutContext < Tool tool_name "simple_without_context" @@ -27,7 +451,8 @@ module MCP class << self def call(message:, server_context: nil) - context_info = server_context ? "with context: #{server_context[:user]}" : "no context" + user = server_context.respond_to?(:[]) ? server_context[:user] : nil + context_info = user ? "with context: #{user}" : "no context" Tool::Response.new([ { type: "text", content: "ToolWithOptionalContext: #{message} (#{context_info})" }, ]) @@ -134,7 +559,7 @@ module MCP response[:result][:content][0][:content] end - test "tool with required server_context fails when server has no context" do + test "tool with required server_context returns protocol error in JSON-RPC format when server has no context" do server_no_context = Server.new( name: "test_server_no_context", tools: [ToolWithRequiredContext], @@ -152,9 +577,10 @@ module MCP response = server_no_context.handle(request) - assert response[:error] - # The error is wrapped as "Internal error calling tool..." + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] + assert_equal "Internal error calling tool tool_with_required_context", response[:error][:data] end test "call_tool_with_args correctly detects server_context parameter presence" do @@ -240,7 +666,8 @@ module MCP class << self def template(args, server_context: nil) - context_info = server_context ? "with context: #{server_context[:user]}" : "no context" + user = server_context.respond_to?(:[]) ? server_context[:user] : nil + context_info = user ? "with context: #{user}" : "no context" Prompt::Result.new( messages: [ Prompt::Message.new( @@ -413,5 +840,278 @@ module MCP assert_equal "FlexiblePrompt: Hello (context: present)", response[:result][:messages][0][:content][:text] end + + test "tool receives _meta when provided in request params" do + class ToolWithMeta < Tool + tool_name "tool_with_meta" + description "A tool that uses _meta" + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) + + class << self + def call(message:, server_context:) + meta_info = server_context.dig(:_meta, :provider, :metadata) + Tool::Response.new([ + { type: "text", content: "Message: #{message}, Metadata: #{meta_info}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolWithMeta], + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_meta", + arguments: { message: "Hello" }, + _meta: { + provider: { + metadata: "test_value", + }, + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Message: Hello, Metadata: test_value", + response[:result][:content][0][:content] + end + + test "_meta is nested within server_context" do + class ToolWithNestedMeta < Tool + tool_name "tool_with_nested_meta" + description "A tool that uses nested _meta" + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) + + class << self + def call(message:, server_context:) + user = server_context[:user] + session_id = server_context.dig(:_meta, :session_id) + Tool::Response.new([ + { type: "text", content: "User: #{user}, Session: #{session_id}, Message: #{message}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolWithNestedMeta], + server_context: { user: "test_user", original_field: "value" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_nested_meta", + arguments: { message: "Hello" }, + _meta: { + session_id: "abc123", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "User: test_user, Session: abc123, Message: Hello", + response[:result][:content][0][:content] + end + + test "_meta preserves original server_context" do + class ToolPreservesContext < Tool + tool_name "tool_preserves_context" + description "A tool that checks context preservation" + + class << self + def call(server_context:) + priority = server_context[:priority] + meta_priority = server_context.dig(:_meta, :priority) + Tool::Response.new([ + { type: "text", content: "Context priority: #{priority}, Meta priority: #{meta_priority}" }, + ]) + end + end + end + + server = Server.new( + name: "test_server", + tools: [ToolPreservesContext], + server_context: { priority: "low" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_preserves_context", + arguments: {}, + _meta: { + priority: "high", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Context priority: low, Meta priority: high", response[:result][:content][0][:content] + end + + test "prompt receives _meta when provided in request params" do + class PromptWithMeta < Prompt + prompt_name "prompt_with_meta" + description "A prompt that uses _meta" + arguments [Prompt::Argument.new(name: "message", required: true)] + + class << self + def template(args, server_context:) + meta_info = server_context.dig(:_meta, :request_id) + Prompt::Result.new( + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("Message: #{args[:message]}, Request ID: #{meta_info}"), + ), + ], + ) + end + end + end + + server = Server.new( + name: "test_server", + prompts: [PromptWithMeta], + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "prompts/get", + params: { + name: "prompt_with_meta", + arguments: { message: "Hello" }, + _meta: { + request_id: "req_12345", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "Message: Hello, Request ID: req_12345", + response[:result][:messages][0][:content][:text] + end + + test "non-Hash server_context is preserved when _meta is present" do + server_obj = Object.new + def server_obj.custom_method + "custom_value" + end + + tool_class = Class.new(Tool) do + tool_name "tool_with_non_hash_context" + + define_singleton_method(:call) do |server_context:, **_args| + Tool::Response.new([ + { type: "text", content: "custom: #{server_context.custom_method}" }, + ]) + end + end + + server = Server.new( + name: "test_server", + tools: [tool_class], + server_context: server_obj, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_non_hash_context", + arguments: {}, + _meta: { progressToken: "token-1" }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "custom: custom_value", response[:result][:content][0][:content] + end + + test "_meta is nested within server_context for prompts" do + class PromptWithNestedContext < Prompt + prompt_name "prompt_with_nested_context" + description "A prompt that uses nested context" + arguments [Prompt::Argument.new(name: "message", required: true)] + + class << self + def template(args, server_context:) + user = server_context[:user] + trace_id = server_context.dig(:_meta, :trace_id) + Prompt::Result.new( + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User: #{user}, Trace: #{trace_id}, Message: #{args[:message]}"), + ), + ], + ) + end + end + end + + server = Server.new( + name: "test_server", + prompts: [PromptWithNestedContext], + server_context: { user: "prompt_user" }, + ) + + request = { + jsonrpc: "2.0", + id: 1, + method: "prompts/get", + params: { + name: "prompt_with_nested_context", + arguments: { message: "World" }, + _meta: { + trace_id: "trace_xyz789", + }, + }, + } + + response = server.handle(request) + + assert response[:result] + assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World", + response[:result][:messages][0][:content][:text] + end + + private + + def build_modern_server_context(notification_target, log_level: nil) + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "modern_client", version: "2.0" }, + client_capabilities: { elicitation: { form: {} } }, + log_level: log_level, + ) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope) + end end end diff --git a/test/mcp/server_elicitation_test.rb b/test/mcp/server_elicitation_test.rb new file mode 100644 index 0000000..03f3609 --- /dev/null +++ b/test/mcp/server_elicitation_test.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerElicitationTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + class MockTransport < Transport + attr_reader :requests, :notifications + + def initialize(server) + super + @requests = [] + @notifications = [] + end + + def send_request(method, params = nil, **_kwargs) + @requests << { method: method, params: params } + { action: "accept" } + end + + def send_response(response); end + + def send_notification(method, params = nil, **_kwargs) + @notifications << { method: method, params: params } + end + + def open; end + def close; end + def handle_request(request); end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: configuration, + ) + + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + + @session = ServerSession.new(server: @server, transport: @mock_transport) + @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) + end + + test "#create_form_elicitation warns when called without related_request_id" do + # Per SEP-2260, server-to-client requests must be associated with an originating client request. + # `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing. + original_verbose = $VERBOSE + $VERBOSE = false + + assert_output(nil, /SEP-2260/) do + @session.create_form_elicitation( + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + ensure + $VERBOSE = original_verbose + end + + test "#create_url_elicitation warns when called without related_request_id" do + @session.instance_variable_set(:@client_capabilities, { elicitation: { url: true } }) + + original_verbose = $VERBOSE + $VERBOSE = false + + assert_output(nil, /SEP-2260/) do + @session.create_url_elicitation( + message: "Please sign in", + url: "https://example.com/signin", + elicitation_id: "el-1", + ) + end + ensure + $VERBOSE = original_verbose + end + + test "#create_form_elicitation does not warn when related_request_id is given" do + assert_silent do + @session.create_form_elicitation( + related_request_id: "req-1", + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + end + + test "#create_form_elicitation sends request through transport" do + result = @session.create_form_elicitation( + related_request_id: "req-1", + message: "Please provide your name", + requested_schema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + ) + + assert_equal 1, @mock_transport.requests.size + + request = @mock_transport.requests.first + + assert_equal Methods::ELICITATION_CREATE, request[:method] + assert_equal "form", request[:params][:mode] + assert_equal "Please provide your name", request[:params][:message] + assert_equal "object", request[:params][:requestedSchema][:type] + assert_equal({ action: "accept" }, result) + end + + test "#create_form_elicitation raises error when client does not support elicitation" do + @session.instance_variable_set(:@client_capabilities, {}) + + error = assert_raises(RuntimeError) do + @session.create_form_elicitation( + related_request_id: "req-1", + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + + assert_equal( + "Client does not support elicitation. The client must declare the `elicitation` capability during initialization.", + error.message, + ) + end + + test "#create_form_elicitation raises error when client capabilities are nil" do + @session.instance_variable_set(:@client_capabilities, nil) + @server.instance_variable_set(:@client_capabilities, nil) + + error = assert_raises(RuntimeError) do + @session.create_form_elicitation( + related_request_id: "req-1", + message: "Please provide your name", + requested_schema: { type: "object", properties: { name: { type: "string" } } }, + ) + end + + assert_equal( + "Client does not support elicitation. The client must declare the `elicitation` capability during initialization.", + error.message, + ) + end + + test "#create_url_elicitation sends url mode request through transport" do + @session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } }) + + result = @session.create_url_elicitation( + related_request_id: "req-1", + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + + assert_equal 1, @mock_transport.requests.size + + request = @mock_transport.requests.first + + assert_equal Methods::ELICITATION_CREATE, request[:method] + assert_equal "url", request[:params][:mode] + assert_equal "Please authorize access", request[:params][:message] + assert_equal "https://example.com/oauth", request[:params][:url] + assert_equal "abc-123", request[:params][:elicitationId] + assert_equal({ action: "accept" }, result) + end + + test "#create_url_elicitation raises error when client does not support url mode" do + error = assert_raises(RuntimeError) do + @session.create_url_elicitation( + related_request_id: "req-1", + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + end + + assert_equal( + "Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.", + error.message, + ) + end + + test "#create_url_elicitation raises error when client capabilities are nil" do + @session.instance_variable_set(:@client_capabilities, nil) + @server.instance_variable_set(:@client_capabilities, nil) + + error = assert_raises(RuntimeError) do + @session.create_url_elicitation( + related_request_id: "req-1", + message: "Please authorize access", + url: "https://example.com/oauth", + elicitation_id: "abc-123", + ) + end + + assert_equal( + "Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.", + error.message, + ) + end + + test "#notify_elicitation_complete sends notification through transport" do + @session.notify_elicitation_complete(elicitation_id: "abc-123") + + assert_equal 1, @mock_transport.notifications.size + + notification = @mock_transport.notifications.first + + assert_equal Methods::NOTIFICATIONS_ELICITATION_COMPLETE, notification[:method] + assert_equal "abc-123", notification[:params][:elicitationId] + end + + test "URLElicitationRequiredError can be raised from a tool handler" do + @server.define_tool(name: "needs_auth", description: "Needs OAuth") do + raise Server::URLElicitationRequiredError, [ + { mode: "url", elicitationId: "abc-123", url: "https://example.com/oauth", message: "Auth required" }, + ] + end + + response = JSON.parse(@server.handle_json(JSON.generate({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "needs_auth", arguments: {} }, + }))) + + assert_equal(-32042, response["error"]["code"]) + assert_equal "URL elicitation required", response["error"]["message"] + + elicitations = response["error"]["data"]["elicitations"] + + assert_equal 1, elicitations.size + assert_equal "abc-123", elicitations.first["elicitationId"] + + assert_instrumentation_data( + method: "tools/call", tool_name: "needs_auth", tool_arguments: {}, error: :url_elicitation_required, + ) + end + end +end diff --git a/test/mcp/server_input_required_legacy_shim_test.rb b/test/mcp/server_input_required_legacy_shim_test.rb new file mode 100644 index 0000000..ce0f4b4 --- /dev/null +++ b/test/mcp/server_input_required_legacy_shim_test.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + # The SEP-2322 dual-era authoring shim: a handler returning an `InputRequiredResult` + # on the legacy wire is fulfilled through real server-to-client requests and re-run, + # so handlers written in the 2026 style serve pre-2026 clients too. + class ServerInputRequiredLegacyShimTest < ActiveSupport::TestCase + # Answers every server-to-client request like a cooperative client and records what was sent. + class AnsweringTransport + attr_reader :sent + + def initialize(answers = {}) + @answers = answers + @sent = [] + end + + def send_request(method, params = nil, related_request_id: nil) + @sent << { method: method, params: params, related_request_id: related_request_id } + @answers.fetch(method) do + case method + when Methods::ELICITATION_CREATE + { action: "accept", content: { name: "Koichi" } } + when Methods::ROOTS_LIST + { roots: [{ uri: "file:///workspace", name: "workspace" }] } + when Methods::SAMPLING_CREATE_MESSAGE + { role: "assistant", content: { type: "text", text: "sampled" }, model: "test-model" } + end + end + end + + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + true + end + end + + # Raises like the bundled transports when the client answers a server-to-client request + # with a JSON-RPC error. + class RejectingTransport + def send_request(method, params = nil, related_request_id: nil) + raise StandardError, "Client returned an error for #{method} request (code: -1): User rejected" + end + + def send_notification(method, params = nil, session_id: nil, related_request_id: nil) + true + end + end + + setup do + @greeting_tool = Tool.define(name: "greeter") do |server_context:| + answer = server_context.input_response("who") + if answer + Tool::Response.new([{ type: "text", text: "Hello, #{answer.dig(:content, :name)}!" }]) + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + request_state: "round-1", + ) + end + end + end + + test "a legacy tools/call is fulfilled through a real elicitation and completes" do + transport = AnsweringTransport.new + session = legacy_session(tools: [@greeting_tool], transport: transport) + + response = session.handle(tool_call_request) + + assert_equal "Hello, Koichi!", response.dig(:result, :content, 0, :text) + refute response[:result].key?(:resultType) + + sent = transport.sent.first + assert_equal Methods::ELICITATION_CREATE, sent[:method] + assert_equal({ message: "Who?" }, sent[:params]) + + # SEP-2260: the fulfilment request stays associated with the originating request. + assert_equal 1, sent[:related_request_id] + end + + test "the handler re-runs with the collected responses and the raw requestState" do + seen = [] + tool = Tool.define(name: "stateful") do |server_context:| + seen << { responses: server_context.input_responses, state: server_context.request_state } + if server_context.request_state == "opaque-state" + Tool::Response.new([{ type: "text", text: "resumed" }]) + else + Server::InputRequiredResult.new( + input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: "opaque-state", + ) + end + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "stateful")) + + assert_equal "resumed", response.dig(:result, :content, 0, :text) + assert_equal 2, seen.size + assert_nil seen.first[:responses] + assert_equal "opaque-state", seen.last[:state] + assert_equal({ action: "accept", content: { name: "Koichi" } }, seen.last[:responses]["k"]) + end + + test "the shim bypasses requestState sealing" do + seen_state = nil + tool = Tool.define(name: "sealed") do |server_context:| + if server_context.request_state + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new( + input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: "plain-state", + ) + end + end + security = Server::RequestStateSecurity.new(key: "k" * 32) + session = legacy_session(tools: [tool], transport: AnsweringTransport.new, request_state_security: security) + + response = session.handle(tool_call_request(name: "sealed")) + + assert_equal "done", response.dig(:result, :content, 0, :text) + + # In-process replay round-trips the handler's own value; sealing is wire hardening. + assert_equal "plain-state", seen_state + end + + test "prompts/get and resources/read are shimmed too" do + prompt = Prompt.define(name: "greeting_prompt") do |_args, server_context:| + if server_context.input_response("who") + Prompt::Result.new(messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))]) + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + ) + end + end + server = Server.new(name: "shim_test", prompts: [prompt], resources: []) + server.resources_read_handler do |params| + # `server_context` is unavailable in this handler shape, so park once via the params-visible retry fields instead. + if params[:inputResponses] + [{ uri: params[:uri], mimeType: "text/plain", text: "read" }] + else + Server::InputRequiredResult.new( + input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, + ) + end + end + transport = AnsweringTransport.new + session = ServerSession.new(server: server, transport: transport, session_id: "legacy") + session.store_client_info(client: { name: "legacy" }, capabilities: { elicitation: { form: {} } }) + + prompt_response = session.handle( + { jsonrpc: "2.0", id: 10, method: Methods::PROMPTS_GET, params: { name: "greeting_prompt" } }, + ) + assert_equal "hi", prompt_response.dig(:result, :messages, 0, :content, :text) + + read_response = session.handle( + { jsonrpc: "2.0", id: 11, method: Methods::RESOURCES_READ, params: { uri: "file:///a.txt" } }, + ) + assert_equal "read", read_response.dig(:result, :contents, 0, :text) + end + + test "multiple rounds collect each round's answers" do + rounds = [] + tool = Tool.define(name: "two_rounds") do |server_context:| + rounds << server_context.input_responses&.keys + if server_context.input_response("second") + Tool::Response.new([{ type: "text", text: "done" }]) + elsif server_context.input_response("first") + Server::InputRequiredResult.new( + input_requests: { "second" => { method: Methods::ELICITATION_CREATE, params: { message: "2?" } } }, + request_state: "after-first", + ) + else + Server::InputRequiredResult.new( + input_requests: { "first" => { method: Methods::ELICITATION_CREATE, params: { message: "1?" } } }, + ) + end + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "two_rounds")) + + assert_equal "done", response.dig(:result, :content, 0, :text) + assert_equal [nil, ["first"], ["second"]], rounds + end + + test "a url-mode elicitation leg gains the elicitationId the legacy wire requires" do + # The 2026-07-28 in-band shape has no `elicitationId` (correlation rides `requestState`), + # but the 2025-11-25 wire requires the field on URL-mode elicitation requests. + transport = AnsweringTransport.new + tool = Tool.define(name: "url_asker") do |server_context:| + if server_context.input_response("auth") + Tool::Response.new([{ type: "text", text: "authed" }]) + else + Server::InputRequiredResult.new( + input_requests: { + "auth" => { + method: Methods::ELICITATION_CREATE, + params: { mode: "url", url: "https://example.com/auth", message: "Sign in" }, + }, + }, + ) + end + end + session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) + + response = session.handle(tool_call_request(name: "url_asker")) + + assert_equal "authed", response.dig(:result, :content, 0, :text) + sent = transport.sent.first + assert_equal "https://example.com/auth", sent[:params][:url] + refute_nil sent[:params][:elicitationId] + end + + test "a handler-supplied elicitationId rides the leg unchanged" do + transport = AnsweringTransport.new + tool = Tool.define(name: "url_asker") do |server_context:| + if server_context.input_response("auth") + Tool::Response.new([{ type: "text", text: "authed" }]) + else + Server::InputRequiredResult.new( + input_requests: { + "auth" => { + method: Methods::ELICITATION_CREATE, + params: { mode: "url", url: "https://example.com/auth", elicitationId: "mine" }, + }, + }, + ) + end + end + session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) + + session.handle(tool_call_request(name: "url_asker")) + + assert_equal "mine", transport.sent.first[:params][:elicitationId] + end + + test "a client answering a fulfilment leg with an error fails the original request" do + session = legacy_session(tools: [@greeting_tool], transport: RejectingTransport.new) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "a handler that never completes fails after the round cap" do + tool = Tool.define(name: "greedy") do |server_context:| + round = (server_context.request_state || "0").to_i + 1 + Server::InputRequiredResult.new( + input_requests: { "k#{round}" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, + request_state: round.to_s, + ) + end + session = legacy_session(tools: [tool], transport: AnsweringTransport.new) + + response = session.handle(tool_call_request(name: "greedy")) + + assert_equal(-32603, response.dig(:error, :code)) + assert_match(/#{Server::LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds/, response.dig(:error, :message)) + end + + test "embedded requests exceeding the declared capabilities fail without contacting the client" do + transport = AnsweringTransport.new + session = legacy_session(tools: [@greeting_tool], transport: transport, capabilities: {}) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + assert_match(/elicitation/, response.dig(:error, :message)) + assert_empty transport.sent + end + + test "opting out restores the strict legacy rejection" do + session = legacy_session( + tools: [@greeting_tool], + transport: AnsweringTransport.new, + input_required_legacy_shim: false, + ) + + response = session.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "a session-less legacy request is still rejected" do + server = Server.new(name: "shim_test", tools: [@greeting_tool]) + + response = server.handle(tool_call_request) + + assert_equal(-32603, response.dig(:error, :code)) + end + + test "modern requests keep the input_required result untouched" do + session = legacy_session(tools: [@greeting_tool], transport: transport = AnsweringTransport.new) + + response = session.handle({ + jsonrpc: "2.0", + id: 1, + method: Methods::TOOLS_CALL, + params: { + name: "greeter", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "modern", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, + }, + }, + }) + + assert_equal "input_required", response.dig(:result, :resultType) + assert_empty transport.sent + end + + private + + def legacy_session(tools:, transport:, capabilities: { elicitation: { form: {} } }, + input_required_legacy_shim: true, request_state_security: nil) + server = Server.new( + name: "shim_test", + tools: tools, + input_required_legacy_shim: input_required_legacy_shim, + request_state_security: request_state_security, + ) + session = ServerSession.new(server: server, transport: transport, session_id: "legacy") + session.store_client_info(client: { name: "legacy" }, capabilities: capabilities) + session + end + + def tool_call_request(name: "greeter") + { jsonrpc: "2.0", id: 1, method: Methods::TOOLS_CALL, params: { name: name, arguments: {} } } + end + end +end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 3b4ebf0..ffeb3a8 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -5,6 +5,7 @@ require "test_helper" module MCP class ServerNotificationTest < ActiveSupport::TestCase include InstrumentationTestHelper + include DeprecationWarningTestHelper class MockTransport < Transport attr_reader :notifications @@ -36,7 +37,6 @@ module MCP ) @mock_transport = MockTransport.new(@server) - @server.transport = @mock_transport end test "#notify_tools_list_changed sends notification through transport" do @@ -66,36 +66,179 @@ module MCP assert_nil notification[:params] end + test "#notify_log_message sends notification through transport" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + + assert_equal 1, @mock_transport.notifications.size + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] + assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error" }, @mock_transport.notifications.first[:params]) + end + + test "#notify_log_message does not warn when no protocol version has been established" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_no_deprecation_warning do + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] + end + + test "#notify_log_message does not warn on any server configuration" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings; + # the notification itself still goes out (or is suppressed below the configured level). + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_no_deprecation_warning do + server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + + assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] + + assert_no_deprecation_warning do + server.notify_log_message(data: { message: "test" }, level: "info") + end + + assert_equal 1, mock_transport.notifications.size + end + + test "#notify_log_message does not warn when configured protocol version is older" do + server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: Configuration.new(protocol_version: "2025-11-25"), + ) + MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + + assert_no_deprecation_warning do + server.notify_log_message(data: { error: "Connection Failed" }, level: "error") + end + end + + test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging + # is not deprecated; the below-configured-level suppression still applies. + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + session = ServerSession.new(server: server, transport: mock_transport) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + session: session, + ) + session.configure_logging(MCP::LoggingMessageNotification.new(level: "error")) + + assert_no_deprecation_warning do + session.notify_log_message(data: { message: "test" }, level: "info") + end + + assert_empty mock_transport.notifications + end + + test "ServerSession#notify_log_message on a modern-era session ignores the server-wide level" do + # SEP-2575: on the modern lifecycle, log delivery is authorized per request via + # the envelope's logLevel; the server-wide level MUST NOT apply. + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") + session = ServerSession.new(server: server, transport: mock_transport, era: :modern) + + session.notify_log_message(data: { message: "test" }, level: "error") + + assert_empty mock_transport.notifications + end + + test "ServerSession#notify_log_message on a modern-era session honors a per-request level" do + server = Server.new(name: "test_server", version: "1.0.0") + mock_transport = MockTransport.new(server) + session = ServerSession.new(server: server, transport: mock_transport, era: :modern) + session.configure_logging(MCP::LoggingMessageNotification.new(level: "info")) + + session.notify_log_message(data: { message: "test" }, level: "error") + + assert_equal 1, mock_transport.notifications.length + assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] + end + + test "#notify_log_message sends notification with logger through transport" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger") + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method] + assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error", "logger" => "DatabaseLogger" }, notification[:params]) + end + + test "#notify_log_message does not send notification with invalid log level" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "invalid") + + assert_equal 0, @mock_transport.notifications.size + end + + test "#notify_log_message does not send notification when level is below configured level" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "info") + + assert_equal 0, @mock_transport.notifications.size + end + + test "#notify_log_message sends notification when level is above configured level through transport" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "critical") + + assert_equal 1, @mock_transport.notifications.size + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications[0][:method] + assert_equal({ "data" => { message: "test" }, "level" => "critical" }, @mock_transport.notifications[0][:params]) + end + test "notification methods work without transport" do server_without_transport = Server.new(name: "test_server") + server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") # Should not raise any errors assert_nothing_raised do server_without_transport.notify_tools_list_changed server_without_transport.notify_prompts_list_changed server_without_transport.notify_resources_list_changed + server_without_transport.notify_log_message(data: { error: "Connection Failed" }, level: "error") end end test "notification methods handle transport errors gracefully" do - # Create a transport that raises errors - error_transport = Class.new(MockTransport) do + # Replace server's transport with one that raises on send_notification. + Class.new(MockTransport) do def send_notification(method, params = nil) raise StandardError, "Transport error" end end.new(@server) - @server.transport = error_transport + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") # Mock the exception reporter expected_contexts = [ { notification: "tools_list_changed" }, { notification: "prompts_list_changed" }, { notification: "resources_list_changed" }, + { notification: "log_message" }, ] call_count = 0 - @server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context| + @server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context| assert_kind_of StandardError, exception assert_equal "Transport error", exception.message assert_includes expected_contexts, context @@ -108,22 +251,44 @@ module MCP @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") end - assert_equal 3, call_count + assert_equal 4, call_count end test "multiple notification methods can be called in sequence" do @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - assert_equal 3, @mock_transport.notifications.size + assert_equal 4, @mock_transport.notifications.size notifications = @mock_transport.notifications assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method] assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method] assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method] + assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method] + end + + test "server.notify_log_message works after logging/setLevel via session" do + session = ServerSession.new(server: @server, transport: @mock_transport) + + # Client sends logging/setLevel through session. + @server.handle( + { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } }, + session: session, + ) + + # Server-level broadcast should still work because logging level + # is stored on both the session and the server. + @server.notify_log_message(data: "broadcast log", level: "info") + + log_notifications = @mock_transport.notifications.select { |n| n[:method] == Methods::NOTIFICATIONS_MESSAGE } + assert_equal 1, log_notifications.size + assert_equal "broadcast log", log_notifications.first[:params]["data"] end end end diff --git a/test/mcp/server_progress_test.rb b/test/mcp/server_progress_test.rb new file mode 100644 index 0000000..e13fde5 --- /dev/null +++ b/test/mcp/server_progress_test.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerProgressTest < ActiveSupport::TestCase + class MockTransport < Transport + attr_reader :notifications + + def initialize(server) + super + @notifications = [] + end + + def send_notification(method, params = nil) + @notifications << { method: method, params: params } + true + end + + def send_response(response); end + def open; end + def close; end + def handle_request(request); end + end + + # Tool without progress parameter. + class SimpleToolWithoutProgress < Tool + tool_name "simple_without_progress" + description "A tool that doesn't use progress" + input_schema(properties: { message: { type: "string" } }, required: ["message"]) + + class << self + def call(message:) + Tool::Response.new([{ type: "text", text: "SimpleToolWithoutProgress: #{message}" }]) + end + end + end + + # Tool with progress via server_context. + class ToolWithProgress < Tool + tool_name "tool_with_progress" + description "A tool that uses progress" + input_schema(properties: { message: { type: "string" } }, required: ["message"]) + + class << self + def call(message:, server_context:) + server_context.report_progress(50, total: 100, message: "halfway") + server_context.report_progress(100, total: 100, message: "done") + Tool::Response.new([{ type: "text", text: "ToolWithProgress: #{message}" }]) + end + end + end + + # Tool with server_context accessing both context data and progress. + class ToolWithContextAndProgress < Tool + tool_name "tool_with_context_and_progress" + description "A tool that uses both server_context and progress" + input_schema(properties: { message: { type: "string" } }, required: ["message"]) + + class << self + def call(message:, server_context:) + server_context.report_progress(100) + context_info = server_context[:user] || "none" + Tool::Response.new([{ type: "text", text: "ToolWithContextAndProgress: #{message} context=#{context_info}" }]) + end + end + end + + # Tool with **kwargs. + class ToolWithKwargs < Tool + tool_name "tool_with_kwargs" + description "A tool that uses **kwargs" + + class << self + def call(**kwargs) + context = kwargs[:server_context] + context.report_progress(75) + Tool::Response.new([{ type: "text", text: "ToolWithKwargs: progress=#{context ? "present" : "absent"}" }]) + end + end + end + + setup do + @server = Server.new( + name: "test_server", + version: "1.0.0", + tools: [SimpleToolWithoutProgress, ToolWithProgress, ToolWithContextAndProgress, ToolWithKwargs], + server_context: { user: "test_user" }, + ) + + @mock_transport = MockTransport.new(@server) + @session = ServerSession.new(server: @server, transport: @mock_transport) + end + + test "tool with progress parameter receives Progress instance and sends notifications via _meta.progressToken" do + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_progress", + arguments: { message: "Hello" }, + _meta: { progressToken: "token-abc" }, + }, + } + + response = @session.handle(request) + + assert response[:result] + assert_equal "ToolWithProgress: Hello", response[:result][:content][0][:text] + + assert_equal 2, @mock_transport.notifications.size + + first = @mock_transport.notifications[0] + assert_equal Methods::NOTIFICATIONS_PROGRESS, first[:method] + assert_equal "token-abc", first[:params]["progressToken"] + assert_equal 50, first[:params]["progress"] + assert_equal 100, first[:params]["total"] + assert_equal "halfway", first[:params]["message"] + + second = @mock_transport.notifications[1] + assert_equal Methods::NOTIFICATIONS_PROGRESS, second[:method] + assert_equal "token-abc", second[:params]["progressToken"] + assert_equal 100, second[:params]["progress"] + assert_equal 100, second[:params]["total"] + assert_equal "done", second[:params]["message"] + end + + test "tool without progress parameter works normally" do + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "simple_without_progress", + arguments: { message: "Hello" }, + }, + } + + response = @session.handle(request) + + assert response[:result] + assert_equal "SimpleToolWithoutProgress: Hello", response[:result][:content][0][:text] + assert_equal 0, @mock_transport.notifications.size + end + + test "server_context.report_progress is a no-op when no progressToken in request" do + received_context = :unset + + tool_class = Class.new(Tool) do + tool_name "progress_nil_tool" + + define_singleton_method(:call) do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "done" }]) + end + end + + server = Server.new( + name: "test_server", + tools: [tool_class], + server_context: { user: "test" }, + ) + server.transport = @mock_transport + session = ServerSession.new(server: server, transport: @mock_transport) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "progress_nil_tool", + arguments: {}, + }, + } + + session.handle(request) + + assert_instance_of ServerContext, received_context + assert_nothing_raised { received_context.report_progress(50) } + assert_equal 0, @mock_transport.notifications.size + end + + test "tool with both server_context and progress receives both" do + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_context_and_progress", + arguments: { message: "Hello" }, + _meta: { progressToken: "token-xyz" }, + }, + } + + response = @session.handle(request) + + assert response[:result] + assert_equal "ToolWithContextAndProgress: Hello context=test_user", response[:result][:content][0][:text] + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] + assert_equal "token-xyz", notification[:params]["progressToken"] + assert_equal 100, notification[:params]["progress"] + end + + test "tool with **kwargs receives Progress instance" do + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "tool_with_kwargs", + arguments: {}, + _meta: { progressToken: "token-kwargs" }, + }, + } + + response = @session.handle(request) + + assert response[:result] + assert_equal "ToolWithKwargs: progress=present", response[:result][:content][0][:text] + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] + assert_equal "token-kwargs", notification[:params]["progressToken"] + assert_equal 75, notification[:params]["progress"] + end + + test "block-defined tool with progress via server_context works" do + server = Server.new(name: "test_server") + server.transport = @mock_transport + + server.define_tool(name: "block_tool") do |server_context:| + server_context.report_progress(42, total: 100) + Tool::Response.new([{ type: "text", text: "block_tool done" }]) + end + + session = ServerSession.new(server: server, transport: @mock_transport) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "block_tool", + arguments: {}, + _meta: { progressToken: "block-token" }, + }, + } + + response = session.handle(request) + + assert response[:result] + assert_equal "block_tool done", response[:result][:content][0][:text] + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_PROGRESS, notification[:method] + assert_equal "block-token", notification[:params]["progressToken"] + assert_equal 42, notification[:params]["progress"] + assert_equal 100, notification[:params]["total"] + end + + test "multiple progress notifications during tool execution" do + server = Server.new(name: "test_server") + server.transport = @mock_transport + + server.define_tool(name: "multi_progress_tool") do |server_context:| + (1..5).each do |i| + server_context.report_progress(i * 20, total: 100) + end + Tool::Response.new([{ type: "text", text: "done" }]) + end + + session = ServerSession.new(server: server, transport: @mock_transport) + + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "multi_progress_tool", + arguments: {}, + _meta: { progressToken: "multi-token" }, + }, + } + + session.handle(request) + + assert_equal 5, @mock_transport.notifications.size + @mock_transport.notifications.each_with_index do |n, i| + assert_equal Methods::NOTIFICATIONS_PROGRESS, n[:method] + assert_equal "multi-token", n[:params]["progressToken"] + assert_equal (i + 1) * 20, n[:params]["progress"] + assert_equal 100, n[:params]["total"] + end + end + + test "incoming notifications/progress is handled as no-op" do + request = { + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: "token-1", + progress: 50, + }, + } + + # Should not raise and should return nil (notification, no id). + result = @session.handle(request) + assert_nil result + end + end +end diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb new file mode 100644 index 0000000..39c4e8f --- /dev/null +++ b/test/mcp/server_roots_test.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerRootsTest < ActiveSupport::TestCase + include DeprecationWarningTestHelper + + class MockTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil) + @requests << { method: method, params: params } + { + roots: [ + { uri: "file:///home/user/projects/myproject", name: "My Project" }, + ], + } + end + + def send_response(response); end + def send_notification(method, params = nil); end + def open; end + def close; end + end + + setup do + @server = Server.new( + name: "test_server", + version: "1.0.0", + ) + + @mock_transport = MockTransport.new(@server) + + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + end + + test "roots_list_changed_handler registers callback invoked when notification received" do + callback_called = false + + @server.roots_list_changed_handler do |_params| + callback_called = true + end + + @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + + assert callback_called + end + + test "notifications/roots/list_changed does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated. + server = Server.new(name: "test", version: "1.0") + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_no_deprecation_warning do + server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + end + end + + test "notifications/roots/list_changed does not warn when negotiated protocol version is older" do + assert_no_deprecation_warning do + @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + end + end + + test "notifications/roots/list_changed is handled as no-op by default" do + result = @server.handle({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }) + + assert_nil result + end + + test "init stores client capabilities" do + server = Server.new(name: "test", version: "1.0") + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_equal({ roots: { listChanged: true } }, server.client_capabilities) + end + + test "init rejects params without capabilities and stores nothing" do + server = Server.new(name: "test", version: "1.0") + response = server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_nil server.client_capabilities + end + + test "list_roots uses per-session capabilities via ServerSession" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + session_with_roots = ServerSession.new(server: @server, transport: transport, session_id: "s1") + session_with_roots.store_client_info(client: { name: "capable" }, capabilities: { roots: {} }) + transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_roots } + + error_with_roots = assert_raises(RuntimeError) do + capture_io { session_with_roots.list_roots } + end + assert_equal("No active stream for roots/list request.", error_with_roots.message) + + session_without_roots = ServerSession.new(server: @server, transport: transport, session_id: "s2") + session_without_roots.store_client_info(client: { name: "incapable" }, capabilities: {}) + + error = assert_raises(RuntimeError) do + capture_io { session_without_roots.list_roots } + end + assert_equal("Client does not support roots.", error.message) + end + + test "ServerSession#list_roots warns when called without related_request_id" do + # Per SEP-2260, server-to-client requests must be associated with an originating client request. + # `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing. + session = ServerSession.new(server: @server, transport: @mock_transport) + session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} }) + + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, /SEP-2260/) { session.list_roots } + ensure + $VERBOSE = original_verbose + end + + test "ServerSession#list_roots does not warn when related_request_id is given" do + session = ServerSession.new(server: @server, transport: @mock_transport) + session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} }) + + assert_silent { session.list_roots(related_request_id: "req-1") } + end + + test "ServerSession#list_roots raises without a warning when the client lacks roots" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) + session.store_client_info(client: { name: "test-client" }, capabilities: {}) + + error = nil + assert_no_deprecation_warning do + error = assert_raises(RuntimeError) do + session.list_roots(related_request_id: "req-1") + end + end + + assert_equal("Client does not support roots.", error.message) + end + + test "ServerSession#client_capabilities falls back to server global capabilities" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + session = ServerSession.new(server: @server, transport: transport, session_id: "s3") + transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session } + + error = assert_raises(RuntimeError) do + capture_io { session.list_roots } + end + assert_equal("No active stream for roots/list request.", error.message) + end + + test "session init does not overwrite server global client_capabilities" do + server = Server.new(name: "test", version: "1.0") + MockTransport.new(server) + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "first-client", version: "1.0" }, + }, + }) + + assert_equal({ roots: {} }, server.client_capabilities) + + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "second-client", version: "1.0" }, + }, + }, + session: session, + ) + + assert_equal({ roots: {} }, server.client_capabilities) + assert_equal({}, session.client_capabilities) + end + + test "ServerSession#list_roots uses session-scoped capabilities from HTTP init" do + server = Server.new(name: "test", version: "1.0") + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { roots: {} }, + clientInfo: { name: "http-client", version: "1.0" }, + }, + }, + session: session, + ) + + transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session } + error = assert_raises(RuntimeError) do + capture_io { session.list_roots } + end + assert_equal("No active stream for roots/list request.", error.message) + end + + test "ServerSession#list_roots does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated; the request itself still goes out. + server = Server.new(name: "test", version: "1.0") + transport = MockTransport.new(server) + + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: { roots: {} }, + clientInfo: { name: "http-client", version: "1.0" }, + }, + }, + session: session, + ) + + assert_no_deprecation_warning do + session.list_roots(related_request_id: "req-1") + end + + assert_equal Methods::ROOTS_LIST, transport.requests.first[:method] + end + end +end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb new file mode 100644 index 0000000..c5d0d59 --- /dev/null +++ b/test/mcp/server_sampling_test.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerSamplingTest < ActiveSupport::TestCase + include InstrumentationTestHelper + include DeprecationWarningTestHelper + + class MockTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil, **_kwargs) + @requests << { method: method, params: params } + { + role: "assistant", + content: { type: "text", text: "Response from LLM" }, + model: "test-model", + stopReason: "endTurn", + } + end + + def send_response(response); end + def send_notification(method, params = nil, **_kwargs); end + def open; end + def close; end + end + + setup do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + + @server = Server.new( + name: "test_server", + version: "1.0.0", + configuration: configuration, + ) + + @mock_transport = MockTransport.new(@server) + + @session = ServerSession.new(server: @server, transport: @mock_transport) + @session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) + end + + test "create_sampling_message sends request with required params" do + result = @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + + assert_equal 1, @mock_transport.requests.size + request = @mock_transport.requests.first + assert_equal Methods::SAMPLING_CREATE_MESSAGE, request[:method] + assert_equal 100, request[:params][:maxTokens] + assert_equal [{ role: "user", content: { type: "text", text: "Hello" } }], request[:params][:messages] + + assert_equal "assistant", result[:role] + assert_equal "Response from LLM", result[:content][:text] + end + + test "create_sampling_message does not warn on a modern-era session" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) + session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) + + assert_no_deprecation_warning do + session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + + test "create_sampling_message does not warn when session negotiated protocol version is older" do + @session.mark_initialized!(protocol_version: "2025-11-25") + + assert_no_deprecation_warning do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + + test "create_sampling_message sends all optional params" do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + system_prompt: "You are helpful", + model_preferences: { intelligencePriority: 0.8 }, + include_context: "none", + temperature: 0.7, + stop_sequences: ["STOP"], + metadata: { key: "value" }, + ) + + request = @mock_transport.requests.first + params = request[:params] + + assert_equal "You are helpful", params[:systemPrompt] + assert_equal({ intelligencePriority: 0.8 }, params[:modelPreferences]) + assert_equal "none", params[:includeContext] + assert_equal 0.7, params[:temperature] + assert_equal ["STOP"], params[:stopSequences] + assert_equal({ key: "value" }, params[:metadata]) + end + + test "create_sampling_message raises error when client does not support sampling" do + @session.store_client_info(client: { name: "test-client" }, capabilities: {}) + + error = assert_raises(RuntimeError) do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + + assert_equal("Client does not support sampling.", error.message) + end + + test "create_sampling_message raises error when tools used but client lacks sampling.tools" do + error = assert_raises(RuntimeError) do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tools: [{ name: "test_tool", inputSchema: { type: "object" } }], + ) + end + + assert_equal("Client does not support sampling with tools.", error.message) + end + + test "create_sampling_message raises error when tool_choice used alone but client lacks sampling.tools" do + error = assert_raises(RuntimeError) do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tool_choice: { mode: "auto" }, + ) + end + + assert_equal("Client does not support sampling with tool_choice.", error.message) + end + + test "create_sampling_message allows tools when client has sampling.tools capability" do + @session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: { tools: {} } }) + + result = @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + tools: [{ name: "test_tool", inputSchema: { type: "object" } }], + tool_choice: { mode: "auto" }, + ) + + request = @mock_transport.requests.first + params = request[:params] + + assert_equal [{ name: "test_tool", inputSchema: { type: "object" } }], params[:tools] + assert_equal({ mode: "auto" }, params[:toolChoice]) + assert_equal "Response from LLM", result[:content][:text] + end + + test "create_sampling_message warns when called without related_request_id" do + # Per SEP-2260, server-to-client requests must be associated with an originating client request. + # `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing. + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, /SEP-2260/) do + @session.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + ensure + $VERBOSE = original_verbose + end + + test "create_sampling_message does not warn when related_request_id is given" do + assert_silent do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + + test "create_sampling_message uses per-session capabilities via ServerSession" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + # Session with sampling capability passes validation (fails at send_request due to no stream). + session_with_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s1") + session_with_sampling.store_client_info(client: { name: "capable" }, capabilities: { sampling: {} }) + transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_sampling } + + error_with_sampling = assert_raises(RuntimeError) do + capture_io do + session_with_sampling.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + assert_equal("No active stream for sampling/createMessage request.", error_with_sampling.message) + + # Session without sampling capability should be rejected. + session_without_sampling = ServerSession.new(server: @server, transport: transport, session_id: "s2") + session_without_sampling.store_client_info(client: { name: "incapable" }, capabilities: {}) + + error = assert_raises(RuntimeError) do + capture_io do + session_without_sampling.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + assert_equal("Client does not support sampling.", error.message) + end + + test "ServerSession#client_capabilities falls back to server global capabilities" do + transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) + + # Initialize server with sampling capability. + @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }) + + # Session without capabilities stored falls back to @server.client_capabilities. + session = ServerSession.new(server: @server, transport: transport, session_id: "s3") + transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session } + + # Server was initialized with sampling capability, so fallback should pass validation. + error = assert_raises(RuntimeError) do + capture_io do + session.create_sampling_message( + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + end + end + assert_equal("No active stream for sampling/createMessage request.", error.message) + end + + test "session init does not overwrite server global client_capabilities" do + server = Server.new(name: "test", version: "1.0") + # Assigns server.transport via Transport#initialize. + MockTransport.new(server) + + # Non-session init sets global capabilities. + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: { sampling: {} }, + clientInfo: { name: "first-client", version: "1.0" }, + }, + }) + + assert_equal({ sampling: {} }, server.client_capabilities) + + # Session-scoped init must NOT overwrite global capabilities. + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + session = ServerSession.new(server: server, transport: transport, session_id: "s1") + + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "second-client", version: "1.0" }, + }, + }, + session: session, + ) + + # Global must still have sampling. + assert_equal({ sampling: {} }, server.client_capabilities) + # Session must have its own (empty) capabilities. + assert_equal({}, session.client_capabilities) + end + + test "create_sampling_message omits nil optional params" do + @session.create_sampling_message( + related_request_id: "req-1", + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + system_prompt: nil, + temperature: nil, + ) + + request = @mock_transport.requests.first + params = request[:params] + + refute params.key?(:systemPrompt) + refute params.key?(:temperature) + assert params.key?(:messages) + assert params.key?(:maxTokens) + end + end +end diff --git a/test/mcp/server_session_ping_test.rb b/test/mcp/server_session_ping_test.rb new file mode 100644 index 0000000..b467d3e --- /dev/null +++ b/test/mcp/server_session_ping_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class ServerSessionPingTest < ActiveSupport::TestCase + class MockTransport < Transport + attr_reader :requests + attr_accessor :response, :error_to_raise + + def initialize(server) + super + @requests = [] + @response = {} + @error_to_raise = nil + end + + # Matches real transports: returns the `result` body directly (not the full envelope). + def send_request(method, params = nil, session_id: nil, related_request_id: nil) + @requests << { method: method, params: params, session_id: session_id, related_request_id: related_request_id } + raise @error_to_raise if @error_to_raise + + @response + end + + def send_response(response); end + def send_notification(method, params = nil); end + def open; end + def close; end + def handle_request(request); end + end + + setup do + @server = Server.new(name: "test_server", version: "1.0.0") + @mock_transport = MockTransport.new(@server) + @server.transport = @mock_transport + end + + test "#ping sends request through transport and returns the result hash" do + session = ServerSession.new(server: @server, transport: @mock_transport) + + result = session.ping + + assert_equal({}, result) + assert_equal(1, @mock_transport.requests.size) + + request = @mock_transport.requests.first + assert_equal(Methods::PING, request[:method]) + assert_nil(request[:params]) + end + + test "#ping passes related_request_id through when session_id is set" do + session = ServerSession.new(server: @server, transport: @mock_transport, session_id: "session-1") + + session.ping(related_request_id: "req-abc") + + request = @mock_transport.requests.first + assert_equal("session-1", request[:session_id]) + assert_equal("req-abc", request[:related_request_id]) + end + + test "#ping raises ValidationError when result is nil" do + @mock_transport.response = nil + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(Server::ValidationError) { session.ping } + assert_equal("Response validation failed: invalid `result`", error.message) + end + + test "#ping raises ValidationError when result is the wrong type" do + @mock_transport.response = "ok" + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(Server::ValidationError) { session.ping } + assert_equal("Response validation failed: invalid `result`", error.message) + end + + test "#ping propagates transport-level errors" do + @mock_transport.error_to_raise = StandardError.new("read timeout") + session = ServerSession.new(server: @server, transport: @mock_transport) + + error = assert_raises(StandardError) { session.ping } + assert_equal("read timeout", error.message) + end + + test "#ping succeeds without a client capability declaration" do + session = ServerSession.new(server: @server, transport: @mock_transport) + assert_nil(session.client_capabilities) + + assert_equal({}, session.ping) + end + end +end diff --git a/test/mcp/server_session_request_timeout_test.rb b/test/mcp/server_session_request_timeout_test.rb new file mode 100644 index 0000000..f3ef1e6 --- /dev/null +++ b/test/mcp/server_session_request_timeout_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + # Every server-to-client request accepts a per-request deadline, which the spec asks SDKs to offer + # ("SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis"). + # `ServerSession` only carries the value; the transport is what waits on it. + class ServerSessionRequestTimeoutTest < ActiveSupport::TestCase + # Declares `timeout:`, as the bundled Streamable HTTP transport does. + class TimeoutAwareTransport < Transport + attr_reader :requests + + def initialize(server) + super + @requests = [] + end + + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) + @requests << { method: method, timeout: timeout } + {} + end + + def send_response(response); end + def send_notification(method, params = nil, **_kwargs); end + def open; end + def close; end + def handle_request(request); end + end + + # Implements only the abstract `send_request(method, params = nil)` contract, as `StdioTransport` does. + class TimeoutUnawareTransport < TimeoutAwareTransport + def send_request(method, params = nil) + @requests << { method: method, timeout: :not_passed } + {} + end + end + + setup do + @server = Server.new(name: "test_server", version: "1.0.0") + @transport = TimeoutAwareTransport.new(@server) + @session = ServerSession.new(server: @server, transport: @transport, session_id: "session-1") + @session.instance_variable_set( + :@client_capabilities, + { roots: {}, sampling: {}, elicitation: { url: {} } }, + ) + end + + test "every server-to-client request carries its timeout down to the transport" do + @session.ping(related_request_id: "req-1", timeout: 11) + @session.list_roots(related_request_id: "req-1", timeout: 22) + @session.create_sampling_message( + related_request_id: "req-1", + timeout: 33, + messages: [{ role: "user", content: { type: "text", text: "Hello" } }], + max_tokens: 100, + ) + @session.create_form_elicitation( + message: "Approve?", + requested_schema: { type: "object", properties: {} }, + related_request_id: "req-1", + timeout: 44, + ) + @session.create_url_elicitation( + message: "Approve?", + url: "https://example.com/approve", + elicitation_id: "elicit-1", + related_request_id: "req-1", + timeout: 55, + ) + + assert_equal( + [ + { method: "ping", timeout: 11 }, + { method: "roots/list", timeout: 22 }, + { method: "sampling/createMessage", timeout: 33 }, + { method: "elicitation/create", timeout: 44 }, + { method: "elicitation/create", timeout: 55 }, + ], + @transport.requests, + ) + end + + test "an omitted timeout leaves the transport to apply its own default" do + @session.ping(related_request_id: "req-1") + + assert_equal [{ method: "ping", timeout: nil }], @transport.requests + end + + test "a transport that does not declare timeout keeps its existing signature" do + transport = TimeoutUnawareTransport.new(@server) + session = ServerSession.new(server: @server, transport: transport, session_id: "session-1") + + session.ping(related_request_id: "req-1", timeout: 11) + + assert_equal [{ method: "ping", timeout: :not_passed }], transport.requests + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index f714e17..80162e5 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -5,6 +5,8 @@ require "test_helper" module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper + include DeprecationWarningTestHelper setup do @tool = Tool.define( name: "test_tool", @@ -52,6 +54,7 @@ module MCP name: "test-resource", title: "Test Resource", description: "Test resource", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @@ -60,6 +63,7 @@ module MCP name: "test-resource", title: "Test Resource", description: "Test resource", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @@ -68,6 +72,8 @@ module MCP configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new( + description: "Test server", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", @@ -76,11 +82,11 @@ module MCP prompts: [@prompt], resources: [@resource], resource_templates: [@resource_template], - configuration:, + configuration: configuration, ) end - # https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping#behavior-requirements + # https://modelcontextprotocol.io/specification/latest/basic/utilities/ping#behavior-requirements test "#handle ping request returns empty response" do request = { jsonrpc: "2.0", @@ -119,11 +125,596 @@ module MCP assert_instrumentation_data({ method: "ping" }) end + test "#handle server/discover returns supported versions, capabilities, server info, and instructions" do + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + result = response[:result] + + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions] + # Without a `subscriptions/listen`-serving transport, the `listChanged` flags are + # stripped from the advertised capabilities (see the dedicated tests below). + assert_equal @server.capabilities.keys, result[:capabilities].keys + # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp, + # not a top-level `serverInfo` field. + assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name) + assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version) + refute result.key?(:serverInfo) + assert_equal "Optional instructions for the client", result[:instructions] + end + + test "#handle server/discover strips listChanged and subscribe flags without a listen-serving transport" do + server = Server.new(name: "discover_test", capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + }) + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal({ tools: {}, resources: {}, logging: {} }, result[:capabilities]) + end + + test "#handle server/discover keeps listChanged flags when the transport serves subscriptions/listen" do + server = Server.new(name: "discover_test", capabilities: { tools: { listChanged: true } }) + transport = mock + transport.stubs(:serves_subscriptions_listen?).returns(true) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal({ tools: { listChanged: true } }, result[:capabilities]) + end + + test "#handle server/discover responds before initialize and regardless of capabilities" do + # Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate. + server = Server.new(name: "discover_test", capabilities: {}) + + response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) + end + + test "#handle server/discover does not mark the session initialized" do + session = ServerSession.new(server: @server, transport: mock) + + @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + + refute_predicate session, :initialized? + # `initialize` must still succeed afterwards. + response = @server.handle( + { jsonrpc: "2.0", method: "initialize", id: 2, params: initialize_params }, + session: session, + ) + refute_nil response[:result] + end + + test "#handle server/discover omits instructions when the server has none" do + server = Server.new(name: "discover_test") + + response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + + refute response[:result].key?(:instructions) + end + + test "#handle server/discover always carries the required cache hints with spec defaults" do + # Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope` + # are REQUIRED on `DiscoverResult`. + server = Server.new(name: "discover_test") + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal 0, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "#handle server/discover reuses the configured SEP-2549 cache hints" do + server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public") + + result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] + + assert_equal 3_600_000, result[:ttlMs] + assert_equal "public", result[:cacheScope] + end + + test "#define_custom_method raises for server/discover" do + assert_raises(Server::MethodAlreadyDefinedError) do + @server.define_custom_method(method_name: "server/discover") { {} } + end + end + + test "UnsupportedProtocolVersionError surfaces as -32022 with the SEP-2575 data shape" do + server = Server.new(name: "error_test", tools: [TestTool]) + server.define_tool(name: "unsupported_version_tool") do + raise Server::UnsupportedProtocolVersionError, "1900-01-01" + end + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "unsupported_version_tool" }, + }) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal "Unsupported protocol version", response.dig(:error, :message) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) + assert_equal "1900-01-01", response.dig(:error, :data, :requested) + end + + test "UnsupportedProtocolVersionError reports an unknown requested version" do + error = Server::UnsupportedProtocolVersionError.new(nil) + + assert_equal "unknown", error.error_data[:requested] + end + + test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do + # On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split + # into keywords and raise an ArgumentError. + request = { name: "echo", arguments: {}, _meta: {} } + + error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request) + + assert_equal "1900-01-01", error.error_data[:requested] + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] + end + + test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do + server = Server.new(name: "error_test", tools: [TestTool]) + server.define_tool(name: "missing_capability_tool") do + raise Server::MissingRequiredClientCapabilityError, { elicitation: {} } + end + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "missing_capability_tool" }, + }) + + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) + assert_equal "Missing required client capability", response.dig(:error, :message) + assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities)) + end + + test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do + server = Server.new(name: "modern_test", tools: []) + received = nil + server.define_tool(name: "modern_tool") do |server_context:| + received = { + modern: server_context.modern?, + client_info: server_context.client_info, + client_capabilities: server_context.client_capabilities, + protocol_version: server_context.protocol_version, + } + Tool::Response.new([{ type: "text", text: "ok" }]) + end + session = ServerSession.new(server: server, transport: mock) + + response = server.handle( + modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }), + session: session, + ) + + refute_nil response[:result] + assert received[:modern] + assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info]) + assert_equal({ elicitation: {} }, received[:client_capabilities]) + assert_equal "2026-07-28", received[:protocol_version] + # Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored. + assert_nil session.client + refute_predicate session, :initialized? + end + + test "#handle tools/call with an unsupported envelope version returns -32022" do + server = Server.new(name: "modern_test", tools: []) + called = false + server.define_tool(name: "modern_tool") do + called = true + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01")) + + refute called + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) + assert_equal "2027-01-01", response.dig(:error, :data, :requested) + end + + test "#handle rejects a claimed but incomplete envelope with -32602 naming the missing key" do + # A request carrying the `protocolVersion` claim key must be validated, never silently served as legacy: + # the spec maps missing required envelope fields to Invalid params, and the TypeScript and Python classifiers + # answer the same way. + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "modern_tool", + arguments: {}, + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities" + end + + test "#handle serves an envelope without the optional clientInfo" do + # `clientInfo` became optional after the SEP was finalized (spec PR #3002). + server = Server.new(name: "modern_test", tools: []) + received_context = nil + server.define_tool(name: "modern_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "modern_tool", + arguments: {}, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, + }, + }, + }) + + refute_nil response[:result] + assert_predicate received_context, :modern? + assert_nil received_context.client_info + assert_equal({ elicitation: {} }, received_context.client_capabilities) + end + + test "#handle requires the envelope for requests on a modern-locked session" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } + session = ServerSession.new(server: server, transport: mock, era: :modern) + + response = server.handle( + { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } }, + session: session, + ) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion" + end + + test "#handle rejects removed lifecycle methods on a modern-locked session with -32601" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index| + response = @server.handle( + { jsonrpc: "2.0", method: method, id: index + 1 }, + session: session, + ) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}" + end + end + + test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do + # `StdioTransport` locks the era only after a response succeeds, so the first request of a connection + # arrives with `era == nil`. The envelope on the request is what identifies it as modern there. + Methods::MODERN_REMOVED_METHODS.each do |method| + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle(modern_request(method, {}), session: session) + + assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" + end + end + + test "#handle does not let an enveloped initialize lock the era before it is settled" do + # Serving it would negotiate the legacy lifecycle for a request that declared the modern one, + # pinning the connection to the wrong era for its whole lifetime. + session = ServerSession.new(server: @server, transport: mock) + + @server.handle(modern_request("initialize", initialize_params), session: session) + + assert_nil session.era + refute_predicate session, :initialized? + end + + test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle(modern_request("tools/list", {}), session: session) + + refute_nil response[:result] + end + + test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version: the client is + # counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected. + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2026-07-28"), + }, + session: session, + ) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion) + assert_equal :legacy, session.era + end + + test "#handle server/discover requires the envelope on a modern-locked session" do + # The 2026-07-28 conformance requirements reject an envelope-less `server/discover` + # on the modern era with -32602 (SEP-2575). + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) + end + + test "#handle server/discover succeeds without an envelope outside the modern era" do + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) + + refute_nil response[:result] + end + + test "#handle tools/call enforces require_client_capability! from the envelope" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "guarded_tool") do |server_context:| + server_context.require_client_capability!(:elicitation, :form) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + declared = server.handle( + modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }), + ) + undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" })) + + refute_nil declared[:result] + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code) + assert_equal( + { elicitation: { form: {} } }, + undeclared.dig(:error, :data, :requiredCapabilities), + ) + end + + test "#handle tools/call serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.input_responses + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "state-1", + ) + end + end + + response = server.handle( + modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }), + ) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "state-1", result[:requestState] + assert_equal( + { method: "elicitation/create", params: { message: "Which region?" } }, + result.dig(:inputRequests, "region"), + ) + end + + test "#handle tools/call retry leg exposes input responses and request state to the handler" do + server = Server.new(name: "mrtr_test", tools: []) + seen = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + seen = { + input_responses: server_context.input_responses, + request_state: server_context.request_state, + region: server_context.input_response(:region), + } + Tool::Response.new([{ type: "text", text: "done" }]) + end + + request = modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } }, + requestState: "state-1", + }) + response = server.handle(request) + + refute_nil response[:result] + assert_equal "state-1", seen[:request_state] + assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region]) + assert_equal seen[:region], seen[:input_responses][:region] + end + + test "#handle rejects an input_required result on a legacy request with an internal error" do + # The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat + # an unknown resultType as a final result, so it must never reach them. + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "mrtr_tool" }, + }) + + assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code) + end + + test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(input_requests: { + region: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {})) + + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) + assert_equal( + { elicitation: { form: {} }, roots: {} }, + response.dig(:error, :data, :requiredCapabilities), + ) + end + + test "#handle tools/call input_required bypasses output schema validation" do + configuration = Configuration.new(validate_tool_call_results: true) + server = Server.new(name: "mrtr_test", tools: [], configuration: configuration) + server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" })) + + assert_equal "input_required", response.dig(:result, :resultType) + end + + test "#handle seals and unseals requestState transparently when request_state_security is set" do + security = Server::RequestStateSecurity.new(key: "k" * 32) + server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) + seen_state = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.request_state + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new(request_state: "plain-state") + end + end + + first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) + sealed = first.dig(:result, :requestState) + + assert sealed.start_with?("v1.") + refute_equal "plain-state", sealed + + retry_response = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + requestState: sealed, + })) + + refute_nil retry_response[:result] + # The handler reads the plaintext it originally wrote. + assert_equal "plain-state", seen_state + end + + test "#handle rejects a tampered or cross-call requestState echo with -32602" do + security = Server::RequestStateSecurity.new(key: "k" * 32) + server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new(request_state: "plain-state") + end + end + + first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) + sealed = first.dig(:result, :requestState) + + tampered = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + requestState: sealed.sub("v1.", "v1.x"), + })) + # Different arguments than the sealing call: the digest claim no longer matches. + cross_call = server.handle(modern_request("tools/call", { + name: "mrtr_tool", + arguments: { other: true }, + requestState: sealed, + })) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, tampered.dig(:error, :code) + assert_equal "Invalid or expired requestState", tampered.dig(:error, :message) + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, cross_call.dig(:error, :code) + end + + test "#handle passes requestState through untouched without request_state_security" do + server = Server.new(name: "mrtr_test", tools: []) + seen_state = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + seen_state = server_context.request_state + Tool::Response.new([{ type: "text", text: "done" }]) + end + + server.handle(modern_request("tools/call", { name: "mrtr_tool", requestState: "raw-state" })) + + assert_equal "raw-state", seen_state + end + + test "#handle prompts/get serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test") + server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| + server_context.request_state # participates via server_context: + Server::InputRequiredResult.new(request_state: "prompt-state") + end + + response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} })) + + assert_equal "input_required", response.dig(:result, :resultType) + assert_equal "prompt-state", response.dig(:result, :requestState) + end + + test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do + server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public") + server.resources_read_handler do |_params, server_context:| + server_context.request_state + Server::InputRequiredResult.new(request_state: "resource-state") + end + + response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" })) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "resource-state", result[:requestState] + refute result.key?(:contents) + refute result.key?(:ttlMs) + refute result.key?(:cacheScope) + end + + test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do + session = ServerSession.new(server: @server, transport: mock) + + assert_nil session.era + session.mark_initialized! + assert_equal :legacy, session.era + + session.lock_era!(:legacy) + assert_equal :legacy, session.era + assert_raises(RuntimeError) { session.lock_era!(:modern) } + end + + test "ServerSession accepts a preset era and validates era values" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + assert_equal :modern, session.era + assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) } + assert_raises(ArgumentError) { session.lock_era!(:future) } + end + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = @server.handle(request) @@ -133,13 +724,16 @@ module MCP jsonrpc: "2.0", id: 1, result: { - protocolVersion: "2025-06-18", + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, capabilities: { prompts: { listChanged: true }, resources: { listChanged: true }, tools: { listChanged: true }, + logging: {}, }, serverInfo: { + description: "Test server", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", @@ -149,7 +743,205 @@ module MCP } assert_equal expected_result, response - assert_instrumentation_data({ method: "initialize" }) + assert_instrumentation_data({ method: "initialize", client: { name: "test-client", version: "1.0.0" } }) + end + + test "#handle initialize result carries declared capability extensions" do + server = Server.new( + name: "extensions_test", + capabilities: { + tools: { listChanged: true }, + extensions: { "com.example/feature" => { enabled: true } }, + }, + ) + + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + + assert_equal( + { "com.example/feature" => { enabled: true } }, + response.dig(:result, :capabilities, :extensions), + ) + end + + test "Server.new accepts an MCP::Server::Capabilities instance" do + capabilities = Server::Capabilities.new + capabilities.support_tools + capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) + + server = Server.new(name: "extensions_test", capabilities: capabilities) + response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + + assert_equal( + { + tools: {}, + extensions: { "io.modelcontextprotocol/tasks" => {} }, + }, + response.dig(:result, :capabilities), + ) + end + + test "client-declared capability extensions are readable via client_capabilities" do + extensions = { "com.example/feature": { enabled: true } } + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(capabilities: { extensions: extensions }), + } + + @server.handle(request) + + assert_equal extensions, @server.client_capabilities[:extensions] + end + + test "client-declared capability extensions are readable via the session" do + session = ServerSession.new(server: @server, transport: mock) + extensions = { "com.example/feature": {} } + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(capabilities: { extensions: extensions }), + } + + @server.handle(request, session: session) + + assert_equal extensions, session.client_capabilities[:extensions] + end + + test "#handle initialize request with clientInfo includes client in instrumentation data" do + client_info = { name: "test_client", version: "1.0.0" } + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(clientInfo: client_info), + } + + @server.handle(request) + assert_instrumentation_data({ method: "initialize", client: client_info }) + end + + test "instrumentation data includes client info for subsequent requests after initialize" do + client_info = { name: "test_client", version: "1.0.0" } + initialize_request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(clientInfo: client_info), + } + @server.handle(initialize_request) + + ping_request = { + jsonrpc: "2.0", + method: "ping", + id: 2, + } + @server.handle(ping_request) + assert_instrumentation_data({ method: "ping", client: client_info }) + end + + test "#handle rejects duplicate initialize on an already-initialized session with -32600" do + session = ServerSession.new(server: @server, transport: mock) + + first_request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(clientInfo: { name: "original", version: "1.0" }), + } + first_response = @server.handle(first_request, session: session) + refute_nil first_response[:result] + + second_request = { + jsonrpc: "2.0", + method: "initialize", + id: 2, + params: initialize_params(clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05"), + } + second_response = @server.handle(second_request, session: session) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, second_response[:error][:code] + assert_equal "Invalid Request", second_response[:error][:message] + assert_equal({ name: "original", version: "1.0" }, session.client) + end + + test "#handle initialize with empty params returns -32602 and does not initialize the session" do + session = ServerSession.new(server: @server, transport: mock) + + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }, session: session) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + refute_predicate session, :initialized? + end + + test "#handle initialize without params returns -32602" do + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + end + + test "#handle initialize with a missing required field returns -32602" do + [:protocolVersion, :capabilities, :clientInfo].each do |field| + params = initialize_params.tap { |hash| hash.delete(field) } + + response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: params }) + + assert_equal(-32602, response[:error][:code], "expected -32602 when #{field} is missing") + assert_includes response[:error][:data], field.to_s + end + end + + test "#handle initialize with clientInfo missing name or version returns -32602" do + [{ name: "client-without-version" }, { version: "1.0.0" }].each do |client_info| + response = @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(clientInfo: client_info), + }) + + assert_equal(-32602, response[:error][:code]) + assert_includes response[:error][:data], "clientInfo" + end + end + + test "#handle initialize with an unsupported protocolVersion still negotiates the fallback version" do + response = @server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "1999-01-01"), + }) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] + end + + test "instrumentation data does not include client key when no clientInfo provided" do + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + @server.handle(request) + assert_instrumentation_data({ method: "ping" }) + end + + test "unsupported method instrumentation includes client from session" do + session = ServerSession.new(server: @server, transport: mock) + session.store_client_info(client: { name: "session-client", version: "1.0" }) + + request = { + jsonrpc: "2.0", + method: "does/not/exist", + id: 1, + } + + @server.handle(request, session: session) + assert_instrumentation_data({ method: "unsupported_method", client: { name: "session-client", version: "1.0" } }) end test "#handle returns nil for notification requests" do @@ -195,7 +987,9 @@ module MCP assert_equal "test_tool", result[:tools][0][:name] assert_equal "Test tool", result[:tools][0][:title] assert_equal "A test tool", result[:tools][0][:description] - assert_equal({ type: "object" }, result[:tools][0][:inputSchema]) + assert_equal( + { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, result[:tools][0][:inputSchema] + ) assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) assert_instrumentation_data({ method: "tools/list" }) end @@ -216,21 +1010,22 @@ module MCP assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) end - test "#tools_list_handler sets the tools/list handler" do - @server.tools_list_handler do - [{ name: "hammer", description: "Hammer time!" }] + test "#handle tools/list emits 2020-12 $schema on inputSchema and outputSchema" do + tool_with_output = Tool.define( + name: "tool_with_output", + description: "tool with output schema", + input_schema: { properties: { msg: { type: "string" } } }, + output_schema: { properties: { result: { type: "string" } } }, + ) do + Tool::Response.new([{ type: "text", content: "OK" }]) end + server = Server.new(name: "test_server", tools: [tool_with_output]) - request = { - jsonrpc: "2.0", - method: "tools/list", - id: 1, - } + response = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) + tool = response[:result][:tools][0] - response = @server.handle(request) - result = response[:result] - assert_equal({ tools: [{ name: "hammer", description: "Hammer time!" }] }, result) - assert_instrumentation_data({ method: "tools/list" }) + assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:inputSchema][:"$schema"] + assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:outputSchema][:"$schema"] end test "#handle tools/call executes tool and returns result" do @@ -238,7 +1033,13 @@ module MCP tool_args = { arg: "value" } tool_response = Tool::Response.new([{ result: "success" }]) - @tool.expects(:call).with(arg: "value", server_context: nil).returns(tool_response) + if RUBY_VERSION >= "3.1" + # Ruby 3.1+: Mocha stub preserves `method.parameters` info. + @tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response) + else + # Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false. + @tool.expects(:call).with(arg: "value").returns(tool_response) + end request = { jsonrpc: "2.0", @@ -252,10 +1053,58 @@ module MCP response = @server.handle(request) assert_equal tool_response.to_h, response[:result] - assert_instrumentation_data({ method: "tools/call", tool_name: }) + assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end - test "#handle tools/call returns error if required tool arguments are missing" do + test "#handle_json tools/call delivers nested object arguments with symbol keys at every level" do + received_payload = nil + server = Server.new(name: "test_server") + server.define_tool( + name: "nested_args_tool", + input_schema: { properties: { message: { type: "string" }, payload: { type: "object" } }, required: ["message"] }, + ) do |message:, payload: nil, server_context:| + received_payload = payload + Tool::Response.new([{ type: "text", text: "#{message} #{server_context.class}" }]) + end + + request_json = JSON.generate( + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "nested_args_tool", + arguments: { message: "hi", payload: { subject: "greet", nested: { deep: "value" } } }, + }, + ) + + server.handle_json(request_json) + + assert_equal({ subject: "greet", nested: { deep: "value" } }, received_payload) + assert_equal "greet", received_payload[:subject] + assert_nil received_payload["subject"] + end + + test "tool receives symbol keys when called under the JSON-round-tripped argument shape" do + received_payload = nil + tool = Tool.define( + name: "nested_args_tool", + input_schema: { properties: { payload: { type: "object" } } }, + ) do |payload: nil, server_context:| + received_payload = payload + Tool::Response.new([{ type: "text", text: server_context.class.to_s }]) + end + + # Round-trip the arguments through JSON the way a transport does, so the tool + # is exercised under the symbolized shape it actually receives at runtime. + arguments = { payload: { "subject" => "greet" } } + delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) + tool.call(**delivered, server_context: nil) + + assert_equal({ subject: "greet" }, received_payload) + assert_nil received_payload["subject"] + end + + test "#handle tools/call returns tool execution error if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", title: "Test tool", @@ -279,8 +1128,10 @@ module MCP response = server.handle(request) - assert_equal "Missing required arguments: message", response[:error][:data] - assert_equal "Internal error", response[:error][:message] + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments: message" end test "#handle_json tools/call executes tool and returns result" do @@ -288,7 +1139,13 @@ module MCP tool_args = { arg: "value" } tool_response = Tool::Response.new([{ result: "success" }]) - @tool.expects(:call).with(arg: "value", server_context: nil).returns(tool_response) + if RUBY_VERSION >= "3.1" + # Ruby 3.1+: Mocha stub preserves `method.parameters` info. + @tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response) + else + # Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false. + @tool.expects(:call).with(arg: "value").returns(tool_response) + end request = JSON.generate({ jsonrpc: "2.0", @@ -300,10 +1157,12 @@ module MCP raw_response = @server.handle_json(request) response = JSON.parse(raw_response, symbolize_names: true) if raw_response assert_equal tool_response.to_h, response[:result] if response - assert_instrumentation_data({ method: "tools/call", tool_name: }) + assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: { arg: "value" } }) end test "#handle_json tools/call executes tool and returns result, when the tool is typed with Sorbet" do + skip "Sorbet is not available" unless defined?(T::Sig) + class TypedTestTool < Tool tool_name "test_tool" description "a test tool for testing" @@ -340,22 +1199,7 @@ module MCP assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result]) end - test "#handle tools/call returns internal error and reports exception if the tool raises an error" do - @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| - assert_not_nil exception - assert_equal( - { - request: { - jsonrpc: "2.0", - method: "tools/call", - params: { name: "tool_that_raises", arguments: { message: "test" } }, - id: 1, - }, - }, - server_context, - ) - end - + test "#handle tools/call returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = { jsonrpc: "2.0", method: "tools/call", @@ -366,14 +1210,59 @@ module MCP id: 1, } + @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| + refute_kind_of MCP::Server::RequestHandlerError, exception + assert_equal({ request: request }, server_context) + end + response = @server.handle(request) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] - assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error }) + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end - test "#handle_json returns internal error and reports exception if the tool raises an error" do + test "registers tools with the same class name in different namespaces" do + module Foo + class Example < Tool + end + end + + module Bar + class Example < Tool + end + end + + error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Foo::Example, Bar::Example]) } + assert_equal(<<~MESSAGE, error.message) + Tool names should be unique. Use `tool_name` to assign unique names to: + example + MESSAGE + end + + test "registers tools with the same tool name" do + module Baz + class Example < Tool + tool_name "foo" + end + end + + module Qux + class Example < Tool + tool_name "foo" + end + end + + error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Baz::Example, Qux::Example]) } + assert_equal(<<~MESSAGE, error.message) + Tool names should be unique. Use `tool_name` to assign unique names to: + foo + MESSAGE + end + + test "#handle_json returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", @@ -385,12 +1274,44 @@ module MCP }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] - assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error }) + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end - test "#handle tools/call returns error for unknown tool" do + test "#handle tools/call returns protocol error in JSON-RPC format if input_schema raises an error during validation" do + tool = Tool.define( + name: "tool_with_faulty_schema", + title: "Tool with faulty schema", + description: "A tool with a faulty schema", + input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + ) { Tool::Response.new("success") } + + tool.input_schema.expects(:missing_required_arguments?).raises(RuntimeError, "Unexpected schema error") + + server = Server.new(name: "test_server", tools: [tool]) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "tool_with_faulty_schema", + arguments: { message: "test" }, + }, + id: 1, + } + + response = server.handle(request) + + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_equal "Internal error calling tool tool_with_faulty_schema", response[:error][:data] + end + + test "#handle tools/call returns JSON-RPC error for unknown tool" do request = { jsonrpc: "2.0", method: "tools/call", @@ -402,12 +1323,14 @@ module MCP } response = @server.handle(request) - assert_equal "Internal error", response[:error][:message] - assert_equal "Tool not found unknown_tool", response[:error][:data] - assert_instrumentation_data({ method: "tools/call", error: :tool_not_found }) + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Tool not found: unknown_tool" + assert_instrumentation_data({ method: "tools/call", tool_name: "unknown_tool", error: :invalid_params }) end - test "#handle_json returns error for unknown tool" do + test "#handle_json returns JSON-RPC error for unknown tool" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", @@ -419,25 +1342,10 @@ module MCP }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) - assert_equal "Internal error", response[:error][:message] - end - - test "#tools_call_handler sets the tools/call handler" do - @server.tools_call_handler do |request| - tool_name = request[:name] - Tool::Response.new("#{tool_name} called successfully").to_h - end - - request = { - jsonrpc: "2.0", - method: "tools/call", - params: { name: "my_tool", arguments: {} }, - id: 1, - } - - response = @server.handle(request) - assert_equal({ content: "my_tool called successfully", isError: false }, response[:result]) - assert_instrumentation_data({ method: "tools/call" }) + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Tool not found: unknown_tool" end test "#handle prompts/list returns list of prompts" do @@ -452,22 +1360,6 @@ module MCP assert_instrumentation_data({ method: "prompts/list" }) end - test "#prompts_list_handler sets the prompts/list handler" do - @server.prompts_list_handler do - [{ name: "foo_prompt", description: "Foo prompt" }] - end - - request = { - jsonrpc: "2.0", - method: "prompts/list", - id: 1, - } - - response = @server.handle(request) - assert_equal({ prompts: [{ name: "foo_prompt", description: "Foo prompt" }] }, response[:result]) - assert_instrumentation_data({ method: "prompts/list" }) - end - test "#handle prompts/get returns templated prompt" do request = { jsonrpc: "2.0", @@ -503,6 +1395,9 @@ module MCP } response = @server.handle(request) + # An unknown prompt is client input, so it maps to Invalid Params (-32602), not the + # default Internal Error (-32603); matches the tools/call and completion/complete siblings. + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal("Prompt not found unknown_prompt", response[:error][:data]) assert_instrumentation_data({ method: "prompts/get", error: :prompt_not_found }) end @@ -519,6 +1414,9 @@ module MCP } response = @server.handle(request) + # A missing required argument is client input, so it maps to Invalid Params (-32602), + # not the default Internal Error (-32603). + assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal "Missing required arguments: test_argument", response[:error][:data] assert_instrumentation_data({ method: "prompts/get", @@ -527,32 +1425,6 @@ module MCP }) end - test "#prompts_get_handler sets the prompts/get handler" do - @server.prompts_get_handler do |request| - prompt_name = request[:name] - Prompt::Result.new( - description: prompt_name, - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new(request[:arguments]["foo"])), - ], - ).to_h - end - - request = { - jsonrpc: "2.0", - method: "prompts/get", - id: 1, - params: { name: "foo_bar_prompt", arguments: { "foo" => "bar" } }, - } - - response = @server.handle(request) - assert_equal( - { description: "foo_bar_prompt", messages: [{ role: "user", content: { type: "text", text: "bar" } }] }, - response[:result], - ) - assert_instrumentation_data({ method: "prompts/get" }) - end - test "#handle resources/list returns a list of resources" do request = { jsonrpc: "2.0", @@ -565,23 +1437,52 @@ module MCP assert_instrumentation_data({ method: "resources/list" }) end - test "#resources_list_handler sets the resources/list handler" do - @server.resources_list_handler do - [{ uri: "https://test_resource.invalid", name: "test-resource", title: "Test Resource", description: "Test resource" }] + test "#resources_list_handler replaces the served resource collection" do + other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain") + @server.resources_list_handler { |_params| [other] } + + response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal({ resources: [other.to_h] }, response[:result]) + end + + test "#resources_list_handler receives server_context when it opts in" do + real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain") + demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain") + @server.resources_list_handler do |_params, server_context:| + server_context[:authenticated] ? [real] : [demo] end - request = { - jsonrpc: "2.0", - method: "resources/list", - id: 1, - } + @server.server_context = { authenticated: false } + anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + @server.server_context = { authenticated: true } + authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - response = @server.handle(request) - assert_equal( - { resources: [{ uri: "https://test_resource.invalid", name: "test-resource", title: "Test Resource", description: "Test resource" }] }, - response[:result], - ) - assert_instrumentation_data({ method: "resources/list" }) + assert_equal({ resources: [demo.to_h] }, anon[:result]) + assert_equal({ resources: [real.to_h] }, authed[:result]) + end + + test "#resources_list_handler paginates and stamps cache hints on the returned collection" do + first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain") + second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain") + server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000) + server.resources_list_handler { |_params| [first, second] } + + response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal([first.to_h], response[:result][:resources]) + assert_equal("1", response[:result][:nextCursor]) + assert_equal(60_000, response[:result][:ttlMs]) + end + + test "#resources_list_handler serves a server with no constructor-provided resources" do + only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain") + server = Server.new(name: @server_name, resources: []) + server.resources_list_handler { |_params| [only] } + + response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + + assert_equal({ resources: [only.to_h] }, response[:result]) end test "#handle resources/read returns an empty array of contents by default" do @@ -624,6 +1525,25 @@ module MCP ) end + test "#handle resources/read returns -32602 with the uri in error data when the handler raises ResourceNotFoundError" do + # Per SEP-2164, resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # and carry the requested URI in `data`. + @server.resources_read_handler do |request| + raise Server::ResourceNotFoundError.new(request[:uri], request) + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///missing.txt" }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: file:///missing.txt", response[:error][:message]) + assert_equal({ uri: "file:///missing.txt" }, response[:error][:data]) + end + test "#handle resources/templates/list returns a list of resource templates" do request = { jsonrpc: "2.0", @@ -641,31 +1561,327 @@ module MCP assert_instrumentation_data({ method: "resources/templates/list" }) end - test "#resources_templates_list_handler sets the resources/templates/list handler" do - @server.resources_templates_list_handler do - [{ uriTemplate: "test_resource_template/{id}", name: "Test resource template", description: "a template" }] + class GreetingResource < Resource + uri "greeting://hello" + resource_name "greeting" + mime_type "text/plain" + + class << self + def contents + [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "hello")] + end + end + end + + class UserProfileTemplate < ResourceTemplate + uri_template "users://{user_id}/profile" + resource_template_name "user_profile" + mime_type "text/plain" + + class << self + def contents(user_id:) + [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] + end + end + end + + def read_resource_request(uri) + { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: uri }, + } + end + + test "#handle resources/read auto-routes to a class-based resource by exact URI" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resources: [GreetingResource], configuration: configuration) + + response = server.handle(read_resource_request("greeting://hello")) + + expected = [{ uri: "greeting://hello", mimeType: "text/plain", text: "hello" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ method: "resources/read", resource_uri: "greeting://hello" }) + end + + test "#handle resources/read wraps a single contents object returned by a class-based resource" do + resource = Resource.define(uri: "single://one", name: "single") do + Resource::TextContents.new(uri: "single://one", mime_type: "text/plain", text: "only one") + end + server = Server.new(name: "test_server", resources: [resource]) + + response = server.handle(read_resource_request("single://one")) + + expected = [{ uri: "single://one", mimeType: "text/plain", text: "only one" }] + assert_equal({ contents: expected }, response[:result]) + end + + test "#handle resources/read passes server_context to contents that opt in" do + received = nil + resource = Resource.define(uri: "ctx://resource", name: "ctx") do |server_context:| + received = server_context + [Resource::TextContents.new(uri: "ctx://resource", mime_type: "text/plain", text: "ctx")] + end + server = Server.new(name: "test_server", resources: [resource]) + + server.handle(read_resource_request("ctx://resource")) + + assert_instance_of ServerContext, received + end + + test "#handle resources/read auto-routes to a class-based resource template with extracted params" do + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate], configuration: configuration) + + response = server.handle(read_resource_request("users://42/profile")) + + expected = [{ uri: "users://42/profile", mimeType: "text/plain", text: "profile of 42" }] + assert_equal({ contents: expected }, response[:result]) + assert_instrumentation_data({ + method: "resources/read", + resource_uri: "users://42/profile", + resource_template: "users://{user_id}/profile", + }) + end + + test "#handle resources/read prefers an exact resource match over a template match" do + resource = Resource.define(uri: "users://42/profile", name: "pinned_profile") do + [Resource::TextContents.new(uri: "users://42/profile", mime_type: "text/plain", text: "pinned")] + end + server = Server.new(name: "test_server", resources: [resource], resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/profile")) + + assert_equal "pinned", response[:result][:contents].first[:text] + end + + test "#handle resources/read returns -32602 for an unknown URI when class-based resources are registered" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + response = server.handle(read_resource_request("greeting://unknown")) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: greeting://unknown", response[:error][:message]) + assert_equal({ uri: "greeting://unknown" }, response[:error][:data]) + end + + test "#handle resources/read returns -32602 for a non-matching URI when class-based templates are registered" do + server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate]) + + response = server.handle(read_resource_request("users://42/settings")) + + assert_equal(-32602, response[:error][:code]) + assert_equal({ uri: "users://42/settings" }, response[:error][:data]) + end + + test "#handle resources/read keeps returning [] for unknown URIs when only instance-based resources are registered" do + server = Server.new(name: "test_server", resources: [@resource], resource_templates: [@resource_template]) + + response = server.handle(read_resource_request("unknown://resource")) + + assert_equal({ contents: [] }, response[:result]) + end + + test "#resources_read_handler overrides auto-routing for class-based resources" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + server.resources_read_handler do |request| + [{ uri: request[:uri], mimeType: "text/plain", text: "handler wins" }] end - request = { - jsonrpc: "2.0", - method: "resources/templates/list", - id: 1, - } + response = server.handle(read_resource_request("greeting://hello")) - response = @server.handle(request) - assert_equal( - { - resourceTemplates: [ - { - uriTemplate: "test_resource_template/{id}", - name: "Test resource template", - description: "a template", - }, - ], - }, - response[:result], + assert_equal "handler wins", response[:result][:contents].first[:text] + end + + test "#handle resources/list and resources/templates/list render class-based and instance-based entries together" do + server = Server.new( + name: "test_server", + resources: [@resource, GreetingResource], + resource_templates: [@resource_template, UserProfileTemplate], ) - assert_instrumentation_data({ method: "resources/templates/list" }) + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal [@resource.to_h, GreetingResource.to_h], list_response[:result][:resources] + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal [@resource_template.to_h, UserProfileTemplate.to_h], templates_response[:result][:resourceTemplates] + end + + test "#resources= rebuilds the resource index used for auto-routing" do + server = Server.new(name: "test_server", resources: [GreetingResource]) + + replacement = Resource.define(uri: "replacement://res", name: "replacement") do + [Resource::TextContents.new(uri: "replacement://res", mime_type: "text/plain", text: "replaced")] + end + server.resources = [replacement] + + response = server.handle(read_resource_request("replacement://res")) + assert_equal "replaced", response[:result][:contents].first[:text] + + stale_response = server.handle(read_resource_request("greeting://hello")) + assert_equal(-32602, stale_response[:error][:code]) + end + + test "#define_resource registers a resource served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource(uri: "defined://res", name: "defined", mime_type: "text/plain") do + [Resource::TextContents.new(uri: "defined://res", mime_type: "text/plain", text: "defined body")] + end + + list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal ["defined"], list_response[:result][:resources].map { |resource| resource[:name] } + + response = server.handle(read_resource_request("defined://res")) + assert_equal "defined body", response[:result][:contents].first[:text] + end + + test "#define_resource_template registers a template served by auto-routing" do + server = Server.new(name: "test_server") + server.define_resource_template(uri_template: "items://{item_id}", name: "item_template") do |item_id:| + [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: "text/plain", text: "item #{item_id}")] + end + + templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + assert_equal ["item_template"], templates_response[:result][:resourceTemplates].map { |template| template[:name] } + + response = server.handle(read_resource_request("items://7")) + assert_equal "item 7", response[:result][:contents].first[:text] + end + + test "#configure_logging_level returns empty hash on success" do + response = @server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "info", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal({}, response[:result]) + refute response.key?(:error) + end + + test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated, + # so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method + # and never reaches this handler. + server = Server.new(tools: [TestTool]) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2026-07-28", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + ) + + response = nil + assert_no_deprecation_warning do + response = server.handle( + { + jsonrpc: "2.0", + id: 2, + method: "logging/setLevel", + params: { + level: "info", + }, + }, + ) + end + + assert_empty response[:result] + end + + test "#configure_logging_level does not warn when negotiated protocol version is older" do + server = Server.new(tools: [TestTool]) + server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0" }, + }, + }, + ) + + assert_no_deprecation_warning do + server.handle( + { + jsonrpc: "2.0", + id: 2, + method: "logging/setLevel", + params: { + level: "info", + }, + }, + ) + end + end + + test "#configure_logging_level returns an error object when invalid log level is provided" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "invalid_level", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32602, response[:error][:code]) + assert_includes response[:error][:data], "Invalid log level invalid_level" + end + + test "#configure_logging_level returns an error object when server has not logging capability" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true }, + }, + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "debug", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32603, response[:error][:code]) + assert_includes response[:error][:data], "Server does not support logging" end test "#handle method with missing required top-level capability returns an error" do @@ -731,6 +1947,263 @@ module MCP assert_instrumentation_data({ method: "notify" }) end + test "#handle tools/call invokes around_request with correct data" do + call_log = [] + data_before = nil + data_after = nil + + configuration = MCP::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + configuration.around_request = ->(data, &request_handler) { + data_before = data.dup + call_log << :before + request_handler.call + call_log << :after + data_after = data.dup + } + + tool = Tool.define(name: "around_test_tool", description: "Test") do |arg:| + Tool::Response.new([{ type: "text", text: arg }]) + end + + server = Server.new(name: "test_server", tools: [tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "around_test_tool", arguments: { arg: "hello" } }, + id: 1, + } + + server.handle(request) + + assert_equal([:before, :after], call_log) + assert_equal("tools/call", data_before[:method]) + assert_nil(data_before[:tool_name]) + assert_equal("around_test_tool", data_after[:tool_name]) + assert_equal({ arg: "hello" }, data_after[:tool_arguments]) + end + + test "#handle around_request and instrumentation_callback coexist" do + around_called = false + callback_data = nil + + configuration = MCP::Configuration.new + configuration.around_request = ->(_data, &request_handler) { + around_called = true + request_handler.call + } + configuration.instrumentation_callback = ->(data) { + callback_data = data.dup + } + + server = Server.new(name: "test_server", configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + server.handle(request) + + assert(around_called) + assert_equal("ping", callback_data[:method]) + assert(callback_data[:duration]) + end + + test "#handle reports exception and sets error when around_request raises" do + reported_exception = nil + reported_context = nil + callback_data = nil + + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, server_context) { + reported_exception = e + reported_context = server_context + } + configuration.instrumentation_callback = ->(data) { callback_data = data.dup } + configuration.around_request = ->(_data, &_request_handler) { raise "around_request failure" } + + server = Server.new(name: "test_server", configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + response = server.handle(request) + + assert_equal("around_request failure", reported_exception.message) + assert_equal({ request: request }, reported_context) + assert_equal(:internal_error, callback_data[:error]) + assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) + end + + test "#handle does not double-report exception_reporter when a tool handler raises" do + report_count = 0 + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + server.handle(request) + + assert_equal(1, report_count) + end + + test "#handle reports both exceptions when around_request ensure raises after tool failure" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + configuration.around_request = ->(_data, &request_handler) do + request_handler.call + ensure + raise "around ensure boom" + end + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal(["tool failure", "around ensure boom"], reported) + assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) + assert_nil(response[:error][:data]) + end + + test "#handle reports the same exception object reused across requests on every call" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e } + + shared_error = RuntimeError.new("reused") + shared_tool = Tool.define(name: "shared_failing_tool", description: "Always fails") do + raise shared_error + end + + server = Server.new(name: "test_server", tools: [shared_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "shared_failing_tool", arguments: {} }, + id: 1, + } + + server.handle(request) + server.handle(request) + + assert_equal(2, reported.size) + assert_same(shared_error, reported[0]) + assert_same(shared_error, reported[1]) + end + + test "#handle reports frozen exceptions raised by tool handlers without wrapping them" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e } + + frozen_error = RuntimeError.new("frozen failure").freeze + frozen_tool = Tool.define(name: "frozen_tool", description: "Raises frozen") do + raise frozen_error + end + + server = Server.new(name: "test_server", tools: [frozen_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "frozen_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal([frozen_error], reported) + assert_equal("Internal error calling tool frozen_tool", response[:error][:data]) + end + + test "#handle still reports via exception_reporter when around_request swallows the tool failure" do + reported = [] + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + configuration.around_request = ->(_data, &request_handler) do + request_handler.call + rescue StandardError + { swallowed: true } + end + + failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do + raise "tool failure" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "failing_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal(["tool failure"], reported) + assert_equal({ swallowed: true }, response[:result]) + end + + test "#handle concurrent requests on a shared server report exceptions independently" do + reported = Queue.new + configuration = MCP::Configuration.new + configuration.exception_reporter = ->(e, _server_context) { reported << e.message } + + failing_tool = Tool.define(name: "concurrent_tool", description: "Raises per-thread") do |i:| + raise "thread #{i}" + end + + server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) + + threads = 10.times.map do |i| + Thread.new do + server.handle({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "concurrent_tool", arguments: { i: i } }, + id: i, + }) + end + end + threads.each(&:join) + + messages = [] + messages << reported.pop until reported.empty? + + assert_equal(10.times.map { |i| "thread #{i}" }.sort, messages.sort) + end + test "#define_custom_method raises an error if the method is already defined" do assert_raises(Server::MethodAlreadyDefinedError) do @server.define_custom_method(method_name: "tools/call") do @@ -756,33 +2229,95 @@ module MCP configuration.instrumentation_callback = local_callback configuration.exception_reporter = local_exception_reporter - server = Server.new(name: "test_server", configuration:) + server = Server.new(name: "test_server", configuration: configuration) assert_equal local_callback, server.configuration.instrumentation_callback assert_equal local_exception_reporter, server.configuration.exception_reporter end - test "server uses default protocol version when not configured" do + test "server uses the latest handshake version when not configured" do request = { jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = @server.handle(request) - assert_equal Configuration::DEFAULT_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end - test "server response does not include title when not configured" do + test "server response does not include optional parameters when configured" do + server = Server.new(title: "Example Server Display Name", name: "test_server", website_url: "https://example.com") + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + + response = server.handle(request) + server_info = response[:result][:serverInfo] + + assert_equal("Example Server Display Name", server_info[:title]) + assert_equal("https://example.com", server_info[:websiteUrl]) + end + + test "server response does not include optional parameters when not configured" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) refute response[:result][:serverInfo].key?(:title) + refute response[:result][:serverInfo].key?(:website_url) + end + + test "server response does not include icons when icons is empty" do + server = Server.new(name: "test_server") + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + response = server.handle(request) + + refute response[:result][:serverInfo].key?(:icons) + end + + test "server response does not include icons when icons is nil" do + server = Server.new(name: "test_server", icons: nil) + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + response = server.handle(request) + + refute response[:result][:serverInfo].key?(:icons) + end + + test "server response includes icons when icons is present" do + server = Server.new( + name: "test_server", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], + ) + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params, + } + response = server.handle(request) + expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }] + + assert_equal expected_icons, response[:result][:serverInfo][:icons] end test "server uses default version when not configured" do @@ -791,6 +2326,7 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) @@ -803,12 +2339,28 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params, } response = server.handle(request) refute response[:result].key?(:instructions) end + test "server uses description when configured with protocol version 2025-11-25" do + configuration = Configuration.new(protocol_version: "2025-11-25") + server = Server.new(description: "This is the MCP server used during tests.", name: "test_server", configuration: configuration) + assert_equal("This is the MCP server used during tests.", server.description) + end + + test "raises error if description is used with protocol version 2025-06-18" do + configuration = Configuration.new(protocol_version: "2025-06-18") + + exception = assert_raises(ArgumentError) do + Server.new(description: "This is the MCP server used during tests.", name: "test_server", configuration: configuration) + end + assert_equal("Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier", exception.message) + end + test "server uses instructions when configured with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") server = Server.new(name: "test_server", instructions: "The server instructions.", configuration: configuration) @@ -846,16 +2398,143 @@ module MCP assert_equal("Error occurred in defined_tool. `annotations` are supported by protocol version 2025-03-26 or higher", exception.message) end + test "raises error if `title` of `server_info` is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", title: "Example Server Display Name", configuration: configuration) + end + assert_equal("Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "raises error if `website_url` of `server_info` is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", website_url: "https://example.com", configuration: configuration) + end + assert_equal("Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "raises error if `title` of tool is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + server = Server.new(name: "test_server", configuration: configuration) + + exception = assert_raises(ArgumentError) do + server.define_tool( + title: "Test tool", + ) + end + assert_equal("Error occurred in Test tool. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "raises error if `title` of prompt is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + server = Server.new(name: "test_server", configuration: configuration) + + exception = assert_raises(ArgumentError) do + server.define_prompt( + title: "Test prompt", + ) + end + assert_equal("Error occurred in Test prompt. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "raises error if `title` of resource is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + resource = Resource.new( + uri: "https://test_resource.invalid", + name: "test-resource", + title: "Test resource", + ) + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", resources: [resource], configuration: configuration) + end + assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "raises error if `title` of class-based resource is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + resource = Resource.define( + uri: "https://test_resource.invalid", + name: "test-resource", + title: "Test resource", + ) + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", resources: [resource], configuration: configuration) + end + assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + + test "allows `$ref` in tool input schema with protocol version 2025-11-25" do + tool = Tool.define( + name: "ref_tool", + description: "Tool with $ref", + input_schema: { + type: "object", + "$defs": { address: { type: "object", properties: { city: { type: "string" } } } }, + properties: { address: { "$ref": "#/$defs/address" } }, + }, + ) + configuration = Configuration.new(protocol_version: "2025-11-25") + + assert_nothing_raised do + Server.new(name: "test_server", tools: [tool], configuration: configuration) + end + end + + test "raises error if `$ref` in tool input schema is used with protocol version 2025-06-18" do + tool = Tool.define( + name: "ref_tool", + description: "Tool with $ref", + input_schema: { + type: "object", + "$defs": { address: { type: "object", properties: { city: { type: "string" } } } }, + properties: { address: { "$ref": "#/$defs/address" } }, + }, + ) + configuration = Configuration.new(protocol_version: "2025-06-18") + + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", tools: [tool], configuration: configuration) + end + assert_equal( + "Error occurred in ref_tool. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher", + exception.message, + ) + end + + test "raises error if `title` of resource template is used with protocol version 2025-03-26" do + configuration = Configuration.new(protocol_version: "2025-03-26") + + resource = Resource.new( + uri: "https://test_resource.invalid", + name: "test-resource", + title: "Test resource template", + ) + exception = assert_raises(ArgumentError) do + Server.new(name: "test_server", resources: [resource], configuration: configuration) + end + assert_equal("Error occurred in Test resource template. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) + end + test "#define_tool adds a tool to the server" do @server.define_tool( name: "defined_tool", description: "Defined tool", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + output_schema: { type: "object", properties: { response: { type: "string" } }, required: ["response"] }, meta: { foo: "bar" }, ) do |message:| - Tool::Response.new(message) + Tool::Response.new({ "response" => message }) end + stored_tool = @server.tools["defined_tool"] + assert_not_nil(stored_tool) + assert_equal(MCP::Tool::OutputSchema.new({ type: "object", properties: { response: { type: "string" } }, required: ["response"] }), stored_tool.output_schema) + response = @server.handle({ jsonrpc: "2.0", method: "tools/call", @@ -863,7 +2542,21 @@ module MCP id: 1, }) - assert_equal({ content: "success", isError: false }, response[:result]) + assert_equal({ content: { "response" => "success" }, isError: false }, response[:result]) + end + + test "#define_tool adds a tool with duplicated tool name to the server" do + error = assert_raises(MCP::ToolNotUnique) do + @server.define_tool( + name: "test_tool", # NOTE: Already registered tool name + description: "Defined tool", + input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + meta: { foo: "bar" }, + ) do |message:| + Tool::Response.new(message) + end + end + assert_match(/\ATool names should be unique. Use `tool_name` to assign unique names to/, error.message) end test "#define_tool call definition allows tool arguments and server context" do @@ -920,18 +2613,114 @@ module MCP jsonrpc: "2.0", method: "initialize", id: 1, + params: initialize_params(protocolVersion: "1999-01-01"), } response = server.handle(request) assert_equal custom_version, response[:result][:protocolVersion] end - test "tools/call handles missing arguments field" do + test "server negotiates protocol version when client requests a supported version" do + server = Server.new(name: "test_server") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2025-06-18"), + } + + response = server.handle(request) + assert_equal "2025-06-18", response[:result][:protocolVersion] + end + + test "server falls back to the latest handshake version when client requests unsupported version" do + server = Server.new(name: "test_server") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "1999-01-01"), + } + + response = server.handle(request) + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] + end + + test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do + # Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries + # its own version on every request and has no handshake at all. The TypeScript and Python servers + # answer the same way. + server = Server.new(name: "test_server") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2026-07-28"), + } + + response = server.handle(request) + assert_equal "2025-11-25", response[:result][:protocolVersion] + end + + test "server removes description and icons from server_info when negotiating to 2025-06-18" do server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), + name: "test_server", + description: "A test server", + icons: [Icon.new(src: "https://example.com/icon.png")], ) + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2025-06-18"), + } + + response = server.handle(request) + assert_equal "2025-06-18", response[:result][:protocolVersion] + refute response[:result][:serverInfo].key?(:description) + refute response[:result][:serverInfo].key?(:icons) + end + + test "server removes title and websiteUrl when negotiating to 2025-03-26" do + server = Server.new(name: "test_server", title: "Test Server", website_url: "https://example.com") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2025-03-26"), + } + + response = server.handle(request) + assert_equal "2025-03-26", response[:result][:protocolVersion] + refute response[:result][:serverInfo].key?(:title) + refute response[:result][:serverInfo].key?(:websiteUrl) + end + + test "server removes instructions when negotiating to 2024-11-05" do + server = Server.new(name: "test_server", instructions: "Some instructions") + + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: initialize_params(protocolVersion: "2024-11-05"), + } + + response = server.handle(request) + assert_equal "2024-11-05", response[:result][:protocolVersion] + refute response[:result].key?(:instructions) + end + + test "tools/call returns tool execution error for missing arguments" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) + response = server.handle( { jsonrpc: "2.0", @@ -945,15 +2734,22 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_equal(-32603, response[:error][:code]) - assert_includes response[:error][:data], "Missing required arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Missing required arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: {}, + error: :missing_required_arguments, + }) end - test "tools/call validates arguments against input schema when validate_tool_call_arguments is true" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(validate_tool_call_arguments: true), - ) + test "tools/call returns tool execution error for invalid arguments when validate_tool_call_arguments is true" do + configuration = Configuration.new(validate_tool_call_arguments: true) + configuration.instrumentation_callback = instrumentation_helper.callback + server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { @@ -969,8 +2765,44 @@ module MCP assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] - assert_equal(-32603, response[:error][:code]) - assert_includes response[:error][:data], "Invalid arguments" + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + assert_instrumentation_data({ + method: "tools/call", + tool_name: "test_tool", + tool_arguments: { message: 123 }, + error: :invalid_schema, + }) + end + + test "tools/call returns tool execution error for nested schema validation failure" do + server = Server.new( + tools: [ComplexTypesTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "complex_types_tool", + arguments: { + numbers: [1, 2, 3], + strings: ["a", "b", "c"], + objects: [{ name: 123 }], + }, + }, + }, + ) + + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" end test "tools/call skips argument validation when validate_tool_call_arguments is false" do @@ -1027,6 +2859,976 @@ module MCP assert_equal "OK", response[:result][:content][0][:content] end + test "tools/call allows additional properties by default" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "test_tool", + arguments: { + message: "Hello, world!", + other_property: "I am allowed", + }, + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert response[:result], "Expected result key in response" + assert_equal "text", response[:result][:content][0][:type] + assert_equal "OK", response[:result][:content][0][:content] + end + + test "tools/call returns tool execution error when additionalProperties set to false" do + server = Server.new( + tools: [TestToolWithAdditionalPropertiesSetToFalse], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "test_tool_with_additional_properties_set_to_false", + arguments: { + message: "Hello, world!", + extra: 123, + }, + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_nil response[:error] + assert(response[:result][:isError]) + assert_equal "text", response[:result][:content][0][:type] + assert_includes response[:result][:content][0][:text], "Invalid arguments" + end + + test "tools/call skips output schema validation by default" do + tool = Tool.define( + name: "invalid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: 123 }, + ) + end + server = Server.new(tools: [tool]) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "invalid_structured_content_tool" }, + }) + + assert_nil response[:error] + assert_equal({ result: 123 }, response[:result][:structuredContent]) + end + + test "tools/call validates structuredContent against output schema when enabled" do + tool = Tool.define( + name: "valid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: "success" }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "valid_structured_content_tool" }, + }) + + assert_nil response[:error] + assert_equal({ result: "success" }, response[:result][:structuredContent]) + end + + test "tools/call returns JSON-RPC error for invalid structuredContent when output schema validation is enabled" do + tool = Tool.define( + name: "invalid_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "ok" }], + structured_content: { result: 123 }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "invalid_structured_content_tool" }, + }) + + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_equal "Internal error calling tool invalid_structured_content_tool", response[:error][:data] + end + + test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do + tool = Tool.define( + name: "missing_structured_content_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new([{ type: "text", text: "ok" }]) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "missing_structured_content_tool" }, + }) + + assert_nil response[:result] + assert_equal(-32603, response[:error][:code]) + assert_equal "Internal error", response[:error][:message] + assert_equal "Internal error calling tool missing_structured_content_tool", response[:error][:data] + end + + test "tools/call skips output schema validation for error responses" do + tool = Tool.define( + name: "error_response_tool", + output_schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, + ) do + Tool::Response.new( + [{ type: "text", text: "failed" }], + error: true, + structured_content: { result: 123 }, + ) + end + server = Server.new( + tools: [tool], + configuration: Configuration.new(validate_tool_call_results: true), + ) + + response = server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "error_response_tool" }, + }) + + assert_nil response[:error] + assert response[:result][:isError] + assert_equal({ result: 123 }, response[:result][:structuredContent]) + end + + test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do + server = Server.new( + tools: [TestTool], + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "unknown_tool", + arguments: {}, + }, + }, + ) + + assert_nil response[:result] + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "Tool not found: unknown_tool" + end + + test "#handle completion/complete returns default completion result" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: [], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete with custom handler for ref/prompt" do + prompt = Prompt.define( + name: "code_review", + arguments: [Prompt::Argument.new(name: "language", required: true)], + ) {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "language", value: "py" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } }, + }, + response, + ) + end + + test "#handle completion/complete with custom handler for ref/resource" do + template = ResourceTemplate.new( + uri_template: "file:///{path}", + name: "file", + ) + server = Server.new( + name: "test_server", + resource_templates: [template], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["file:///src", "file:///spec"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "file:///{path}" }, + argument: { name: "path", value: "s" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["file:///src", "file:///spec"], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete passes context arguments to handler" do + prompt = Prompt.define( + name: "code_review", + arguments: [ + Prompt::Argument.new(name: "language", required: true), + Prompt::Argument.new(name: "framework", required: false), + ], + ) {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + received_params = nil + server.completion_handler do |params| + received_params = params + { completion: { values: ["flask"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "code_review" }, + argument: { name: "framework", value: "fla" }, + context: { arguments: { language: "python" } }, + }, + }) + + assert_equal({ language: "python" }, received_params.dig(:context, :arguments)) + end + + test "#handle completion/complete truncates values exceeding 100 items" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: (1..150).map(&:to_s), hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + completion = response[:result][:completion] + assert_equal 100, completion[:values].length + assert_equal "1", completion[:values].first + assert_equal "100", completion[:values].last + assert(completion[:hasMore]) + assert_equal 150, completion[:total] + end + + test "#handle completion/complete returns error for nonexistent prompt" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "nonexistent" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for nonexistent resource template" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "unknown://template" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete resource-not-found error carries the uri in error data" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "unknown://template" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: unknown://template", response[:error][:message]) + assert_equal({ uri: "unknown://template" }, response[:error][:data]) + end + + test "#handle completion/complete returns error for invalid ref type" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/invalid" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for missing ref" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: {}, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete with custom handler for ref/resource with resource URI" do + resource = Resource.new( + uri: "file:///README.md", + name: "readme", + ) + server = Server.new( + name: "test_server", + resources: [resource], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { completion: { values: ["file:///README.md"], hasMore: false } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "file:///README.md" }, + argument: { name: "path", value: "R" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: ["file:///README.md"], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete returns error for missing argument" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns error for missing argument value" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg" }, + }, + }) + + assert_equal(-32_602, response[:error][:code]) + end + + test "#handle completion/complete returns default when handler returns nil" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + nil + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: { completion: { values: [], hasMore: false } }, + }, + response, + ) + end + + test "#handle completion/complete with string-keyed handler result" do + prompt = Prompt.define(name: "test") {} + server = Server.new( + name: "test_server", + prompts: [prompt], + capabilities: { completions: {} }, + ) + + server.completion_handler do |_params| + { "completion" => { "values" => ["alpha", "beta"], "hasMore" => true } } + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert_equal ["alpha", "beta"], response[:result][:completion][:values] + assert response[:result][:completion][:hasMore] + end + + test "#handle completion/complete returns invalid params for non-Hash params" do + server = Server.new( + name: "test_server", + prompts: [], + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: "invalid", + }) + + assert_equal(-32602, response[:error][:code]) + end + + test "#handle completion/complete returns error when completions capability is not declared" do + server = Server.new( + name: "test_server", + prompts: [], + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + }, + }) + + assert response[:error] + assert_includes response[:error][:data], "completions" + end + + test "#handle resources/subscribe returns empty result" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + end + + test "#handle resources/unsubscribe returns empty result" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/unsubscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + end + + test "#handle resources/subscribe with custom handler calls the handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + received_params = nil + server.resources_subscribe_handler do |params| + received_params = params + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + assert_equal "https://example.com/resource", received_params[:uri] + end + + test "#handle resources/unsubscribe with custom handler calls the handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + received_params = nil + server.resources_unsubscribe_handler do |params| + received_params = params + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/unsubscribe", + params: { uri: "https://example.com/resource" }, + }) + + assert_equal( + { + jsonrpc: "2.0", + id: 2, + result: {}, + }, + response, + ) + assert_equal "https://example.com/resource", received_params[:uri] + end + + test "#handle resources/subscribe without uri returns -32602" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + assert_includes response[:error][:data], "uri" + end + + test "#handle resources/unsubscribe without uri returns -32602" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/unsubscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal "Invalid params", response[:error][:message] + end + + # Builds an initialized server that advertises the `resources.subscribe` capability. + def subscription_server + server = Server.new(name: "test_server", capabilities: { resources: { subscribe: true } }) + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + server + end + + # Sends `method` (`resources/subscribe` or `resources/unsubscribe`) and returns the JSON-RPC result. + def handle_subscription(server, method) + server.handle({ + jsonrpc: "2.0", + id: 2, + method: method, + params: { uri: "https://example.com/resource" }, + })[:result] + end + + test "#handle resources/subscribe passes a handler-returned _meta through to the result" do + server = subscription_server + server.resources_subscribe_handler { |_params| { _meta: { "acme.example/subscriptionId" => "sub-1" } } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({ _meta: { "acme.example/subscriptionId" => "sub-1" } }, result) + end + + test "#handle resources/unsubscribe passes a handler-returned _meta through to the result" do + server = subscription_server + server.resources_unsubscribe_handler { |_params| { _meta: { "acme.example/note" => "gone" } } } + + result = handle_subscription(server, "resources/unsubscribe") + + assert_equal({ _meta: { "acme.example/note" => "gone" } }, result) + end + + test "#handle resources/subscribe drops a handler-returned field that is not _meta" do + # The spec's result defines no member other than `_meta`, so a top-level field the handler adds is not + # a subscription protocol; it stays out of the response. + server = subscription_server + server.resources_subscribe_handler { |_params| { subscriptionId: "sub-1" } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + + test "#handle resources/subscribe accepts a string _meta key from the handler" do + server = subscription_server + server.resources_subscribe_handler { |_params| { "_meta" => { "k" => "v" } } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({ _meta: { "k" => "v" } }, result) + end + + test "#handle resources/subscribe ignores a handler-returned _meta that is not a hash" do + server = subscription_server + server.resources_subscribe_handler { |_params| { _meta: "not-a-hash" } } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + + test "#handle resources/subscribe keeps an empty result when the handler returns a non-hash" do + server = subscription_server + server.resources_subscribe_handler { |_params| nil } + + result = handle_subscription(server, "resources/subscribe") + + assert_equal({}, result) + end + + test "#handle resources/subscribe without uri does not invoke a custom handler" do + server = Server.new( + name: "test_server", + capabilities: { resources: { subscribe: true } }, + ) + + handler_called = false + server.resources_subscribe_handler do |_params| + handler_called = true + {} + end + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "resources/subscribe", + params: {}, + }) + + assert_equal(-32602, response[:error][:code]) + refute handler_called + end + test "tools/call with no args" do server = Server.new(tools: [@tool_with_no_args]) @@ -1054,7 +3856,19 @@ module MCP input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) class << self - def call(message:, server_context: nil) + def call(server_context: nil, **kwargs) + Tool::Response.new([{ type: "text", content: "OK" }]) + end + end + end + + class TestToolWithAdditionalPropertiesSetToFalse < Tool + tool_name "test_tool_with_additional_properties_set_to_false" + description "a test tool with additionalProperties set to false for testing" + input_schema({ properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false }) + + class << self + def call(server_context: nil, **kwargs) Tool::Response.new([{ type: "text", content: "OK" }]) end end @@ -1087,5 +3901,666 @@ module MCP end end end + + class JsonObjectValue + def initialize(value) + @value = value + end + + def to_json(*args) + @value.to_json(*args) + end + end + + test "server_context_with_meta uses accessor method, not ivar directly" do + subclass = Class.new(Server) do + def server_context + { custom: "from_accessor" } + end + end + + server = subclass.new(name: "test", tools: []) + + received_context = nil + server.define_tool(name: "ctx_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "ctx_tool", arguments: {} }, + } + + server.handle(request) + assert_equal "from_accessor", received_context[:custom] + end + + test "#handle tools/call passes W3C trace context _meta keys through to the handler" do + # Per SEP-414, `traceparent`, `tracestate`, and `baggage` are reserved + # un-prefixed `_meta` keys and must never be stripped by the SDK. + server = Server.new(name: "trace_test", tools: []) + received_context = nil + server.define_tool(name: "trace_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "trace_tool", + arguments: {}, + _meta: { + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=value", + baggage: "userId=alice", + progressToken: "token-1", + }, + }, + }) + + meta = received_context[:_meta] + assert_equal "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", meta[:traceparent] + assert_equal "vendor=value", meta[:tracestate] + assert_equal "userId=alice", meta[:baggage] + assert_equal "token-1", meta[:progressToken] + end + + test "#handle prompts/get passes W3C trace context _meta keys through to the handler" do + server = Server.new(name: "trace_test", prompts: []) + received_context = nil + server.define_prompt(name: "trace_prompt", arguments: []) do |_args, server_context:| + received_context = server_context + Prompt::Result.new( + description: "a prompt description", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))], + ) + end + + server.handle({ + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "trace_prompt", + arguments: {}, + _meta: { + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=value", + baggage: "userId=alice", + }, + }, + }) + + meta = received_context[:_meta] + MCP::TraceContext::META_KEYS.each do |key| + assert meta.key?(key.to_sym), "expected _meta to retain #{key}" + end + end + + test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do + # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, + # so the server adds a serialized fallback when the tool provided no content blocks. + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "nil_content_tool") do + Tool::Response.new(nil, structured_content: [1, 2]) + end + server.define_tool(name: "omitted_content_tool") do + Tool::Response.new(structured_content: [1, 2]) + end + + ["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index| + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: index + 1, + params: { name: tool_name, arguments: {} }, + }) + + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + end + end + + test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new([{ type: "text", text: "two items" }], structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [{ type: "text", text: "two items" }], response.dig(:result, :content) + end + + test "#handle tools/call leaves object structuredContent without a text fallback" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new(nil, structured_content: { answer: 42 }) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal({ answer: 42 }, response.dig(:result, :structuredContent)) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do + structured_content = JsonObjectValue.new(answer: 42) + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new(nil, structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for non-object structuredContent" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new([], structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for object-like structuredContent" do + structured_content = JsonObjectValue.new(answer: 42) + + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new([], structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "list results carry ttlMs and cacheScope when ttl_ms is configured" do + # SEP-2549 cache hints. The unset cacheScope fills as "private", the side that cannot + # leak a user-dependent result through a shared cache, matching the TypeScript and + # Python SDK defaults (the spec names no default scope). + server = Server.new(name: "ttl_test", ttl_ms: 5000) + + ["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index| + result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result] + + assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs" + assert_equal "private", result[:cacheScope], "#{method} missing cacheScope" + end + end + + test "resources/read carries cache hints when cache_scope is configured" do + # The ttlMs default is 0 (do not cache), the only universally safe value. + server = Server.new(name: "ttl_test", cache_scope: "private") + + result = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///x" }, + })[:result] + + assert_equal 0, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "results omit cache hints when ttl_ms and cache_scope are not configured" do + # Wire-format regression: opt-in emission keeps default output unchanged. + list_result = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] + read_result = @server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 2, + params: { uri: "file:///x" }, + })[:result] + + refute list_result.key?(:ttlMs) + refute list_result.key?(:cacheScope) + refute read_result.key?(:ttlMs) + refute read_result.key?(:cacheScope) + end + + test "cache hints appear alongside nextCursor when paginating" do + tool_a = Tool.define(name: "tool_a", description: "Tool A") + tool_b = Tool.define(name: "tool_b", description: "Tool B") + server = Server.new(name: "ttl_test", tools: [tool_a, tool_b], page_size: 1, ttl_ms: 1000, cache_scope: "private") + + result = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] + + assert_not_nil result[:nextCursor] + assert_equal 1000, result[:ttlMs] + assert_equal "private", result[:cacheScope] + end + + test "a resources_read_handler can override the server-level cache hints per result" do + server = Server.new(name: "ttl_test", ttl_ms: 5000) + server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }], ttlMs: 60_000 } + end + + result = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///x" }, + })[:result] + + assert_equal 60_000, result[:ttlMs] + assert_equal "private", result[:cacheScope] + assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents] + end + + test "ttl_ms and cache_scope writers reject invalid values" do + assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: -1) } + assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: 1.5) } + assert_raises(ArgumentError) { Server.new(name: "ttl_test", cache_scope: "internal") } + end + + test "#handle tools/list returns paginated results when page_size is set" do + tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") + tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") + tool_c = Tool.define(name: "tool_c", title: "Tool C", description: "Tool C") + + server = Server.new( + name: "pagination_test", + tools: [tool_a, tool_b, tool_c], + page_size: 2, + ) + + first_request = { jsonrpc: "2.0", method: "tools/list", id: 1 } + first_response = server.handle(first_request) + first_result = first_response[:result] + + assert_equal 2, first_result[:tools].size + assert_equal "tool_a", first_result[:tools][0][:name] + assert_equal "tool_b", first_result[:tools][1][:name] + assert_not_nil first_result[:nextCursor] + + second_request = { jsonrpc: "2.0", method: "tools/list", id: 2, params: { cursor: first_result[:nextCursor] } } + second_response = server.handle(second_request) + second_result = second_response[:result] + + assert_equal 1, second_result[:tools].size + assert_equal "tool_c", second_result[:tools][0][:name] + # Final page omits the nextCursor key entirely (not just sets it to nil). + refute second_result.key?(:nextCursor) + end + + test "#handle tools/list returns all tools when page_size is not set" do + response = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) + result = response[:result] + + assert_kind_of Array, result[:tools] + assert_nil result[:nextCursor] + end + + test "#handle tools/list returns error for invalid cursor" do + server = Server.new(name: "pagination_test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: { cursor: "!!!invalid!!!" } } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle prompts/list returns paginated results when page_size is set" do + prompt_a = Prompt.define(name: "prompt_a", title: "Prompt A", description: "A") { Prompt::Result.new(description: "A", messages: []) } + prompt_b = Prompt.define(name: "prompt_b", title: "Prompt B", description: "B") { Prompt::Result.new(description: "B", messages: []) } + + server = Server.new(name: "pagination_test", prompts: [prompt_a, prompt_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:prompts].size + assert_equal "prompt_a", first_result[:prompts][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:prompts].size + assert_equal "prompt_b", second_result[:prompts][0][:name] + assert_nil second_result[:nextCursor] + end + + test "#handle resources/list returns paginated results when page_size is set" do + resource_a = Resource.new(uri: "https://a.invalid", name: "a", description: "A", mime_type: "text/plain") + resource_b = Resource.new(uri: "https://b.invalid", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resources: [resource_a, resource_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resources].size + assert_equal "a", first_result[:resources][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resources].size + assert_equal "b", second_result[:resources][0][:name] + assert_nil second_result[:nextCursor] + end + + test "Server.new raises ArgumentError when page_size is zero" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: 0) + end + end + + test "Server.new raises ArgumentError when page_size is negative" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: -1) + end + end + + test "Server.new raises ArgumentError when page_size is non-Integer" do + assert_raises(ArgumentError) do + Server.new(name: "test", page_size: "10") + end + end + + test "page_size= raises ArgumentError for invalid values" do + server = Server.new(name: "test") + + assert_raises(ArgumentError) { server.page_size = 0 } + assert_raises(ArgumentError) { server.page_size = -1 } + assert_raises(ArgumentError) { server.page_size = "5" } + + server.page_size = nil + server.page_size = 10 + assert_equal 10, server.page_size + end + + test "#handle tools/list returns -32602 for non-Hash params" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: [1, 2, 3] } + response = server.handle(request) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle_json tools/list returns -32602 for numeric cursor (spec requires string)" do + server = Server.new(name: "test", tools: [@tool], page_size: 1) + + request_json = '{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{"cursor":0}}' + response = JSON.parse(server.handle_json(request_json), symbolize_names: true) + + assert_not_nil response[:error] + assert_equal(-32602, response[:error][:code]) + end + + test "#handle resources/templates/list returns paginated results when page_size is set" do + template_a = ResourceTemplate.new(uri_template: "https://a.invalid/{id}", name: "a", description: "A", mime_type: "text/plain") + template_b = ResourceTemplate.new(uri_template: "https://b.invalid/{id}", name: "b", description: "B", mime_type: "text/plain") + + server = Server.new(name: "pagination_test", resource_templates: [template_a, template_b], page_size: 1) + + first_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) + first_result = first_response[:result] + + assert_equal 1, first_result[:resourceTemplates].size + assert_equal "a", first_result[:resourceTemplates][0][:name] + assert_not_nil first_result[:nextCursor] + + second_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 2, params: { cursor: first_result[:nextCursor] } }) + second_result = second_response[:result] + + assert_equal 1, second_result[:resourceTemplates].size + assert_equal "b", second_result[:resourceTemplates][0][:name] + assert_nil second_result[:nextCursor] + end + + test "an MCP Apps server advertises the extension and serves ui:// templates" do + # SEP-1865: the extension rides the SEP-2133 `capabilities.extensions` machinery, + # UI templates are ordinary resources, and tools link to them via `_meta.ui.resourceUri`. + dashboard = Apps.ui_resource(uri: "ui://weather/dashboard", name: "weather_dashboard") + capabilities = Server::Capabilities.new + capabilities.support_tools + capabilities.support_resources + capabilities.support_extensions(Apps.capability) + server = Server.new(name: "apps_test", capabilities: capabilities, resources: [dashboard]) + server.define_tool( + name: "get_weather", + meta: Apps.tool_meta(resource_uri: "ui://weather/dashboard"), + ) do + Tool::Response.new([{ type: "text", text: "Sunny" }]) + end + + server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: Apps::RESOURCE_MIME_TYPE, text: "dashboard" }] + end + + init_response = server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "host", version: "1.0" }, + capabilities: { extensions: Apps.capability }, + }, + }) + + tools_response = server.handle({ + jsonrpc: "2.0", + method: "tools/list", + id: 2, + }) + + read_response = server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 3, + params: { uri: "ui://weather/dashboard" }, + }) + + assert_equal( + { mimeTypes: [Apps::RESOURCE_MIME_TYPE] }, + init_response.dig(:result, :capabilities, :extensions, Apps::EXTENSION_ID), + ) + tool_entry = tools_response.dig(:result, :tools, 0) + + assert_equal "ui://weather/dashboard", tool_entry.dig(:_meta, :ui, :resourceUri) + assert_equal "dashboard", read_response.dig(:result, :contents, 0, :text) + # The server can gate the text-only fallback on the client's declaration. + assert Apps.client_supports?(server.client_capabilities) + end + + test "an MCP Apps tool falls back to text for clients without the extension" do + capabilities = Server::Capabilities.new + capabilities.support_extensions(Apps.capability) + server = Server.new(name: "apps_test", capabilities: capabilities) + + server.handle({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2025-11-25", + clientInfo: { name: "plain_client", version: "1.0" }, + capabilities: {}, + }, + }) + + refute Apps.client_supports?(server.client_capabilities) + end + + # SEP-2322 makes `resultType` REQUIRED on every result of a 2026-07-28 server; + # `"complete"` is the standard shape, and legacy results stay unstamped. + test "modern results carry resultType complete" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + # Every method here has to be one the modern lifecycle still has. `ping` does not qualify: + # SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result. + [ + modern_request(Methods::TOOLS_LIST, {}), + modern_request(Methods::PROMPTS_LIST, {}), + modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }), + ].each do |request| + response = server.handle(request) + assert_equal "complete", response.dig(:result, :resultType), "expected stamp for #{request[:method]}" + end + end + + test "legacy results carry no resultType" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + [ + { jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }, + { jsonrpc: "2.0", method: Methods::PING, id: 2 }, + { jsonrpc: "2.0", method: Methods::TOOLS_CALL, id: 3, params: { name: "result_type_tool", arguments: {} } }, + ].each do |request| + response = server.handle(request) + refute response[:result].key?(:resultType), "expected no stamp for #{request[:method]}" + end + end + + test "results after a counter-offered modern initialize carry no resultType" do + # The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on + # 2025-11-25, where `resultType` does not exist, so its absence is the correct shape + # (clients MUST read an absent `resultType` as "complete" on that revision). + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + session = ServerSession.new(server: server, transport: mock) + + init_response = server.handle( + { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") }, + session: session, + ) + response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion) + refute response[:result].key?(:resultType) + end + + test "server/discover carries resultType complete" do + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + + response = server.handle({ jsonrpc: "2.0", method: Methods::SERVER_DISCOVER, id: 1 }) + + assert_equal "complete", response.dig(:result, :resultType) + end + + # SEP-2549 makes the `ttlMs`/`cacheScope` cache hints REQUIRED on cacheable results at 2026-07-28; + # unset hints get the spec defaults, and stable protocol versions keep the opt-in emission. + test "modern cacheable results carry the default cache hints" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + response = server.handle(modern_request(Methods::TOOLS_LIST, {})) + + assert_equal 0, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "modern non-cacheable results carry no cache hints" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + # `tools/call` is the non-cacheable example here: the spec types `CallToolResult` as a plain `Result`, + # while the results it types as `CacheableResult` are the discover, list, and read families. + # `ping` cannot stand in for it - SEP-2575 removed that method, so a modern request naming it is refused. + response = server.handle(modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} })) + + refute response[:result].key?(:ttlMs) + refute response[:result].key?(:cacheScope) + end + + test "configured cache hints win over the modern defaults" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool], ttl_ms: 5000, cache_scope: "private") + + response = server.handle(modern_request(Methods::TOOLS_LIST, {})) + + assert_equal 5000, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "modern resources/read results carry the default cache hints" do + server = Server.new(name: "cache_hints_test") + server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }] + end + + response = server.handle(modern_request(Methods::RESOURCES_READ, { uri: "file:///x" })) + + assert_equal 0, response.dig(:result, :ttlMs) + assert_equal "private", response.dig(:result, :cacheScope) + end + + test "legacy cacheable results keep the opt-in cache hint emission" do + server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) + + response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }) + + refute response[:result].key?(:ttlMs) + refute response[:result].key?(:cacheScope) + end + + private + + # Builds a request carrying the SEP-2575 modern `_meta` envelope. + def modern_request(method, params, version: "2026-07-28", capabilities: {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + ), + } + end + + def result_type_tool + @result_type_tool ||= Tool.define(name: "result_type_tool") do |**| + Tool::Response.new([{ type: "text", text: "ok" }]) + end + end end end diff --git a/test/mcp/string_utils_test.rb b/test/mcp/string_utils_test.rb index 6613e89..948c34a 100644 --- a/test/mcp/string_utils_test.rb +++ b/test/mcp/string_utils_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "timeout" module MCP class StringUtilsTest < Minitest::Test @@ -18,5 +19,18 @@ module MCP assert_equal("test", StringUtils.handle_from_class_name("Module::Submodule::Test")) assert_equal("test_class", StringUtils.handle_from_class_name("Module::Submodule::TestClass")) end + + def test_handle_from_class_name_does_not_cause_redos + # A long string of uppercase letters followed by a non-lowercase character + # would trigger catastrophic backtracking with the vulnerable regex patterns. + malicious_input = "A" * 50_000 + "!" + + result = nil + Timeout.timeout(1) do + result = StringUtils.handle_from_class_name(malicious_input) + end + + assert_equal("a" * 50_000 + "!", result) + end end end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 6c96051..6b239eb 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -5,37 +5,115 @@ require "test_helper" module MCP class Tool class InputSchemaTest < ActiveSupport::TestCase - test "required arguments are converted to symbols" do - input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) - assert_equal [:message], input_schema.required + test "required arguments are converted to strings" do + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + assert_equal ["message"], input_schema.to_h[:required] end test "to_h returns a hash representation of the input schema" do - input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) assert_equal( - { type: "object", properties: { message: { type: "string" } }, required: [:message] }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, input_schema.to_h, ) end + test "to_h returns a hash representation of the input schema with additionalProperties set to false" do + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + additionalProperties: false, + }, + input_schema.to_h, + ) + end + + test "to_h preserves user-supplied $schema dialect" do + input_schema = InputSchema.new( + "$schema": "https://json-schema.org/draft/2019-09/schema", + properties: { message: { type: "string" } }, + ) + assert_equal "https://json-schema.org/draft/2019-09/schema", input_schema.to_h[:"$schema"] + end + + test "validate_arguments works when user supplies a 2020-12 $schema" do + input_schema = InputSchema.new( + "$schema": "https://json-schema.org/draft/2020-12/schema", + properties: { foo: { type: "string" } }, + required: ["foo"], + ) + assert_nil(input_schema.validate_arguments(foo: "bar")) + assert_raises(InputSchema::ValidationError) do + input_schema.validate_arguments({ foo: 123 }) + end + end + + test "to_h preserves user-supplied $schema given via string key" do + # The initializer normalizes input through `JSON.parse(..., + # symbolize_names: true)`, so a string-keyed `"$schema"` should + # arrive at `schema_for_validation` the same as a symbol-keyed one. + input_schema = InputSchema.new( + { + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "properties" => { "foo" => { "type" => "string" } }, + "required" => ["foo"], + }, + ) + assert_equal "https://json-schema.org/draft/2020-12/schema", input_schema.to_h[:"$schema"] + assert_nil(input_schema.validate_arguments(foo: "bar")) + end + test "missing_required_arguments returns an array of missing required arguments" do - input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) - assert_equal [:message], input_schema.missing_required_arguments({}) + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) + assert_equal ["message"], input_schema.missing_required_arguments({}) end test "missing_required_arguments returns an empty array if no required arguments are missing" do - input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) - assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" }) + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) + assert_equal([], input_schema.missing_required_arguments({ message: "Hello, world!" })) end test "valid schema initialization" do - schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) - assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: [:foo] }, schema.to_h) + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }, + schema.to_h, + ) end test "invalid schema raises argument error" do assert_raises(ArgumentError) do - InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo]) + InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: ["foo"]) + end + end + + test "rejects a draft-04-only boolean exclusiveMinimum under the 2020-12 dialect" do + # SEP-2106 validates tool schemas against the JSON Schema 2020-12 metaschema, + # where `exclusiveMinimum` must be a number. The draft-04 boolean form (deprecated since draft-06) + # is rejected at construction, matching the Python SDK's `jsonschema` validator selection. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { age: { type: "integer", minimum: 0, exclusiveMinimum: true } }) + end + assert_includes error.message, "Invalid JSON Schema" + end + + test "accepts the 2020-12 numeric exclusiveMinimum form" do + assert_nothing_raised do + InputSchema.new(properties: { age: { type: "integer", exclusiveMinimum: 0 } }) end end @@ -46,52 +124,137 @@ module MCP end test "validate arguments with valid data" do - schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) assert_nil(schema.validate_arguments({ foo: "bar" })) end test "validate arguments with invalid data" do - schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) assert_raises(InputSchema::ValidationError) do schema.validate_arguments({ foo: 123 }) end end + test "validate arguments with valid data when additionalProperties set to nil (default)" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 })) + end + + test "validate arguments with valid data when additionalProperties set to true (default)" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: true) + assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 })) + end + + test "validate arguments with invalid data when additionalProperties set to false" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false) + assert_raises(InputSchema::ValidationError) do + schema.validate_arguments({ foo: "bar", extra: 123 }) + end + end + test "unexpected errors bubble up from validate_arguments" do - schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do - assert_raises(RuntimeError) do - schema.validate_arguments({ foo: "bar" }) - end + JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error") + + assert_raises(RuntimeError) do + schema.validate_arguments(foo: "bar") end end - test "rejects schemas with $ref references" do - assert_raises(ArgumentError) do - InputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo]) - end + test "accepts schemas with $ref references" do + schema = InputSchema.new( + properties: { + foo: { type: "string" }, + }, + definitions: { + bar: { type: "string" }, + }, + required: ["foo"], + ) + assert_includes schema.to_h.keys, :definitions end - test "rejects schemas with symbol $ref references" do - assert_raises(ArgumentError) do - InputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo]) - end + test "accepts schemas with $ref string key and includes $ref in to_h" do + schema = InputSchema.new({ + "properties" => { + "foo" => { "$ref" => "#/definitions/bar" }, + }, + "definitions" => { + "bar" => { "type" => "string" }, + }, + }) + assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref] end - test "== compares two input schemas with the same properties and required fields" do - schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) - schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + test "accepts schemas with $ref symbol key and includes $ref in to_h" do + schema = InputSchema.new({ + properties: { + foo: { :$ref => "#/definitions/bar" }, + }, + definitions: { + bar: { type: "string" }, + }, + }) + assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref] + end + + test "keeps the object root type while accepting 2020-12 composition keywords" do + # Per SEP-2106, an input schema root must stay `type: "object"` but may use + # the full 2020-12 vocabulary below the root. + schema = InputSchema.new( + "$defs": { name: { type: "string", minLength: 1 } }, + properties: { + name: { "$ref": "#/$defs/name" }, + value: { oneOf: [{ type: "string" }, { type: "integer" }] }, + }, + if: { properties: { value: { type: "integer" } } }, + then: { required: ["name"] }, + allOf: [{ properties: { extra: { type: "boolean" } } }], + ) + + assert_equal "object", schema.to_h[:type] + assert schema.to_h.key?(:"$defs") + assert schema.to_h.key?(:if) + assert schema.to_h.key?(:allOf) + end + + test "== compares two input schemas with the same properties, required fields" do + schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) assert_equal schema1, schema2 - schema3 = InputSchema.new(properties: { bar: { type: "string" } }, required: [:bar]) + schema3 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["bar"]) refute_equal schema1, schema3 - schema4 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:bar]) + schema4 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["bar"]) refute_equal schema1, schema4 - schema5 = InputSchema.new(properties: { bar: { type: "string" } }, required: [:foo]) + schema5 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["foo"]) refute_equal schema1, schema5 + + schema6 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false) + refute_equal schema1, schema6 + end + + test "format keyword is not enforced (legacy behavior)" do + schema = InputSchema.new( + properties: { email: { type: "string", format: "email" } }, + required: ["email"], + ) + assert_nil(schema.validate_arguments(email: "not_an_email")) + end + + test "invalid pattern raises ArgumentError, not RegexpError" do + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { id: { type: "string", pattern: "[" } }) + end + assert_includes error.message, "Invalid JSON Schema" + end + + test "Symbol values in arguments are treated as strings" do + schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + assert_nil(schema.validate_arguments(foo: :bar)) end end end diff --git a/test/mcp/tool/output_schema_test.rb b/test/mcp/tool/output_schema_test.rb index ef4ef4c..a9522cc 100644 --- a/test/mcp/tool/output_schema_test.rb +++ b/test/mcp/tool/output_schema_test.rb @@ -6,42 +6,98 @@ module MCP class Tool class OutputSchemaTest < ActiveSupport::TestCase test "to_h returns a hash representation of the output schema" do - output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result]) + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_equal( - { type: "object", properties: { result: { type: "string" } }, required: ["result"] }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, output_schema.to_h, ) end + test "to_h preserves user-supplied $schema dialect" do + output_schema = OutputSchema.new( + "$schema": "https://json-schema.org/draft/2019-09/schema", + properties: { result: { type: "string" } }, + ) + assert_equal "https://json-schema.org/draft/2019-09/schema", output_schema.to_h[:"$schema"] + end + + test "validate_result works when user supplies a 2020-12 $schema" do + output_schema = OutputSchema.new( + "$schema": "https://json-schema.org/draft/2020-12/schema", + properties: { result: { type: "string" } }, + required: ["result"], + ) + assert_nothing_raised do + output_schema.validate_result({ result: "success" }) + end + assert_raises(OutputSchema::ValidationError) do + output_schema.validate_result({ result: 123 }) + end + end + test "validate_result validates result against the schema" do - output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result]) + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_nothing_raised do output_schema.validate_result({ result: "success" }) end end + test "validate_result validates result with additional properties against the schema when additionalProperties set to nil (default)" do + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) + assert_nothing_raised do + output_schema.validate_result({ result: "success", extra: 123 }) + end + end + + test "validate_result validates result with additional properties against the schema when additionalProperties set to true" do + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"], additionalProperties: true) + assert_nothing_raised do + output_schema.validate_result({ result: "success", extra: 123 }) + end + end + + test "validate_result raises error with additional properties when additionalProperties set to false)" do + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"], additionalProperties: false) + assert_raises(OutputSchema::ValidationError) do + output_schema.validate_result({ result: "success", extra: 123 }) + end + end + test "validate_result raises error for invalid result" do - output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result]) + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_raises(OutputSchema::ValidationError) do output_schema.validate_result({ result: 123 }) end end test "validate_result raises error for missing required field" do - output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result]) + output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) assert_raises(OutputSchema::ValidationError) do output_schema.validate_result({}) end end test "valid schema initialization" do - schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) - assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h) + schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }, + schema.to_h, + ) end test "invalid schema raises argument error" do assert_raises(ArgumentError) do - OutputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo]) + OutputSchema.new(properties: { foo: { type: "invalid_type" } }, required: ["foo"]) end end @@ -52,45 +108,73 @@ module MCP end test "unexpected errors bubble up from validate_result" do - schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) - JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do - assert_raises(RuntimeError) do - schema.validate_result({ foo: "bar" }) - end + JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error") + + assert_raises(RuntimeError) do + schema.validate_result(foo: "bar") end end - test "rejects schemas with $ref references" do - assert_raises(ArgumentError) do - OutputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo]) - end + test "accepts schemas with $ref references" do + schema = OutputSchema.new( + properties: { + foo: { type: "string" }, + }, + definitions: { + bar: { type: "string" }, + }, + required: ["foo"], + ) + assert_includes schema.to_h.keys, :definitions end - test "rejects schemas with symbol $ref references" do - assert_raises(ArgumentError) do - OutputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo]) - end + test "accepts schemas with $ref string key and includes $ref in to_h" do + schema = OutputSchema.new({ + "properties" => { + "foo" => { "$ref" => "#/definitions/bar" }, + }, + "definitions" => { + "bar" => { "type" => "string" }, + }, + }) + assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref] + end + + test "accepts schemas with $ref symbol key and includes $ref in to_h" do + schema = OutputSchema.new({ + properties: { + foo: { :$ref => "#/definitions/bar" }, + }, + definitions: { + bar: { type: "string" }, + }, + }) + assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref] end test "== compares two output schemas with the same properties and required fields" do - schema1 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) - schema2 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo]) + schema1 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) + schema2 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"]) assert_equal schema1, schema2 - schema3 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:bar]) + schema3 = OutputSchema.new(properties: { bar: { type: "string" } }, required: ["bar"]) refute_equal schema1, schema3 - schema4 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:bar]) + schema4 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["bar"]) refute_equal schema1, schema4 - schema5 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:foo]) + schema5 = OutputSchema.new(properties: { bar: { type: "string" } }, required: ["foo"]) refute_equal schema1, schema5 end test "empty schema is valid" do schema = OutputSchema.new - assert_equal({ type: "object" }, schema.to_h) + assert_equal( + { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, + schema.to_h, + ) end test "validates complex nested schemas" do @@ -105,7 +189,7 @@ module MCP required: ["items"], }, }, - required: [:data], + required: ["data"], ) valid_result = { @@ -131,16 +215,62 @@ module MCP end end + test "does not inject a root type into a root-level oneOf schema" do + # Per SEP-2106, an output schema may be any JSON Schema 2020-12 document. + # Merging `type: "object"` into a root combinator would produce a wrong schema. + schema = OutputSchema.new(oneOf: [{ type: "string" }, { type: "integer" }]) + + refute schema.to_h.key?(:type) + assert_equal [{ type: "string" }, { type: "integer" }], schema.to_h[:oneOf] + assert_nothing_raised { schema.validate_result("text") } + assert_nothing_raised { schema.validate_result(42) } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(1.5) } + end + + test "does not inject a root type into a root-level $ref schema" do + schema = OutputSchema.new( + "$ref": "#/$defs/result", + "$defs": { result: { type: "string" } }, + ) + + refute schema.to_h.key?(:type) + assert_nothing_raised { schema.validate_result("text") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) } + end + + test "allows primitive root schemas" do + schema = OutputSchema.new(type: "string") + + assert_nothing_raised { schema.validate_result("text") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result(42) } + end + + test "does not inject a root type into a root-level enum schema" do + schema = OutputSchema.new(enum: ["red", "green", "blue"]) + + refute schema.to_h.key?(:type) + assert_nothing_raised { schema.validate_result("red") } + assert_raises(OutputSchema::ValidationError) { schema.validate_result("yellow") } + end + + test "defaults a properties-only schema to a root object" do + # Wire-format regression: the common shorthand keeps serializing with the injected `type: "object"`. + schema = OutputSchema.new(properties: { result: { type: "string" } }) + + assert_equal "object", schema.to_h[:type] + end + test "allow to declare array schemas" do schema = OutputSchema.new({ type: "array", items: { properties: { foo: { type: "string" } }, - required: [:foo], + required: ["foo"], }, }) assert_equal( { + "$schema": "https://json-schema.org/draft/2020-12/schema", type: "array", items: { properties: { foo: { type: "string" } }, diff --git a/test/mcp/tool/response_test.rb b/test/mcp/tool/response_test.rb index 5fd70a8..afa7d3b 100644 --- a/test/mcp/tool/response_test.rb +++ b/test/mcp/tool/response_test.rb @@ -16,6 +16,12 @@ module MCP refute response.error? end + test "#content_provided? distinguishes empty content from omitted or nil content" do + assert Response.new([]).content_provided? + refute Response.new.content_provided? + refute Response.new(nil).content_provided? + end + test "#initialize with content and error set to true" do content = [{ type: "text", @@ -107,7 +113,7 @@ module MCP actual = response.to_h assert_equal [:content, :isError, :structuredContent], actual.keys - assert_empty actual[:content] + assert_equal([], actual[:content]) assert_equal structured_content, actual[:structuredContent] refute actual[:isError] end @@ -118,10 +124,24 @@ module MCP actual = response.to_h assert_equal [:content, :isError, :structuredContent], actual.keys - assert_empty actual[:content] + assert_equal([], actual[:content]) assert_equal structured_content, actual[:structuredContent] refute actual[:isError] end + + test "#to_h includes _meta when present" do + meta = { "application/vnd.ant.mcp-app" => { "csp" => "default-src 'self'" } } + response = Response.new([{ type: "text", text: "ok" }], meta: meta) + actual = response.to_h + + assert_equal meta, actual[:_meta] + end + + test "#to_h omits _meta when nil" do + response = Response.new([{ type: "text", text: "ok" }]) + + refute response.to_h.key?(:_meta) + end end end end diff --git a/test/mcp/tool/schema_test.rb b/test/mcp/tool/schema_test.rb new file mode 100644 index 0000000..cb3fd7a --- /dev/null +++ b/test/mcp/tool/schema_test.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Tool + class SchemaTest < ActiveSupport::TestCase + setup do + Schema::VALIDATION_CACHE.clear + end + + test "validates a schema once and reuses the result for identical schemas" do + JSONSchemer::Schema.any_instance.expects(:validate_schema).once.returns([]) + + schema = { properties: { validates_once: { type: "string" } } } + InputSchema.new(schema) + InputSchema.new(schema) + end + + test "validates distinct schemas separately" do + JSONSchemer::Schema.any_instance.expects(:validate_schema).twice.returns([]) + + InputSchema.new(properties: { distinct_a: { type: "string" } }) + InputSchema.new(properties: { distinct_b: { type: "string" } }) + end + + test "a cache hit still yields a usable, validated schema" do + schema = { properties: { cache_hit: { type: "string" } }, required: ["cache_hit"] } + InputSchema.new(schema) + cached = InputSchema.new(schema) + + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { cache_hit: { type: "string" } }, + required: ["cache_hit"], + }, + cached.to_h, + ) + assert_nil(cached.validate_arguments(cache_hit: "value")) + assert_raises(InputSchema::ValidationError) do + cached.validate_arguments(cache_hit: 123) + end + end + + test "an invalid schema raises every time and is not cached" do + invalid = { properties: { not_cached: { type: "invalid_type" } } } + + assert_raises(ArgumentError) { InputSchema.new(invalid) } + assert_raises(ArgumentError) { InputSchema.new(invalid) } + end + + test "a schema nested deeper than MAX_SCHEMA_DEPTH raises" do + # SEP-2106 resource bounds: unbounded nesting would make downstream validation arbitrarily expensive. + # Each wrapping adds two levels (the schema hash and its `properties` hash), so this is the smallest + # nesting that exceeds MAX_SCHEMA_DEPTH. + wrappings = Schema::MAX_SCHEMA_DEPTH / 2 + 1 + schema = { type: "string" } + wrappings.times do + schema = { type: "object", properties: { child: schema } } + end + + error = assert_raises(ArgumentError) { InputSchema.new(schema) } + assert_match(/maximum depth/, error.message) + end + + test "a schema with more subschema objects than MAX_SUBSCHEMA_COUNT raises" do + properties = {} + (Schema::MAX_SUBSCHEMA_COUNT + 1).times do |i| + properties[:"property_#{i}"] = { type: "string" } + end + + error = assert_raises(ArgumentError) { InputSchema.new(properties: properties) } + assert_match(/subschema/, error.message) + end + + test "rejects a $ref pointing outside the schema document" do + # SEP-2106 requires same-document `$ref` resolution only; a remote URI or a sibling file must never trigger network + # or file access. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$ref": "https://example.com/schema.json#/defs/bar" } }) + end + assert_match(/same-document/, error.message) + + assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$ref": "other.json#/defs/bar" } }) + end + end + + test "rejects a $dynamicRef pointing outside the schema document" do + # 2020-12 allows `$dynamicRef` to carry an absolute URI too, so the same-document restriction must cover it as well as `$ref`. + error = assert_raises(ArgumentError) do + InputSchema.new(properties: { foo: { "$dynamicRef": "https://example.com/schema.json#meta" } }) + end + assert_match(/same-document/, error.message) + assert_match(/\$dynamicRef/, error.message) + end + + test "accepts a same-document $ref" do + assert_nothing_raised do + InputSchema.new( + "$defs": { bar: { type: "string" } }, + properties: { foo: { "$ref": "#/$defs/bar" } }, + ) + end + end + + test "ValidationCache evicts the oldest entry beyond its max size" do + cache = Schema::ValidationCache.new(max_size: 2) + cache.store("a") + cache.store("b") + cache.store("c") + + refute cache.validated?("a") + assert cache.validated?("b") + assert cache.validated?("c") + end + + test "ValidationCache#clear empties the cache" do + cache = Schema::ValidationCache.new + cache.store("a") + cache.clear + + refute cache.validated?("a") + end + end + end +end diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index b717267..bdec7b6 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -7,6 +7,7 @@ module MCP class TestTool < Tool tool_name "test_tool" description "a test tool for testing" + icons [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")] input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) annotations( destructive_hint: false, @@ -26,13 +27,22 @@ module MCP end end - test "#to_h returns a hash with name, description, and inputSchema" do + test "#to_h returns a hash including name, description, icons, and inputSchema" do + expected = { + name: "mock_tool", + title: "Mock Tool", + description: "a mock tool for testing", + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], + inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, + } tool = Tool.define( name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")], ) - assert_equal({ name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", inputSchema: { type: "object" } }, tool.to_h) + + assert_equal(expected, tool.to_h) end test "#to_h does not have `:title` key when title is omitted" do @@ -43,6 +53,25 @@ module MCP refute tool.to_h.key?(:title) end + test "#to_h does not have `:icons` key when icons is empty" do + tool = Tool.define( + name: "tool_without_icons", + description: "a tool without icons", + ) + + refute tool.to_h.key?(:icons) + end + + test "#to_h does not have `:icons` key when icons is nil" do + tool = Tool.define( + name: "tool_without_icons", + description: "a tool without icons", + icons: nil, + ) + + refute tool.to_h.key?(:icons) + end + test "#to_h includes annotations when present" do tool = TestTool expected_annotations = { @@ -74,13 +103,21 @@ module MCP class MockTool < Tool tool_name "my_mock_tool" description "a mock tool for testing" - input_schema({ properties: { message: { type: "string" } }, required: [:message] }) + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) end tool = MockTool assert_equal "my_mock_tool", tool.name_value assert_equal "a mock tool for testing", tool.description - assert_equal({ type: "object", properties: { message: { type: "string" } }, required: [:message] }, tool.input_schema.to_h) + assert_equal( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + tool.input_schema.to_h, + ) end test "defaults to class name as tool name" do @@ -97,18 +134,23 @@ module MCP tool = NoInputSchemaTool - expected = { type: "object" } + expected = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" } assert_equal expected, tool.input_schema.to_h end test "accepts input schema as an InputSchema object" do class InputSchemaTool < Tool - input_schema InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + input_schema InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) end tool = InputSchemaTool - expected = { type: "object", properties: { message: { type: "string" } }, required: [:message] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + } assert_equal expected, tool.input_schema.to_h end @@ -119,14 +161,14 @@ module MCP properties: { count: { type: "integer", minimum: "not a number" }, }, - required: [:count], + required: ["count"], ) end end assert_includes error.message, "Invalid JSON Schema" - assert_includes error.message, "#/properties/count/minimum" - assert_includes error.message, "string did not match the following type: number" + assert_includes error.message, "properties/count/minimum" + assert_includes error.message, "number" end test ".define allows definition of simple tools with a block" do @@ -233,6 +275,8 @@ module MCP end test "#call with Sorbet typed tools invokes the tool block and returns the response" do + skip "sorbet-static-and-runtime requires Ruby 3.0+." if RUBY_VERSION < "3.0" + class TypedTestTool < Tool tool_name "test_tool" description "a test tool for testing" @@ -289,7 +333,7 @@ module MCP assert_equal [{ type: "text", content: "OK" }], response.content end - test "input_schema rejects any $ref in schema" do + test "input_schema accepts $ref in schema" do schema_with_ref = { properties: { foo: { "$ref" => "#/definitions/bar" }, @@ -299,28 +343,34 @@ module MCP bar: { type: "string" }, }, } - error = assert_raises(ArgumentError) do - Class.new(MCP::Tool) do - input_schema schema_with_ref - end + tool_class = Class.new(MCP::Tool) do + input_schema schema_with_ref end - assert_match(/Invalid JSON Schema/, error.message) + assert_equal "#/definitions/bar", tool_class.input_schema.to_h[:properties][:foo][:$ref] end test "#to_h includes outputSchema when present" do - tool = Tool.define( - name: "mock_tool", - title: "Mock Tool", - description: "a mock tool for testing", - output_schema: { properties: { result: { type: "string" } }, required: ["result"] }, - ) expected = { name: "mock_tool", title: "Mock Tool", description: "a mock tool for testing", - inputSchema: { type: "object" }, - outputSchema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] }, + icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], + inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, + outputSchema: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + }, } + tool = Tool.define( + name: "mock_tool", + title: "Mock Tool", + description: "a mock tool for testing", + icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")], + output_schema: { properties: { result: { type: "string" } }, required: ["result"] }, + ) + assert_equal expected, tool.to_h end @@ -340,21 +390,31 @@ module MCP test "accepts output_schema as a hash" do class HashOutputSchemaTool < Tool - output_schema({ properties: { result: { type: "string" } }, required: [:result] }) + output_schema({ properties: { result: { type: "string" } }, required: ["result"] }) end tool = HashOutputSchemaTool - expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected, tool.output_schema.to_h end test "accepts output_schema as an OutputSchema object" do class OutputSchemaObjectTool < Tool - output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: [:result]) + output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"]) end tool = OutputSchemaObjectTool - expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected, tool.output_schema.to_h end @@ -365,17 +425,17 @@ module MCP properties: { count: { type: "integer", minimum: "not a number" }, }, - required: [:count], + required: ["count"], ) end end assert_includes error.message, "Invalid JSON Schema" - assert_includes error.message, "#/properties/count/minimum" - assert_includes error.message, "string did not match the following type: number" + assert_includes error.message, "properties/count/minimum" + assert_includes error.message, "number" end - test "output_schema rejects any $ref in schema" do + test "output_schema accepts $ref in schema" do schema_with_ref = { properties: { foo: { "$ref" => "#/definitions/bar" }, @@ -385,12 +445,10 @@ module MCP bar: { type: "string" }, }, } - error = assert_raises(ArgumentError) do - Class.new(MCP::Tool) do - output_schema schema_with_ref - end + tool_class = Class.new(MCP::Tool) do + output_schema schema_with_ref end - assert_match(/Invalid JSON Schema/, error.message) + assert_equal "#/definitions/bar", tool_class.output_schema.to_h[:properties][:foo][:$ref] end test ".define allows definition of tools with output_schema" do @@ -406,7 +464,12 @@ module MCP assert_equal "mock_tool", tool.name_value assert_equal "a mock tool for testing", tool.description assert_instance_of Tool::OutputSchema, tool.output_schema - expected_output_schema = { type: "object", properties: { result: { type: "string" } }, required: ["result"] } + expected_output_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + } assert_equal expected_output_schema, tool.output_schema.to_h end @@ -428,11 +491,62 @@ module MCP assert_equal "test_tool_with_output", tool.name_value assert_equal "a test tool with output schema", tool.description - expected_input = { type: "object", properties: { message: { type: "string" } }, required: [:message] } + expected_input = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + } assert_equal expected_input, tool.input_schema.to_h - expected_output = { type: "object", properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] } + expected_output = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { result: { type: "string" }, success: { type: "boolean" } }, + required: ["result", "success"], + } assert_equal expected_output, tool.output_schema.to_h end + + test "accepts valid tool names" do + assert Tool.define(name: "getUser") + assert Tool.define(name: "DATA_EXPORT_v2") + assert Tool.define(name: "admin.tools.list") + assert Tool.define(name: "a" * 128) + end + + test "raises an error when tool name is empty in class definition" do + error = assert_raises(ArgumentError) do + class EmptyTitleNameTool < Tool + tool_name "" + end + end + assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message) + end + + test "allows nil tool name in class definition" do + assert_nothing_raised do + class EmptyTitleNameTool < Tool + tool_name nil + end + end + end + + test "raises an error when tool name is empty" do + error = assert_raises(ArgumentError) { Tool.define(name: "") } + assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message) + end + + test "raises an error when tool name exceeds 128 characters" do + error = assert_raises(ArgumentError) { Tool.define(name: "a" * 129) } + assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message) + end + + test "raises an error when tool name includes invalid characters (e.g., spaces)" do + error = assert_raises(ArgumentError) { Tool.define(name: "foo bar") } + assert_equal(<<~MESSAGE, error.message) + Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.). + MESSAGE + end end end diff --git a/test/mcp/trace_context_test.rb b/test/mcp/trace_context_test.rb new file mode 100644 index 0000000..cfe59d8 --- /dev/null +++ b/test/mcp/trace_context_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class TraceContextTest < ActiveSupport::TestCase + test "exposes the W3C Trace Context reserved _meta key names" do + # The exact un-prefixed names are reserved by SEP-414; renaming any of them + # would break interoperability with other SDKs. + assert_equal "traceparent", TraceContext::TRACEPARENT_META_KEY + assert_equal "tracestate", TraceContext::TRACESTATE_META_KEY + assert_equal "baggage", TraceContext::BAGGAGE_META_KEY + end + + test "META_KEYS lists all reserved keys and is frozen" do + assert_equal ["traceparent", "tracestate", "baggage"], TraceContext::META_KEYS + assert_predicate TraceContext::META_KEYS, :frozen? + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9a712f5..8cd6b9e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,9 +6,38 @@ require "minitest/autorun" require "minitest/mock" require "mocha/minitest" +# mocha relies on `Hash.ruby2_keywords_hash?`, which is absent on Ruby 2.7.0 and the earlier 2.7.x releases +# before it was backported. Those versions also lack the flag-setting APIs, so no hash is ever flagged as +# ruby2_keywords and returning `false` is correct. Without this shim, mocha raises `NoMethodError` and +# the suite cannot run on Ruby 2.7.0. +unless Hash.respond_to?(:ruby2_keywords_hash?) + def Hash.ruby2_keywords_hash?(_hash) + false + end +end + require "active_support" require "active_support/test_case" -require "sorbet-runtime" +require "sorbet-runtime" if RUBY_VERSION >= "3.0" require_relative "instrumentation_test_helper" +require_relative "initialize_params_test_helper" + +module DeprecationWarningTestHelper + def assert_deprecation_warning(message_pattern, &block) + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, message_pattern, &block) + ensure + $VERBOSE = original_verbose + end + + def assert_no_deprecation_warning(&block) + original_verbose = $VERBOSE + $VERBOSE = false + assert_output(nil, "", &block) + ensure + $VERBOSE = original_verbose + end +end