feat: add subscription listen streams (SEP-2575) (#1000)

* feat: add subscription listen streams

* refactor: reuse legacy request helper
This commit is contained in:
Dale Seo 2026-07-21 09:53:04 -04:00 • committed by GitHub
commit ff1a715b6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3617 additions and 223 deletions

View file

@ -85,79 +85,6 @@ jobs:
echo "draft conformance server did not become ready" >&2
exit 1
# useful for enforcing partial conformance for SEP-2575.
# when `server-stateless` is removed from `expected-failures-2026-07-28.yaml`,
# this step can be removed.
- name: Run SEP-2575 discovery contract
run: |
endpoint=http://127.0.0.1:8002/mcp
common_headers=(
-H "Content-Type: application/json"
-H "Accept: application/json, text/event-stream"
-H "Mcp-Method: server/discover"
)
discover_response="$(
curl --fail-with-body --silent --show-error \
"${common_headers[@]}" \
-H "MCP-Protocol-Version: 2026-07-28" \
--data '{
"jsonrpc": "2.0",
"id": "discover",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "conformance-workflow",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}' \
"$endpoint"
)"
jq -e '
.result.resultType == "complete" and
(.result.supportedVersions | index("2026-07-28") != null) and
(.result.capabilities | type == "object") and
(.result.serverInfo.name | type == "string") and
.result.ttlMs == 0 and
.result.cacheScope == "private"
' <<<"$discover_response"
status="$(
curl --silent --show-error \
--output /tmp/unsupported-version.json \
--write-out "%{http_code}" \
"${common_headers[@]}" \
-H "MCP-Protocol-Version: 2099-01-01" \
--data '{
"jsonrpc": "2.0",
"id": "unsupported",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2099-01-01",
"io.modelcontextprotocol/clientInfo": {
"name": "conformance-workflow",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}' \
"$endpoint"
)"
test "$status" = "400"
jq -e '
.id == "unsupported" and
.error.code == -32022 and
.error.data.requested == "2099-01-01" and
(.error.data.supported | index("2026-07-28") != null)
' /tmp/unsupported-version.json
- name: Run 2026-07-28 server suite
run: |
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \

103
README.md
View file

@ -882,90 +882,91 @@ context.peer.notify_resource_list_changed().await?;
## Subscriptions
Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it.
Protocol `2026-07-28` replaces `resources/subscribe`, `resources/unsubscribe`, and
the standalone HTTP GET stream with the transport-neutral, long-lived
`subscriptions/listen` request. Each requested notification category is opt-in.
**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions)
**MCP Spec:** [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions)
### Server-side
Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers:
Declare the notification capabilities you serve, return the accepted subset,
and use the filter-enforcing subscription sink:
```rust
use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer};
use std::sync::Arc;
use tokio::sync::Mutex;
use std::collections::HashSet;
#[derive(Clone)]
struct MyServer {
subscriptions: Arc<Mutex<HashSet<String>>>,
}
use rmcp::{
ErrorData, ServerHandler,
model::*,
service::SubscriptionContext,
};
impl ServerHandler for MyServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_resources()
.enable_resources_subscribe()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
async fn subscribe(
fn accepted_subscription_filter(
&self,
request: SubscribeRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<(), McpError> {
self.subscriptions.lock().await.insert(request.uri);
Ok(())
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.clone())
}
async fn unsubscribe(
&self,
request: UnsubscribeRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<(), McpError> {
self.subscriptions.lock().await.remove(&request.uri);
async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
if context.accepted().tools_list_changed == Some(true) {
context.sink().notify_tool_list_changed().await
.map_err(|error| ErrorData::internal_error(error.to_string(), None))?;
}
context.cancelled().await;
Ok(())
}
}
```
When a subscribed resource changes, notify the client:
```rust
// Check if the resource has subscribers, then notify
context.peer.notify_resource_updated(
ResourceUpdatedNotificationParam::new("file:///config.json"),
).await?;
```
The SDK intersects the handler's filter with the requested categories and the
capabilities advertised by `get_info()`. It sends the acknowledgment before
`listen`, tags every sink notification with the listen request ID, and rejects
categories or resource URIs outside the accepted filter.
### Client-side
```rust
use rmcp::model::*;
// Subscribe to updates for a resource
client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?;
let mut subscription = client.listen(
SubscriptionFilter::builder()
.tools_list_changed()
.resource_subscription("file:///config.json")
.build(),
).await?;
// Unsubscribe when no longer needed
client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?;
```
Handle update notifications in `ClientHandler`:
```rust
impl ClientHandler for MyClient {
async fn on_resource_updated(
&self,
params: ResourceUpdatedNotificationParam,
_context: NotificationContext<RoleClient>,
) {
// Re-read the resource at params.uri
}
println!("accepted: {:?}", subscription.acknowledged());
while let Some(notification) = subscription.next().await? {
println!("notification: {notification:?}");
}
subscription.cancel().await?;
```
`listen()` buffers up to 64 notifications per subscription. Use
`listen_with_capacity()` to choose a different non-zero capacity; if a consumer
falls behind, `Subscription::end()` reports `SubscriptionEnd::Lagged`.
For older negotiated protocol versions, the deprecated `subscribe()` and
`unsubscribe()` APIs retain their legacy wire behavior. Modern Streamable HTTP
uses the listen POST response stream directly and does not use sessions, GET,
DELETE, or `Last-Event-ID`. After an abrupt transport close, call `listen`
again; subscription state is not resumed across HTTP or stdio reconnects.
See the
[modern subscription server](examples/servers/src/subscriptions_streamhttp.rs)
and [client](examples/clients/src/subscriptions_streamhttp.rs) examples.
---
## Tasks (long-running tool invocations)

View file

@ -10,12 +10,6 @@
# `conformance list --spec-version 2026-07-28` and update #977.
server:
# SEP-2575 stateless lifecycle gaps: discover capability declaration,
# missing-capability rejection, HTTP 404 for removed methods, and
# diagnostic tools (test_missing_capability, test_streaming_elicitation,
# test_logging_tool) not yet implemented.
# tracked in #1004
- server-stateless
# SEP-2106: composition/conditional/$anchor keywords are stripped from
# published tool input schemas.
# tracked in #1003

View file

@ -1,10 +1,16 @@
#![allow(deprecated)]
use std::{collections::HashSet, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use rmcp::{
ErrorData, RoleServer, ServerHandler,
model::*,
service::RequestContext,
service::{RequestContext, SubscriptionContext, SubscriptionSink},
transport::{
StreamableHttpServerConfig, StreamableHttpService,
streamable_http_server::session::local::LocalSessionManager,
@ -48,7 +54,9 @@ const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!";
#[derive(Clone)]
struct ConformanceServer {
subscriptions: Arc<Mutex<HashSet<String>>>,
legacy_resource_subscriptions: Arc<Mutex<HashSet<String>>>,
subscriptions: Arc<Mutex<HashMap<u64, SubscriptionSink>>>,
next_subscription: Arc<AtomicU64>,
log_level: Arc<Mutex<LoggingLevel>>,
request_state_codec: RequestStateCodec,
}
@ -56,7 +64,9 @@ struct ConformanceServer {
impl ConformanceServer {
fn new() -> Self {
Self {
subscriptions: Arc::new(Mutex::new(HashSet::new())),
legacy_resource_subscriptions: Arc::new(Mutex::new(HashSet::new())),
subscriptions: Arc::new(Mutex::new(HashMap::new())),
next_subscription: Arc::new(AtomicU64::new(0)),
log_level: Arc::new(Mutex::new(LoggingLevel::Debug)),
request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY),
}
@ -379,22 +389,51 @@ impl ServerHandler for ConformanceServer {
(name == "test_custom_header").then(custom_header_tool)
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_prompts()
.enable_prompts_list_changed()
.enable_resources()
.enable_resources_subscribe()
.enable_resources_list_changed()
.enable_tools()
.enable_tool_list_changed()
.enable_logging()
.build(),
)
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
.with_instructions("Rust MCP conformance test server")
}
async fn initialize(
&self,
request: InitializeRequestParams,
_cx: RequestContext<RoleServer>,
) -> Result<InitializeResult, ErrorData> {
Ok(InitializeResult::new(
ServerCapabilities::builder()
.enable_prompts()
.enable_resources()
.enable_tools()
.enable_logging()
.build(),
)
.with_protocol_version(request.protocol_version)
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
.with_instructions("Rust MCP conformance test server"))
let info = self.get_info();
Ok(InitializeResult::new(info.capabilities)
.with_protocol_version(request.protocol_version)
.with_server_info(info.server_info)
.with_instructions(info.instructions.unwrap_or_default()))
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
let key = self.next_subscription.fetch_add(1, Ordering::Relaxed);
self.subscriptions
.lock()
.await
.insert(key, context.sink().clone());
context.cancelled().await;
self.subscriptions.lock().await.remove(&key);
Ok(())
}
async fn ping(&self, _cx: RequestContext<RoleServer>) -> Result<(), ErrorData> {
@ -540,6 +579,46 @@ impl ServerHandler for ConformanceServer {
})),
),
custom_header_tool(),
Tool::new(
"test_trigger_tool_change",
"Triggers a tools/list_changed notification on matching subscriptions",
json_object(json!({
"type": "object",
"properties": {}
})),
),
Tool::new(
"test_trigger_prompt_change",
"Triggers a prompts/list_changed notification on matching subscriptions",
json_object(json!({
"type": "object",
"properties": {}
})),
),
Tool::new(
"test_missing_capability",
"Requires the sampling client capability",
json_object(json!({
"type": "object",
"properties": {}
})),
),
Tool::new(
"test_streaming_elicitation",
"Returns an input_required result containing an elicitation request",
json_object(json!({
"type": "object",
"properties": {}
})),
),
Tool::new(
"test_logging_tool",
"Emits notifications/message only when logLevel is requested",
json_object(json!({
"type": "object",
"properties": {}
})),
),
];
// SEP-2322 MRTR test tools; all take no arguments.
let mrtr_tools = [
@ -922,6 +1001,81 @@ impl ServerHandler for ConformanceServer {
)]))
}
"test_trigger_tool_change" => {
let subscriptions = self
.subscriptions
.lock()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for subscription in subscriptions {
let _ = subscription.notify_tool_list_changed().await;
}
Ok(CallToolResult::success(vec![ContentBlock::text(
"Tool list change triggered",
)]))
}
"test_trigger_prompt_change" => {
let subscriptions = self
.subscriptions
.lock()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for subscription in subscriptions {
let _ = subscription.notify_prompt_list_changed().await;
}
Ok(CallToolResult::success(vec![ContentBlock::text(
"Prompt list change triggered",
)]))
}
"test_missing_capability" => {
let capabilities = cx.meta.client_capabilities().unwrap_or_default();
if capabilities.sampling.is_none() {
return Err(ErrorData::missing_required_client_capability(
ClientCapabilities::builder().enable_sampling().build(),
));
}
Ok(CallToolResult::success(vec![ContentBlock::text(
"Required capability declared",
)]))
}
"test_streaming_elicitation" => {
let mut requests = InputRequests::new();
requests.insert(
"streaming_elicitation".into(),
mrtr_elicitation_request(
"Provide a value",
json!({ "value": { "type": "string" } }),
json!(["value"]),
),
);
return Ok(InputRequiredResult::from_input_requests(requests).into());
}
"test_logging_tool" => {
if let Some(level) = cx.meta.log_level() {
let _ = cx
.peer
.notify_logging_message(
LoggingMessageNotificationParam::new(
level,
json!("logLevel was requested"),
)
.with_logger("conformance-server"),
)
.await;
}
Ok(CallToolResult::success(vec![ContentBlock::text(
"Logging tool completed",
)]))
}
_ => Err(ErrorData::invalid_params(
format!("Unknown tool: {}", request.name),
None,
@ -1029,7 +1183,7 @@ impl ServerHandler for ConformanceServer {
request: SubscribeRequestParams,
_cx: RequestContext<RoleServer>,
) -> Result<(), ErrorData> {
let mut subs = self.subscriptions.lock().await;
let mut subs = self.legacy_resource_subscriptions.lock().await;
subs.insert(request.uri.to_string());
Ok(())
}
@ -1039,7 +1193,7 @@ impl ServerHandler for ConformanceServer {
request: UnsubscribeRequestParams,
_cx: RequestContext<RoleServer>,
) -> Result<(), ErrorData> {
let mut subs = self.subscriptions.lock().await;
let mut subs = self.legacy_resource_subscriptions.lock().await;
subs.remove(request.uri.as_str());
Ok(())
}

View file

@ -66,6 +66,10 @@ impl<H: ClientHandler> Service<RoleClient> for H {
ServerNotification::PromptListChangedNotification(_notification_no_param) => {
self.on_prompt_list_changed(context).await
}
ServerNotification::SubscriptionsAcknowledgedNotification(notification) => {
self.on_subscriptions_acknowledged(notification.params, context)
.await
}
ServerNotification::TaskStatusNotification(notification) => {
self.on_task_status(notification.params, context).await
}
@ -237,6 +241,13 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_subscriptions_acknowledged(
&self,
params: SubscriptionsAcknowledgedNotificationParams,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_task_status(
&self,
@ -365,6 +376,14 @@ macro_rules! impl_client_handler_for_wrapper {
(**self).on_prompt_list_changed(context)
}
fn on_subscriptions_acknowledged(
&self,
params: SubscriptionsAcknowledgedNotificationParams,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
(**self).on_subscriptions_acknowledged(params, context)
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,

View file

@ -7,7 +7,7 @@ use crate::{
model::{TaskSupport, *},
service::{
MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole,
negotiate_protocol_version,
SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle,
},
};
@ -69,6 +69,8 @@ impl<H: ServerHandler> Service<RoleServer> for H {
));
}
}
let legacy_request =
uses_legacy_lifecycle(protocol_version.as_ref(), requires_request_metadata);
let result = match request {
ClientRequest::InitializeRequest(request) => self
.initialize(request.params, context)
@ -79,7 +81,11 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.await
.map(ServerResult::DiscoverResult),
ClientRequest::PingRequest(_request) => {
self.ping(context).await.map(ServerResult::empty)
if !legacy_request {
Err(McpError::method_not_found::<PingRequestMethod>())
} else {
self.ping(context).await.map(ServerResult::empty)
}
}
ClientRequest::CompleteRequest(request) => self
.complete(request.params, context)
@ -109,14 +115,60 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.read_resource(request.params, context)
.await
.map(ServerResult::from),
ClientRequest::SubscribeRequest(request) => self
.subscribe(request.params, context)
.await
.map(ServerResult::empty),
ClientRequest::UnsubscribeRequest(request) => self
.unsubscribe(request.params, context)
.await
.map(ServerResult::empty),
ClientRequest::SubscriptionsListenRequest(request) => {
if legacy_request {
Err(McpError::method_not_found::<SubscriptionsListenRequestMethod>())
} else {
let requested = request.params.notifications;
let Some(candidate) = self.accepted_subscription_filter(&requested) else {
return Err(
McpError::method_not_found::<SubscriptionsListenRequestMethod>(),
);
};
let advertised = requested.supported_by(&self.get_info().capabilities);
let handler_accepted = requested.intersection(&candidate);
let accepted = handler_accepted.intersection(&advertised);
if accepted != handler_accepted {
tracing::debug!(
requested_resource_count = requested
.resource_subscriptions
.as_ref()
.map_or(0, Vec::len),
accepted_resource_count =
accepted.resource_subscriptions.as_ref().map_or(0, Vec::len),
"subscription filter reduced to advertised server capabilities"
);
}
let subscription_id = context.id.clone();
let subscription =
SubscriptionContext::establish(context, requested, accepted).await?;
// The integrated draft schema defines a final result for graceful
// server teardown; explicit stdio cancellation remains a notification.
self.listen(subscription).await.map(|()| {
ServerResult::SubscriptionsListenResult(
SubscriptionsListenResult::complete(subscription_id),
)
})
}
}
ClientRequest::SubscribeRequest(request) => {
if !legacy_request {
Err(McpError::method_not_found::<SubscribeRequestMethod>())
} else {
self.subscribe(request.params, context)
.await
.map(ServerResult::empty)
}
}
ClientRequest::UnsubscribeRequest(request) => {
if !legacy_request {
Err(McpError::method_not_found::<UnsubscribeRequestMethod>())
} else {
self.unsubscribe(request.params, context)
.await
.map(ServerResult::empty)
}
}
ClientRequest::CallToolRequest(request) => {
let is_task = request.params.task.is_some();
@ -335,6 +387,36 @@ macro_rules! server_handler_methods {
McpError::method_not_found::<ReadResourceRequestMethod>(),
))
}
/// Return the subset of a requested notification filter this server accepts.
///
/// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK
/// intersects the returned filter with both `requested` and the notification
/// capabilities advertised by [`Self::get_info`] before acknowledging it.
/// Categories that were not requested or advertised are always removed.
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
None
}
/// Run one established subscription until it is cancelled or closed gracefully.
///
/// The SDK sends the acknowledgment before invoking this method. Returning
/// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the
/// integrated draft schema, marking graceful server teardown. Explicit
/// stdio cancellation uses `notifications/cancelled` instead.
fn listen(
&self,
context: SubscriptionContext,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
async move {
context.cancelled().await;
Ok(())
}
}
#[deprecated(
note = "resources/subscribe is legacy-only; implement accepted_subscription_filter and listen for protocol version 2026-07-28"
)]
fn subscribe(
&self,
request: SubscribeRequestParams,
@ -342,6 +424,9 @@ macro_rules! server_handler_methods {
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
std::future::ready(Err(McpError::method_not_found::<SubscribeRequestMethod>()))
}
#[deprecated(
note = "resources/unsubscribe is legacy-only; subscriptions/listen is cancelled through its request lifecycle"
)]
fn unsubscribe(
&self,
request: UnsubscribeRequestParams,
@ -604,6 +689,20 @@ macro_rules! impl_server_handler_for_wrapper {
(**self).read_resource(request, context)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
(**self).accepted_subscription_filter(requested)
}
fn listen(
&self,
context: SubscriptionContext,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
(**self).listen(context)
}
fn subscribe(
&self,
request: SubscribeRequestParams,

View file

@ -3,8 +3,10 @@
#![expect(deprecated)]
use std::{
borrow::Cow,
collections::hash_map::RandomState,
hash::{BuildHasher, Hasher},
ops::{Deref, DerefMut},
sync::Arc,
sync::{Arc, OnceLock},
};
mod annotated;
mod capabilities;
@ -558,6 +560,8 @@ pub struct ErrorData {
}
impl ErrorData {
const TRANSPORT_CLOSED_MARKER: &str = "io.modelcontextprotocol/transportClosed";
pub fn new(
code: ErrorCode,
message: impl Into<Cow<'static, str>>,
@ -616,6 +620,33 @@ impl ErrorData {
pub fn internal_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
Self::new(ErrorCode::INTERNAL_ERROR, message, data)
}
#[cfg(feature = "transport-streamable-http-client")]
pub(crate) fn transport_closed(message: impl Into<Cow<'static, str>>) -> Self {
let mut data = JsonObject::new();
data.insert(
Self::TRANSPORT_CLOSED_MARKER.to_owned(),
Value::from(Self::transport_closed_token()),
);
Self::internal_error(message, Some(Value::Object(data)))
}
pub(crate) fn is_transport_closed(&self) -> bool {
self.data
.as_ref()
.and_then(|data| data.get(Self::TRANSPORT_CLOSED_MARKER))
.and_then(Value::as_u64)
== Some(Self::transport_closed_token())
}
fn transport_closed_token() -> u64 {
static TOKEN: OnceLock<u64> = OnceLock::new();
*TOKEN.get_or_init(|| {
let mut hasher = RandomState::new().build_hasher();
hasher.write(b"rmcp transport-closed marker");
hasher.finish()
})
}
}
/// Represents any JSON-RPC message that can be sent or received.
@ -1661,6 +1692,9 @@ impl RequestParamsMeta for SubscribeRequestParams {
pub type SubscribeRequestParam = SubscribeRequestParams;
/// Request to subscribe to resource updates
#[deprecated(
note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28"
)]
pub type SubscribeRequest = Request<SubscribeRequestMethod, SubscribeRequestParams>;
const_string!(UnsubscribeRequestMethod = "resources/unsubscribe");
@ -1701,6 +1735,9 @@ impl RequestParamsMeta for UnsubscribeRequestParams {
pub type UnsubscribeRequestParam = UnsubscribeRequestParams;
/// Request to unsubscribe from resource updates
#[deprecated(
note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28"
)]
pub type UnsubscribeRequest = Request<UnsubscribeRequestMethod, UnsubscribeRequestParams>;
const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated");
@ -1730,6 +1767,390 @@ impl ResourceUpdatedNotificationParam {
pub type ResourceUpdatedNotification =
Notification<ResourceUpdatedNotificationMethod, ResourceUpdatedNotificationParam>;
// =============================================================================
// SUBSCRIPTIONS
// =============================================================================
/// Notification categories a client opts in to on a `subscriptions/listen` stream.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionFilter {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub tools_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub prompts_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub resources_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "Vec<String>"))]
pub resource_subscriptions: Option<Vec<String>>,
}
impl SubscriptionFilter {
/// Create an empty filter that opts in to no notifications.
pub fn new() -> Self {
Self::default()
}
/// Create a builder for a subscription filter.
pub fn builder() -> SubscriptionFilterBuilder {
SubscriptionFilterBuilder::default()
}
/// Return the subset present in both filters.
pub fn intersection(&self, other: &Self) -> Self {
let resource_subscriptions = self
.resource_subscriptions
.as_ref()
.and_then(|requested| {
other.resource_subscriptions.as_ref().map(|accepted| {
requested
.iter()
.filter(|uri| accepted.contains(uri))
.cloned()
.collect()
})
})
.filter(|uris: &Vec<String>| !uris.is_empty());
Self {
tools_list_changed: (self.tools_list_changed == Some(true)
&& other.tools_list_changed == Some(true))
.then_some(true),
prompts_list_changed: (self.prompts_list_changed == Some(true)
&& other.prompts_list_changed == Some(true))
.then_some(true),
resources_list_changed: (self.resources_list_changed == Some(true)
&& other.resources_list_changed == Some(true))
.then_some(true),
resource_subscriptions,
}
}
/// Return whether this filter accepts only notifications requested by `other`.
pub fn is_subset_of(&self, other: &Self) -> bool {
let booleans_are_subset = [
(self.tools_list_changed, other.tools_list_changed),
(self.prompts_list_changed, other.prompts_list_changed),
(self.resources_list_changed, other.resources_list_changed),
]
.into_iter()
.all(|(accepted, requested)| accepted != Some(true) || requested == Some(true));
let resources_are_subset = self.resource_subscriptions.as_ref().is_none_or(|accepted| {
accepted.iter().all(|uri| {
other
.resource_subscriptions
.as_ref()
.is_some_and(|requested| requested.contains(uri))
})
});
booleans_are_subset && resources_are_subset
}
/// Return the requested notification types advertised by server capabilities.
pub fn supported_by(&self, capabilities: &ServerCapabilities) -> Self {
Self {
tools_list_changed: (self.tools_list_changed == Some(true)
&& capabilities
.tools
.as_ref()
.is_some_and(|tools| tools.list_changed == Some(true)))
.then_some(true),
prompts_list_changed: (self.prompts_list_changed == Some(true)
&& capabilities
.prompts
.as_ref()
.is_some_and(|prompts| prompts.list_changed == Some(true)))
.then_some(true),
resources_list_changed: (self.resources_list_changed == Some(true)
&& capabilities
.resources
.as_ref()
.is_some_and(|resources| resources.list_changed == Some(true)))
.then_some(true),
resource_subscriptions: capabilities
.resources
.as_ref()
.is_some_and(|resources| resources.subscribe == Some(true))
.then(|| self.resource_subscriptions.clone())
.flatten(),
}
}
}
/// Builder for [`SubscriptionFilter`].
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct SubscriptionFilterBuilder {
filter: SubscriptionFilter,
}
impl SubscriptionFilterBuilder {
/// Opt in to `notifications/tools/list_changed`.
pub fn tools_list_changed(mut self) -> Self {
self.filter.tools_list_changed = Some(true);
self
}
/// Opt in to `notifications/prompts/list_changed`.
pub fn prompts_list_changed(mut self) -> Self {
self.filter.prompts_list_changed = Some(true);
self
}
/// Opt in to `notifications/resources/list_changed`.
pub fn resources_list_changed(mut self) -> Self {
self.filter.resources_list_changed = Some(true);
self
}
/// Opt in to updates for all supplied resource URIs.
pub fn resource_subscriptions(
mut self,
uris: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.filter.resource_subscriptions = Some(uris.into_iter().map(Into::into).collect());
self
}
/// Add one resource URI to the update subscription set.
pub fn resource_subscription(mut self, uri: impl Into<String>) -> Self {
self.filter
.resource_subscriptions
.get_or_insert_default()
.push(uri.into());
self
}
/// Build the filter.
pub fn build(self) -> SubscriptionFilter {
self.filter
}
}
const_string!(SubscriptionsListenRequestMethod = "subscriptions/listen");
#[cfg(feature = "schemars")]
fn subscriptions_listen_request_meta_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
let progress_token = generator.subschema_for::<ProgressToken>();
let client_info = generator.subschema_for::<Implementation>();
let client_capabilities = generator.subschema_for::<ClientCapabilities>();
let log_level = generator.subschema_for::<LoggingLevel>();
schemars::json_schema!({
"type": "object",
"properties": {
"progressToken": progress_token,
"io.modelcontextprotocol/protocolVersion": {
"type": "string",
},
"io.modelcontextprotocol/clientInfo": client_info,
"io.modelcontextprotocol/clientCapabilities": client_capabilities,
"io.modelcontextprotocol/logLevel": log_level,
},
"required": RequestMetaObject::DRAFT_REQUIRED_KEYS,
"additionalProperties": true,
})
}
/// Parameters for opening a long-lived notification subscription.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsListenRequestParams {
/// Protocol-level metadata. Required by the draft wire schema.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schemars",
schemars(required, schema_with = "subscriptions_listen_request_meta_schema")
)]
pub meta: Option<RequestMetaObject>,
/// Notification categories requested for this stream.
pub notifications: SubscriptionFilter,
}
impl SubscriptionsListenRequestParams {
/// Create listen parameters for a notification filter.
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
meta: None,
notifications,
}
}
/// Set protocol-level request metadata.
pub fn with_meta(mut self, meta: RequestMetaObject) -> Self {
self.meta = Some(meta);
self
}
}
impl RequestParamsMeta for SubscriptionsListenRequestParams {
fn meta(&self) -> Option<&RequestMetaObject> {
self.meta.as_ref()
}
fn meta_mut(&mut self) -> &mut Option<RequestMetaObject> {
&mut self.meta
}
}
/// Request that opens a long-lived notification subscription.
pub type SubscriptionsListenRequest =
Request<SubscriptionsListenRequestMethod, SubscriptionsListenRequestParams>;
const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId";
/// Metadata on the final result of a `subscriptions/listen` request.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(transparent)]
#[non_exhaustive]
pub struct SubscriptionsListenResultMeta(MetaObject);
impl SubscriptionsListenResultMeta {
/// Create result metadata for the originating listen request.
pub fn new(subscription_id: RequestId) -> Self {
let mut meta = MetaObject::new();
meta.insert(
SUBSCRIPTION_ID_META_KEY.to_owned(),
subscription_id.into_json_value(),
);
Self(meta)
}
/// Return the originating listen request ID, if the metadata remains valid.
pub fn subscription_id(&self) -> Option<RequestId> {
self.0
.get(SUBSCRIPTION_ID_META_KEY)
.and_then(|value| RequestId::deserialize(value).ok())
}
/// Replace the originating listen request ID.
pub fn set_subscription_id(&mut self, subscription_id: RequestId) {
self.0.insert(
SUBSCRIPTION_ID_META_KEY.to_owned(),
subscription_id.into_json_value(),
);
}
}
impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let meta = MetaObject::deserialize(deserializer)?;
let Some(value) = meta.get(SUBSCRIPTION_ID_META_KEY) else {
return Err(serde::de::Error::missing_field(SUBSCRIPTION_ID_META_KEY));
};
RequestId::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(Self(meta))
}
}
impl std::ops::Deref for SubscriptionsListenResultMeta {
type Target = MetaObject;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for SubscriptionsListenResultMeta {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for SubscriptionsListenResultMeta {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("SubscriptionsListenResultMeta")
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let subscription_id = generator.subschema_for::<RequestId>();
schemars::json_schema!({
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": subscription_id,
},
"required": ["io.modelcontextprotocol/subscriptionId"],
"additionalProperties": true,
})
}
}
/// Final response indicating that a subscription ended gracefully.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsListenResult {
pub result_type: ResultType,
#[serde(rename = "_meta")]
pub meta: SubscriptionsListenResultMeta,
}
impl SubscriptionsListenResult {
/// Create a completed subscription result.
pub fn new(meta: SubscriptionsListenResultMeta) -> Self {
Self {
result_type: ResultType::COMPLETE,
meta,
}
}
/// Create a completed result for the originating listen request.
pub fn complete(subscription_id: RequestId) -> Self {
Self::new(SubscriptionsListenResultMeta::new(subscription_id))
}
}
const_string!(
SubscriptionsAcknowledgedNotificationMethod = "notifications/subscriptions/acknowledged"
);
/// Parameters reporting the accepted subset of a subscription filter.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsAcknowledgedNotificationParams {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "NotificationMetaObject"))]
pub meta: Option<NotificationMetaObject>,
pub notifications: SubscriptionFilter,
}
impl SubscriptionsAcknowledgedNotificationParams {
/// Create acknowledgment parameters for the accepted filter.
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
meta: None,
notifications,
}
}
/// Set notification metadata.
pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self {
self.meta = Some(meta);
self
}
}
/// First notification sent on an established subscription stream.
pub type SubscriptionsAcknowledgedNotification = Notification<
SubscriptionsAcknowledgedNotificationMethod,
SubscriptionsAcknowledgedNotificationParams,
>;
// =============================================================================
// PROMPT MANAGEMENT
// =============================================================================
@ -3937,6 +4358,7 @@ ts_union!(
| ListResourcesRequest
| ListResourceTemplatesRequest
| ReadResourceRequest
| SubscriptionsListenRequest
| SubscribeRequest
| UnsubscribeRequest
| CallToolRequest
@ -3961,6 +4383,7 @@ impl ClientRequest {
ClientRequest::ListResourcesRequest(r) => r.method.as_str(),
ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(),
ClientRequest::ReadResourceRequest(r) => r.method.as_str(),
ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(),
ClientRequest::SubscribeRequest(r) => r.method.as_str(),
ClientRequest::UnsubscribeRequest(r) => r.method.as_str(),
ClientRequest::CallToolRequest(r) => r.method.as_str(),
@ -4019,6 +4442,7 @@ ts_union!(
| ResourceListChangedNotification
| ToolListChangedNotification
| PromptListChangedNotification
| SubscriptionsAcknowledgedNotification
| TaskStatusNotification
| CustomNotification;
);
@ -4033,6 +4457,7 @@ ts_union!(
| ListResourcesResult
| ListResourceTemplatesResult
| ReadResourceResult
| SubscriptionsListenResult
| ListToolsResult
| ElicitResult
| CreateTaskResult
@ -4096,6 +4521,19 @@ mod tests {
let _: ResourceReference = ResourceTemplateReference::new("res://x");
}
#[cfg(feature = "transport-streamable-http-client")]
#[test]
fn transport_closed_marker_accepts_only_the_process_local_token() {
let local = ErrorData::transport_closed("closed");
let spoofed = ErrorData::internal_error(
"spoofed",
Some(json!({ "io.modelcontextprotocol/transportClosed": true })),
);
assert!(local.is_transport_closed());
assert!(!spoofed.is_transport_closed());
}
#[test]
fn cancelled_notification_request_id_is_optional_on_wire() {
// None → requestId 생략

View file

@ -197,6 +197,7 @@ variant_extension! {
ListResourcesRequest
ListResourceTemplatesRequest
ReadResourceRequest
SubscriptionsListenRequest
SubscribeRequest
UnsubscribeRequest
CallToolRequest
@ -239,6 +240,7 @@ variant_extension! {
ResourceListChangedNotification
ToolListChangedNotification
PromptListChangedNotification
SubscriptionsAcknowledgedNotification
TaskStatusNotification
CustomNotification
}

View file

@ -85,6 +85,8 @@ pub enum ServiceError {
TransportClosed,
#[error("Unexpected response type")]
UnexpectedResponse,
#[error("subscription consumer lagged behind its {capacity}-message buffer")]
SubscriptionLagged { capacity: usize },
#[error("task cancelled for reason {}", reason.as_deref().unwrap_or("<unknown>"))]
Cancelled { reason: Option<String> },
#[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())]
@ -128,6 +130,20 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
const IS_CLIENT: bool;
type Info: TransferObject;
type PeerInfo: TransferObject;
#[doc(hidden)]
fn configure_direct_peer(_peer: &Peer<Self>, _info: &Self::Info) {}
#[doc(hidden)]
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
None
}
}
pub(crate) fn uses_legacy_lifecycle(
protocol_version: Option<&ProtocolVersion>,
uses_discover_lifecycle: bool,
) -> bool {
!uses_discover_lifecycle
&& protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28)
}
pub type TxJsonRpcMessage<R> =
@ -340,6 +356,8 @@ impl ProgressNotificationToken for ServerNotification {
type Responder<T> = tokio::sync::oneshot::Sender<T>;
type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>;
type SubscriptionChannel<N> = (mpsc::Sender<N>, usize);
type SubscriptionChannelMap<N> = HashMap<RequestId, SubscriptionChannel<N>>;
/// A handle to a remote request
///
@ -400,10 +418,12 @@ impl<R: ServiceRole> RequestHandle<R> {
has_progress_reset_rx,
)
.await;
self.peer.unregister_subscription(&self.id);
result
}
async fn send_timeout_cancel_notification(&self, reason: &str) {
self.peer.unregister_subscription(&self.id);
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(self.id.clone()),
@ -478,6 +498,7 @@ impl<R: ServiceRole> RequestHandle<R> {
self.progress_reset_rx.is_some(),
)
.await;
self.peer.unregister_subscription(&self.id);
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(self.id),
@ -530,7 +551,6 @@ pub(crate) struct ClientRequestMetadata {
/// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer.
///
/// To create a cancellable request, call [`Peer::send_request_with_option`].
#[derive(Clone)]
pub struct Peer<R: ServiceRole> {
tx: mpsc::Sender<PeerSinkMessage<R>>,
request_id_provider: Arc<dyn RequestIdProvider>,
@ -539,6 +559,25 @@ pub struct Peer<R: ServiceRole> {
info: Arc<std::sync::RwLock<Option<Arc<R::PeerInfo>>>>,
client_request_metadata: Arc<OnceLock<ClientRequestMetadata>>,
request_metadata_required: Arc<std::sync::atomic::AtomicBool>,
subscription_channels: Arc<std::sync::RwLock<SubscriptionChannelMap<R::PeerNot>>>,
}
impl<R: Clone + ServiceRole> Clone for Peer<R>
where
R::PeerInfo: Clone,
{
fn clone(&self) -> Peer<R> {
Self {
tx: self.tx.clone(),
request_id_provider: self.request_id_provider.clone(),
progress_token_provider: self.progress_token_provider.clone(),
progress_timeout_watchers: self.progress_timeout_watchers.clone(),
info: self.info.clone(),
client_request_metadata: self.client_request_metadata.clone(),
request_metadata_required: self.request_metadata_required.clone(),
subscription_channels: self.subscription_channels.clone(),
}
}
}
impl<R: ServiceRole> std::fmt::Debug for Peer<R> {
@ -610,6 +649,7 @@ impl<R: ServiceRole> Peer<R> {
info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))),
client_request_metadata: Default::default(),
request_metadata_required: Default::default(),
subscription_channels: Default::default(),
},
rx,
)
@ -641,9 +681,19 @@ impl<R: ServiceRole> Peer<R> {
}
pub async fn send_request_with_option(
&self,
request: R::Req,
options: PeerRequestOptions,
) -> Result<RequestHandle<R>, ServiceError> {
self.send_request_with_option_and_subscription(request, options, None)
.await
}
async fn send_request_with_option_and_subscription(
&self,
mut request: R::Req,
options: PeerRequestOptions,
subscription_sender: Option<SubscriptionChannel<R::PeerNot>>,
) -> Result<RequestHandle<R>, ServiceError> {
let id = self.request_id_provider.next_request_id();
let progress_token = self.progress_token_provider.next_progress_token();
@ -670,6 +720,10 @@ impl<R: ServiceRole> Peer<R> {
} else {
None
};
if let Some(channel) = subscription_sender {
self.subscription_channels_write()
.insert(id.clone(), channel);
}
if self
.tx
.send(PeerSinkMessage::Request {
@ -686,6 +740,7 @@ impl<R: ServiceRole> Peer<R> {
.await
.remove(&progress_token);
}
self.unregister_subscription(&id);
return Err(ServiceError::TransportClosed);
}
Ok(RequestHandle {
@ -698,6 +753,67 @@ impl<R: ServiceRole> Peer<R> {
})
}
#[cfg(feature = "client")]
pub(crate) async fn send_subscription_request(
&self,
request: R::Req,
options: PeerRequestOptions,
channel_capacity: usize,
) -> Result<(RequestHandle<R>, mpsc::Receiver<R::PeerNot>), ServiceError> {
let (sender, receiver) = mpsc::channel(channel_capacity);
let handle = self
.send_request_with_option_and_subscription(
request,
options,
Some((sender, channel_capacity)),
)
.await?;
Ok((handle, receiver))
}
fn subscription_sender(&self, id: &RequestId) -> Option<SubscriptionChannel<R::PeerNot>> {
self.subscription_channels_read().get(id).cloned()
}
pub(crate) fn unregister_subscription(&self, id: &RequestId) {
self.subscription_channels_write().remove(id);
}
fn subscription_channels_read(
&self,
) -> std::sync::RwLockReadGuard<'_, SubscriptionChannelMap<R::PeerNot>> {
match self.subscription_channels.read() {
Ok(channels) => channels,
Err(poisoned) => poisoned.into_inner(),
}
}
fn subscription_channels_write(
&self,
) -> std::sync::RwLockWriteGuard<'_, SubscriptionChannelMap<R::PeerNot>> {
match self.subscription_channels.write() {
Ok(channels) => channels,
Err(poisoned) => poisoned.into_inner(),
}
}
pub(crate) fn try_cancel_request(&self, id: RequestId, reason: Option<String>) {
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(id),
reason,
meta: None,
},
method: crate::model::CancelledNotificationMethod,
extensions: Default::default(),
};
let (responder, _receiver) = tokio::sync::oneshot::channel();
let _ = self.tx.try_send(PeerSinkMessage::Notification {
notification: notification.into(),
responder,
});
}
async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) {
let sender = self
.progress_timeout_watchers
@ -999,6 +1115,7 @@ where
E: std::error::Error + Send + Sync + 'static,
{
let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info);
R::configure_direct_peer(&peer, &service.get_info());
serve_inner(service, transport.into_transport(), peer, peer_rx, ct)
}
@ -1274,19 +1391,67 @@ where
..
})) => {
tracing::info!(?notification, "received notification");
// catch cancelled notification
let mut notification = match notification.try_into() {
Ok::<CancelledNotification, _>(cancelled) => {
if let Some(request_id) = &cancelled.params.request_id {
if let Some(ct) = local_ct_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = cancelled.params.reason, "cancelled");
let cancellation_request_id =
if let Some(cancelled) = R::peer_cancelled_params(&notification) {
let request_id = cancelled.request_id.clone();
if let Some(request_id) = request_id.as_ref() {
if R::IS_CLIENT {
if let Some(responder) =
local_responder_pool.remove(request_id)
{
let _ = responder.send(Err(ServiceError::Cancelled {
reason: cancelled.reason.clone(),
}));
}
} else if let Some(ct) = local_ct_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled");
ct.cancel();
}
}
cancelled.into()
request_id
} else {
None
};
let subscription_id = notification
.get_meta()
.subscription_id()
.or(cancellation_request_id);
if let Some(subscription_id) = subscription_id
&& let Some((sender, capacity)) =
peer.subscription_sender(&subscription_id)
{
match sender.try_send(notification) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
id = %subscription_id,
capacity,
"subscription notification buffer full"
);
if R::IS_CLIENT
&& let Some(responder) =
local_responder_pool.remove(&subscription_id)
{
let _ = responder
.send(Err(ServiceError::SubscriptionLagged { capacity }));
}
peer.unregister_subscription(&subscription_id);
peer.try_cancel_request(
subscription_id,
Some("subscription notification buffer full".to_owned()),
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
peer.unregister_subscription(&subscription_id);
peer.try_cancel_request(
subscription_id,
Some("subscription notification receiver closed".to_owned()),
);
}
}
Err(notification) => notification,
};
continue;
}
let mut notification = notification;
if let Some(progress_token) = notification.progress_token() {
peer.notify_progress_timeout_watcher(progress_token).await;
}
@ -1331,7 +1496,12 @@ where
continue;
};
if let Some(responder) = local_responder_pool.remove(&id) {
let _response_result = responder.send(Err(ServiceError::McpError(error)));
let service_error = if error.is_transport_closed() {
ServiceError::TransportClosed
} else {
ServiceError::McpError(error)
};
let _response_result = responder.send(Err(service_error));
if let Err(_error) = _response_result {
tracing::warn!(%id, "Error sending response");
}

View file

@ -1,6 +1,6 @@
// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected.
#![expect(deprecated)]
use std::{borrow::Cow, sync::Arc, time::Duration};
use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration};
use thiserror::Error;
@ -21,8 +21,9 @@ use crate::{
ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult,
Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo,
ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest,
SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest,
UnsubscribeRequestParams,
SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter,
SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult,
UnsubscribeRequest, UnsubscribeRequestParams,
},
transport::DynamicTransportError,
};
@ -184,10 +185,298 @@ impl ServiceRole for RoleClient {
type PeerInfo = ServerInfo;
type InitializeError = ClientInitializeError;
const IS_CLIENT: bool = true;
fn configure_direct_peer(peer: &Peer<Self>, info: &Self::Info) {
let Some(server_info) = peer.peer_info() else {
return;
};
if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() {
return;
}
peer.set_client_request_metadata(ClientRequestMetadata {
protocol_version: server_info.protocol_version.clone(),
client_info: info.client_info.clone(),
client_capabilities: info.capabilities.clone(),
});
}
fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
match notification {
ServerNotification::CancelledNotification(notification) => Some(&notification.params),
_ => None,
}
}
}
pub type ServerSink = Peer<RoleClient>;
/// Default number of notifications buffered for one subscription.
pub const DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY: usize = 64;
/// How a client-side subscription stream ended.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum SubscriptionEnd {
/// The server returned a final `SubscriptionsListenResult`.
Graceful(SubscriptionsListenResult),
/// The transport closed without a final result. Call `Peer::listen` again
/// after reconnecting; subscription streams are not resumable.
Abrupt,
/// The subscription was explicitly cancelled by either peer.
Cancelled,
/// The consumer did not drain notifications before the channel filled.
Lagged { capacity: usize },
}
/// Handle for one active `subscriptions/listen` request.
#[derive(Debug)]
pub struct Subscription {
id: RequestId,
acknowledged: SubscriptionFilter,
notifications: tokio::sync::mpsc::Receiver<ServerNotification>,
request: Option<RequestHandle<RoleClient>>,
end: Option<SubscriptionEnd>,
}
type SubscriptionResponse =
Result<Result<ServerResult, ServiceError>, tokio::sync::oneshot::error::RecvError>;
struct PendingSubscriptionRequest {
handle: Option<RequestHandle<RoleClient>>,
}
impl PendingSubscriptionRequest {
fn new(handle: RequestHandle<RoleClient>) -> Self {
Self {
handle: Some(handle),
}
}
async fn recv(&mut self) -> Option<SubscriptionResponse> {
let handle = self.handle.as_mut()?;
Some((&mut handle.rx).await)
}
fn take(&mut self) -> Option<RequestHandle<RoleClient>> {
self.handle.take()
}
fn unregister(&self, id: &RequestId) {
if let Some(handle) = self.handle.as_ref() {
handle.peer.unregister_subscription(id);
}
}
fn disarm(&mut self) {
self.handle.take();
}
async fn cancel(&mut self, reason: &'static str) {
if let Some(handle) = self.handle.take() {
let _ = handle.cancel(Some(reason.to_owned())).await;
}
}
}
impl Drop for PendingSubscriptionRequest {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
handle.peer.unregister_subscription(&handle.id);
handle.peer.try_cancel_request(
handle.id,
Some("subscription establishment cancelled".to_owned()),
);
}
}
impl Subscription {
/// Return the originating listen request ID.
pub fn id(&self) -> &RequestId {
&self.id
}
/// Return the notification filter accepted by the server.
pub fn acknowledged(&self) -> &SubscriptionFilter {
&self.acknowledged
}
/// Return the terminal state after this subscription has ended.
pub fn end(&self) -> Option<&SubscriptionEnd> {
self.end.as_ref()
}
/// Receive the next notification, or `None` after the subscription ends.
///
/// A graceful final result and an abrupt transport close are distinguished
/// through [`Self::end`].
///
/// # Errors
///
/// Returns a service or protocol error when the stream carries an invalid
/// message, an unexpected final result, or another request failure.
pub async fn next(&mut self) -> Result<Option<ServerNotification>, ServiceError> {
if self.end.is_some() {
return Ok(None);
}
let Some(request) = self.request.as_mut() else {
self.end = Some(SubscriptionEnd::Abrupt);
return Ok(None);
};
tokio::select! {
biased;
notification = self.notifications.recv() => {
let Some(notification) = notification else {
let response = (&mut request.rx).await;
return self.handle_response(response);
};
if let ServerNotification::CancelledNotification(cancelled) = &notification {
if cancelled.params.request_id.as_ref() != Some(&self.id) {
self.cancel_as_abrupt("subscription cancellation ID mismatch")
.await;
return Err(ServiceError::UnexpectedResponse);
}
self.finish(SubscriptionEnd::Cancelled);
return Ok(None);
}
if notification.get_meta().subscription_id().as_ref() != Some(&self.id) {
self.cancel_as_abrupt("subscription notification ID mismatch")
.await;
return Err(ServiceError::UnexpectedResponse);
}
if !self.accepts(&notification) {
self.cancel_as_abrupt(
"subscription notification was outside the acknowledged filter",
)
.await;
return Err(ServiceError::UnexpectedResponse);
}
Ok(Some(notification))
}
response = &mut request.rx => {
self.handle_response(response)
}
}
}
/// Cancel this subscription.
///
/// # Errors
///
/// Returns a transport error when the cancellation signal cannot be sent.
pub async fn cancel(&mut self) -> Result<(), ServiceError> {
self.cancel_with_reason(None).await
}
/// Cancel this subscription with a diagnostic reason.
///
/// # Errors
///
/// Returns a transport error when the cancellation signal cannot be sent.
pub async fn cancel_with_reason(&mut self, reason: Option<String>) -> Result<(), ServiceError> {
let Some(request) = self.request.take() else {
return Ok(());
};
request.cancel(reason).await?;
self.end = Some(SubscriptionEnd::Cancelled);
Ok(())
}
fn finish(&mut self, end: SubscriptionEnd) {
if let Some(request) = self.request.take() {
request.peer.unregister_subscription(&self.id);
}
self.end = Some(end);
}
async fn cancel_as_abrupt(&mut self, reason: &'static str) {
if let Some(request) = self.request.take() {
let _ = request.cancel(Some(reason.to_owned())).await;
}
self.end = Some(SubscriptionEnd::Abrupt);
}
fn accepts(&self, notification: &ServerNotification) -> bool {
match notification {
ServerNotification::ToolListChangedNotification(_) => {
self.acknowledged.tools_list_changed == Some(true)
}
ServerNotification::PromptListChangedNotification(_) => {
self.acknowledged.prompts_list_changed == Some(true)
}
ServerNotification::ResourceListChangedNotification(_) => {
self.acknowledged.resources_list_changed == Some(true)
}
ServerNotification::ResourceUpdatedNotification(update) => self
.acknowledged
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.contains(&update.params.uri)),
ServerNotification::SubscriptionsAcknowledgedNotification(_)
| ServerNotification::CancelledNotification(_)
| ServerNotification::ProgressNotification(_)
| ServerNotification::LoggingMessageNotification(_)
| ServerNotification::TaskStatusNotification(_)
| ServerNotification::CustomNotification(_) => false,
}
}
fn handle_response(
&mut self,
response: SubscriptionResponse,
) -> Result<Option<ServerNotification>, ServiceError> {
let response = match response {
Ok(response) => response,
Err(_) => {
self.finish(SubscriptionEnd::Abrupt);
return Ok(None);
}
};
let response = match response {
Ok(response) => response,
Err(ServiceError::TransportClosed) => {
self.finish(SubscriptionEnd::Abrupt);
return Ok(None);
}
Err(ServiceError::SubscriptionLagged { capacity }) => {
self.finish(SubscriptionEnd::Lagged { capacity });
return Ok(None);
}
Err(error) => {
self.finish(SubscriptionEnd::Abrupt);
return Err(error);
}
};
let ServerResult::SubscriptionsListenResult(result) = response else {
self.finish(SubscriptionEnd::Abrupt);
return Err(ServiceError::UnexpectedResponse);
};
if !result.result_type.is_complete()
|| result.meta.subscription_id().as_ref() != Some(&self.id)
{
self.finish(SubscriptionEnd::Abrupt);
return Err(ServiceError::UnexpectedResponse);
}
self.finish(SubscriptionEnd::Graceful(result));
Ok(None)
}
}
impl Drop for Subscription {
fn drop(&mut self) {
let Some(request) = self.request.take() else {
return;
};
request.peer.unregister_subscription(&self.id);
request.peer.try_cancel_request(
self.id.clone(),
Some("subscription handle dropped".to_owned()),
);
}
}
/// Selects how a client establishes its MCP lifecycle.
///
/// Existing [`ServiceExt::serve`] behavior remains legacy initialization.
@ -621,6 +910,106 @@ macro_rules! method {
}
impl Peer<RoleClient> {
/// Open a long-lived notification subscription and wait for its acknowledgment.
///
/// Notifications routed to the returned [`Subscription`] are not also
/// delivered through [`ClientHandler`](crate::ClientHandler) callbacks.
///
/// # Errors
///
/// Returns a service, transport, or protocol error when the request cannot
/// be established or the acknowledgment is invalid.
pub async fn listen(
&self,
notifications: SubscriptionFilter,
) -> Result<Subscription, ServiceError> {
self.listen_with_channel_capacity_inner(
notifications,
DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY,
)
.await
}
/// Open a subscription with an explicit notification buffer capacity.
///
/// Notifications routed to the returned [`Subscription`] are not also
/// delivered through [`ClientHandler`](crate::ClientHandler) callbacks.
///
/// # Errors
///
/// Returns a service, transport, or protocol error when the request cannot
/// be established or the acknowledgment is invalid.
pub async fn listen_with_capacity(
&self,
notifications: SubscriptionFilter,
channel_capacity: NonZeroUsize,
) -> Result<Subscription, ServiceError> {
self.listen_with_channel_capacity_inner(notifications, channel_capacity.get())
.await
}
async fn listen_with_channel_capacity_inner(
&self,
notifications: SubscriptionFilter,
channel_capacity: usize,
) -> Result<Subscription, ServiceError> {
let request = ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new(
SubscriptionsListenRequestParams::new(notifications.clone()),
));
let (handle, mut subscription_notifications) = self
.send_subscription_request(request, PeerRequestOptions::no_options(), channel_capacity)
.await?;
let id = handle.id.clone();
let mut pending = PendingSubscriptionRequest::new(handle);
tokio::select! {
biased;
notification = subscription_notifications.recv() => {
let Some(notification) = notification else {
pending.cancel("subscription stream closed before acknowledgment").await;
return Err(ServiceError::TransportClosed);
};
if notification.get_meta().subscription_id().as_ref() != Some(&id) {
pending.cancel("subscription acknowledgment ID mismatch").await;
return Err(ServiceError::UnexpectedResponse);
}
let ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
) = notification else {
pending.cancel("notification received before subscription acknowledgment").await;
return Err(ServiceError::UnexpectedResponse);
};
let accepted = acknowledgment.params.notifications;
if !accepted.is_subset_of(&notifications) {
pending.cancel("subscription acknowledged an unrequested filter").await;
return Err(ServiceError::UnexpectedResponse);
}
let Some(handle) = pending.take() else {
return Err(ServiceError::TransportClosed);
};
Ok(Subscription {
id,
acknowledged: accepted,
notifications: subscription_notifications,
request: Some(handle),
end: None,
})
}
response = pending.recv() => {
pending.unregister(&id);
pending.disarm();
let Some(response) = response else {
return Err(ServiceError::TransportClosed);
};
match response {
Ok(Err(error)) => Err(error),
Ok(Ok(_)) => Err(ServiceError::UnexpectedResponse),
Err(_) => Err(ServiceError::TransportClosed),
}
}
}
}
/// Discover the server's supported protocol versions and capabilities.
///
/// The high-level client currently exposes this peer only after initialization;
@ -716,8 +1105,18 @@ impl Peer<RoleClient> {
method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult);
method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult);
method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult);
method!(peer_req subscribe SubscribeRequest(SubscribeRequestParams) );
method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams));
method!(
#[deprecated(
note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28"
)]
peer_req subscribe SubscribeRequest(SubscribeRequestParams)
);
method!(
#[deprecated(
note = "resources/unsubscribe is legacy-only; cancel the Subscription handle instead"
)]
peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams)
);
method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult);
method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult);

