Merge pull request #523 from koic/restructure_readme_into_docs_site
Move the Documentation From README.md to the Documentation Site
This commit is contained in:
commit
79d89e0ac6
51 changed files with 4253 additions and 3516 deletions
|
|
@ -16,6 +16,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
|||
- `rake test` - Run all tests
|
||||
- `rake rubocop` - Run linter
|
||||
- `rake` - Run tests and linting (default task)
|
||||
- `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md)
|
||||
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
|
||||
- `gem build mcp.gemspec` - Build the gem
|
||||
|
||||
|
|
@ -34,6 +35,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
|||
- 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
|
||||
|
||||
- Use conventional commit format when possible
|
||||
|
|
|
|||
260
docs/_client/authorization.md
Normal file
260
docs/_client/authorization.md
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
---
|
||||
layout: default
|
||||
title: Authorization
|
||||
nav_order: 8
|
||||
---
|
||||
|
||||
# Authorization
|
||||
|
||||
Authorization is handled by the transport layer. This page covers authenticating the HTTP transport,
|
||||
from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements
|
||||
(PKCE with dynamic client registration, the client credentials grant, and cross-app access).
|
||||
|
||||
## HTTP Authorization
|
||||
|
||||
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
|
||||
|
||||
```ruby
|
||||
http_transport = MCP::Client::HTTP.new(
|
||||
url: "https://api.example.com/mcp",
|
||||
headers: {
|
||||
"Authorization" => "Bearer my_token"
|
||||
}
|
||||
)
|
||||
|
||||
client = MCP::Client.new(transport: http_transport)
|
||||
client.tools # will make the call using Bearer auth
|
||||
```
|
||||
|
||||
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
|
||||
|
||||
## OAuth 2.1 Authorization
|
||||
|
||||
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization),
|
||||
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
|
||||
|
||||
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
|
||||
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
||||
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
|
||||
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
|
||||
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
|
||||
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
||||
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
||||
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
||||
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
||||
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
|
||||
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
|
||||
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
|
||||
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
|
||||
|
||||
```ruby
|
||||
require "mcp"
|
||||
|
||||
provider = MCP::Client::OAuth::Provider.new(
|
||||
client_metadata: {
|
||||
client_name: "My MCP App",
|
||||
redirect_uris: ["http://localhost:3030/callback"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
},
|
||||
redirect_uri: "http://localhost:3030/callback",
|
||||
redirect_handler: ->(authorization_url) {
|
||||
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
|
||||
# or a manual `puts authorization_url` in CLI tools.
|
||||
},
|
||||
callback_handler: -> {
|
||||
# Capture the redirect (for example, by running a small HTTP listener on
|
||||
# `redirect_uri`) and return [code, state] from the query string.
|
||||
},
|
||||
)
|
||||
|
||||
transport = MCP::Client::HTTP.new(
|
||||
url: "https://api.example.com/mcp",
|
||||
oauth: provider,
|
||||
)
|
||||
client = MCP::Client.new(transport: transport)
|
||||
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
|
||||
client.tools
|
||||
```
|
||||
|
||||
Required keyword arguments to `Provider.new`:
|
||||
|
||||
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
|
||||
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
|
||||
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
|
||||
an explicit value always wins.
|
||||
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
|
||||
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
|
||||
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
|
||||
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
|
||||
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
|
||||
|
||||
Optional keyword arguments:
|
||||
|
||||
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
||||
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
|
||||
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
|
||||
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
|
||||
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
|
||||
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
|
||||
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
|
||||
When the authorization server advertises `client_id_metadata_document_supported: true`,
|
||||
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
||||
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
|
||||
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
|
||||
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
|
||||
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
|
||||
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
||||
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
|
||||
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
||||
|
||||
{: .warning }
|
||||
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
|
||||
> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it
|
||||
> automatically wherever the authorization server advertises support.
|
||||
|
||||
To persist credentials across restarts, supply your own storage:
|
||||
|
||||
```ruby
|
||||
class FileTokenStorage
|
||||
def initialize(path)
|
||||
@path = path
|
||||
end
|
||||
|
||||
def tokens
|
||||
read["tokens"]
|
||||
end
|
||||
|
||||
def save_tokens(value)
|
||||
write("tokens" => value)
|
||||
end
|
||||
|
||||
def client_information
|
||||
read["client"]
|
||||
end
|
||||
|
||||
def save_client_information(value)
|
||||
write("client" => value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read
|
||||
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
||||
end
|
||||
|
||||
def write(updates)
|
||||
File.write(@path, JSON.dump(read.merge(updates)))
|
||||
end
|
||||
end
|
||||
|
||||
provider = MCP::Client::OAuth::Provider.new(
|
||||
# ... required keywords ...
|
||||
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
|
||||
)
|
||||
```
|
||||
|
||||
### Client Credentials Grant
|
||||
|
||||
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
|
||||
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
|
||||
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
|
||||
|
||||
```ruby
|
||||
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||
client_id: "my-service",
|
||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
||||
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
|
||||
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
|
||||
)
|
||||
|
||||
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
||||
```
|
||||
|
||||
Keyword arguments:
|
||||
|
||||
- `client_id`: Required. `client_secret`: Required with the secret-based methods; the grant is
|
||||
for confidential clients, so a credential is mandatory.
|
||||
- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`,
|
||||
or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected
|
||||
with `ClientCredentialsProvider::InvalidCredentialsError`.
|
||||
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
||||
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
|
||||
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
||||
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
||||
|
||||
### Cross-App Access (JWT Bearer) Grant
|
||||
|
||||
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
|
||||
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
|
||||
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
|
||||
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
|
||||
|
||||
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
|
||||
an enterprise secret store or a test double without changing the transport wiring.
|
||||
|
||||
```ruby
|
||||
provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
|
||||
client_id: "my-mcp-client",
|
||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
||||
assertion_provider: ->(audience:, resource:) {
|
||||
MCP::Client::OAuth::IDJAGTokenExchange.request(
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
id_token: ENV.fetch("IDP_ID_TOKEN"),
|
||||
client_id: "my-idp-client",
|
||||
audience: audience,
|
||||
resource: resource,
|
||||
)
|
||||
},
|
||||
# scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
|
||||
)
|
||||
|
||||
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
||||
```
|
||||
|
||||
Keyword arguments:
|
||||
|
||||
- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
|
||||
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
||||
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
|
||||
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
||||
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
||||
|
||||
### Communication Security
|
||||
|
||||
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
||||
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
|
||||
|
||||
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
|
||||
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
|
||||
whether the swap was triggered by
|
||||
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
|
||||
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
|
||||
|
||||
### Discovery URL Destinations
|
||||
|
||||
The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations.
|
||||
Both checks run before the request is sent, and neither is configurable.
|
||||
|
||||
- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server,
|
||||
so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728,
|
||||
which does not require it.
|
||||
- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be
|
||||
IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7).
|
||||
The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`,
|
||||
and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name.
|
||||
- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act,
|
||||
and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working.
|
||||
|
||||
The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed,
|
||||
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
|
||||
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
|
||||
|
||||
If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against
|
||||
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
||||
|
||||
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
|
||||
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
|
||||
Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through
|
||||
`http_client_factory:` is bounded as well, so there is no way to opt out of it.
|
||||
71
docs/_client/cancellation.md
Normal file
71
docs/_client/cancellation.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
layout: default
|
||||
title: Cancellation
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Cancellation
|
||||
|
||||
`MCP::Client` lets the caller cancel a request it has already issued,
|
||||
per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation).
|
||||
The recommended pattern is to pass
|
||||
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
||||
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
||||
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
Thread.new do
|
||||
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
||||
rescue MCP::CancelledError
|
||||
# cleanup
|
||||
end
|
||||
|
||||
# Later, from another thread:
|
||||
cancellation.cancel(reason: "user pressed cancel")
|
||||
```
|
||||
|
||||
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
||||
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword.
|
||||
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
||||
|
||||
{: .note }
|
||||
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
||||
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
||||
> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
||||
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
||||
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
||||
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
||||
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
||||
|
||||
{: .note }
|
||||
> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight
|
||||
> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests
|
||||
> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still
|
||||
> raises `MCP::CancelledError` - but the server runs the request to completion.
|
||||
|
||||
## Wire-order guarantees
|
||||
|
||||
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
||||
so the server is guaranteed to read the request line before the cancel line.
|
||||
|
||||
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
||||
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
||||
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
||||
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)),
|
||||
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
||||
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
||||
|
||||
## Custom transports
|
||||
|
||||
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
||||
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
||||
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
||||
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
||||
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
||||
|
||||
## Server Side
|
||||
|
||||
How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page.
|
||||
46
docs/_client/index.md
Normal file
46
docs/_client/index.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
layout: default
|
||||
title: Overview
|
||||
nav_order: 1
|
||||
permalink: /client/
|
||||
redirect_from:
|
||||
- /building-clients.html
|
||||
- /building-clients/
|
||||
---
|
||||
|
||||
# Building an MCP Client
|
||||
|
||||
The `MCP::Client` class provides an interface for interacting with MCP servers.
|
||||
|
||||
This class supports:
|
||||
|
||||
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
|
||||
when the server serves it; see [Lifecycle](/client/lifecycle/)
|
||||
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
|
||||
- Liveness check via the `ping` method (`MCP::Client#ping`)
|
||||
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
|
||||
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
||||
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
||||
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
|
||||
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
|
||||
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
|
||||
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
|
||||
- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completions](/server/completions/)
|
||||
- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`,
|
||||
or `on_roots` handlers are registered; see [Multi-Round-Trip Results](/client/multi-round-trip-results/)
|
||||
- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/)
|
||||
- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with
|
||||
the `max_pages` guard; see [Pagination](/client/pagination/)
|
||||
- Automatic JSON-RPC 2.0 message formatting
|
||||
- UUID request ID generation
|
||||
|
||||
Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics.
|
||||
Authorization is handled by the transport layer; see [Authorization](/client/authorization/).
|
||||
|
||||
## Tool Objects
|
||||
|
||||
The client provides a wrapper class for tools returned by the server:
|
||||
|
||||
- `MCP::Client::Tool` - Represents a single tool with its metadata
|
||||
|
||||
This class provides easy access to tool properties like name, description, input schema, and output schema.
|
||||
78
docs/_client/lifecycle.md
Normal file
78
docs/_client/lifecycle.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
layout: default
|
||||
title: Lifecycle
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Lifecycle
|
||||
|
||||
Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake
|
||||
on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28.
|
||||
This page covers `MCP::Client#connect` and how it negotiates between the two.
|
||||
|
||||
## Handshake
|
||||
|
||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
||||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
```
|
||||
|
||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
||||
|
||||
This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page.
|
||||
|
||||
By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake
|
||||
only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it.
|
||||
|
||||
## Lifecycle Negotiation
|
||||
|
||||
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
|
||||
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discovery/) first and adopts
|
||||
the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to
|
||||
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
|
||||
a `mode:` keyword always receive the classic call shape, unchanged.
|
||||
|
||||
```ruby
|
||||
client.connect # negotiate automatically (default)
|
||||
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
|
||||
client.connect(mode: :legacy) # force the classic initialize handshake
|
||||
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
|
||||
```
|
||||
|
||||
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
|
||||
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
|
||||
the legacy lifecycle.
|
||||
|
||||
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
|
||||
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
|
||||
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
|
||||
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
|
||||
|
||||
```ruby
|
||||
client.protocol_version # negotiated or adopted version, either lifecycle
|
||||
client.server_capabilities # capabilities Hash, either lifecycle
|
||||
client.instructions # instructions text, either lifecycle
|
||||
client.server_implementation # server name/version; nil when a modern server does not identify itself
|
||||
```
|
||||
|
||||
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
|
||||
the server now serves the modern lifecycle and the automatic negotiation adopted it.
|
||||
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
|
||||
|
||||
## Explicit Discovery
|
||||
|
||||
`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery
|
||||
that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct
|
||||
exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and
|
||||
the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discovery/) page
|
||||
for the wire shapes.
|
||||
|
||||
```ruby
|
||||
result = client.discover
|
||||
result.supported_versions # => ["2026-07-28"]
|
||||
```
|
||||
60
docs/_client/multi-round-trip-results.md
Normal file
60
docs/_client/multi-round-trip-results.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
layout: default
|
||||
title: Multi-Round-Trip Results
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
# Multi-Round-Trip Results
|
||||
|
||||
MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`,
|
||||
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
|
||||
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
|
||||
|
||||
## Automatic Driving
|
||||
|
||||
The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`,
|
||||
and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds`
|
||||
(10 by default, matching the TypeScript and Python SDKs).
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
client.connect(capabilities: { elicitation: { form: {} } })
|
||||
|
||||
client.on_elicitation do |params|
|
||||
{ action: "accept", content: { name: "Alice" } }
|
||||
end
|
||||
|
||||
# The input_required round trips are driven automatically; this returns the final result.
|
||||
response = client.call_tool(name: "collect_name", arguments: {})
|
||||
```
|
||||
|
||||
Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds
|
||||
the client declared.
|
||||
|
||||
## Manual Driving
|
||||
|
||||
Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final;
|
||||
the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords:
|
||||
|
||||
```ruby
|
||||
begin
|
||||
client.call_tool(name: "collect_name", arguments: {})
|
||||
rescue MCP::Client::InputRequiredError => error
|
||||
answers = error.input_requests.transform_values { |request| answer_for(request) }
|
||||
|
||||
client.call_tool(
|
||||
name: "collect_name",
|
||||
arguments: {},
|
||||
input_responses: answers,
|
||||
request_state: error.request_state,
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility.
|
||||
Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged.
|
||||
|
||||
## Server Side
|
||||
|
||||
Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment
|
||||
shim that serves pre-2026 clients are documented on the server [Multi-Round-Trip Results](/server/multi-round-trip-results/) page.
|
||||
80
docs/_client/pagination.md
Normal file
80
docs/_client/pagination.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
layout: default
|
||||
title: Pagination
|
||||
nav_order: 7
|
||||
---
|
||||
|
||||
# Pagination
|
||||
|
||||
Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses
|
||||
per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination).
|
||||
Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
|
||||
|
||||
## Iterating Pages
|
||||
|
||||
`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
||||
**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection.
|
||||
The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor
|
||||
as method accessors; a `meta` accessor also mirrors the response's `_meta` field:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
|
||||
cursor = nil
|
||||
loop do
|
||||
page = client.list_tools(cursor: cursor)
|
||||
page.tools.each { |tool| process(tool) }
|
||||
cursor = page.next_cursor
|
||||
break unless cursor
|
||||
end
|
||||
```
|
||||
|
||||
The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
|
||||
`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
|
||||
|
||||
Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
|
||||
|
||||
| Server `page_size` | `client.list_tools(cursor: nil)` |
|
||||
|--------------------|---------------------------------------------------------------------|
|
||||
| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
|
||||
| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
|
||||
|
||||
If your application needs the complete collection regardless of how the server is configured, either loop on
|
||||
`next_cursor` as shown above, or use the whole-collection methods described below.
|
||||
|
||||
## Fetching the Complete Collection
|
||||
|
||||
`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
|
||||
through all pages and return a plain array of items, guaranteeing the full collection regardless
|
||||
of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
|
||||
trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor`
|
||||
it has already sent, and it stops after `max_pages` pages.
|
||||
|
||||
```ruby
|
||||
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
|
||||
```
|
||||
|
||||
`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods
|
||||
will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that
|
||||
point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it
|
||||
if you legitimately expect more pages than that.
|
||||
|
||||
Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
|
||||
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
|
||||
|
||||
## Cache Hints
|
||||
|
||||
Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`)
|
||||
and whether shared intermediaries may cache it (`cacheScope`); see
|
||||
[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted.
|
||||
On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`:
|
||||
|
||||
```ruby
|
||||
page = client.list_tools
|
||||
page.ttl_ms # => 60000 (nil when the server sent no hint)
|
||||
page.cache_scope # => "private"
|
||||
```
|
||||
|
||||
## Server Side
|
||||
|
||||
Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page.
|
||||
35
docs/_client/ping.md
Normal file
35
docs/_client/ping.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
layout: default
|
||||
title: Ping
|
||||
nav_order: 6
|
||||
---
|
||||
|
||||
# Ping
|
||||
|
||||
The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping)
|
||||
allows either side of the connection to verify that the peer is still responsive.
|
||||
|
||||
{: .note }
|
||||
> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of
|
||||
> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself
|
||||
> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle,
|
||||
> so on a modern connection a conforming server (the Ruby SDK server included) rejects it
|
||||
> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility.
|
||||
|
||||
`MCP::Client` exposes `ping` to send a ping to the server:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
client.ping # => {} on success
|
||||
```
|
||||
|
||||
`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
|
||||
It raises `MCP::Client::ValidationError` when the response `result` is missing or
|
||||
is not a Hash (matching the spec requirement that `result` be an object).
|
||||
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
|
||||
propagate as exceptions raised by the transport layer.
|
||||
|
||||
## Server Side
|
||||
|
||||
How servers answer `ping` requests and ping the client themselves is documented on
|
||||
the server [Ping](/server/ping/) page.
|
||||
261
docs/_client/transports.md
Normal file
261
docs/_client/transports.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
---
|
||||
layout: default
|
||||
title: Transports
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Transports
|
||||
|
||||
`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with.
|
||||
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
|
||||
their sessions and server-to-client request handling, and the interface a custom transport must implement.
|
||||
|
||||
## Stdio Transport Layer
|
||||
|
||||
Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.
|
||||
|
||||
`MCP::Client::Stdio.new` accepts the following keyword arguments:
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|---|---|---|
|
||||
| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). |
|
||||
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
||||
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
||||
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
|
||||
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
|
||||
|
||||
Example usage:
|
||||
|
||||
```ruby
|
||||
stdio_transport = MCP::Client::Stdio.new(
|
||||
command: "bundle",
|
||||
args: ["exec", "ruby", "path/to/server.rb"],
|
||||
env: { "API_KEY" => "my_secret_key" },
|
||||
read_timeout: 30
|
||||
)
|
||||
client = MCP::Client.new(transport: stdio_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools.
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
puts "Tool: #{tool.name} - #{tool.description}"
|
||||
end
|
||||
|
||||
# Call a specific tool.
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { message: "Hello, world!" }
|
||||
)
|
||||
|
||||
# Close the transport when done.
|
||||
stdio_transport.close
|
||||
```
|
||||
|
||||
The stdio transport automatically handles:
|
||||
|
||||
- Spawning the server process with `Open3.popen3`
|
||||
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
|
||||
- JSON-RPC 2.0 message framing over newline-delimited JSON
|
||||
|
||||
## HTTP Transport Layer
|
||||
|
||||
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
|
||||
|
||||
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses:
|
||||
|
||||
```ruby
|
||||
gem "mcp"
|
||||
gem "faraday", ">= 2.0"
|
||||
gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses
|
||||
```
|
||||
|
||||
Example usage:
|
||||
|
||||
```ruby
|
||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
||||
client = MCP::Client.new(transport: http_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
puts <<~TOOL_INFORMATION
|
||||
Tool: #{tool.name}
|
||||
Description: #{tool.description}
|
||||
Input Schema: #{tool.input_schema}
|
||||
TOOL_INFORMATION
|
||||
end
|
||||
|
||||
# Call a specific tool
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { message: "Hello, world!" }
|
||||
)
|
||||
|
||||
# Call a tool with progress tracking.
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { count: 10 },
|
||||
progress_token: "my-progress-token"
|
||||
)
|
||||
```
|
||||
|
||||
The server sends `notifications/progress` during execution; the bundled transports do not currently
|
||||
expose these notifications to application code, so the token's effect is visible on the server side.
|
||||
See the [Progress](/server/progress/) page.
|
||||
|
||||
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
|
||||
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
|
||||
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
|
||||
|
||||
`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
|
||||
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread,
|
||||
so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes).
|
||||
The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead,
|
||||
the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin
|
||||
the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at.
|
||||
|
||||
### Sessions
|
||||
|
||||
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
|
||||
|
||||
```ruby
|
||||
http_transport.session_id # => "abc123..."
|
||||
http_transport.protocol_version # => "2025-11-25"
|
||||
```
|
||||
|
||||
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
||||
|
||||
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
|
||||
|
||||
```ruby
|
||||
http_transport.close
|
||||
```
|
||||
|
||||
These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is
|
||||
no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead.
|
||||
|
||||
### Server-to-Client Requests (Elicitation)
|
||||
|
||||
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
|
||||
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
|
||||
Register a handler and advertise the capability on `connect` to respond to them:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { elicitation: {} })
|
||||
|
||||
client.on_elicitation do |params|
|
||||
{
|
||||
action: "accept",
|
||||
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
|
||||
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
Registering a handler opens a standalone HTTP GET SSE stream on a background thread
|
||||
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
|
||||
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
|
||||
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
|
||||
`http_transport.on_server_request("method/name") { |params| ... }`.
|
||||
|
||||
On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same
|
||||
registered handlers instead drive the requests embedded in `input_required` results,
|
||||
as documented on [Multi-Round-Trip Results](/client/multi-round-trip-results/).
|
||||
|
||||
### Server-to-Client Requests (Sampling)
|
||||
|
||||
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
|
||||
letting a server leverage the client's model access without its own API keys.
|
||||
|
||||
{: .warning }
|
||||
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
|
||||
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
|
||||
> new servers should call LLM provider APIs directly.
|
||||
|
||||
Register a handler and advertise the capability on `connect`:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { sampling: {} })
|
||||
|
||||
client.on_sampling do |params|
|
||||
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
|
||||
{
|
||||
role: "assistant",
|
||||
content: { type: "text", text: completion.text },
|
||||
model: completion.model,
|
||||
stopReason: "endTurn",
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
|
||||
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
|
||||
|
||||
```ruby
|
||||
client.on_sampling do |params|
|
||||
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
|
||||
|
||||
generate_completion(params)
|
||||
end
|
||||
```
|
||||
|
||||
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
|
||||
|
||||
### Custom Headers from Tool Parameters
|
||||
|
||||
On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries
|
||||
an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route
|
||||
on the values without parsing bodies. The declarations are learned from `tools/list` responses:
|
||||
list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values
|
||||
(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`,
|
||||
and a `null` or absent argument omits its header.
|
||||
|
||||
Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names,
|
||||
duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from
|
||||
`tools/list` results on modern connections, with a warning naming the tool.
|
||||
Legacy connections are unaffected: nothing is learned, mirrored, or excluded.
|
||||
|
||||
### Customizing the Faraday Connection
|
||||
|
||||
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
|
||||
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:
|
||||
|
||||
```ruby
|
||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
|
||||
faraday.use MyApp::Middleware::HttpRecorder
|
||||
faraday.adapter :typhoeus
|
||||
end
|
||||
```
|
||||
|
||||
## Custom Transports
|
||||
|
||||
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:
|
||||
|
||||
```ruby
|
||||
class CustomTransport
|
||||
# Sends a JSON-RPC request to the server and returns the raw response.
|
||||
#
|
||||
# @param request [Hash] A complete JSON-RPC request object.
|
||||
# https://www.jsonrpc.org/specification#request_object
|
||||
# @return [Hash] A hash modeling a JSON-RPC response object.
|
||||
# https://www.jsonrpc.org/specification#response_object
|
||||
def send_request(request:)
|
||||
# Your transport-specific logic here
|
||||
# - HTTP: POST to endpoint with JSON body
|
||||
# - WebSocket: Send message over WebSocket
|
||||
# - stdio: Write to stdout, read from stdin
|
||||
# - etc.
|
||||
end
|
||||
end
|
||||
|
||||
client = MCP::Client.new(transport: CustomTransport.new)
|
||||
```
|
||||
|
||||
Custom transports that need to support client-side cancellation have additional requirements;
|
||||
see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page.
|
||||
|
|
@ -2,18 +2,44 @@
|
|||
title: "MCP Ruby SDK"
|
||||
description: "The official Ruby SDK for Model Context Protocol servers and clients."
|
||||
remote_theme: just-the-docs/just-the-docs
|
||||
color_scheme: ruby-light
|
||||
|
||||
# Include generated files and directories which may start with underscores
|
||||
include:
|
||||
- "_*"
|
||||
plugins:
|
||||
- jekyll-redirect-from
|
||||
|
||||
# Sidebar sections: each collection renders as a plain category heading
|
||||
# with its pages listed beneath it, instead of a clickable parent page.
|
||||
collections:
|
||||
server:
|
||||
permalink: "/server/:name/"
|
||||
output: true
|
||||
client:
|
||||
permalink: "/client/:name/"
|
||||
output: true
|
||||
extensions:
|
||||
permalink: "/extensions/:name/"
|
||||
output: true
|
||||
|
||||
just_the_docs:
|
||||
collections:
|
||||
server:
|
||||
name: Building Servers
|
||||
client:
|
||||
name: Building Clients
|
||||
extensions:
|
||||
name: Extensions
|
||||
|
||||
# Search
|
||||
search_enabled: true
|
||||
|
||||
# Footer
|
||||
gh_edit_link: true
|
||||
gh_edit_link_text: "Edit this page on GitHub."
|
||||
gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk"
|
||||
gh_edit_branch: "main"
|
||||
gh_edit_source: "docs"
|
||||
gh_edit_view_mode: "edit"
|
||||
# Callouts
|
||||
callouts:
|
||||
note:
|
||||
title: Note
|
||||
color: blue
|
||||
important:
|
||||
title: Important
|
||||
color: yellow
|
||||
warning:
|
||||
title: Warning
|
||||
color: red
|
||||
|
|
|
|||
35
docs/_extensions/capability-extensions.md
Normal file
35
docs/_extensions/capability-extensions.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
layout: default
|
||||
title: Capability Extensions
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Capability Extensions
|
||||
|
||||
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
|
||||
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
||||
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
|
||||
|
||||
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
|
||||
|
||||
```ruby
|
||||
capabilities = MCP::Server::Capabilities.new
|
||||
capabilities.support_tools
|
||||
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
||||
|
||||
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
|
||||
```
|
||||
|
||||
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
|
||||
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
|
||||
|
||||
On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result,
|
||||
and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake.
|
||||
Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations
|
||||
with envelope-first resolution, on either lifecycle.
|
||||
|
||||
On the client, pass extensions through `connect`:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
|
||||
```
|
||||
17
docs/_extensions/index.md
Normal file
17
docs/_extensions/index.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
layout: default
|
||||
title: Overview
|
||||
nav_order: 1
|
||||
permalink: /extensions/
|
||||
---
|
||||
|
||||
# Extensions
|
||||
|
||||
Extensions add optional functionality on top of the core MCP protocol.
|
||||
Support for an extension is negotiated per connection:
|
||||
clients and servers declare the extensions they speak under the `extensions` member of their capabilities,
|
||||
as described on [Capability Extensions](/extensions/capability-extensions/).
|
||||
|
||||
The extensions this SDK ships support for:
|
||||
|
||||
- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results
|
||||
48
docs/_extensions/mcp-apps.md
Normal file
48
docs/_extensions/mcp-apps.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
layout: default
|
||||
title: MCP Apps
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# MCP Apps
|
||||
|
||||
MCP Apps (SEP-1865) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) that lets a server ship interactive
|
||||
HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention,
|
||||
and `MCP::Apps` provides the vocabulary and helpers:
|
||||
|
||||
```ruby
|
||||
capabilities = MCP::Server::Capabilities.new
|
||||
capabilities.support_tools
|
||||
capabilities.support_resources
|
||||
capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } }
|
||||
|
||||
server = MCP::Server.new(
|
||||
name: "weather_server",
|
||||
capabilities: capabilities,
|
||||
# UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type.
|
||||
resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")],
|
||||
)
|
||||
|
||||
server.resources_read_handler do |params|
|
||||
[{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "<html>...</html>" }]
|
||||
end
|
||||
|
||||
# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also
|
||||
# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec).
|
||||
server.define_tool(
|
||||
name: "get_weather",
|
||||
meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"),
|
||||
) do |server_context:|
|
||||
# The extension is optional: always return a meaningful text result, and use
|
||||
# `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content.
|
||||
MCP::Apps.client_supports?(server_context.client_capabilities) # => true when the host declared the extension
|
||||
MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }])
|
||||
end
|
||||
```
|
||||
|
||||
`MCP::Apps.tool_meta` also accepts `visibility:` (an array of `"model"` / `"app"`) to restrict who sees the tool,
|
||||
and merges non-destructively into caller-supplied `meta:`.
|
||||
|
||||
Everything else the extension defines (the sandboxed iframe, the `ui/*` `postMessage` bridge, consent for UI-initiated actions)
|
||||
is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests.
|
||||
See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).
|
||||
48
docs/_includes/footer_custom.html
Normal file
48
docs/_includes/footer_custom.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{%- comment -%}
|
||||
Previous/Next pager following the sidebar order: top-level pages by
|
||||
nav_order, then the server, client, and extensions collections.
|
||||
{%- endcomment -%}
|
||||
{%- assign top_pages = site.html_pages | where_exp: "item", "item.nav_order" | sort: "nav_order" -%}
|
||||
{%- assign server_docs = site.server | sort: "nav_order" -%}
|
||||
{%- assign client_docs = site.client | sort: "nav_order" -%}
|
||||
{%- assign extensions_docs = site.extensions | sort: "nav_order" -%}
|
||||
{%- assign ordered_pages = top_pages | concat: server_docs | concat: client_docs | concat: extensions_docs -%}
|
||||
{%- assign pager_state = "before" -%}
|
||||
{%- assign prev_page = nil -%}
|
||||
{%- assign next_page = nil -%}
|
||||
{%- for item in ordered_pages -%}
|
||||
{%- if pager_state == "current" -%}
|
||||
{%- assign next_page = item -%}
|
||||
{%- assign pager_state = "after" -%}
|
||||
{%- elsif item.url == page.url -%}
|
||||
{%- assign pager_state = "current" -%}
|
||||
{%- elsif pager_state == "before" -%}
|
||||
{%- assign prev_page = item -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if pager_state != "before" -%}
|
||||
<nav class="docs-pager" aria-label="Pager">
|
||||
{%- if prev_page %}
|
||||
<a class="docs-pager-link docs-pager-prev" href="{{ prev_page.url | relative_url }}">
|
||||
<span class="docs-pager-direction">Previous</span>
|
||||
<span class="docs-pager-title">
|
||||
{%- if prev_page.collection != page.collection -%}
|
||||
{%- if prev_page.collection == "server" -%}Building Servers · {% elsif prev_page.collection == "client" -%}Building Clients · {% elsif prev_page.collection == "extensions" -%}Extensions · {% endif -%}
|
||||
{%- endif -%}
|
||||
{{- prev_page.title -}}
|
||||
</span>
|
||||
</a>
|
||||
{%- endif -%}
|
||||
{%- if next_page %}
|
||||
<a class="docs-pager-link docs-pager-next" href="{{ next_page.url | relative_url }}">
|
||||
<span class="docs-pager-direction">Next</span>
|
||||
<span class="docs-pager-title">
|
||||
{%- if next_page.collection != page.collection -%}
|
||||
{%- if next_page.collection == "server" -%}Building Servers · {% elsif next_page.collection == "client" -%}Building Clients · {% elsif next_page.collection == "extensions" -%}Extensions · {% endif -%}
|
||||
{%- endif -%}
|
||||
{{- next_page.title -}}
|
||||
</span>
|
||||
</a>
|
||||
{%- endif %}
|
||||
</nav>
|
||||
{%- endif -%}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const storageKey = "mcp-ruby-sdk-docs-theme";
|
||||
|
|
@ -63,8 +65,9 @@
|
|||
/*
|
||||
* Swap the color stylesheet without going through jtd.setTheme, which
|
||||
* rewrites the href of the render-blocking link and lets the page paint
|
||||
* unstyled until the replacement CSS arrives. The site has no color_scheme,
|
||||
* so the default stylesheet equals the light one and needs no swap at all;
|
||||
* unstyled until the replacement CSS arrives. The site's color_scheme is
|
||||
* the light variant, so the default stylesheet equals the light one
|
||||
* and needs no swap at all;
|
||||
* for dark, keep the old sheet applied until the new one has loaded.
|
||||
*/
|
||||
function setColorStylesheet(theme) {
|
||||
|
|
@ -188,3 +191,19 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/*
|
||||
* Open external links (GitHub, the MCP specification, rubydoc, and so on)
|
||||
* in a new tab, keeping the docs page the reader came from. Line comments
|
||||
* would swallow the script here: the theme compresses the HTML to one line.
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
for (const anchor of document.querySelectorAll("a[href^='http']")) {
|
||||
if (anchor.hostname !== window.location.hostname) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
17
docs/_sass/color_schemes/ruby-dark.scss
Normal file
17
docs/_sass/color_schemes/ruby-dark.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Dark counterpart of ruby-light: the link color is lightened from
|
||||
// the #b61d1d accent so it keeps WCAG AA contrast, and the theme's
|
||||
// purple-tinted dark greys become neutral ones.
|
||||
@import "./color_schemes/dark";
|
||||
|
||||
$link-color: #f0776c;
|
||||
$btn-primary-color: #b61d1d;
|
||||
|
||||
// The dark scheme derives $feedback-color before these overrides apply,
|
||||
// so it is restated.
|
||||
$body-background-color: #252525;
|
||||
$sidebar-color: #252525;
|
||||
$feedback-color: #1e1e1e;
|
||||
$search-background-color: #202020;
|
||||
$table-background-color: #202020;
|
||||
$base-button-color: #202020;
|
||||
$border-color: #3f3f3f;
|
||||
8
docs/_sass/color_schemes/ruby-light.scss
Normal file
8
docs/_sass/color_schemes/ruby-light.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// The include imports the light scheme first, so only overrides live here.
|
||||
$link-color: #990000;
|
||||
$btn-primary-color: #b61d1d;
|
||||
$sidebar-color: #fff;
|
||||
|
||||
// The light scheme derives $feedback-color before these overrides apply,
|
||||
// so it is restated (the hover shade on the white sidebar).
|
||||
$feedback-color: #f2f2f2;
|
||||
|
|
@ -65,13 +65,85 @@
|
|||
/*
|
||||
* Applied before the dark stylesheet finishes loading, so browsers that
|
||||
* do not support blocking="render" avoid a light-colored first paint.
|
||||
* Values match $grey-dk-300 and $grey-lt-300 from the dark color scheme.
|
||||
* Values match $body-background-color from the ruby-dark color scheme
|
||||
* and $grey-lt-300 for the text.
|
||||
*/
|
||||
:root[data-theme="dark"] body {
|
||||
background-color: #27262b;
|
||||
background-color: #252525;
|
||||
color: #e6e1e8;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/*
|
||||
* Center the sidebar-plus-content block as one unit, splitting the leftover viewport equally
|
||||
* on both sides. The theme instead widens the sidebar to absorb the whole left overflow,
|
||||
* and its margin rule lives on `.side-bar + .main`, which a bare `.main` selector cannot override.
|
||||
*/
|
||||
$docs-content-width: 64rem;
|
||||
|
||||
@media (min-width: 66.5rem) {
|
||||
.side-bar {
|
||||
/* Capitalized Max/Min pass through the Sass compiler as plain CSS. */
|
||||
left: Max(0px, calc((100% - #{$nav-width + $docs-content-width}) / 2));
|
||||
width: $nav-width;
|
||||
}
|
||||
|
||||
.side-bar + .main {
|
||||
max-width: $docs-content-width;
|
||||
margin-left: Max(#{$nav-width}, calc((100% - #{$nav-width + $docs-content-width}) / 2 + #{$nav-width}));
|
||||
}
|
||||
|
||||
/* Breathing room between the sidebar and the content. */
|
||||
.main-content-wrap {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Previous/Next pager in the page footer. */
|
||||
.docs-pager {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.docs-pager-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 48%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-pager-next {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.docs-pager-direction {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $body-text-color;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.docs-pager-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/*
|
||||
* Category headings read as section labels, not separators: no underline,
|
||||
* spaced apart from the preceding group and letter-spaced instead.
|
||||
*/
|
||||
.nav-category {
|
||||
margin-top: 1.5rem;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
|
|
|||
2
docs/_sass/custom/setup.scss
Normal file
2
docs/_sass/custom/setup.scss
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Widen the sidebar from the theme's 16.5rem default.
|
||||
$nav-width: 19rem;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue