Skip to content

MCP 2026-07-28: deprecation notices and documentation - #234

Open
simonx1 wants to merge 26 commits into
mcp-2026/json-schemafrom
mcp-2026/deprecations-docs
Open

MCP 2026-07-28: deprecation notices and documentation#234
simonx1 wants to merge 26 commits into
mcp-2026/json-schemafrom
mcp-2026/deprecations-docs

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Eleventh (final) PR of the MCP 2026-07-28 series, stacked on #233.

What

MCP 2026-07-28 adopts a feature lifecycle policy and places six features in the Deprecated state (changelog "Deprecated" 1–4: SEP-2577, SEP-2596, PR #2858). They remain fully functional during the deprecation window; new integrations should not adopt them.

  • MCPClient::DeprecationsREGISTRY names every deprecated feature with its reference and suggested migration; Deprecations.warn(feature, logger, detail:) logs one notice per feature per process on first use (peer-supplied detail is sanitized and bounded); Deprecations.enabled = false silences the notices; reset! / emitted? for tests.
  • Wired in: Roots (roots: at construction and Client#roots=), Sampling (sampling_handler:), Logging (Client#log_level=), the HTTP+SSE transport (ServerSSE.new), and a sampling request carrying includeContext: "thisServer" | "allServers" (still served, per SEP-2596). Dynamic Client Registration already warned in MCP 2026-07-28: authorization (RFC 9207 iss validation, application_type, AS binding) #232 and is listed in the registry.
  • README — the "MCP Protocol Support" section now describes the dual-era 2026-07-28 client, a new "MCP 2026-07-28 Features" section documents discovery and per-request metadata, multi round-trip requests, x-mcp-header, subscriptions/listen, cacheable results, the tasks extension and the authorization changes, and a "Deprecated features" table gives each migration. Roots / Sampling / Logging subsections carry a deprecation note.
  • CHANGELOG gains the Deprecations section.

Behaviour is otherwise unchanged; the notices are disabled for the test suite except in the deprecations spec.

Tests

spec/lib/mcp_client/deprecations_2026_spec.rb (12 examples): registry completeness, one notice per feature per process, silence without the feature, each wiring point, includeContext detail (served, none/absent silent), the disabled switch, unknown feature rejection, log-line forging. Full suite: 2263 examples, 0 failures; rubocop clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from c4bc3de to 8302d6c Compare September 1, 2026 23:18
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch 2 times, most recently from ff00d1c to 7c035c2 Compare September 1, 2026 23:30
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from c66edee to 868c566 Compare September 1, 2026 23:52
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 610e4d5 to 0194107 Compare September 1, 2026 23:55
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 647d7f3 to 2592218 Compare September 2, 2026 00:24
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 93a3410 to 7e0ed7e Compare September 2, 2026 00:25
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch 2 times, most recently from da6872c to 47c020c Compare September 2, 2026 05:09
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 7e0ed7e to 1788a52 Compare September 2, 2026 05:11
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 7ff45a0 to e5f54ed Compare September 2, 2026 07:28
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 1788a52 to 452193d Compare September 2, 2026 07:31
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from f6de20d to 1f3343d Compare September 2, 2026 17:02
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 5c5663e to 2678302 Compare September 2, 2026 17:07
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 1f3343d to c0e5832 Compare September 2, 2026 18:10
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 3f5ee6f to 48481b0 Compare September 2, 2026 18:12
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from c0e5832 to 5c2ef8d Compare September 2, 2026 19:10
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 48481b0 to 8414792 Compare September 2, 2026 19:14
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 5c2ef8d to d24d10f Compare September 2, 2026 19:56
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 8414792 to 9d3e767 Compare September 2, 2026 19:59
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from d24d10f to 20afd3d Compare September 2, 2026 20:38
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 9d3e767 to acc48a7 Compare September 2, 2026 20:40
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 20afd3d to 095de23 Compare September 2, 2026 21:22
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from acc48a7 to 36d5582 Compare September 2, 2026 21:25
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 095de23 to 0e85781 Compare September 2, 2026 22:04
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 36d5582 to 40e2988 Compare September 2, 2026 22:06
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 0e85781 to 9a02391 Compare September 2, 2026 23:07
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 40e2988 to a81ba60 Compare September 2, 2026 23:09
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 9a02391 to 6e1c2e5 Compare September 2, 2026 23:56
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 9127c10 to 9893fa4 Compare September 6, 2026 15:42
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from 9178a10 to fa89ee4 Compare September 6, 2026 15:42
simonx1 and others added 26 commits September 6, 2026 19:00
…support

MCP 2026-07-28 places Roots, Sampling, Logging, the HTTP+SSE transport,
the includeContext values thisServer/allServers and Dynamic Client
Registration in the Deprecated state of its feature lifecycle policy
(SEP-2577, SEP-2596, PR #2858). They keep working; MCPClient::Deprecations
now names them (REGISTRY) and logs one notice per feature per process on
first use with the suggested migration, silenced with
Deprecations.enabled = false. README documents the 2026-07-28 features
(discovery and per-request metadata, multi round-trip requests,
x-mcp-header, subscriptions, cacheable results, the tasks extension,
authorization) and the deprecations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…stry

- client.rb, server_sse.rb and oauth_provider.rb require the deprecations
  module themselves, so `require 'mcp_client/client'` keeps working with
  the deprecated options.
- The Dynamic Client Registration notice goes through
  MCPClient::Deprecations too: once per process, silenced with the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…nnect, logging entry points

- The registry records when each feature entered the Deprecated state
  (HTTP+SSE since 2025-03-26, includeContext values since 2025-11-25) and
  the docs no longer promise twelve months for those two.
- The HTTP+SSE notice is logged once the transport is connected, so
  MCPClient.connect probing a URL that ends up elsewhere does not spend it.
- Logging warns on every entry point: log_level= on a client or a server
  and an incoming notifications/message.
- A logger that drops warnings or raises does not consume the notice; the
  Unicode line separators are escaped in quoted detail.
- Deprecated APIs carry YARD @deprecated tags; README names the prefixed
  _meta keys and where protocol:/discover_timeout: live; OAUTH.md and the
  README OAuth summary treat Dynamic Client Registration as the deprecated
  fallback and show the iss parameter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ock-free logging

- A logger that raises is swallowed by Deprecations.warn (the slot is
  released for a later use) so the deprecated feature keeps working: an
  established HTTP+SSE connection is not torn down, includeContext
  requests are still served, log_level= still runs.
- The slot is reserved under the lock and the logger is called outside
  it, so a logger that consults the registry cannot deadlock and slow
  log I/O does not stall other threads.
- Only a real Logger with a level above WARN skips the notice (no warn?
  call on strict doubles).
- Each transport's log_level= carries a YARD @deprecated tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A Logger subclass whose `level` accessor raises aborted the deprecated
operation itself, because the level probe ran before the protected path.
Ask through `accepts_warnings?`, which rescues and declines the notice
without spending its once-per-process slot, so no logger failure ever
reaches the feature it guards.

Record each feature's `earliest_removal` in the registry and stop the
module comment and the README from lumping the includeContext values in
with the HTTP+SSE transport: only the transport has the short clock
(three months after SEP-2596 is Final), while SEP-2596's transition
provision ties includeContext to Sampling's twelve-month window. The
README table now carries the registry's strings verbatim and a spec pins
them, so the prose cannot drift from the registry again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ces, fork safety

The earliest removal of each deprecated feature now carries the
2026-07-28 deprecated-features registry's own "Earliest removal" wording
instead of a paraphrase of the lifecycle policy floor: Roots, Sampling,
Logging and Dynamic Client Registration wait for "the first revision
released on or after 2027-07-28" (a revision, not a date a host can plan
around), the includeContext values "follow Sampling (SEP-2577)", and only
the HTTP+SSE transport has a clock of its own ("three months after
SEP-2596 reaches Final"). The runtime notice interpolates it, and every
YARD @deprecated tag names it beside the deprecation SEP, which is what
the feature lifecycle policy's tier-1 SDK obligation asks of an SDK.

The notices also had to move off MCPClient::Client. on_roots_list_request,
on_sampling_request and on_notification are public transport APIs: a host
can negotiate and serve Roots, Sampling and Logging without ever
constructing a Client, and the constructor- and Client-only notices never
fired for it. A new MCPClient::DeprecationNotices mixin, included in
JsonRpcCommon so every transport has it, warns where the callback dispatch
actually invokes the handler -- in each transport's handle_roots_list and
handle_sampling_create_message (with the includeContext values that ride
along on the request), in the multi round-trip input-request path that
reaches the same callbacks, and in SubscriptionSupport#route_notification
for notifications/message, so a bare on_notification callback is covered
too.

Two more holes in the once-only bookkeeping. ServerSSE#connect left the
notice unspent when the first connection found notices disabled or a
logger that drops or raises on warnings, but returned at the
already-connected fast path afterwards, so a long-lived connection never
retried it; that branch now gets its own chance. And Deprecations tracks
the PID that owns @emitted, so a prefork deployment (Puma, Unicorn) that
warned while preloading no longer hands every worker a spent set and
silences the worker's own first use.

The round 5 README pin iterated the registry against the whole section, so
one shared string satisfied four features; it now resolves each feature's
own table row first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…I windows

Round 6 moved the Roots notice into the transports, where it fired for any
host serving a roots/list request. MCPClient::Client registers a roots/list
handler on every server unconditionally (so a later `roots=` is served
without reconnecting) and answers with an empty list until a root is set, so
every default Client that answered a roots/list request was told it was
using a deprecated feature it had never opted into.

The Roots notice now follows use of the feature, not the presence of a
handler: `warn_roots_deprecated` takes the answer about to be served and
warns only when it carries a root. The three transports that serve
server-initiated roots/list requests raise it after the handler returns, and
the multi round-trip path splits its notice in two — Sampling before the
handler (asking is the use), Roots after it. A configured `roots:`, a
`Client#roots=` call and a non-empty answer all warn; the default empty
answer warns nothing. README states the trigger it actually has.

The deprecated keyword arguments now name the earliest removal next to
their SEP, as every `@deprecated` tag already did: `Client.new`'s `roots:`
and `sampling_handler:`, and `MCPClient.connect`'s `sampling_handler`
option, which carried no deprecation at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
`roots=` tells every connected server the list changed, so the example
built around a `true` command spent the full fifteen-second discovery
timeout waiting for a process that never answers — fifteen seconds added
to every full run. Stubbing the notification keeps what the example
asserts (the notice fires on the call itself) and takes the file from
15.01s to 0.02s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ck tags

SEP-2596 puts the HTTP+SSE transport on the only short clock in the
deprecated features registry — three months after SEP-2596 reaches Final —
and it is the transport a new integration is most likely to reach for
first. Mark it wherever it is presented as an ordinary choice, consistently
with the deprecated-features table and the ServerSSE class tag: the README
Overview bullet, the Quick Connect example (which now leads with Streamable
HTTP) and the transport-detection table, and in YARD the /sse target bullet
and the transport: option of MCPClient.connect, its SSE @example, and
MCPClient.sse_config, which gains a @deprecated tag pointing at
MCPClient.streamable_http_config.

Tag on_roots_list_request and on_sampling_request on ServerStdio,
ServerSSE, ServerHTTP and ServerStreamableHTTP @deprecated with SEP-2577
and the earliest removal, so a host that drives a transport directly meets
the deprecation in the YARD rather than in the first runtime notice. Round
7 made a registered roots/list handler deliberately silent at runtime, so
the Roots tag is the only mark that handler gets: it says registering is
not itself use and that an answer carrying a root is.

The round 7 doc-block sweep is extended to the eight transport callbacks
(same SEP-and-window assertions), and the round 6 tag sweep's floor is
raised to the nine new @deprecated tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A deprecation notice was marked spent the moment a caller claimed the
right to log it, so two first uses racing with different loggers could
lose it outright: the claiming caller blocked inside a broken logger, the
contender holding a working one saw the slot taken and stood down, and
the claim's later failure left the notice owed to a use that might never
come. A feature is now :pending while a caller is inside logger.warn and
:emitted only once one succeeded, so a contender waits for the outcome
and takes the notice over when the reservation fails. The wait is bounded
by PENDING_WAIT_SECONDS, because a notice must never hold up the
deprecated operation: a logger that blocks forever costs the contender
the notice, not its progress. Deprecations.emitted? now reports notices
that actually went out, never one still in flight.

Round 8's HTTP+SSE sweep left four places offering the transport as an
ordinary choice: the README's Advanced Configuration sse_config example
and the auto-detect fallback row, and, in MCPClient.connect's YARD, the
"Other HTTP URLs" fallback text and the multiple-server @example, which
no longer reaches for an /sse URL at all.

The README also offered protocol: and discover_timeout: on
MCPClient.connect without qualification, but MCPClient::ServerSSE accepts
neither and extract_sse_options drops both. HTTP+SSE has no era of its
own and is on the registry's shortest clock, so the documentation now
matches the code rather than the code growing era support for a transport
on its way out. And the deprecated-features table's Sampling row names
the transport-level on_sampling_request trigger round 8 tagged, so the
table that claims to list the first-use notices is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Round 9's reservation states existed to serve one promise: that a
deprecation notice never holds up the deprecated operation. The promise
cannot be kept here, and the machinery did not keep it.

PENDING_WAIT_SECONDS bounded the CONTENDERS, never the caller actually
stuck in the logger. A first use whose logger blocks forever (a
synchronous remote logger, stalled I/O) never returned from `warn` at
all, so the client construction, SSE connection or incoming request that
triggered it never completed either — exactly what the promise said could
not happen. And the bounded wait bought the contenders nothing: a
reservation that hangs is never released, so every later first use of
Sampling, Logging or the HTTP+SSE transport — features whose trigger
points are hit repeatedly — waited the timeout out again and still came
away without the notice.

The alternatives that would keep the stronger promise are both worse.
Emitting on a detached thread stops blocking anything, but loses the
ordering between the notice and the operation it describes and needs a
thread per stalled notice. `Timeout.timeout` around a host's logger
raises inside arbitrary third-party code — during its own IO, ensure
blocks or internal locking — which is how a logger gets corrupted, not
how a caller gets protected.

So the promise is narrowed to the one the rest of the library already
makes. Every other `logger.warn` in this codebase blocks its caller
exactly the same way; a logger that blocks forever blocks the library
everywhere, not only in this path, and claiming an exemption here was
claiming more for a notice than for any other warning. A notice costs its
caller what one `logger.warn` costs it, and no more.

The reservation states, the condition variable and the timeout go with
the claim. An emission attempt is serialized on a gate held only for the
feature being logged: nothing is timed, a caller that meets a notice in
flight either finds it emitted (and stands down) or takes it over when
that attempt failed, and no caller comes away from a wait with the notice
neither emitted nor its own to write. What earlier rounds established
stands — one notice per feature per process, a logger that raises or
drops warnings neither breaks the deprecated operation nor spends the
notice, the logger called outside the module's lock so a notice in flight
never delays another feature's notice, `emitted?` or `reset!`, and the
bookkeeping (gates included, whose Mutexes a fork could leave locked)
rebuilt when the owning PID no longer matches.

Round 9's example that pinned the bounded wait now pins what replaced it:
a notice never holds up anything but the feature it is logging. New
round 10 examples cover the third caller the review asked for — a second
and a third first use queued behind a stuck logger both come away with
the notice emitted, once, whichever way the stuck one ends — plus the
narrowed guarantee in the source and the README.

Also: the `ServerHTTP` elicitation note offered `ServerSSE` as an
ordinary alternative with no deprecation mark. It now leads with
`ServerStreamableHTTP` and marks the HTTP+SSE entry with SEP-2596 and its
earliest removal, like the README, `MCPClient.connect` and `sse_config`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Round 10's per-feature emission gate is held across `logger.warn`, which
is host code that may itself reach a deprecated feature (a formatter, a
log subscriber). A thread already inside a notice that then queued for a
gate could wait forever: for its own gate, when the callback warns for the
feature being logged, or — through a logger that serializes its writes,
as ::Logger does — for a second feature's gate held by a thread waiting on
that very logger. Both threads hang, and the sampling request, log level
or SSE `connect` behind the notice never returns. A thread inside a notice
now never queues: the nested attempt stands down and leaves that notice
owed to a later use, exactly as a dropped one is. Round 10's contract
survives: one notice per feature per process, and a plain contender still
waits for another thread's emission and takes it over when it fails.

`Logger.new(nil)` is a supported no-output logger whose level sits below
WARN, so the level probe passed it and its `warn` returned successfully
having written nothing — spending a notice nobody could read and silencing
every later use. A logger with no device is now treated like one that drops
warnings: the notice stays owed.

MCP 2026-07-28 moved the log level onto every request, so `log_level=` and
an incoming `notifications/message` are not the only ways into the
deprecated Logging utility: a host that puts
`io.modelcontextprotocol/logLevel` in `request_meta` or a per-call `_meta`
adopts it too, and `with_request_meta` forwards the key on the wire. That
now raises the Logging notice on first use, on the modern path and on the
legacy one that passes a supplied `_meta` through untouched.

Docs: the README's Protocol Support elicitation bullet no longer offers SSE
as an unmarked elicitation transport, and `MCPClient.connect`'s
`transport: :sse` entry names the earliest removal beside SEP-2596.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
MCP 2026-07-28 (changelog, minor change 11) removes the
notifications/elicitation/complete notification and the elicitationId
field of URL-mode elicitation/create requests, both introduced in
2025-11-25. Under the multi round-trip requests pattern the client learns
the outcome by retrying the original request, so a server-initiated
completion signal and its correlation id no longer fit the protocol; a
server that must correlate an elicitation across retries carries its own
identifier in the opaque requestState.

Client#handle_url_elicitation read params['elicitationId'] unconditionally
and passed it to the host's elicitation handler inside the URL-mode
metadata hash, for a 2026-07-28 server as well as a 2025-11-25 one. The
metadata now follows the asking server's protocol era: a modern server's
hash has no elicitationId key at all — not even a nil one — so a
non-conforming modern server cannot smuggle a correlation id to the host
through it, and one warning names the field (never its value). On a
2025-11-25 server, and when the era was never established, the contract is
unchanged, key present (nil when the server sent none) and all.

The transports call the elicitation callback with (request_id, params)
only, so MCPClient::Client now closes over the asking server when it
registers the callback; the multi round-trip path reaches the same
callback and is covered too. handle_elicitation_request keeps its
two-argument form.

This library never implemented notifications/elicitation/complete, so
nothing there had to be removed.

Also pins the other removed field a server could still push at the client:
a modern server that sends Mcp-Session-Id back anyway has it discarded
(the only capture point is the initialize response, which a modern server
is never sent), never echoed on a later request and never terminated with
a DELETE. That held already but nothing exercised it — no stub fed a
modern response carrying the header. logging/setLevel,
resources/subscribe, resources/unsubscribe, tasks/list and SSE
resumability (Last-Event-ID and event ids, including the id: retention on
a modern POST stream) were audited in both directions and are sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Dropping `elicitationId` for a modern server is the protocol decision; the
warning that says so is a courtesy. It went out through a bare
`@logger.warn` inside the elicitation's rescue, so a logger that raises —
or a strict double in a host's own suite — turned a still-valid URL
elicitation into a -32603 InputRequiredError and the handler never ran.

The notice is now isolated the way MCPClient::Deprecations.warn already
isolates its own, and the field is dropped whether or not the notice was
written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Round 11 kept a thread already inside a notice from queueing for an
emission gate. The thread holding the logger's lock need not be inside a
notice at all, though: an ordinary `logger.info` holds ::Logger's device
lock for the length of the write, and the device — a formatter, a log
subscriber, an audit hook — may itself reach a deprecated feature. It
then queued for the gate of a second thread that was waiting for the very
device lock it held, and neither moved again; the `emitting?` guard could
not see it, because that thread was doing ordinary logging.

No rule about who may queue fixes it, since a waiter cannot know what it
is holding, so the gates are gone. The once-per-process accounting is an
atomic claim, taken and released under a mutex this module never holds
while calling out, and a caller that meets a notice in flight stands down
instead of queueing behind it. Round 9's takeover goes with the wait — a
contender no longer writes the notice a failed emission owed — and what
survives is what mattered: one notice per feature per process, and a
notice that was not written is not spent, so the next use raises it.

That also closes the second hole. `Logger#warn` returns true whether it
wrote the line or dropped it for its level, and a contender that waited
carried a level probe taken before the wait: with the level raised to
ERROR in between, its warning was filtered, the process's one notice was
marked emitted having been written nowhere, and lowering the level again
did not bring it back. The probe now sits next to the write.

Coverage. The Roots and Sampling hooks on both SSE transports and the
Sampling hook on the multi round-trip path could each be deleted with the
focused suite still green; they now have examples that route a request
through the transport's own dispatch and assert both the notice and the
complete response, and an MRTR exchange that asserts the Sampling notice,
both deprecated includeContext values and the retry that carries the
answer — all with notices enabled, which the suite otherwise disables.

Marking. Counting @deprecated tags cannot show that a given API carries
one, and accepting any registry window cannot show that a tag names its
own; an explicit API-to-feature inventory replaces both, and named three
unmarked surfaces: MCPClient::Root, the Client#roots reader and
declare_sampling_tools. Every tag in the library is now accounted for by
that inventory. Three weak examples are strengthened too: the DCR
disable check resets the notice first (it was silent either way), the
sanitization example asserts the emission and the escaped detail, and the
URL-mode elicitation example asserts the handler's own answer rather than
only that the handler ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The reentrancy guard now covers the level probe, not only the write.
`warn` asked `accepts_warnings?` — and so `logger.level` — before
`emit_once` put the guard up, so a host whose `level` accessor reaches a
deprecated feature recursed until the stack ended. SystemStackError is
not a StandardError, so it escaped every rescue meant to keep the feature
working: `client.roots = [...]` raised and left the roots unset. The mark
goes up in `warn` instead, before the first word to the host, and comes
down in its ensure; `emit_once` no longer sets it. It is still a
thread-local mark, never a lock, so nothing waits and no lock of this
module is held across host code.

A closed standard logger no longer eats the notice. `Logger::LogDevice`
rescues a write to a closed IO and reports it through `Kernel#warn`, so
`Logger#warn` returns exactly as it does after a real write; the notice
was marked emitted although nothing was written, and a later use holding
a working logger wrote nothing. Absence of a device was the only case
asked about, so ask about a closed one too — the notice stays owed.

`Client#roots` carries its deprecation in the generated API docs again.
The tag sat inside an `@!attribute` directive above a combined
`attr_reader`, and YARD drops the docstring of the LAST directive in such
a block, re-registering that name from the statement with the leftover
empty docstring. YARD published `Client#roots` with no tags at all while
every source-text check passed. The readers are declared one per line
with ordinary doc comments, which parse — for all six, not just roots.

Coverage for the three mutations that survived the previous suite:
- the registry's name, revision and SEP are pinned per feature, and
  against the notice text (Sampling's date could be changed freely);
- a modern and a legacy server on one client, both elicitation callbacks
  invoked in both orders (using the first server's era passed before);
- the symbol-keyed roots answer and the legacy `_meta` symbol log-level
  key, which is the only path that reaches that branch — the modern one
  stringifies its keys first.

Also: the deprecated APIs are now checked through YARD's parsed objects
and their tags rather than nearby source text, and the "keeps serving
Logging when the logger fails" example drives a server of each era
instead of a client with none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The branch below taught plain HTTP to answer a legacy server's request on
a response stream instead of leaving it waiting, and reworded this note
to say so; this branch reordered the same list to offer Streamable HTTP
before the transport on its way out, and marked HTTP+SSE deprecated. The
rebase kept both lists. There is one now: the accurate prose, and the
ordered list with the deprecation on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Three things this round's reviews found, and the coverage that lets each
of them be found only by reading.

A logger the host wrapped could spend a notice it never wrote. The level
probe asked `logger.level`, which only a bare ::Logger answers, and a host's
logger is rarely one: a SimpleDelegator around an ERROR-level Logger
forwarded `warn`, returned as if it had written, and marked the process's one
notice emitted having printed nothing — silencing the host's own working
logger for good. The probe now asks `warn?`, the form a wrapper forwards, of
any logger that offers it, and looks through Delegator wrappers for the
missing device `Logger.new(nil)` has. A logger implementing `warn` and
nothing else is still taken at its word (a strict double answers no `warn?`,
and that stays pinned), and asking still cannot fail the deprecated
operation: a predicate that raises leaves the notice owed, as a raising
`warn` does. A wrapper that filters on something a level cannot express — a
tag, a source allow-list — remains beyond reach, and the README and the
method say so.

The Logging notice did not fire on a transport-direct HTTP+SSE session. It
was raised in the routing every other transport shares, but ServerSSE
carries no subscriptions/listen stream and parses its own notifications, so
the one caller these transport-level notices exist for — a host driving a
ServerSSE with `on_notification` — received log messages and was never told.

The elicitation section still gave the 2025-11-25 URL-mode hash as
universal, so a host reading it looked for `metadata['elicitationId']` on a
2026-07-28 server, found nothing, and correlated against a completion
notification the revision removed as well. Both places the README shows that
hash now split the two revisions.

Coverage, each confirmed against the mutation that motivated it:

- `includeContext` "still served" is now read off the params the handler was
  given, on the transport path and on the Client's five-argument handler;
  deleting the key after the notice reddens both.
- The 2025-11-25 elicitationId now goes through a real ServerStdio's
  `handle_server_request`, so a strip inside the transport reddens; the same
  examples negotiate the era AFTER the client registered its callback, and
  back again, so capturing `modern?` at registration reddens too.
- The migration text is pinned to what the registry column tells a host to
  use instead, not to the production table's agreement with itself; blanking
  a row reddens.
- DCR stays silent for pre-registered credentials and for a Client ID
  Metadata Document client, and a registration failure survives a notice
  whose logger raises. Hanging the notice on `get_or_register_client` instead
  of `register_client` reddens the first two.
- Notice-enabled failure paths on both HTTP transports: a sampling handler
  that raises spends the notice and answers the peer a constant error, a
  missing handler spends nothing, a roots handler that raises spends nothing,
  and sampling keeps being served after the notice is gone.
- `roots=` still sends `notifications/roots/list_changed` to a legacy server
  and still spares a modern one, with notices ON — the rest of the suite runs
  with them off, so nothing else could catch a `roots=` that warned and
  returned. A Client's configured roots still come back through the modern
  MRTR path.
- A fork while a claim is in flight, and the reentrancy guard across the
  fibers of one thread: the two bookkeeping edges the module documents and
  nothing drove. A fiber-local guard, or a child inheriting the claim, redden
  only these.
- Round 9's contended-claim example waited on a 50ms sleep and could fail for
  being right; it now joins the contender while the claim is provably held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The four private methods that hook a transport's notifications up and
act on them -- dropping caches, running the host's listeners -- are one
unit, and moving them keeps Client under its length cop the same way the
listing and task mixins already do. Nothing about them changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ers behind the notices (round 13)

Codex round 4 and grok round 4 on PR11 found that every example on the
deprecation paths pinned the notice and stopped there: a transport that
logged the notice and dropped the peer's response, or a hook that deleted
includeContext after reporting it, passed all 211 of them. Round 13 pins:

- the stdio transport's sampling response envelope (result, handler error
  and Internal error) under the request id, params intact, and again once
  the notice is spent;
- includeContext thisServer / allServers / none / absent reaching the
  sampling handler unchanged on the multi round-trip path;
- multi round-trip failures with notices enabled (handler raises, answers
  an error, returns a non-object; no handler; undeclared sampling.tools)
  as InputRequiredError with no continuation request;
- a complete discovery -> input_required -> continuation exchange for a
  modern URL elicitation (host metadata without elicitationId, echoed
  requestState, inputResponses) beside the legacy response envelope;
- notifications/elicitation/complete ignored on either era;
- the strict-Logger example asserting the notice was written.

Grok's one behavioural finding: on the multi round-trip path the
undeclared sampling.tools refusal fired before the Sampling and
includeContext notices, while the 2025-11-25 server-initiated path warns
before its handler rejects the same payload. The notice now precedes the
refusal on both eras.

The fork examples in rounds 6 and 12 wait with a bound, so a bookkeeping
regression fails them instead of wedging the suite; the round 9
contender runs on the example's own thread while the reservation is held
instead of hoping a 50ms sleep scheduled it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…out a handler, pin the deprecated operations

Review round 5 (codex, grok) on the deprecations branch.

A 2025-11-25 sampling/createMessage carrying tools or toolChoice is now
refused by the transport itself with -32602 when sampling.tools was never
declared (SEP-1577: the client MUST return an error), on the server-initiated
path as on the multi round-trip one and after the Sampling/includeContext
notices a served-or-refused request owes; a host driving a transport directly
no longer depends on its own callback for the check. A request reaching a
transport with no sampling handler is answered with -32601 rather than -1,
which sampling.mdx reserves for a user rejection.

The deprecation notices are pinned as side effects of the operations rather
than substitutes for them: the level a notice-enabled client sets reaches the
legacy server as logging/setLevel and the modern one on its next request, on
the first call and after the notice is spent, and through a logger that
writes nothing; deprecated capabilities stay advertised on the first modern
request, on the continuation and on the 2025-11-25 handshake; the removed
completion notification arriving during an unfinished modern interaction is
handed to a generic listener and finishes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The stateless stdio branch folds a `_meta` written under the Symbol key
into the one String-keyed member in every era, so the deprecation notice
for a symbol-keyed legacy `_meta` now reads the merged member, and the
example pins that the request carries exactly one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The line logged when a tool-enabled sampling/createMessage is refused for an
undeclared sampling.tools is written guarded, so a logger that fails on it
leaves the SEP-1577 refusal (-32602) on the wire instead of the dispatcher's
-32603. The stdio transport's no-handler answer (-32601, no notice spent)
and its service of a tool-enabled request once sampling.tools was declared
are pinned transport-direct; the completion-notification example observes
through the Client's own listener; the roots examples assert the answer that
reached the peer. The retry guarantee's boundary is named and pinned: a
standard Logger over an open device that fails to write reports it on
$stderr only, so that notice is spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The tasks branch pins that a URL-mode elicitation carrying elicitationId
hands the field to the host; the session that example builds is 2026-07-28,
and this branch removed the field from the modern contract so a server
cannot smuggle a correlation id through it. The example now pins that
contract, and the 2025-11-25 one stays pinned in this branch's own specs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A notice is a side effect of a deprecated operation, never a substitute for
it, and four examples asserted only the notice:

- The HTTP transports carry their own log_level=, and replacing any of them
  with a notice-only no-op left the whole deprecation suite green. Each is
  now driven on both eras: a modern session stores the level and every later
  request carries it in _meta, a legacy session sends logging/setLevel, and
  an undefined level is refused before anything goes out. HTTP+SSE, which
  negotiates no modern revision, is pinned on the request path.
- The per-call _meta examples discarded the parameters they built, so they
  would have passed if the level vanished with the warning that announced
  it; they now read the level back, and the notification example reads it
  off the notification.
- The modern Roots answer was taken from fulfil_input_requests directly.
  A scripted stdio exchange now drives discovery, the roots input request
  and the continuation, pinning that the answer leaves the client under the
  key the server named with its opaque state echoed.
- The detail bound read MAX_DETAIL_LENGTH, so it agreed with whatever the
  constant said. It spells the documented limit out, pins the 200/201
  boundary, and pins that the bound counts escaped characters.

Clearing the roots list is use of Roots — the README says every roots= call
raises the notice — so that example pins it instead of commenting the
opposite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 9893fa4 to ae786ae Compare September 6, 2026 17:04
@simonx1
simonx1 force-pushed the mcp-2026/deprecations-docs branch from fa89ee4 to e440c4d Compare September 6, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant