Skip to content

add a client helper for subscription streams - #679

Open
Yusufihsangorgel wants to merge 48 commits into
dart-lang:mainfrom
Yusufihsangorgel:issue-client-listen-close
Open

Yusufihsangorgel wants to merge 48 commits into
dart-lang:mainfrom
Yusufihsangorgel:issue-client-listen-close

Conversation

@Yusufihsangorgel

Copy link
Copy Markdown
Contributor

Adds ServerConnection.listen, the client half of the subscription streams the server has served since #636. A handle comes back right away, reports the filter the server agreed to, and routes each notification to the subscription that opened it. Closing one subscription ends only that stream. Over stdio it sends notifications/cancelled for that request, over Streamable HTTP it drops the SSE response. A cancellation arriving for one subscription ends that one and leaves the connection alone.

Three helper names here are this package's own and not the schema's. Reading the outgoing request ID off the channel sink leaves package:json_rpc_2 untouched.

Tracked in #668.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements MCP subscriptions and per-request cancellation support. It introduces the Subscription class, adds the listen method to ServerConnection to handle subscriptions/listen streams, and integrates request cancellation tracking within MCPBase and streamable HTTP channels. Additionally, the SubscriptionsListenRequest factory now requires MetaWithRequestEnvelope. Feedback on the changes suggests enhancing type safety in the _forward method of Subscription by utilizing type promotion instead of explicit casting and null-assertion.

Comment thread pkgs/dart_mcp/lib/src/client/subscriptions.dart Outdated
@Yusufihsangorgel

Copy link
Copy Markdown
Contributor Author

#678 puts a notifications/cancelled handler on MCPBase, and listen here registers one of its own. package:json_rpc_2 throws on a second registration for one method. Whichever of these lands first, the other needs its handler moved to MCPBase.cancellations before it goes in.

Comment thread pkgs/dart_mcp/lib/src/client/client.dart Outdated

@jakemac53 jakemac53 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.

Still trying to wrap my head around this one, left a few comments though

Comment thread pkgs/dart_mcp/lib/src/client/client.dart
Comment thread pkgs/dart_mcp/lib/src/client/client.dart Outdated
Comment thread pkgs/dart_mcp/lib/src/shared.dart
Comment thread pkgs/dart_mcp/lib/src/shared.dart
Read the client's reply to the server ping through a Completer instead
of a plain variable, and await it, since an async function's future
completes synchronously on the VM but only after an await on dart2js
and one microtask later on dart2wasm.

Answer the fake server's ping in a scheduled microtask instead of
inside the write: on a sync channel json_rpc_2 writes a request before
registering it, so a reply delivered inside that write is dropped and
the pending ping times out. A real transport never replies inside the
write.
_forwardSubscriptionNotification now reads the subscription ID off the
raw _meta map and delivers only to that subscription, instead of
letting every open subscription filter the same params by its own ID.
Subscription._forward keeps only its acknowledged-and-open check.

Add a test that a notification whose subscription ID matches no open
subscription reaches none of them.
A synchronous listener could otherwise send progress for the request before it was marked cancelled, and that progress went out on the wire.

@jakemac53 jakemac53 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.

Can we add an example for how we expect users to deal with subscriptions across various versions now?

Lets say I want to subscribe to the tools list, across versions. Can we abstract this away better? Maybe the existing toolsListChanged (and others) should go away, and we should force everything to go through the this new listen method, which would only send an actual subscriptions/listen request on the versions that require it? And otherwise it just provides a view over the normal notifications stream which filters out the unwanted things.

@jakemac53

jakemac53 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

A follow up possible design, could be to move the various streams we expose today to the Subscription class itself. They could be of type Future<Stream<T>?>, which would wait for the subscription ack, and return null if the server did not return the desired stream type in its ack? On legacy, the ack would by synchronous and return a value based on the specified server capabilities.

@Yusufihsangorgel

Copy link
Copy Markdown
Contributor Author

Before 2026-07-28, listen now sends nothing. It acks locally from the server capabilities and filters the list-changed notifications (24c0df1). multi_version_client.dart has the tools list case. I kept the connection-level streams here, and can do typed ones on Subscription as the follow-up you suggested.

Comment thread pkgs/dart_mcp/example/multi_version_client.dart Outdated
/// requested list-changed types [serverCapabilities] supports. Notifications
/// are a local view of the connection's list-changed streams, and closing
/// the subscription sends nothing. Its [Subscription.id] is `null`, and
/// [meta] is not sent. `resourceSubscriptions` is never acknowledged on

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.

Can't this also support resource subscriptions on older versions?

/// requested list-changed types [serverCapabilities] supports. Notifications
/// are a local view of the connection's list-changed streams, and closing
/// the subscription sends nothing. Its [Subscription.id] is `null`, and
/// [meta] is not sent. `resourceSubscriptions` is never acknowledged on

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.

Can't this also support resource subscriptions on older versions?

}) {
final version = protocolVersion;
if (version != null &&
!version.methodIsValid(SubscriptionsListenRequest.methodName)) {

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.

I think we should specifically check for legacy version ranges here. Otherwise if subscriptions/listen gets removed in a later version, we could start incorrectly going down this code path, when we should be throwing an error instead.

We should still check if it is a valid method, but return an UnsupportedError or something instead, after checking for the legacy code path.

final version = protocolVersion;
if (version != null &&
!version.methodIsValid(SubscriptionsListenRequest.methodName)) {
return Subscription._local(

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.

Lets make this be a factory which takes the subscriptions filter and serverCapabilities object so that there is less stuff hard coded here at the call site.

I don't think we need to recreate the subscription filter either - the ACK should be based on the server capabilities and originally provided filter. The current implementation risks losing additional data that may be attached to the filter (if for instance more is added later).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants