Skip to content

Commit ff1a715

Browse files
authored
feat: add subscription listen streams (SEP-2575) (#1000)
* feat: add subscription listen streams * refactor: reuse legacy request helper
1 parent 8ee9ee6 commit ff1a715

29 files changed

Lines changed: 3617 additions & 223 deletions

.github/workflows/conformance.yml

Lines changed: 0 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -85,79 +85,6 @@ jobs:
8585
echo "draft conformance server did not become ready" >&2
8686
exit 1
8787
88-
# useful for enforcing partial conformance for SEP-2575.
89-
# when `server-stateless` is removed from `expected-failures-2026-07-28.yaml`,
90-
# this step can be removed.
91-
- name: Run SEP-2575 discovery contract
92-
run: |
93-
endpoint=http://127.0.0.1:8002/mcp
94-
common_headers=(
95-
-H "Content-Type: application/json"
96-
-H "Accept: application/json, text/event-stream"
97-
-H "Mcp-Method: server/discover"
98-
)
99-
100-
discover_response="$(
101-
curl --fail-with-body --silent --show-error \
102-
"${common_headers[@]}" \
103-
-H "MCP-Protocol-Version: 2026-07-28" \
104-
--data '{
105-
"jsonrpc": "2.0",
106-
"id": "discover",
107-
"method": "server/discover",
108-
"params": {
109-
"_meta": {
110-
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
111-
"io.modelcontextprotocol/clientInfo": {
112-
"name": "conformance-workflow",
113-
"version": "1.0.0"
114-
},
115-
"io.modelcontextprotocol/clientCapabilities": {}
116-
}
117-
}
118-
}' \
119-
"$endpoint"
120-
)"
121-
jq -e '
122-
.result.resultType == "complete" and
123-
(.result.supportedVersions | index("2026-07-28") != null) and
124-
(.result.capabilities | type == "object") and
125-
(.result.serverInfo.name | type == "string") and
126-
.result.ttlMs == 0 and
127-
.result.cacheScope == "private"
128-
' <<<"$discover_response"
129-
130-
status="$(
131-
curl --silent --show-error \
132-
--output /tmp/unsupported-version.json \
133-
--write-out "%{http_code}" \
134-
"${common_headers[@]}" \
135-
-H "MCP-Protocol-Version: 2099-01-01" \
136-
--data '{
137-
"jsonrpc": "2.0",
138-
"id": "unsupported",
139-
"method": "server/discover",
140-
"params": {
141-
"_meta": {
142-
"io.modelcontextprotocol/protocolVersion": "2099-01-01",
143-
"io.modelcontextprotocol/clientInfo": {
144-
"name": "conformance-workflow",
145-
"version": "1.0.0"
146-
},
147-
"io.modelcontextprotocol/clientCapabilities": {}
148-
}
149-
}
150-
}' \
151-
"$endpoint"
152-
)"
153-
test "$status" = "400"
154-
jq -e '
155-
.id == "unsupported" and
156-
.error.code == -32022 and
157-
.error.data.requested == "2099-01-01" and
158-
(.error.data.supported | index("2026-07-28") != null)
159-
' /tmp/unsupported-version.json
160-
16188
- name: Run 2026-07-28 server suite
16289
run: |
16390
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \

README.md

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -882,89 +882,90 @@ context.peer.notify_resource_list_changed().await?;
882882

883883
## Subscriptions
884884

885-
Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it.
885+
Protocol `2026-07-28` replaces `resources/subscribe`, `resources/unsubscribe`, and
886+
the standalone HTTP GET stream with the transport-neutral, long-lived
887+
`subscriptions/listen` request. Each requested notification category is opt-in.
886888

887-
**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions)
889+
**MCP Spec:** [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions)
888890

889891
### Server-side
890892

891-
Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers:
893+
Declare the notification capabilities you serve, return the accepted subset,
894+
and use the filter-enforcing subscription sink:
892895

893896
```rust
894-
use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer};
895-
use std::sync::Arc;
896-
use tokio::sync::Mutex;
897-
use std::collections::HashSet;
898-
899-
#[derive(Clone)]
900-
struct MyServer {
901-
subscriptions: Arc<Mutex<HashSet<String>>>,
902-
}
897+
use rmcp::{
898+
ErrorData, ServerHandler,
899+
model::*,
900+
service::SubscriptionContext,
901+
};
903902

904903
impl ServerHandler for MyServer {
905904
fn get_info(&self) -> ServerInfo {
906905
ServerInfo::new(
907906
ServerCapabilities::builder()
908-
.enable_resources()
909-
.enable_resources_subscribe()
907+
.enable_tools()
908+
.enable_tool_list_changed()
910909
.build(),
911910
)
912911
}
913912

914-
async fn subscribe(
913+
fn accepted_subscription_filter(
915914
&self,
916-
request: SubscribeRequestParams,
917-
_context: RequestContext<RoleServer>,
918-
) -> Result<(), McpError> {
919-
self.subscriptions.lock().await.insert(request.uri);
920-
Ok(())
915+
requested: &SubscriptionFilter,
916+
) -> Option<SubscriptionFilter> {
917+
Some(requested.clone())
921918
}
922919

923-
async fn unsubscribe(
924-
&self,
925-
request: UnsubscribeRequestParams,
926-
_context: RequestContext<RoleServer>,
927-
) -> Result<(), McpError> {
928-
self.subscriptions.lock().await.remove(&request.uri);
920+
async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
921+
if context.accepted().tools_list_changed == Some(true) {
922+
context.sink().notify_tool_list_changed().await
923+
.map_err(|error| ErrorData::internal_error(error.to_string(), None))?;
924+
}
925+
context.cancelled().await;
929926
Ok(())
930927
}
931928
}
932929
```
933930

934-
When a subscribed resource changes, notify the client:
935-
936-
```rust
937-
// Check if the resource has subscribers, then notify
938-
context.peer.notify_resource_updated(
939-
ResourceUpdatedNotificationParam::new("file:///config.json"),
940-
).await?;
941-
```
931+
The SDK intersects the handler's filter with the requested categories and the
932+
capabilities advertised by `get_info()`. It sends the acknowledgment before
933+
`listen`, tags every sink notification with the listen request ID, and rejects
934+
categories or resource URIs outside the accepted filter.
942935

943936
### Client-side
944937

945938
```rust
946939
use rmcp::model::*;
947940

948-
// Subscribe to updates for a resource
949-
client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?;
941+
let mut subscription = client.listen(
942+
SubscriptionFilter::builder()
943+
.tools_list_changed()
944+
.resource_subscription("file:///config.json")
945+
.build(),
946+
).await?;
947+
948+
println!("accepted: {:?}", subscription.acknowledged());
949+
while let Some(notification) = subscription.next().await? {
950+
println!("notification: {notification:?}");
951+
}
950952

951-
// Unsubscribe when no longer needed
952-
client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?;
953+
subscription.cancel().await?;
953954
```
954955

955-
Handle update notifications in `ClientHandler`:
956+
`listen()` buffers up to 64 notifications per subscription. Use
957+
`listen_with_capacity()` to choose a different non-zero capacity; if a consumer
958+
falls behind, `Subscription::end()` reports `SubscriptionEnd::Lagged`.
956959

957-
```rust
958-
impl ClientHandler for MyClient {
959-
async fn on_resource_updated(
960-
&self,
961-
params: ResourceUpdatedNotificationParam,
962-
_context: NotificationContext<RoleClient>,
963-
) {
964-
// Re-read the resource at params.uri
965-
}
966-
}
967-
```
960+
For older negotiated protocol versions, the deprecated `subscribe()` and
961+
`unsubscribe()` APIs retain their legacy wire behavior. Modern Streamable HTTP
962+
uses the listen POST response stream directly and does not use sessions, GET,
963+
DELETE, or `Last-Event-ID`. After an abrupt transport close, call `listen`
964+
again; subscription state is not resumed across HTTP or stdio reconnects.
965+
966+
See the
967+
[modern subscription server](examples/servers/src/subscriptions_streamhttp.rs)
968+
and [client](examples/clients/src/subscriptions_streamhttp.rs) examples.
968969

969970
---
970971

conformance/expected-failures-2026-07-28.yaml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@
1010
# `conformance list --spec-version 2026-07-28` and update #977.
1111

1212
server:
13-
# SEP-2575 stateless lifecycle gaps: discover capability declaration,
14-
# missing-capability rejection, HTTP 404 for removed methods, and
15-
# diagnostic tools (test_missing_capability, test_streaming_elicitation,
16-
# test_logging_tool) not yet implemented.
17-
# tracked in #1004
18-
- server-stateless
1913
# SEP-2106: composition/conditional/$anchor keywords are stripped from
2014
# published tool input schemas.
2115
# tracked in #1003

0 commit comments

Comments
 (0)