Compare commits
216 changed files with 4034 additions and 47932 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
entry:
|
entry:
|
||||||
- { ruby: '2.7.0', allowed-failure: false } # Minimum supported Ruby version.
|
- { ruby: '2.7', allowed-failure: false }
|
||||||
- { ruby: '3.0', allowed-failure: false }
|
- { ruby: '3.0', allowed-failure: false }
|
||||||
- { ruby: '3.1', allowed-failure: false }
|
- { ruby: '3.1', allowed-failure: false }
|
||||||
- { ruby: '3.2', allowed-failure: false }
|
- { ruby: '3.2', allowed-failure: false }
|
||||||
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- { ruby: 'head', allowed-failure: true }
|
- { ruby: 'head', allowed-failure: true }
|
||||||
name: Test Ruby ${{ matrix.entry.ruby }}
|
name: Test Ruby ${{ matrix.entry.ruby }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: ${{ matrix.entry.ruby }}
|
ruby-version: ${{ matrix.entry.ruby }}
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
name: RuboCop
|
name: RuboCop
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0 # Specify the latest supported Ruby version.
|
ruby-version: 4.0 # Specify the latest supported Ruby version.
|
||||||
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
name: YARD Documentation
|
name: YARD Documentation
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0 # Specify the latest supported Ruby version.
|
ruby-version: 4.0 # Specify the latest supported Ruby version.
|
||||||
|
|
|
||||||
8
.github/workflows/conformance.yml
vendored
8
.github/workflows/conformance.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: conformance-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }}
|
group: conformance-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -18,12 +18,12 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '4.0' # Specify the latest supported Ruby version.
|
ruby-version: '4.0' # Specify the latest supported Ruby version.
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24' # Specify the latest Node.js version.
|
node-version: '24' # Specify the latest Node.js version.
|
||||||
- run: bundle exec rake conformance:test
|
- run: bundle exec rake conformance
|
||||||
|
|
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
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
|
contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||||
|
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,7 +9,3 @@
|
||||||
/spec/reports/
|
/spec/reports/
|
||||||
/tmp/
|
/tmp/
|
||||||
Gemfile.lock
|
Gemfile.lock
|
||||||
docs/_site/
|
|
||||||
docs/.jekyll-cache/
|
|
||||||
docs/.jekyll-metadata
|
|
||||||
docs/_data/versions.yml
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ plugins:
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
TargetRubyVersion: 2.7
|
TargetRubyVersion: 2.7
|
||||||
SuggestExtensions: false
|
|
||||||
|
|
||||||
Gemspec/DevelopmentDependencies:
|
Gemspec/DevelopmentDependencies:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
@ -17,7 +16,3 @@ Lint/IncompatibleIoSelectWithFiberScheduler:
|
||||||
|
|
||||||
Minitest/LiteralAsActualArgument:
|
Minitest/LiteralAsActualArgument:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Naming/MethodName:
|
|
||||||
AllowedPatterns:
|
|
||||||
- '\Atest_'
|
|
||||||
|
|
|
||||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -15,9 +15,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
||||||
- `bundle install` - Install dependencies
|
- `bundle install` - Install dependencies
|
||||||
- `rake test` - Run all tests
|
- `rake test` - Run all tests
|
||||||
- `rake rubocop` - Run linter
|
- `rake rubocop` - Run linter
|
||||||
- `rake` - Run linting, tests, and the conformance suite (default task)
|
- `rake` - Run tests and linting (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
|
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
|
||||||
- `gem build mcp.gemspec` - Build the gem
|
- `gem build mcp.gemspec` - Build the gem
|
||||||
|
|
||||||
|
|
@ -34,15 +32,6 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
||||||
- Use frozen string literals
|
- Use frozen string literals
|
||||||
- Follow Ruby community conventions
|
- Follow Ruby community conventions
|
||||||
- Keep dependencies minimal
|
- 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
|
## Commit message conventions
|
||||||
|
|
||||||
|
|
@ -127,6 +116,6 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
||||||
|
|
||||||
### Integration patterns
|
### Integration patterns
|
||||||
|
|
||||||
- **Rails/Rack apps**: Mount `StreamableHTTPTransport` as a Rack app (e.g., `mount transport => "/mcp"`)
|
- **Rails controllers**: Use `server.handle_json(request.body.read)` for HTTP endpoints
|
||||||
- **Command-line tools**: Use `StdioTransport.new(server).open` for CLI applications
|
- **Command-line tools**: Use `StdioTransport.new(server).open` for CLI applications
|
||||||
- **HTTP services**: Use `StreamableHTTPTransport` for web-based servers
|
- **HTTP services**: Use `StreamableHttpTransport` for web-based servers
|
||||||
|
|
|
||||||
371
CHANGELOG.md
371
CHANGELOG.md
|
|
@ -7,377 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.10.0] - 2026-03-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
@AGENTS.md
|
|
||||||
5
Gemfile
5
Gemfile
|
|
@ -6,7 +6,7 @@ source "https://rubygems.org"
|
||||||
gemspec
|
gemspec
|
||||||
|
|
||||||
# Specify development dependencies below
|
# Specify development dependencies below
|
||||||
gem "rubocop-minitest", ">= 0.39.0", require: false
|
gem "rubocop-minitest", require: false
|
||||||
gem "rubocop-rake", require: false
|
gem "rubocop-rake", require: false
|
||||||
gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1"
|
gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1"
|
||||||
|
|
||||||
|
|
@ -17,15 +17,12 @@ gem "rackup", ">= 2.1.0"
|
||||||
gem "activesupport"
|
gem "activesupport"
|
||||||
# Fix io-console install error when Ruby 3.0.
|
# Fix io-console install error when Ruby 3.0.
|
||||||
gem "debug" if RUBY_VERSION >= "3.1"
|
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 "rake", "~> 13.0"
|
||||||
gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0"
|
gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0"
|
||||||
gem "yard", "~> 0.9"
|
gem "yard", "~> 0.9"
|
||||||
gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
|
gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem "event_stream_parser", ">= 1.0"
|
|
||||||
gem "faraday", ">= 2.0"
|
gem "faraday", ">= 2.0"
|
||||||
gem "minitest", "~> 5.1", require: false
|
gem "minitest", "~> 5.1", require: false
|
||||||
gem "mocha"
|
gem "mocha"
|
||||||
|
|
|
||||||
51
ROADMAP.md
51
ROADMAP.md
|
|
@ -1,51 +0,0 @@
|
||||||
# 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)).
|
|
||||||
91
Rakefile
91
Rakefile
|
|
@ -14,85 +14,38 @@ require "rubocop/rake_task"
|
||||||
|
|
||||||
RuboCop::RakeTask.new
|
RuboCop::RakeTask.new
|
||||||
|
|
||||||
task default: [:rubocop, :test, "conformance:test"]
|
task default: [:rubocop, :test, :conformance]
|
||||||
|
|
||||||
namespace :conformance do
|
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
task :conformance do |t|
|
||||||
task :test do |t|
|
next unless npx_available?(t.name)
|
||||||
next unless npx_available?(t.name)
|
|
||||||
|
|
||||||
require_relative "conformance/server_runner"
|
require_relative "conformance/runner"
|
||||||
require_relative "conformance/client_runner"
|
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||||
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
||||||
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
||||||
options[:verbose] = true if ENV["VERBOSE"]
|
options[:verbose] = true if ENV["VERBOSE"]
|
||||||
|
|
||||||
Conformance::ServerRunner.new(**options).run
|
Conformance::Runner.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
|
end
|
||||||
|
|
||||||
# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance`
|
desc "List available conformance scenarios"
|
||||||
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
|
task :conformance_list do |t|
|
||||||
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
|
next unless npx_available?(t.name)
|
||||||
desc "Alias for conformance:test"
|
|
||||||
task conformance: "conformance:test"
|
|
||||||
|
|
||||||
namespace :docs do
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||||
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
|
end
|
||||||
|
|
||||||
# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from
|
desc "Start the conformance server (PORT)"
|
||||||
# the version tags at deploy time, and the preview generates the same data so the nav footer
|
task :conformance_server do
|
||||||
# shows the released-gem version line.
|
require_relative "conformance/server"
|
||||||
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"))
|
options = {}
|
||||||
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
|
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||||
|
|
||||||
|
Conformance::Server.new(**options).start
|
||||||
end
|
end
|
||||||
|
|
||||||
def npx_available?(task_name)
|
def npx_available?(task_name)
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -12,45 +12,45 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
|
||||||
### Run all scenarios
|
### Run all scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance:test
|
bundle exec rake conformance
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs both server and client conformance tests in sequence. Server conformance starts the
|
Starts the conformance server, runs all active scenarios against it, prints a pass/fail
|
||||||
conformance server and tests it. Client conformance spawns test servers for each scenario
|
summary for each scenario, and exits with a non-zero status code if any unexpected failures
|
||||||
and invokes `conformance/client.rb` against them. Scenarios listed in `expected_failures.yml`
|
are detected. Scenarios listed in `expected_failures.yml` are allowed to fail without
|
||||||
are allowed to fail without affecting the exit code.
|
affecting the exit code.
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------------|---------------------------------------|---------|
|
|----------------|--------------------------------------|---------|
|
||||||
| `PORT` | Server port (server conformance only) | `9292` |
|
| `PORT` | Server port | `9292` |
|
||||||
| `SCENARIO` | Run a single scenario by name | (all) |
|
| `SCENARIO` | Run a single scenario by name | (all) |
|
||||||
| `SPEC_VERSION` | Filter scenarios by spec version | (all) |
|
| `SPEC_VERSION` | Filter scenarios by spec version | (all) |
|
||||||
| `VERBOSE` | Show raw JSON output when set | (off) |
|
| `VERBOSE` | Show raw JSON output when set | (off) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a single server scenario
|
# Run a single scenario
|
||||||
bundle exec rake conformance:test SCENARIO=ping
|
bundle exec rake conformance SCENARIO=ping
|
||||||
|
|
||||||
# Use a different port with verbose output
|
# Use a different port with verbose output
|
||||||
bundle exec rake conformance:test PORT=3000 VERBOSE=1
|
bundle exec rake conformance PORT=3000 VERBOSE=1
|
||||||
|
|
||||||
# Start the server on a specific port
|
# Start the server on a specific port
|
||||||
bundle exec rake conformance:server PORT=3000
|
bundle exec rake conformance_server PORT=3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start the server and test separately
|
### Start the server and test separately
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: start the server
|
# Terminal 1: start the server
|
||||||
bundle exec rake conformance:server
|
bundle exec rake conformance_server
|
||||||
|
|
||||||
# Terminal 2: run all scenarios
|
# Terminal 2: run all scenarios
|
||||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
|
npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp
|
||||||
|
|
||||||
# Terminal 2: run a single scenario
|
# Terminal 2: run a single scenario
|
||||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp --scenario ping
|
npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp --scenario ping
|
||||||
```
|
```
|
||||||
|
|
||||||
Keeps the server alive between test runs, which avoids the startup overhead when iterating
|
Keeps the server alive between test runs, which avoids the startup overhead when iterating
|
||||||
|
|
@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done.
|
||||||
### List available scenarios
|
### List available scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance:list
|
bundle exec rake conformance_list
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints all scenario names that can be passed to `SCENARIO`.
|
Prints all scenario names that can be passed to `SCENARIO`.
|
||||||
|
|
@ -76,7 +76,7 @@ repository with the conformance server running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 (this repository): start the conformance server
|
# Terminal 1 (this repository): start the conformance server
|
||||||
bundle exec rake conformance:server
|
bundle exec rake conformance_server
|
||||||
|
|
||||||
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
|
# 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
|
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
|
||||||
|
|
@ -91,10 +91,8 @@ submissions.
|
||||||
```
|
```
|
||||||
conformance/
|
conformance/
|
||||||
server.rb # Conformance server (Rack + Puma, default port 9292)
|
server.rb # Conformance server (Rack + Puma, default port 9292)
|
||||||
server_runner.rb # Starts the server, runs npx conformance server, exits with result code
|
runner.rb # Starts the server, runs npx conformance, exits with result code
|
||||||
client.rb # Conformance client (invoked by npx conformance client)
|
expected_failures.yml # Baseline of known-failing scenarios
|
||||||
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
|
README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -102,4 +100,4 @@ conformance/
|
||||||
|
|
||||||
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
|
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.
|
fail without affecting the exit code and are tracked to catch regressions.
|
||||||
These are shown in the output of `bundle exec rake conformance:test`.
|
These are shown in the output of `bundle exec rake conformance`.
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Conformance test client for the MCP Ruby SDK.
|
|
||||||
# Invoked by the conformance runner:
|
|
||||||
# MCP_CONFORMANCE_SCENARIO=<scenario> bundle exec ruby conformance/client.rb <server-url>
|
|
||||||
#
|
|
||||||
# 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=<scenario> ruby conformance/client.rb <server-url>")
|
|
||||||
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
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
server: []
|
server:
|
||||||
client:
|
# TODO: Server-to-client requests (sampling/createMessage, elicitation/create) are not implemented.
|
||||||
# Unimplemented authorization extensions.
|
# `Transport#send_request` does not exist in the current SDK.
|
||||||
- auth/dpop
|
- tools-call-sampling
|
||||||
- auth/dpop-nonce
|
- tools-call-elicitation
|
||||||
- auth/wif-jwt-bearer
|
- elicitation-sep1034-defaults
|
||||||
|
- elicitation-sep1330-enums
|
||||||
|
|
|
||||||
101
conformance/runner.rb
Normal file
101
conformance/runner.rb
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it.
|
||||||
|
require "English"
|
||||||
|
require "net/http"
|
||||||
|
require_relative "server"
|
||||||
|
|
||||||
|
module Conformance
|
||||||
|
class Runner
|
||||||
|
# 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", "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)
|
||||||
|
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
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
require "rackup"
|
require "rackup"
|
||||||
require "json"
|
require "json"
|
||||||
require "securerandom"
|
|
||||||
require "set"
|
|
||||||
require "uri"
|
require "uri"
|
||||||
require_relative "../lib/mcp"
|
require_relative "../lib/mcp"
|
||||||
|
|
||||||
|
|
@ -14,43 +12,6 @@ module Conformance
|
||||||
# Minimal WAV file (matches TypeScript SDK and Python SDK)
|
# Minimal WAV file (matches TypeScript SDK and Python SDK)
|
||||||
BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
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
|
module Tools
|
||||||
class TestSimpleText < MCP::Tool
|
class TestSimpleText < MCP::Tool
|
||||||
tool_name "test_simple_text"
|
tool_name "test_simple_text"
|
||||||
|
|
@ -195,6 +156,7 @@ module Conformance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: Implement when `Transport` supports server-to-client requests.
|
||||||
class TestSampling < MCP::Tool
|
class TestSampling < MCP::Tool
|
||||||
tool_name "test_sampling"
|
tool_name "test_sampling"
|
||||||
description "A tool that requests LLM sampling from the client"
|
description "A tool that requests LLM sampling from the client"
|
||||||
|
|
@ -204,19 +166,16 @@ module Conformance
|
||||||
)
|
)
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(prompt:, server_context:)
|
def call(prompt:)
|
||||||
result = server_context.create_sampling_message(
|
MCP::Tool::Response.new(
|
||||||
messages: [{ role: "user", content: { type: "text", text: prompt } }],
|
[MCP::Content::Text.new("Sampling not supported in this SDK version").to_h],
|
||||||
max_tokens: 100,
|
error: true,
|
||||||
)
|
)
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: Implement when `Transport` supports server-to-client requests.
|
||||||
class TestElicitation < MCP::Tool
|
class TestElicitation < MCP::Tool
|
||||||
tool_name "test_elicitation"
|
tool_name "test_elicitation"
|
||||||
description "A tool that requests user input from the client"
|
description "A tool that requests user input from the client"
|
||||||
|
|
@ -226,90 +185,41 @@ module Conformance
|
||||||
)
|
)
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(server_context:, message:)
|
def call(message:)
|
||||||
result = server_context.create_form_elicitation(
|
MCP::Tool::Response.new(
|
||||||
message: message,
|
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
|
||||||
requested_schema: {
|
error: true,
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: Implement when `Transport` supports server-to-client requests.
|
||||||
class TestElicitationSep1034Defaults < MCP::Tool
|
class TestElicitationSep1034Defaults < MCP::Tool
|
||||||
tool_name "test_elicitation_sep1034_defaults"
|
tool_name "test_elicitation_sep1034_defaults"
|
||||||
description "A tool that tests elicitation with default values"
|
description "A tool that tests elicitation with default values"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(server_context:, **_args)
|
def call(**_args)
|
||||||
result = server_context.create_form_elicitation(
|
MCP::Tool::Response.new(
|
||||||
message: "Please provide your information (with defaults)",
|
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
|
||||||
requested_schema: {
|
error: true,
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: Implement when `Transport` supports server-to-client requests.
|
||||||
class TestElicitationSep1330Enums < MCP::Tool
|
class TestElicitationSep1330Enums < MCP::Tool
|
||||||
tool_name "test_elicitation_sep1330_enums"
|
tool_name "test_elicitation_sep1330_enums"
|
||||||
description "A tool that tests elicitation with enum schemas"
|
description "A tool that tests elicitation with enum schemas"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(server_context:, **_args)
|
def call(**_args)
|
||||||
# Built with the SEP-1330 builders so the conformance run exercises the same schemas
|
MCP::Tool::Response.new(
|
||||||
# the SDK produces for users.
|
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
|
||||||
result = server_context.create_form_elicitation(
|
error: true,
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -324,377 +234,6 @@ module Conformance
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
module Prompts
|
module Prompts
|
||||||
|
|
@ -793,45 +332,6 @@ module Conformance
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
class Server
|
class Server
|
||||||
|
|
@ -891,7 +391,7 @@ module Conformance
|
||||||
|
|
||||||
def start
|
def start
|
||||||
server = build_server
|
server = build_server
|
||||||
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
transport = build_transport(server)
|
||||||
configure_handlers(server)
|
configure_handlers(server)
|
||||||
rack_app = build_rack_app(transport)
|
rack_app = build_rack_app(transport)
|
||||||
|
|
||||||
|
|
@ -924,33 +424,15 @@ module Conformance
|
||||||
Tools::TestElicitationSep1034Defaults,
|
Tools::TestElicitationSep1034Defaults,
|
||||||
Tools::TestElicitationSep1330Enums,
|
Tools::TestElicitationSep1330Enums,
|
||||||
Tools::TestReconnection,
|
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: [
|
||||||
Prompts::TestSimplePrompt,
|
Prompts::TestSimplePrompt,
|
||||||
Prompts::TestPromptWithArguments,
|
Prompts::TestPromptWithArguments,
|
||||||
Prompts::TestPromptWithEmbeddedResource,
|
Prompts::TestPromptWithEmbeddedResource,
|
||||||
Prompts::TestPromptWithImage,
|
Prompts::TestPromptWithImage,
|
||||||
Prompts::TestInputRequiredResultPrompt,
|
|
||||||
],
|
],
|
||||||
resources: resources,
|
resources: resources,
|
||||||
resource_templates: resource_templates,
|
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: {
|
capabilities: {
|
||||||
tools: { listChanged: true },
|
tools: { listChanged: true },
|
||||||
prompts: { listChanged: true },
|
prompts: { listChanged: true },
|
||||||
|
|
@ -995,13 +477,17 @@ module Conformance
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_transport(server)
|
||||||
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
||||||
|
server.transport = transport
|
||||||
|
transport
|
||||||
|
end
|
||||||
|
|
||||||
def configure_handlers(server)
|
def configure_handlers(server)
|
||||||
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
|
||||||
server.server_context = server
|
server.server_context = server
|
||||||
|
|
||||||
configure_resources_read_handler(server)
|
configure_resources_read_handler(server)
|
||||||
configure_subscription_handlers(server)
|
|
||||||
configure_completion_handler(server)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def configure_resources_read_handler(server)
|
def configure_resources_read_handler(server)
|
||||||
|
|
@ -1037,54 +523,11 @@ module Conformance
|
||||||
).to_h,
|
).to_h,
|
||||||
]
|
]
|
||||||
else
|
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
|
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)
|
def build_rack_app(transport)
|
||||||
mcp_app = proc do |env|
|
mcp_app = proc do |env|
|
||||||
request = Rack::Request.new(env)
|
request = Rack::Request.new(env)
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ruby.sdk.modelcontextprotocol.io
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue