Skip to content

MCP 2026-07-28: resources, prompts, subscriptions/listen and stdio - #1665

Merged
jlouvel merged 5 commits into
3.0from
feat/mcp-2026-07-28-stdio-listen
Nov 9, 2026
Merged

jlouvel merged 5 commits into
3.0from
feat/mcp-2026-07-28-stdio-listen

Conversation

@jlouvel

@jlouvel jlouvel commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Second R6b delivery for #1640, after #1664. It completes the MCP 2026-07-28 server side, apart from the client: modern resources and prompts, subscriptions/listen, and 2026-07-28 over stdio.

Modern resources and prompts.

  • McpServerAdapter.getModernResources() and getModernResourceTemplates() add resources served to 2026-07-28 clients only. Each item uses a small new record, McpModernResource or McpModernResourceTemplate, pairing the SDK's modern definition with its McpAsyncResource read handler, which may answer input_required.
  • getModernPrompts() takes the SDK's McpAsyncPrompt.
  • These are merged with the bridged 2025-11-25 lists. Exact URIs are matched first, then templates.
  • A URI, URI template or prompt name used twice across both lists fails start(), as tool names already did.

subscriptions/listen.

  • getChanges() returns the SDK's McpChangeBroadcaster. Applications publish tools, prompts and resources list changes, and resource updates, through it.
  • A listen stream is acknowledged, then carries the changes the client asked for.
  • Listen streams run on their own worker pool:
    • their limit is setMaxListenStreams(), default 64, and a stream beyond it gets 503;
    • they have no request timeout, only heartbeats;
    • they don't use the call limit's slots.
  • stop() first ends the SDK's listen streams gracefully. Each client gets the listen result, and the adapter waits at most 2 s, capped by the request timeout. Anything still running is then cut short as before. A disconnect also ends a stream.

Stdio.

  • StdioServerHelper reads each message's _meta protocol version. A 2026-07-28 message goes to the modern exchange with the headers it would have had over HTTP, and the HTTP header checks are skipped. Mcp-Param headers count as not mirrored, as on the SDK's own stdio transport.
  • Streamed messages (progress, then the result) are written as separate lines as they come. A listen stream stays open next to other calls.
  • notifications/cancelled naming an in-flight 2026-07-28 request aborts it, with or without _meta, and the request gets no response.
  • A 2026-07-28 request runs without a stdio-level cap or timeout: the adapter's call limit, listen limit and request timeout apply. A request that is refused, too large or broken is answered with an error carrying its id, and the connector stays up.
  • Only a valid notifications/cancelled notification cancels. A request that names that method is answered. Marking a request cancelled and writing its lines share a lock, so no line of the request follows its cancellation.
  • 2025-11-25 stdio traffic is unchanged.

API additions:

  • McpServerAdapter: getModernResources(), getModernResourceTemplates(), getModernPrompts(), getChanges(), and get/setMaxListenStreams();
  • the records McpModernResource and McpModernResourceTemplate;
  • McpProtocol.DEFAULT_MAX_LISTEN_STREAMS.

Tests.

  • McpModernServerTestCase has 4 more tests:
    • modern resources (exact and template) and prompts served next to the bridged ones;
    • duplicate URI, template and prompt failing the start;
    • a listen stream acknowledged, carrying a published change while a call still gets through with a call limit of 1, then ending with its result, and nothing after it, on stop();
    • the listen limit refusing a second stream.
  • McpModernStdioTestCase is new, over pipes:
    • discovery and calls;
    • progress and result as separate lines;
    • a cancel without _meta ending the handler with no response;
    • a listen stream carrying a change next to a call;
    • a request named notifications/cancelled answered without cancelling anything;
    • the adapter's listen limit applying over stdio, above the default of 64;
    • an invalid id answered with a null id.

An independent review found, and this PR fixes:

  • stop() cut listen streams short with an error instead of letting them end with their result;
  • a possible leak of cancelled requests;
  • a streamed message near the size limit tearing down the stdio connector;
  • the 30 s stdio timeout killing the connector on a long 2026-07-28 call;
  • cancellations without _meta being ignored.

Copilot's review then found, and this PR fixes:

  • the stdio listen limit ignoring setMaxListenStreams();
  • a request named notifications/cancelled being taken as a cancellation;
  • a reply that could still be written after its cancellation;
  • a streamed message of exactly the size limit being rejected;
  • workers and responses piling up when a stdio client stops reading. A 2026-07-28 request now takes a pending slot first, and keeps it until its response or error is written. There are as many slots as the adapter's listen limit plus the default call limit. A request arriving while every slot is held is refused at once, so the reader keeps reading cancellations;
  • stop() appending an error after a listen stream's result. A worker now frees its slot only after signalling the call's end;
  • an id that is neither a string nor a number being echoed in an error. Such a request is answered with a null id.

A test inherited from #1664 sometimes failed on CI: it called again before the cancelled call's worker had freed its slot. It now waits for the slot.

