## Motivation and Context
`Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none"
and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL.
A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes,
so a transient failure moved the flow to a different authorization server, discarded client information
and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin,
rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so:
outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them
and refuses the legacy path after a `5xx` or `429` from any candidate.
Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted.
Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path;
moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError`
(a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path;
`MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead.
Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from
a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered.
A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path.
The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length,
but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are.
The default legacy endpoints for an origin whose own metadata document is absent are unchanged.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library
before this change, discovery in the scenarios these tests set up took the legacy path instead of raising.
## Breaking Changes
When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server,
or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin,
and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback.
## Motivation and Context
`MCP::Client::OAuth::Flow` takes an `http_client_factory:`, but `MCP::Client::HTTP` builds
the flow itself and passes none, so an application using `oauth:` could not add middleware to
the requests the flow makes: Protected Resource Metadata and authorization server metadata discovery,
dynamic client registration, and every token request.
The block that customizes the transport's Faraday connection does not help,
because that connection is bound to the MCP server URL and carries the `headers:` meant for that server,
and the authorization server is a different origin. The TypeScript SDK hands the transport's `fetch` to `auth()`
and the Python SDK sends the provider's requests through the transport's httpx client, so both let
an application instrument these requests; this SDK did not. (#558)
Providers now take `http_client_customizer:`, a callable invoked with the Faraday connection
the flow builds for its own requests, next to the other callables a provider already holds.
It runs after the SDK's defaults (`Accept: application/json`, no redirect following) and
between two middlewares of the SDK's own: `Flow::RequestedURLStamp`, registered before the customizer is invoked,
records the URL the flow asked for on the request environment, and `Flow::RequestedOriginGuard`, registered last,
sees what the customizer's middleware did to the request after that record. A request that would leave that origin,
by following a redirect or rewriting the URL, or that arrives without the record, is refused with
`Flow::DestinationMismatchError` before it reaches the adapter. The record lives on the environment rather than
in `env.request.context`, which stays the application's to fill or replace. The error is an `ArgumentError`
rather than an `AuthorizationError` so neither discovery nor the transport's refresh fallback can absorb
a configuration mistake. The boundary resembles the one the Python SDK keeps for its own requests,
which follows a redirect only within the origin; this flow follows none itself.
`Flow.build_http_client` assembles that connection and `Flow.new(http_client_factory:)` keeps replacing it whole.
Following done inside an adapter cannot be seen by any guard, so the documentation keeps that, `Accept-Encoding`,
and `raise_error` off the list of things a customizer may add, and notes that a recording middleware sees
client credentials and tokens.
Fixes#558.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb`, `test/mcp/client/oauth/http_oauth_test.rb`,
and the three provider test files. Against the library before this change, every one of them fails,
on the unknown keyword or on the missing reader.
## Breaking Changes
None. The customizer is opt-in, and the guard cannot fire on the SDK's own middleware-free connection.
## Motivation and Context
`Flow#refresh!` reads `client_id_metadata_document_url` from the provider unconditionally, once to decide whether
a provider without stored `client_information` may still refresh and once to build the CIMD `client_id`.
Only `Provider` defines that reader: `CrossAppAccessProvider` and `ClientCredentialsProvider`
raise `NoMethodError` once a stored `refresh_token` is found. `MCP::Client::HTTP` calls `refresh!` on any `401` while
a `refresh_token` is stored, so a `jwt-bearer` client whose authorization server issued one fails with
`NoMethodError` on the first expired token instead of refreshing. The error is not an `AuthorizationError`,
so the transport does not fall back to the grant either. RFC 7521 Section 4.1 makes a refresh token unusual
for an assertion grant, not forbidden, and one in storage must not crash the client.
The flow now reads the URL through a duck-typed helper, like `authorization_flow` and `client_assertion`,
so a provider without the reader has no CIMD URL and refreshes with its stored `client_information`:
the `refresh_token` grant is sent with the stored client authentication, and no new ID-JAG assertion is obtained.
The authorization-code registration path reads the URL through the same helper.
The docs now say that a refresh token issued anyway is used on the next `401`, and that the assertion provider
is not called again on refresh.
A refresh token stored without the `issuer` the SDK records is refused for these two providers before any request:
unlike `Provider`, they have no tokens that predate the record, and a refresh asks no validator, so such a token
would be presented to whatever authorization server discovery names. The refusal is an `AuthorizationError`,
which sends the transport back through the grant, and the grant asks.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb` refresh through a `CrossAppAccessProvider` and
a `ClientCredentialsProvider` holding a `refresh_token`, asserting the `refresh_token` grant, the Basic credentials,
and that the assertion provider is not called. A transport test in `test/mcp/client/oauth/http_oauth_test.rb` sends
a `401` to a `CrossAppAccessProvider` with a stored `refresh_token` and expects the refreshed bearer token on the retry.
All three raise `NoMethodError` against the previous library.
Further tests pin the issuer stamp on the refreshed tokens, the refusal of an authorization server that did not issue them
for a `CrossAppAccessProvider`, a clean `AuthorizationError` for a provider with neither the reader
nor stored `client_information`, and registration through DCR for a provider without the reader.
A refresh token without a recorded issuer is refused for both providers before any request is sent.
## Breaking Changes
None.
## Motivation and Context
On the legacy 2025-03-26 path, taken when a server publishes no Protected Resource Metadata,
the authorization server metadata is fetched from the MCP server's origin, which that spec names
as the authorization base URL, but its `issuer` was never compared with that origin.
RFC 8414 Section 3.3 requires the comparison, the 2025-03-26 spec makes RFC 8414 a MUST for clients,
and the TypeScript and Python SDKs both compare on this path, tolerating only a trailing slash.
The flow nevertheless used the unverified `issuer` as the authorization server's identity everywhere:
tokens were stamped with it, stored client information was matched against it, the `private_key_jwt`
and ID-JAG assertions took it as their audience, and `authorization_request_validator` was shown it.
A server on that path could therefore claim any issuer. Claiming the one that minted the stored tokens made
`refresh!` send the refresh token and the stored client secret to the server's own token endpoint, claiming
the one that issued stored client information made the flow reuse those credentials there, and a validator
allow-listing the claimed issuer approved the request.
The legacy path now applies the same check as the modern one: the document's `issuer` must be the origin,
rendered with or without a trailing slash, and any other document is refused before registration, redirect,
or token request. A server without a metadata document still gets the spec's default endpoints.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb` serve legacy metadata at the MCP server origin that claims
`https://auth.example.com` and check that the flow, a refresh of tokens issued by that server,
and a flow holding client information bound to it are all refused before any request, with the validator never asked
and the credentials never presented. Metadata naming the origin, with or without a trailing slash and with
endpoints under a path prefix, completes the flow, including the RFC 9207 `iss` check. The former test
of a path-prefix issuer now expects the refusal. The refusal tests fail against the previous library.
## Breaking Changes
A server without Protected Resource Metadata whose authorization server metadata names an issuer other than
its origin is refused instead of used. The TypeScript and Python SDKs already refuse such a server,
and the 2025-03-26 spec places the metadata at the origin.
## Motivation and Context
`ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`,
`resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server
that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all,
and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides.
RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets
a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes
`token_request_params:`, a Hash of String keys and values added to every token request the provider makes,
and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider`
subclass that defines the method gets the same treatment on its authorization code exchange, refresh,
or `jwt-bearer` request.
`Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like
`authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored.
The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself
(`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError`
from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow,
before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars
and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to
the caller's Hash cannot alter what is sent.
A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice
by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than
`AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow,
which would then fail the same way after the user signed in.
Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them
from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys
whose class is exactly `String`.
Fixes#554.
## How Has This Been Tested?
New tests cover every token request the parameters can ride (the `client_credentials` grant under
all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`),
both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method),
and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow.
All of them fail against the previous library.
## Breaking Changes
None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends
the same requests as before.
The Subscriptions page documented the acknowledgement-first contract but not what delivery means:
the stream carries only changes that happen after the acknowledgement, nothing is queued while
no stream is connected, and a dropped stream is gone together with anything it missed.
The Python SDK's subscriptions page states the same contract ("It is not a replay log").
Spelling it out gives client implementers the fetch-after-subscribing guidance and
makes the acknowledgement-window behavior explicit.
## Motivation and Context
The client answers server pings automatically, but the docs leave out where that applies and what the stdio transport
does with the other server-to-client requests.
The client Transports page now lists the automatic ping answer among the things the stdio transport handles,
and a note states that `ping` is the only server-to-client request answered there: a wire-level `elicitation/create`
or `sampling/createMessage` is ignored over stdio, so a server that sends one waits for an answer that never comes,
while the SEP-2322 `input_required` route works on every transport and keeps `on_elicitation` and `on_sampling` handlers
working over stdio. Without this note, the only trace of the limitation is a hung tool call.
The client Ping page now says that over HTTP a ping reaches the client only on a stream that is already open
and that the client never opens one to receive pings, so a server can only ping a client that has a stream open.
Registering `on_server_request("ping")` moves to the end of the section as the advanced option it is, together with
its side effect of opening the GET listening stream on `connect`.
The client overview mentions the automatic answer next to `MCP::Client#ping`,
and the server Ping page's Client Side section points at it.
## How Has This Been Tested?
Documentation only; the linked headings and pages exist.
## Breaking Changes
None.
## Motivation and Context
The Installation page names faraday as the example of an optional dependency but leaves out event_stream_parser,
which the HTTP client transport needs as soon as a server answers with SSE. The Transports page documents both gems,
so a reader who starts from Installation learns about the second one only when a streaming response fails to parse.
The Transports page also marks event_stream_parser as "optional, required only for SSE responses",
which reads as a corner case. Whether a response is JSON or SSE is the server's choice, made for each response,
a client must accept both, and this SDK's own server picks SSE by default and always on the modern lifecycle,
so a client written for arbitrary servers needs both gems. Both pages now say so, and the Transports page spells out
the one setup that can leave the gem out: a server known to answer with JSON alone, such as this SDK's server
in JSON response mode serving handshake-lifecycle clients, and no listening stream.
The Installation page shows the faraday-only Gemfile first and then the one that adds event_stream_parser,
so both cases are visible at a glance.
## How Has This Been Tested?
Documentation only; the Gemfile snippets on both pages match.
## Breaking Changes
None.
## Motivation and Context
The ping utility says the receiver "MUST respond promptly with an empty response",
and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor
that on the client side (the TypeScript protocol layer installs an automatic pong,
and the Python session answers `PingRequest` with an empty result), but this client answered
a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio,
leaving long-lived legacy sessions vulnerable to a liveness-checking server.
Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`.
The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens
the GET listening stream on connect, and a built-in pong must not change listener behavior -
a server can only ping on a stream that is already open. A handler registered via
`on_server_request("ping")` still wins.
Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response.
Frames are told apart by shape rather than id: one carrying `method` is a request or notification,
never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered -
and a request frame can no longer be returned to the caller as if it were the response.
Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading,
which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay
unsupported as documented.
The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written
over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading,
and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright -
is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found,
exactly as before this change.
The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only.
## How Has This Been Tested?
New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with
the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while
a response is awaited is answered on stdin and the original response still returns. Both fail without the change
(Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over
a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio
and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None. A server ping that previously received Method not found (or nothing) now receives the empty result
the specification requires.
The overview's Key Features and Supported Methods listed `roots`, `sampling`, and `logging/setLevel` with no deprecation marking,
while their own pages carry the SEP-2577 warnings, presenting a current-looking feature surface to a reader skimming the overview.
The five entries now carry the pages' "deprecated as of 2026-07-28" wording; `elicitation/create` stays unmarked - it is not deprecated,
and the modern lifecycle keeps carrying it as requests embedded per the multi round-trip requests pattern.
## Motivation and Context
An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers`
in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported`
or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity.
No origin rule can settle this, because an authorization server is legitimately a different origin
from the resource it protects. Validating that a token was issued for the intended audience is a responsibility
the specification places on MCP servers rather than on clients, and there is no portable way for a client
to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor
the `resource` parameter or to report an audience in the token response.
Two things follow from that, and they are separate.
The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows
which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing
the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse.
It is one object rather than keyword arguments so that later revisions of the specification can add to it
without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it
runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`:
a refusal must not leave this client registered at an authorization server the host just rejected.
The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers
the authorization server on every call, so the server named for a session can differ from the one
the stored tokens came from. SEP-2352 already binds client credentials to their issuer,
but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL,
which is portable across authorization servers. The issuer that minted the tokens is now recorded with them,
and `refresh!` refuses to present them to a different one. The transport answers that refusal by running
a full authorization, which brings the new authorization server to the validator above. Tokens stored before
this carry no issuer and keep refreshing.
The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way,
because the specification requires a client to treat the scopes in the challenge as authoritative for the operation,
and narrowing them silently would break that.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises
before any registration request is sent and before the redirect handler runs, and that the `client_credentials`
and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against
a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb`
drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees
the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of
this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens,
and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected
until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer
it authorizes as before.
## Motivation and Context
The documentation site under `docs/` had no development command: previewing a change meant hand-assembling
a Jekyll environment, since the site only builds for real when a release publishes it through
`bin/generate-gh-pages.sh` and the GitHub Pages runtime. `rake docs:preview` now builds and serves
the site at http://localhost:4000 (override with `PORT`), with watch-based rebuilds on edit.
The preview dependencies live in their own `docs/Gemfile` rather than the gem's Gemfile:
`github-pages` mirrors the runtime that builds the released site (jekyll-remote-theme,
jekyll-redirect-from, and the Jekyll version Pages actually runs), which keeps the preview faithful
but is far too heavy to impose on the gem itself. The task subshells into that bundle with
`Bundler.with_unbundled_env`, the same delegation shape the conformance tasks use for npx.
Two support pieces keep the pinned toolchain running on current Ruby. `docs/_preview/taint_shim.rb` restores
the taint API as no-ops: Liquid 4.0.3 calls `obj.tainted?` on every variable render,
ahead of its own lax-mode check (`liquid/variable.rb:124`), so on Ruby 3.2 and later,
where the taint API was removed, the first layout render dies with NoMethodError, while on Ruby 3.1
and earlier the methods still exist as deprecated no-ops and the shim is inert. The shim is loaded only into
the preview process via RUBYOPT. The former default gems the pinned Jekyll expects are listed in the docs Gemfile.
The task also regenerates `docs/_data/versions.yml` from the version tags, mirroring what `bin/generate-gh-pages.sh`
produces at deploy time, so the nav footer shows the released-gem version line exactly as it does on the published site.
Build products and the generated data file are ignored.
## How Has This Been Tested?
By running the task on a spare port and verifying the served pages, the footer's version line,
and that a SIGINT stops it cleanly with exit 0; `git status` stays clean while it runs (all build products ignored).
Verified on both Ruby 3.1.5 (shim inert) and Ruby 4.0.6, where a shimless build reproduces the NoMethodError
and the task runs green with the shim. RuboCop and the full suite are green.
## Breaking Changes
None. The task and its Gemfile are development-only additions; the gem and its dependencies are untouched.
## Motivation and Context
The documented Rails controller pattern builds a fresh transport per request and renders `body.first`,
but a modern client's `subscriptions/listen` made `handle_request` return the streaming `Proc` body,
so exactly the documented configuration answered every notification-stream request with a 500 - quietly,
since tool calls and listings kept working. Nothing gated the route: `serves_subscriptions_listen?` was
hardcoded `true` and consulted only by `Server#discover` for capability stripping,
and the modern path deliberately ignores `stateless:`.
`StreamableHTTPTransport.new` now accepts `serve_subscriptions_listen:` (default `true`, behavior unchanged).
When `false`, the transport skips the listen interception so the method falls through the dispatcher as
unimplemented - the spec's 404 with JSON-RPC `-32601`, in an Array body the controller pattern can render - and
`serves_subscriptions_listen?` reflects the setting, so `Server#discover` stops advertising the `listChanged`/`subscribe`
capability flags through its existing wiring and the advertisement agrees with the actual behavior.
The documented controller example passes the flag and explains why; refusing implicitly in `stateless:` mode
was rejected because a stateless transport mounted as a long-lived Rack app serves listen streams correctly
(listen delivery deliberately precedes the stateless notification guard).
On the default streaming side, the listen body is now a small streaming-body object rather than a bare `Proc`:
it still responds to `call` and deliberately not to `each` (Rack would otherwise classify it as enumerable
and break the streaming), while `first` raises an error naming `serve_subscriptions_listen: false`
and pointing at the transports documentation, so a host that buffers the body the documented way fails with
guidance instead of a bare `NoMethodError`.
Fixes#531.
## How Has This Been Tested?
With the issue's reproduction script (default: a streaming body whose `body.first` raises the guidance;
with the flag: 404, `application/json`, and an Array body whose first element is the `-32601` error),
new transport tests covering the refusal shape, the capability stripping on `server/discover`, and the guidance
raise with the body's streaming classification, the full suite, RuboCop, and the conformance suite, all green;
the docs build passes every internal link and anchor check.
## Breaking Changes
None. The keyword defaults to the current behavior; refusal is opt-in.
The bottom of the sidebar (and the mobile page footer) showed the theme's fallback line
"This site uses Just the Docs, a documentation theme for Jekyll", which tells readers nothing about the SDK.
Overriding nav_footer_custom.html replaces it with the release the documentation describes, linking the RubyGems
page of the version deployed via _data/versions.yml by bin/generate-gh-pages.sh, and with the licensing:
the documentation is CC-BY-4.0 per LICENSE, while the code licensing is delegated to LICENSE itself,
whose consent-based MIT to Apache-2.0 transition does not fit a footer phrase.
Without versions data (local previews of the docs/ source) only the license sentences render.
Three pages carried names the 2026-07-28 specification does not use: "Multi-Round-Trip Results" for
the pattern the specification titles Multi Round-Trip Requests (MRTR) and serves at basic/patterns/mrtr -
the results are the `InputRequiredResult` wire type, not the feature - plus "Completions" for the utility
the specification titles Completion, and "Notification Subscriptions" for the pattern it titles Subscriptions.
Retitle the three pages to the specification's names, move them to /server/mrtr/ and /client/mrtr/,
/server/completion/, and /server/subscriptions/ following the specification's slugs, update every internal reference,
and keep the URLs published with v1.3.0 working via jekyll-redirect-from.
The Discovery page keeps its title but moves from /server/discovery/ to /server/discover/, matching
the specification's slug and the `server/discover` RPC, with its published URL redirected the same way.
Move the documentation that had grown to 3000+ lines in README.md onto https://ruby.sdk.modelcontextprotocol.io
and slim the README down to a quick start and feature overview, following the Python SDK layout:
- Add 21 server pages as the docs/_server/ collection (overview plus transports, discovery, tools, prompts, resources,
roots, sampling, elicitation, multi round-trip results, notifications, notification subscriptions, cancellation, progress,
ping, completions, logging, pagination, server context, configuration, and custom methods), 8 client pages as
the docs/_client/ collection (overview, transports, lifecycle, multi round-trip results, cancellation, ping, pagination,
and authorization), and 3 extension pages as the docs/_extensions/ collection (overview, capability extensions, and MCP Apps,
following the Extensions Overview recommendation that SDK documentation list the supported extensions) -
one page per topic, ordered to match the sidebar of the 2026-07-28 specification, with the client-side APIs
(pinging, cancelling, and paginating from MCP::Client) documented under Building Clients
- Add top-level Examples and Protocol Versions pages after Installation, linking the runnable examples in examples/
and summarizing the supported protocol versions, the era model, and client negotiation
- Render the three sections as just-the-docs collections, which list every page beneath a plain category heading in
the sidebar with no folding, and serve every page at an extensionless URL under /server/, /client/, and /extensions/,
matching the sibling SDK documentation sites
- Replace docs/building-servers.md and docs/building-clients.md with redirects to the new section overview pages via
jekyll-redirect-from, redirect the previously published /installation.html to its extensionless URL, fold their content
that was missing from README.md into the new pages, and update the Tool argument keys reference comment in lib/mcp/server.rb to
the relocated Tools page
- Reduce README.md to the badges, a compact feature overview in the Python SDK style, installation instructions, a stdio
server and client quick start, and a License section, using absolute URLs for the remaining repository links
so they resolve on rubygems.org
- Adapt formatting where GitHub rendering habits break on the site: convert the numbered "three ways to define" lists into headings,
since kramdown restarts numbering at 1 when code blocks split list items, convert GitHub-style alerts into just-the-docs callouts
with the SEP-2260 server-to-client association note raised to a red warning, merge the duplicated Exception Reporting
and Configuration Block Data sections into the Configuration page, and relocate the Streamable HTTP settings that were nested under
the Logging section into the Transports page
- Refresh the migrated content against the current implementation: correct stale claims and broken samples the README carried,
note on each session-era feature how it relates to the modern lifecycle of MCP 2026-07-28, and point spec links at the latest revision,
keeping deliberate 2025-11-25 pins for pages the modern revision removed
- Style the site after the Rails API documentation palette (red links and accents on neutral surfaces) with matching
light and dark color schemes, center the sidebar and content as one block, add a Previous/Next footer pager following
the sidebar order, open external links in a new tab, and serve the MCP logo as the favicon
Every code block and heading from the previous README was verified to have a home in the new docs pages or the slimmed README before
the reduction; a few samples were corrected rather than copied, as noted above.
## Motivation and Context
Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761
A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.
The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.
`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.
The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.
Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).
A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.
Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.
`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.
The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.
## How Has This Been Tested?
New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.
Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.
Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.
The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.
## Breaking Changes
Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
## Motivation and Context
Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK
does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25,
the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header,
and `server/discover` omits the version from `supportedVersions`.
Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once,
since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model:
negotiable through the legacy `initialize` handshake, and simultaneously the version of
the per-request modern lifecycle.
Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle
over the transports, and are being built separately. This change deliberately limits itself to
the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit.
Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle
when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate
needs rework on top of this change.
Closes#474
## How Has This Been Tested?
New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`),
a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation
(`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted
(`configuration_test.rb`).
`bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30.
The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins
the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO
to remove it when the framework catches up.
## Breaking Changes
Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks
and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported
and negotiable; clients that request it are unaffected.
## Motivation and Context
Opening https://ruby.sdk.modelcontextprotocol.io/ briefly showed a black half-circle
at the bottom of the screen. The theme preference script in `head_custom.html` called
`window.jtd.setTheme` on every page load. That helper rewrites the `href` of
the render-blocking color stylesheet link, which detaches the loaded stylesheet
and refetches it, so the page paints unstyled until the replacement arrives.
During that window the search magnifier SVG, sized only by CSS, rendered at its fallback size
and appeared as a large black half-circle near the bottom of the viewport.
The site sets no `color_scheme`, so the default stylesheet is identical to the light one
and most visitors paid the flash for a no-op swap.
This change replaces the `jtd.setTheme` call with a local swapper that:
- Leaves the stylesheet untouched when the resolved theme already matches
(treating `default` as `light`).
- Swaps non-destructively otherwise: the new link is inserted next to the current one
and the old link is removed only after the new stylesheet has loaded. On error the new link
is dropped and the page keeps its current styles.
- Sets `blocking="render"` on the new link so supporting browsers hold first paint until
the dark stylesheet arrives.
It also pre-applies the dark background and text colors in `custom.scss`, keyed on
the `data-theme` attribute that is set synchronously in the head, to soften the light-colored
first paint for dark-preference visitors in browsers without `blocking="render"` support.
## How Has This Been Tested?
Mirrored the live site locally, patched the inline script with the new code,
and drove it in Chrome:
- Unpatched baseline reproduced the old behavior: the link `href` was rewritten
and `just-the-docs-light.css` was fetched in addition to `just-the-docs-default.css`.
- Patched light and system-light loads keep the original link untouched with no extra stylesheet request.
- Patched dark loads settle on a single `just-the-docs-dark.css` link, with `jtd.getTheme()`
still reporting the right theme.
- Rapid toggling keeps at most two color stylesheet links and settles on exactly one matching
the final preference.
- With `just-the-docs-dark.css` returning 404, the failed link is dropped and the page stays styled.
## Breaking Changes
None.
## Motivation and Context
Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions
while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block
with manual URI routing.
`MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool`
and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`,
`size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods
and `MCP::Server#define_resource` / `#define_resource_template`.
The default `resources/read` handler now routes requests automatically: an exact URI match calls
the class-based resource's `contents` method, and class-based resource templates match
simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments.
`contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered
and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164.
## How Has This Been Tested?
- Added regression tests covering the class-level DSL, URI template matching, auto-routing,
`server_context:` opt-in, error responses, and backward compatibility of the no-op default.
- Verified end-to-end with a smoke script exercising exact-match reads, template-match reads,
unknown URIs, and legacy instance-only setups.
## Breaking Changes
No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for
setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds
the internal URI index.
Closes#98
## Motivation and Context
CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.
This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.
Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.
Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.
## How Has This Been Tested?
Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.
Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.
Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.
The full test suite and RuboCop pass.
## Breaking Changes
None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
## Motivation and Context
The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key,
at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs
a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream.
Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments,
and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path.
A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then
returns nil against a real client.
Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for
the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and
the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API.
This change therefore documents the existing contract rather than altering behavior.
Closes#412
## How Has This Been Tested?
New regression tests in `test/mcp/server_test.rb`:
- `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level,
and string-key access on a nested value returns nil.
- A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime.
## Breaking Changes
None. This is a documentation and test change with no behavior change.
The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling.
## Additional context
Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement
that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and
can be designed separately, since it is a behavior and dependency decision rather than a documentation fix.
## Motivation and Context
This commit implements Phase 1 of 3 for #334.
The MCP specification requires an `initialize` request followed by
a `notifications/initialized` notification before any other interaction.
PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect`
to expose this handshake explicitly for the HTTP transport,
but stdio remained on its private `initialize_session` lazy-init path,
inconsistent with the Python SDK (`ClientSession.initialize()`) and
TypeScript SDK (`Client.connect(transport)`),
both of which require an explicit handshake call regardless of transport.
The implicit init path on the first `send_request` is
preserved as a non-breaking compatibility shim. Public API added on `Stdio`:
- `connect(client_info:, protocol_version:, capabilities:)` performs the
handshake, validates the negotiated `protocolVersion` against
`MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`,
caches the `InitializeResult`, and is idempotent.
- `connected?` reports handshake completion.
- `server_info` exposes the cached `InitializeResult`.
Documentation and examples are updated so new users learn the explicit pattern
from the start.
## How Has This Been Tested?
Fifteen regression tests cover: explicit handshake, `server_info` caching,
idempotence, custom keyword arguments, JSON-RPC error response, missing
or non-Hash `result`, unsupported protocol version with state rollback,
state rollback when the `notifications/initialized` write fails,
retry after a failed handshake, and the `connected?` / `server_info` lifecycle.
The pre-existing stdio tests pass without modification, verifying that
the implicit-init compatibility path still works.
## Breaking Changes
None for the typical "implicit init via `client.tools` / `client.call_tool`" usage.
Existing stdio clients keep working unchanged.
Three observable behavior changes for code that inspects transport state directly:
- `Client#connected?` for a never-initialized stdio client returns `false`
(previously fell through to a `true` default via the `respond_to?` fallback).
- `Client#server_info` for stdio returns the cached `InitializeResult`
after the handshake (previously always `nil` via the fallback).
- The implicit handshake path now validates the server's negotiated
protocol version, raising `RequestHandlerError` for unsupported versions.
Production servers that return one of the supported versions are unaffected;
this closes a pre-existing spec-conformance gap on stdio.
Per MCP spec, clients that no longer need a session SHOULD send an HTTP
DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
terminate it. Servers MAY respond with 405 Method Not Allowed when they
don't support client-initiated termination.
`MCP::Client::HTTP#close` sends the DELETE with session headers, clears
local session state regardless of outcome, and is a no-op when no
session has been established.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
Per the Streamable HTTP spec, if the server returns an Mcp-Session-Id
header during `initialize`, the client MUST include it on subsequent
requests. Capture the session ID and protocol version from the
`initialize` response and attach them automatically on subsequent
POSTs.
Per the Protocol Version Header section of the spec, the client MUST
also include `MCP-Protocol-Version` on all subsequent requests so the
server can respond based on the negotiated protocol version.
Map 404 responses to a new `SessionExpiredError` (a subclass of
`RequestHandlerError` for backward compatibility) and clear local
session state so callers can start a fresh session with a new
`initialize` request, as required by the spec.
- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Streamable HTTP spec allows servers to respond with either
`application/json` or `text/event-stream`. The client previously
rejected the latter. Add SSE parsing via the optional
`event_stream_parser` gem (users add it to their Gemfile when their
server uses SSE), dispatch on the response Content-Type, and scan the
event stream for the first JSON-RPC response message.
Document the new optional dependency in both the README's HTTP
Transport Layer section and docs/building-clients.md.
No other Streamable HTTP features (202 handling, session IDs,
`connect`, DELETE termination) are included here; those will land in
follow-up PRs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/pull/181.
The landing page at https://ruby.sdk.modelcontextprotocol.io previously only listed version links
to RubyDoc.info. Add multi-page documentation using the just-the-docs Jekyll theme with
sidebar navigation, search, and code highlighting.
Pages: Introduction, Installation, Building Servers, and Building Clients.
This PR introduces a basic set of pages. Additional content can be added incrementally in future updates.
This adds automated deployment of a versioned API documentation landing
page using GitHub Pages. The landing page links to documentation hosted
on RubyDoc.info, the de facto standard documentation site for the Ruby
ecosystem. The landing page will update to reflect new releases.
Changes
-------
1. **Documentation generation script** (see `bin/generate-gh-pages.sh`)
Copies and commits documentation files to the gh-pages branch. The
script uses git worktrees to isolate the process from the main
workspace.
The script:
- Creates the gh-pages branch as an orphan branch if it doesn't exist
- Parses semantic versions from tag names, ignoring arbitrary
prefixes (e.g., tags `1.2.3`, `v1.2.3`, and `release-1.2.3` all
create `v1.2.3`)
- Determines the latest version using semantic version sorting
- Copies static Jekyll template files from `docs/` to the gh-pages
root (for the latest version only)
- Generates `docs/_data/versions.yml` with a list of all versions
for the Jekyll templates to consume
2. **Jekyll template files** (see `docs/` directory)
- `docs/_config.yml` - Jekyll configuration
- `docs/index.md` - Landing page template that links to all versions
on RubyDoc.info based on generated `docs/_data/versions.yml`
- `docs/latest/index.html` - Redirect page template that redirects
to the latest documentation on RubyDoc.info
3. **GitHub Actions workflow** (see `.github/workflows/release.yml`)
Added a `publish_gh_pages` job that runs after the `publish_gem` job
on release events. The job invokes the generation script with the
release tag name, then pushes the updated gh-pages branch to deploy
the documentation.
4. **CI validation** (see `.github/workflows/ci.yml`)
Added a `yard` job that runs YARD validation with `--no-output`.
This ensures YARD can successfully parse the codebase (without
generating output), catching documentation issues early in CI.
5. **YARD dependencies** (see `Gemfile`)
Added `yard` and `yard-sorbet` gems as development dependencies to
support documentation validation in CI.
YARD documentation is automatically generated and hosted by
RubyDoc.info when the gem is published to RubyGems.org.
6. **Documentation links** (see `README.md` and `mcp.gemspec`)
Added links to the published API documentation on RubyDoc.info in
the README and gemspec metadata.
Documentation URL
-----------------
Custom documentation will be available at:
https://modelcontextprotocol.github.io/ruby-sdk/
RubyDoc.info documentation will be accessible at:
- https://rubydoc.info/gems/mcp (latest version)
- https://rubydoc.info/gems/mcp/1.2.3 (specific versions)
Co-Authored-By: Claude <noreply@anthropic.com>