Skip to content

Stream modern request notifications and honor the envelope logLevel per SEP-2575 - #490

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:stream_modern_request_notifications
Aug 8, 2026
Merged

Stream modern request notifications and honor the envelope logLevel per SEP-2575#490
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:stream_modern_request_notifications

Conversation

@koic

@koic koic commented Aug 7, 2026

Copy link
Copy Markdown
Member

Motivation and Context

The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange, and two delivery requirements of that model were unimplemented:

  • Notifications a handler emits while serving a modern request (progress, log messages) had nowhere to go: the modern path answered with a single JSON object, and the ephemeral per-request session degraded every notification to non-delivery. The conformance requirement expects them as frames on the request's own response stream (tools-call-with-progress fails with "No progress notifications received" at 2026-07-28).
  • The envelope's logLevel member, which replaces the removed logging/setLevel RPC, was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement that no notifications/message is sent for requests that did not set _meta logLevel.

The changes wire both up:

  • StreamableHTTPTransport#handle_modern registers a per-request notification sink for its ephemeral session; send_notification routes into the sink ahead of the stateless guard. A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response; a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
  • Server#handle_request applies the lifted envelope's logLevel to the session via the existing configure_logging, and on modern-era sessions ServerSession#notify_log_message no longer falls back to the server-wide level: without a per-request logLevel it stays silent, on every transport. An unrecognized level reads as absent, the safe direction, matching the Python SDK's allowed_log_levels.

One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs: delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved and the conformance checks are satisfied, but long-running handlers get no real-time interleaving. The sink is bounded (MAX_MODERN_REQUEST_NOTIFICATIONS, 1000): a handler emitting notifications in proportion to client-supplied input cannot grow one request's memory without bound, and past the cap the notify helpers report false, the same non-delivery degradation they have on every other undeliverable path. Live streaming can follow without changing the wire shape.

How Has This Been Tested?

bundle exec rake test passes with zero failures and RuboCop reports no offenses. New tests cover: a modern tools/call streaming notifications/progress frames ahead of the result, the single JSON exchange staying in place without notifications, log suppression without the envelope logLevel, log delivery with it, the modern-era session ignoring the server-wide level while honoring a per-request one, and the notification cap bounding one request's buffered frames while the notify helpers report false past it.

Against the conformance fixture server, the tools-call-with-progress progress check passes at --spec-version 2026-07-28 (its remaining wire-schema check belongs to the separate resultType change), and the --requirements 2025-11-25 server leg passes 78/78, unchanged.

Breaking Changes

None for stable protocol versions: legacy requests and their notification delivery are unchanged. Within the modern lifecycle, notifications become deliverable where they were previously dropped, and log messages without a per-request logLevel stop leaking through the server-wide level, as the specification requires.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic
koic force-pushed the stream_modern_request_notifications branch from a3d7981 to 1addd06 Compare August 7, 2026 20:24
atesgoral
atesgoral previously approved these changes Aug 7, 2026
… per SEP-2575

## Motivation and Context

The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange,
and two delivery requirements of that model were unimplemented:

- Notifications a handler emits while serving a modern request (progress, log messages)
  had nowhere to go: the modern path answered with a single JSON object,
  and the ephemeral per-request session degraded every notification to non-delivery.
  The conformance requirement expects them as frames on the request's own response stream
  (`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28).
- The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC,
  was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement
  that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`.

The changes wire both up:

- `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session;
  `send_notification` routes into the sink ahead of the stateless guard.
  A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response;
  a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
- `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`,
  and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level:
  without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent,
  the safe direction, matching the Python SDK's `allowed_log_levels`.

The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline
rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as
not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation`
and requires that every frame on the request's response stream is a notification or the final response,
and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel`
and requires that no `notifications/message` reaches the stream.

The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes;
`test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate,
which drops the notification for modern requests that did not authorize logging through
`io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds:
with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame
ahead of the final response rather than being dropped.

One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs:
delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved
and the conformance checks are satisfied, but long-running handlers get no real-time interleaving.
The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications
in proportion to client-supplied input cannot grow one request's memory without bound,
and past the cap the notify helpers report `false`, the same non-delivery degradation
they have on every other undeliverable path. Live streaming can follow without changing the wire shape.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover:
a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying
in place without notifications, log suppression without the envelope `logLevel`, log delivery with it,
the modern-era session ignoring the server-wide level while honoring a per-request one,
and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it.

Against the conformance fixture server, the `tools-call-with-progress` progress check passes
at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change),
the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and
`sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive
directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`,
`test_logging_tool` streams its `notifications/message` frame ahead of the result, and
with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame.
The `--requirements 2025-11-25` server leg passes 78/78, unchanged.

## Breaking Changes

None for stable protocol versions: legacy requests and their notification delivery are unchanged.
Within the modern lifecycle, notifications become deliverable where they were previously dropped,
and log messages without a per-request `logLevel` stop leaking through the server-wide level,
as the specification requires.
@koic
koic force-pushed the stream_modern_request_notifications branch from 1addd06 to da2a7f8 Compare August 8, 2026 06:24
@koic
koic merged commit 02aed75 into modelcontextprotocol:main Aug 8, 2026
11 checks passed
@koic
koic deleted the stream_modern_request_notifications branch August 8, 2026 06:25
koic added a commit that referenced this pull request Aug 8, 2026
## Motivation and Context

#490 and #492 merged independently green but broke each other on main: #492 made the modern path reject
a name-bearing POST whose body carries a target name without `Mcp-Name` (-32020, HTTP 400)
and taught the `modern_rack_request` helper to derive `Mcp-Method` from the body, while #490,
merged in between, added five modern `tools/call` tests that build their requests through that helper
and therefore send no `Mcp-Name`. Since the #492 merge every CI run on main fails those five tests
with 400 where 200 is expected; the library behavior itself is correct.

The helper now derives `Mcp-Name` the same way it derives `Mcp-Method`: from `params.name` or
`params.uri` when the body's method is one of the name-bearing three. That is what a conforming client sends per
the 2026-07-28 specification, and what the TypeScript SDK's client, the bundled `MCP::Client::HTTP`,
and the conformance harness's standard headers all do, so tests built through
the helper model a compliant client by default. `name_header: nil` omits the header for tests of
the requirement itself, symmetric with `method_header: nil`, and an explicit `headers:` entry still overrides
the derived value, which keeps the base64 mismatch tests exercising their divergent names.

## How Has This Been Tested?

The five failing tests pass again without being touched, and the #492 requirement tests
(missing `Mcp-Method`, missing `Mcp-Name`, header mismatches, base64 decoding) still pass:
the transport test file reports 217 runs with zero failures. `bundle exec rake` is green,
including RuboCop and the `--requirements 2025-11-25` conformance baseline.

## Breaking Changes

None. The change is confined to a test helper; the shipped gem is untouched.
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.

2 participants