mvn -pl org.restlet.ext.mcp verify: 68 tests, coverage and SpotBugs pass.

🤖 Generated with Claude Code

…stdio

Modern-only resources, resource templates and prompts are served next to
the bridged ones, with duplicates across both lists failing the start.
subscriptions/listen streams carry the changes published through
getChanges(), on their own limit and without time limit; stop() first
ends them with their result. Over stdio, a message whose _meta names
2026-07-28 goes to the modern exchange: streamed messages are written as
separate lines, and notifications/cancelled aborts the request it names
without a response.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jlouvel jlouvel self-assigned this Nov 7, 2026
@jlouvel
jlouvel requested a balanced review from Copilot November 7, 2026 21:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved stdio defects reject valid maximum-size messages, mishandle cancellation requests, allow responses after cancellation, and ignore the configured listener limit.

4 open issues
What changed in this PR

Extends the experimental MCP 2026-07-28 server support in org.restlet.ext.mcp, building on the earlier HTTP delivery while retaining legacy 2025-11-25 handling.

Changes:

  • Adds modern resources, resource templates and prompts with duplicate-registration checks.
  • Adds subscription streams with separate admission limits and graceful shutdown.
  • Adds modern stdio routing, streamed responses and cancellation handling, with HTTP and pipe-based tests.
File Description
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModernStdioTestCase.java Tests modern stdio calls, streaming, cancellation and subscriptions.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModernServerTestCase.java Tests modern registrations, duplicate rejection and subscription lifecycle.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​StdioServerHelper.java Routes modern messages and handles streaming, cancellation and separate worker pools.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpServerAdapter.java Exposes modern registrations, change broadcasting and listen limits.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpProtocol.java Defines the default listen-stream limit.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpModernResourceTemplate.java Pairs a modern resource template with its read handler.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpModernResource.java Pairs a modern resource definition with its read handler.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpWorkers.java Supports workers without request timeouts and bounded idle waits.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpModernFeatures.java Merges modern and bridged registrations and supports resource subscriptions.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpModernExchange.java Separates listen admission and adapts validation for stdio.
changes.md Documents the expanded experimental MCP server support.

Review effort: Balanced

2026-07-28 requests over stdio run without a stdio-level cap, so the
adapter's call and listen limits govern; an error it answers without the
request id, having refused the request unread, gets it. Only a valid
notifications/cancelled notification cancels; a request naming that
method is answered. Marking a request cancelled and writing its frames
share a lock, so no frame follows a cancellation. A streamed message of
exactly the message size is accepted. The tests waiting for a freed call
slot now wait for its worker to free it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical issues remain: shutdown can emit both a listen result and an error for the same request ID, and blocked stdout writes can cause unbounded worker and response-buffer accumulation despite call limits.

2 open issues
4 resolved since last review

Review effort: Balanced

… signal

2026-07-28 requests over stdio now take a pending slot before a worker:
the adapter's listen limit plus the default call limit. A client that
stops reading the output stops being read in turn, instead of piling up
workers and responses. A worker frees its slot only after signalling the
call's end, so stop() no longer appends an error after a listen stream's
result; the stop test checks that nothing follows the result. Also
splits the stdio helper's longer methods and tidies the tests for Sonar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical admission and cleanup flaws can block cancellation and let fallback error writers bypass the pending-output bound, while invalid request IDs can produce malformed JSON-RPC responses.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Validate request IDs before echoing them in error responses

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​StdioServerHelper.java:196

A modern request with an object, array, or boolean id can be rejected by the adapter's size check before SDK validation, after which refuse() echoes the invalid ID in its error response. JSON-RPC response IDs must be strings, numbers, or null. Validate IDs before registration and correlation, and answer malformed requests with a null-ID error rather than copying invalid values.

2 resolved since last review

Review effort: Balanced

jlouvel and others added 2 commits November 8, 2026 19:06
… written

A 2026-07-28 request over stdio arriving while every pending slot is
held is now refused at once, instead of blocking the reader, so
cancellations are still read. A failed request keeps its slot and its
in-flight registration until the error answering it is written. An id
that is neither a string nor a number is not echoed: such a request is
answered with a null id. Also splits the stdio helper's stop() for
Sonar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@sonarqubecloud

sonarqubecloud Bot commented Nov 9, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed to evaluate a permit-release ordering issue that can reject chained sends at a call limit of one in both client transports.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@jlouvel
jlouvel requested a balanced review from Copilot November 9, 2026 13:36
@jlouvel
jlouvel merged commit 46791b1 into 3.0 Nov 9, 2026
3 checks passed
@jlouvel
jlouvel deleted the feat/mcp-2026-07-28-stdio-listen branch November 9, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Active issues cause sequential sends to be rejected before admission permits are released and allow out-of-range cancellation IDs to match and abort a different request.

2 open issues

Review effort: Balanced

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