View file

@ -1,8 +1,8 @@
// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected.
#![expect(deprecated)]
use std::borrow::Cow;
#[cfg(feature = "elicitation")]
use std::collections::HashSet;
use std::{borrow::Cow, sync::Arc};
use thiserror::Error;
#[cfg(feature = "elicitation")]
@ -20,7 +20,8 @@ use crate::{
ProgressNotification, ProgressNotificationParam, PromptListChangedNotification,
ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification,
ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest,
ServerResult, ToolListChangedNotification,
ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification,
SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification,
},
transport::DynamicTransportError,
};
@ -41,6 +42,13 @@ impl ServiceRole for RoleServer {
type InitializeError = ServerInitializeError;
const IS_CLIENT: bool = false;
fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
match notification {
ClientNotification::CancelledNotification(notification) => Some(&notification.params),
_ => None,
}
}
}
/// It represents the error that may occur when serving the server.
@ -98,6 +106,284 @@ impl ServerInitializeError {
}
pub type ClientSink = Peer<RoleServer>;
/// Failure to send a notification through a [`SubscriptionSink`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SubscriptionSendError {
#[error("subscription is no longer active")]
SubscriptionClosed,
#[error("notification is not allowed on a subscription stream: {0}")]
UnsupportedNotification(&'static str),
#[error("notification was not accepted for this subscription: {0}")]
NotificationNotAccepted(&'static str),
#[error(transparent)]
Service(#[from] ServiceError),
}
/// A server-side notification sink scoped to one `subscriptions/listen` request.
///
/// The sink applies the accepted filter and adds the subscription request ID to
/// every notification it sends.
#[derive(Debug, Clone)]
pub struct SubscriptionSink {
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
}
impl SubscriptionSink {
fn new(
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
) -> Self {
Self {
peer,
id,
accepted,
active,
}
}
/// Return the JSON-RPC ID of the originating listen request.
pub fn id(&self) -> &RequestId {
&self.id
}
/// Return the filter accepted for this subscription.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Send an allowed change notification with subscription metadata attached.
///
/// # Errors
///
/// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request
/// ends, a filter error for disallowed notifications, or a transport error.
pub async fn send(
&self,
mut notification: ServerNotification,
) -> Result<(), SubscriptionSendError> {
if self.active.is_cancelled() {
return Err(SubscriptionSendError::SubscriptionClosed);
}
match &notification {
ServerNotification::ToolListChangedNotification(_) => {
if self.accepted.tools_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/tools/list_changed",
));
}
}
ServerNotification::PromptListChangedNotification(_) => {
if self.accepted.prompts_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/prompts/list_changed",
));
}
}
ServerNotification::ResourceListChangedNotification(_) => {
if self.accepted.resources_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/list_changed",
));
}
}
ServerNotification::ResourceUpdatedNotification(update) => {
let accepted = self
.accepted
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.contains(&update.params.uri));
if !accepted {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/updated",
));
}
}
ServerNotification::SubscriptionsAcknowledgedNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/subscriptions/acknowledged",
));
}
ServerNotification::CancelledNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/cancelled",
));
}
ServerNotification::ProgressNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/progress",
));
}
ServerNotification::LoggingMessageNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/message",
));
}
ServerNotification::TaskStatusNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/tasks/status",
));
}
ServerNotification::CustomNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"custom notification",
));
}
}
notification
.get_meta_mut()
.set_subscription_id(self.id.clone());
self.peer.send_notification(notification).await?;
Ok(())
}
/// Send `notifications/tools/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ToolListChangedNotification(
ToolListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/prompts/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::PromptListChangedNotification(
PromptListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceListChangedNotification(
ResourceListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/updated` for an accepted URI.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_updated(
&self,
uri: impl Into<String>,
) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceUpdatedNotification(
ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)),
))
.await
}
}
/// Context for one established server-side notification subscription.
///
/// The acknowledgment has already been sent before this context is handed to
/// [`ServerHandler::listen`](crate::ServerHandler::listen).
#[derive(Debug)]
pub struct SubscriptionContext {
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: Arc<SubscriptionFilter>,
sink: SubscriptionSink,
_active_guard: DropGuard,
}
impl SubscriptionContext {
pub(crate) async fn establish(
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: SubscriptionFilter,
) -> Result<Self, ErrorData> {
let active = request.ct.child_token();
let accepted = Arc::new(accepted);
let sink = SubscriptionSink::new(
request.peer.clone(),
request.id.clone(),
accepted.clone(),
active.clone(),
);
let mut acknowledgment = SubscriptionsAcknowledgedNotification::new(
SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()),
);
let mut meta = NotificationMetaObject::new();
meta.set_subscription_id(request.id.clone());
acknowledgment.extensions.insert(meta);
request
.peer
.send_notification(ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
))
.await
.map_err(|error| {
ErrorData::internal_error(
format!("failed to acknowledge subscription: {error}"),
None,
)
})?;
Ok(Self {
request,
requested,
accepted,
sink,
_active_guard: active.drop_guard(),
})
}
/// Return the filter requested by the client.
pub fn requested(&self) -> &SubscriptionFilter {
&self.requested
}
/// Return the subset accepted by the server.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Return a cloneable, filter-enforcing notification sink.
pub fn sink(&self) -> &SubscriptionSink {
&self.sink
}
/// Wait until the subscription request is cancelled.
pub async fn cancelled(&self) {
self.request.ct.cancelled().await;
}
/// Access the underlying request context.
pub fn request_context(&self) -> &RequestContext<RoleServer> {
&self.request
}
}
impl<S: Service<RoleServer>> ServiceExt<RoleServer> for S {
fn serve_with_ct<T, E, A>(
self,

View file

@ -6,7 +6,7 @@ use http::Response;
use http_body::Body;
use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody};
use sse_stream::{KeepAlive, Sse, SseBody};
use tokio_util::sync::CancellationToken;
use tokio_util::sync::{CancellationToken, DropGuard};
use super::http_header::EVENT_STREAM_MIME_TYPE;
use crate::model::{ClientJsonRpcMessage, ServerJsonRpcMessage};
@ -57,6 +57,25 @@ impl sse_stream::Timer for TokioTimer {
}
}
pin_project_lite::pin_project! {
struct CancelOnDropStream<S> {
#[pin]
inner: S,
_drop_guard: DropGuard,
}
}
impl<S: futures::Stream> futures::Stream for CancelOnDropStream<S> {
type Item = S::Item;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.project().inner.poll_next(cx)
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ServerSseMessage {
@ -108,6 +127,7 @@ pub(crate) fn sse_stream_response(
ct: CancellationToken,
) -> Response<BoxBody<Bytes, Infallible>> {
use futures::StreamExt;
let cancelled = ct.clone();
let stream = stream
.map(|message| {
let mut sse = if let Some(ref msg) = message.message {
@ -126,7 +146,11 @@ pub(crate) fn sse_stream_response(
Result::<Sse, Infallible>::Ok(sse)
})
.take_until(async move { ct.cancelled().await });
.take_until(async move { cancelled.cancelled().await });
let stream = CancelOnDropStream {
inner: stream,
_drop_guard: ct.drop_guard(),
};
let stream = SseBody::new(stream);
let stream = match keep_alive {
@ -140,6 +164,7 @@ pub(crate) fn sse_stream_response(
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, EVENT_STREAM_MIME_TYPE)
.header(http::header::CACHE_CONTROL, "no-cache")
.header("X-Accel-Buffering", "no")
.body(stream)
.expect("valid response")
}

View file

@ -30,6 +30,7 @@ use crate::{
};
type BoxedSseStream = BoxStream<'static, Result<Sse, SseError>>;
type SseTaskResult<E> = (Option<RequestId>, Result<(), StreamableHttpError<E>>);
const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
fn build_request_headers(
@ -683,7 +684,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
}
fn spawn_common_stream(
streams: &mut tokio::task::JoinSet<Result<(), StreamableHttpError<C::Error>>>,
streams: &mut tokio::task::JoinSet<SseTaskResult<C::Error>>,
client: C,
session_id: Arc<str>,
config: &StreamableHttpClientTransportConfig,
@ -699,7 +700,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
let max_sse_event_size = config.max_sse_event_size;
streams.spawn(async move {
match client
let result = match client
.get_stream_with_max_sse_event_size(
uri,
session_id.clone(),
@ -739,7 +740,8 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
tracing::error!("fail to get common stream: {error}");
Err(error)
}
}
};
(None, result)
});
}
@ -887,7 +889,10 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
));
}
};
let mut session_id: Option<Arc<str>> = if let Some(session_id) = session_id {
let mut uses_modern_http = !is_legacy_startup;
let mut session_id: Option<Arc<str>> = if uses_modern_http {
None
} else if let Some(session_id) = session_id {
Some(session_id.into())
} else {
if !self.config.allow_stateless {
@ -951,10 +956,14 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
enum Event<W: Worker, E: std::error::Error + Send + Sync + 'static> {
ClientMessage(WorkerSendRequest<W>),
ServerMessage(ServerJsonRpcMessage),
StreamResult(Result<(), StreamableHttpError<E>>),
StreamResult {
request_id: Option<RequestId>,
result: Result<(), StreamableHttpError<E>>,
},
}
let mut streams = tokio::task::JoinSet::new();
let mut pending_stream_response_ids = HashSet::new();
let mut request_stream_cancellations = HashMap::<RequestId, CancellationToken>::new();
let mut awaiting_fallback_initialized = false;
if let Some(session_id) = &session_id {
Self::spawn_common_stream(
@ -990,7 +999,15 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
terminated_stream = streams.join_next(), if !streams.is_empty() => {
match terminated_stream {
Some(result) => {
Event::StreamResult(result.map_err(StreamableHttpError::TokioJoinError).and_then(std::convert::identity))
match result {
Ok((request_id, result)) => {
Event::StreamResult { request_id, result }
}
Err(error) => Event::StreamResult {
request_id: None,
result: Err(StreamableHttpError::TokioJoinError(error)),
},
}
}
None => {
continue
@ -1001,6 +1018,25 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
match event {
Event::ClientMessage(send_request) => {
let WorkerSendRequest { message, responder } = send_request;
let cancellation_request_id = match &message {
ClientJsonRpcMessage::Notification(notification) => {
match &notification.notification {
ClientNotification::CancelledNotification(cancelled) => {
cancelled.params.request_id.clone()
}
_ => None,
}
}
_ => None,
};
if uses_modern_http && let Some(request_id) = cancellation_request_id {
if let Some(stream_ct) = request_stream_cancellations.remove(&request_id) {
stream_ct.cancel();
}
pending_stream_response_ids.remove(&request_id);
let _ = responder.send(Ok(()));
continue;
}
let is_fallback_initialize = saved_init_request.is_none()
&& matches!(
&message,
@ -1021,6 +1057,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
&& streams.is_empty(),
"discover bootstrap must not create session state"
);
uses_modern_http = false;
let response = self
.client
@ -1228,6 +1265,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
Ok(())
}
Ok(StreamableHttpPostResponse::Sse(stream, ..)) => {
let stream_request_id = request_id.clone();
Self::mark_stream_response_pending(
&mut pending_stream_response_ids,
request_id,
@ -1242,12 +1280,24 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
config.max_sse_event_size,
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream(
sse_stream,
sse_worker_tx.clone(),
true,
transport_task_ct.child_token(),
));
let stream_ct = transport_task_ct.child_token();
if uses_modern_http
&& let Some(request_id) =
stream_request_id.as_ref()
{
request_stream_cancellations.insert(
request_id.clone(),
stream_ct.clone(),
);
}
let stream_tx = sse_worker_tx.clone();
streams.spawn(async move {
let result = Self::execute_sse_stream(
sse_stream, stream_tx, true, stream_ct,
)
.await;
(stream_request_id, result)
});
tracing::trace!("got new sse stream after re-init");
Ok(())
}
@ -1278,6 +1328,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
Ok(())
}
Ok(StreamableHttpPostResponse::Sse(stream, ..)) => {
let stream_request_id = request_id.clone();
Self::mark_stream_response_pending(
&mut pending_stream_response_ids,
request_id,
@ -1292,12 +1343,20 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
config.max_sse_event_size,
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream(
sse_stream,
sse_worker_tx.clone(),
true,
transport_task_ct.child_token(),
));
let stream_ct = transport_task_ct.child_token();
if uses_modern_http && let Some(request_id) = stream_request_id.as_ref()
{
request_stream_cancellations
.insert(request_id.clone(), stream_ct.clone());
}
let stream_tx = sse_worker_tx.clone();
streams.spawn(async move {
let result = Self::execute_sse_stream(
sse_stream, stream_tx, true, stream_ct,
)
.await;
(stream_request_id, result)
});
tracing::trace!("got new sse stream");
Ok(())
}
@ -1322,6 +1381,11 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
let _ = responder.send(send_result);
}
Event::ServerMessage(mut json_rpc_message) => {
if let Some(response_id) = Self::server_response_id(&json_rpc_message)
&& let Some(stream_ct) = request_stream_cancellations.remove(response_id)
{
stream_ct.cancel();
}
Self::clear_stream_response_pending(
&mut pending_stream_response_ids,
&json_rpc_message,
@ -1336,7 +1400,26 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
break 'main_loop Err(e);
}
}
Event::StreamResult(result) => {
Event::StreamResult { request_id, result } => {
if let Some(request_id) = request_id {
Self::drain_queued_stream_messages(
&mut sse_worker_rx,
&mut context,
&mut pending_stream_response_ids,
)
.await?;
request_stream_cancellations.remove(&request_id);
if pending_stream_response_ids.remove(&request_id) {
context
.send_to_handler(ServerJsonRpcMessage::error(
ErrorData::transport_closed(
"streamable HTTP response stream closed before its final response",
),
Some(request_id),
))
.await?;
}
}
if result.is_err() {
tracing::warn!(
"sse client event stream terminated with error: {:?}",

View file

@ -30,7 +30,7 @@ use crate::{
ProtocolVersion, RequestId, ServerJsonRpcMessage,
},
serve_server,
service::serve_directly_with_ct,
service::{serve_directly_with_ct, uses_legacy_lifecycle},
transport::{
OneshotTransport, TransportAdapterIdentity,
common::{
@ -259,8 +259,9 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b
clippy::result_large_err,
reason = "BoxResponse is intentionally large; matches other handlers in this file"
)]
// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy.
// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request.
// SEP-2567: sessions are removed from the discover lifecycle. Validate
// protocol-version consistency, then classify the request with the shared
// lifecycle helper.
fn is_legacy_request(
message: Option<&ClientJsonRpcMessage>,
headers: &HeaderMap,
@ -280,6 +281,17 @@ fn is_legacy_request(
validate_request_protocol_version_meta(headers, message)?;
}
let uses_discover_lifecycle = matches!(
message,
Some(ClientJsonRpcMessage::Request(req))
if !matches!(&req.request, ClientRequest::InitializeRequest(_))
&& req
.request
.get_meta()
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
.is_empty()
);
let from_body = match message {
Some(ClientJsonRpcMessage::Request(req)) => match &req.request {
ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()),
@ -295,7 +307,10 @@ fn is_legacy_request(
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
})
.unwrap_or(ProtocolVersion::V_2025_03_26);
Ok(version < ProtocolVersion::V_2026_07_28)
Ok(uses_legacy_lifecycle(
Some(&version),
uses_discover_lifecycle,
))
}
fn method_not_allowed_response() -> BoxResponse {

View file

@ -1160,21 +1160,24 @@
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/Request13"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1567,6 +1570,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1582,7 +1601,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1598,7 +1617,7 @@
"params"
]
},
"Request12": {
"Request13": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1699,10 +1718,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscribeRequestMethod"
"$ref": "#/definitions/SubscriptionsListenRequestMethod"
},
"params": {
"$ref": "#/definitions/SubscribeRequestParams"
"$ref": "#/definitions/SubscriptionsListenRequestParams"
}
},
"required": [
@ -1715,10 +1734,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/UnsubscribeRequestMethod"
"$ref": "#/definitions/SubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/UnsubscribeRequestParams"
"$ref": "#/definitions/SubscribeRequestParams"
}
},
"required": [
@ -1731,10 +1750,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
"$ref": "#/definitions/UnsubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
"$ref": "#/definitions/UnsubscribeRequestParams"
}
},
"required": [
@ -2303,6 +2322,77 @@
"uri"
]
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsListenRequestMethod": {
"type": "string",
"format": "const",
"const": "subscriptions/listen"
},
"SubscriptionsListenRequestParams": {
"description": "Parameters for opening a long-lived notification subscription.",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level metadata. Required by the draft wire schema.",
"type": "object",
"properties": {
"io.modelcontextprotocol/clientCapabilities": {
"$ref": "#/definitions/ClientCapabilities"
},
"io.modelcontextprotocol/clientInfo": {
"$ref": "#/definitions/Implementation"
},
"io.modelcontextprotocol/logLevel": {
"$ref": "#/definitions/LoggingLevel"
},
"io.modelcontextprotocol/protocolVersion": {
"type": "string"
},
"progressToken": {
"$ref": "#/definitions/ProgressToken"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/clientCapabilities"
]
},
"notifications": {
"description": "Notification categories requested for this stream.",
"allOf": [
{
"$ref": "#/definitions/SubscriptionFilter"
}
]
}
},
"required": [
"_meta",
"notifications"
]
},
"TaskMetadata": {
"description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).",
"type": "object",

View file

@ -1160,21 +1160,24 @@
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/Request13"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1567,6 +1570,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1582,7 +1601,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1598,7 +1617,7 @@
"params"
]
},
"Request12": {
"Request13": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1699,10 +1718,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscribeRequestMethod"
"$ref": "#/definitions/SubscriptionsListenRequestMethod"
},
"params": {
"$ref": "#/definitions/SubscribeRequestParams"
"$ref": "#/definitions/SubscriptionsListenRequestParams"
}
},
"required": [
@ -1715,10 +1734,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/UnsubscribeRequestMethod"
"$ref": "#/definitions/SubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/UnsubscribeRequestParams"
"$ref": "#/definitions/SubscribeRequestParams"
}
},
"required": [
@ -1731,10 +1750,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
"$ref": "#/definitions/UnsubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
"$ref": "#/definitions/UnsubscribeRequestParams"
}
},
"required": [
@ -2303,6 +2322,77 @@
"uri"
]
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsListenRequestMethod": {
"type": "string",
"format": "const",
"const": "subscriptions/listen"
},
"SubscriptionsListenRequestParams": {
"description": "Parameters for opening a long-lived notification subscription.",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level metadata. Required by the draft wire schema.",
"type": "object",
"properties": {
"io.modelcontextprotocol/clientCapabilities": {
"$ref": "#/definitions/ClientCapabilities"
},
"io.modelcontextprotocol/clientInfo": {
"$ref": "#/definitions/Implementation"
},
"io.modelcontextprotocol/logLevel": {
"$ref": "#/definitions/LoggingLevel"
},
"io.modelcontextprotocol/protocolVersion": {
"type": "string"
},
"progressToken": {
"$ref": "#/definitions/ProgressToken"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/clientCapabilities"
]
},
"notifications": {
"description": "Notification categories requested for this stream.",
"allOf": [
{
"$ref": "#/definitions/SubscriptionFilter"
}
]
}
},
"required": [
"_meta",
"notifications"
]
},
"TaskMetadata": {
"description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).",
"type": "object",

View file

@ -1650,6 +1650,9 @@
{
"$ref": "#/definitions/Notification5"
},
{
"$ref": "#/definitions/Notification6"
},
{
"$ref": "#/definitions/CustomNotification"
}
@ -2222,6 +2225,21 @@
]
},
"Notification5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod"
},
"params": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams"
}
},
"required": [
"method",
"params"
]
},
"Notification6": {
"type": "object",
"properties": {
"method": {
@ -3295,6 +3313,9 @@
{
"$ref": "#/definitions/ReadResourceResult"
},
{
"$ref": "#/definitions/SubscriptionsListenResult"
},
{
"$ref": "#/definitions/ListToolsResult"
},
@ -3438,6 +3459,75 @@
"format": "const",
"const": "string"
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsAcknowledgedNotificationMethod": {
"type": "string",
"format": "const",
"const": "notifications/subscriptions/acknowledged"
},
"SubscriptionsAcknowledgedNotificationParams": {
"description": "Parameters reporting the accepted subset of a subscription filter.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/NotificationMetaObject"
},
"notifications": {
"$ref": "#/definitions/SubscriptionFilter"
}
},
"required": [
"notifications"
]
},
"SubscriptionsListenResult": {
"description": "Final response indicating that a subscription ended gracefully.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/SubscriptionsListenResultMeta"
},
"resultType": {
"$ref": "#/definitions/ResultType"
}
},
"required": [
"resultType",
"_meta"
]
},
"SubscriptionsListenResultMeta": {
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": {
"$ref": "#/definitions/NumberOrString"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/subscriptionId"
]
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).",
"type": "object",

View file

@ -1650,6 +1650,9 @@
{
"$ref": "#/definitions/Notification5"
},
{
"$ref": "#/definitions/Notification6"
},
{
"$ref": "#/definitions/CustomNotification"
}
@ -2222,6 +2225,21 @@
]
},
"Notification5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod"
},
"params": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams"
}
},
"required": [
"method",
"params"
]
},
"Notification6": {
"type": "object",
"properties": {
"method": {
@ -3295,6 +3313,9 @@
{
"$ref": "#/definitions/ReadResourceResult"
},
{
"$ref": "#/definitions/SubscriptionsListenResult"
},
{
"$ref": "#/definitions/ListToolsResult"
},
@ -3438,6 +3459,75 @@
"format": "const",
"const": "string"
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsAcknowledgedNotificationMethod": {
"type": "string",
"format": "const",
"const": "notifications/subscriptions/acknowledged"
},
"SubscriptionsAcknowledgedNotificationParams": {
"description": "Parameters reporting the accepted subset of a subscription filter.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/NotificationMetaObject"
},
"notifications": {
"$ref": "#/definitions/SubscriptionFilter"
}
},
"required": [
"notifications"
]
},
"SubscriptionsListenResult": {
"description": "Final response indicating that a subscription ended gracefully.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/SubscriptionsListenResultMeta"
},
"resultType": {
"$ref": "#/definitions/ResultType"
}
},
"required": [
"resultType",
"_meta"
]
},
"SubscriptionsListenResultMeta": {
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": {
"$ref": "#/definitions/NumberOrString"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/subscriptionId"
]
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).",
"type": "object",

View file

@ -281,9 +281,9 @@ fn client_info(protocol_version: ProtocolVersion) -> ClientInfo {
.with_protocol_version(protocol_version)
}
fn server_info_2026() -> ServerInfo {
fn server_info(protocol_version: ProtocolVersion) -> ServerInfo {
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
info.protocol_version = ProtocolVersion::V_2026_07_28;
info.protocol_version = protocol_version;
info
}
@ -302,6 +302,7 @@ where
.run_until(async move {
let (server_transport, client_transport) = tokio::io::duplex(8192);
let server_peer_info = client_info(client_protocol);
let client_peer_info = server_info(server_peer_info.protocol_version.clone());
let server_task = tokio::task::spawn_local(async move {
let running = serve_directly::<RoleServer, _, _, _, _>(
server,
@ -315,7 +316,7 @@ where
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(server_info_2026()),
Some(client_peer_info),
);
let result = body(client).await;
@ -579,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(server_info_2026()),
Some(server_info(ProtocolVersion::V_2026_07_28)),
);
let result = client

View file

@ -1,4 +1,5 @@
#![cfg(not(feature = "local"))]
#![allow(deprecated)]
use std::sync::Arc;
use rmcp::{

Some files were not shown because too many files have changed in this diff Show more