feat: add subscription listen streams (SEP-2575) (#1000)
* feat: add subscription listen streams * refactor: reuse legacy request helper
This commit is contained in:
parent
8ee9ee68f1
commit
ff1a715b6e
29 changed files with 3617 additions and 223 deletions
73
.github/workflows/conformance.yml
vendored
73
.github/workflows/conformance.yml
vendored
|
|
@ -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
103
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 생략
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬ification) {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬ification.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) = ¬ification {
|
||||
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(¬ification) {
|
||||
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(¬ifications) {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(¬ification.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 ¬ification {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ¬ification.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: {:?}",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue