diff --git a/crates/agentkit-mcp/src/lib.rs b/crates/agentkit-mcp/src/lib.rs index 56de871..9303df6 100644 --- a/crates/agentkit-mcp/src/lib.rs +++ b/crates/agentkit-mcp/src/lib.rs @@ -1648,14 +1648,18 @@ impl McpConnection { if !resources.is_empty() { filter.resource_subscriptions = Some(resources.iter().cloned().collect()); } - Some( - subscriptions::SubscriptionController::start( - new_peer.clone(), - filter, - candidate_sink.clone(), + if subscription_filter_is_empty(&filter) { + None + } else { + Some( + subscriptions::SubscriptionController::start( + new_peer.clone(), + filter, + candidate_sink.clone(), + ) + .await?, ) - .await?, - ) + } } else { // Legacy subscriptions are session-scoped and must be replayed on // the candidate before retiring the authoritative old transport. @@ -1771,12 +1775,13 @@ impl McpConnection { if !resources.is_empty() { filter.resource_subscriptions = Some(resources.iter().cloned().collect()); } - let controller = subscriptions::SubscriptionController::start( - self.peer(), - filter, - self.sink.clone(), - ) - .await?; + if subscription_filter_is_empty(&filter) { + return Ok(()); + } + let mut sink = self.sink.clone(); + sink.active = self.inner.lock().await.service().active.clone(); + let controller = + subscriptions::SubscriptionController::start(self.peer(), filter, sink).await?; *subscriptions = Some(controller); } Ok(()) @@ -1813,6 +1818,18 @@ impl McpConnection { if let Some(controller) = self.subscriptions.lock().await.as_ref() { return controller.subscribe_resource(uri).await; } + if self.modern() { + // Resource-only servers have no catalog stream to extend. Start a + // modern stream with the explicit URI, never a legacy subscribe RPC. + let mut filter = self.subscription_filter(); + filter.resource_subscriptions = Some(vec![uri]); + let mut sink = self.sink.clone(); + sink.active = self.inner.lock().await.service().active.clone(); + let controller = + subscriptions::SubscriptionController::start(self.peer(), filter, sink).await?; + *self.subscriptions.lock().await = Some(controller); + return Ok(()); + } self.peer() .subscribe(rmcp_model::SubscribeRequestParams::new(uri.clone())) .await @@ -1836,6 +1853,10 @@ impl McpConnection { if let Some(controller) = self.subscriptions.lock().await.as_ref() { return controller.unsubscribe_resource(uri).await; } + if self.modern() { + // No modern controller means no resource subscription exists. + return Ok(()); + } self.peer() .unsubscribe(rmcp_model::UnsubscribeRequestParams::new(uri.clone())) .await @@ -2185,12 +2206,34 @@ impl McpConnection { fn filter_for_capabilities(capabilities: &McpServerCapabilities) -> McpSubscriptionFilter { let mut filter = McpSubscriptionFilter::default(); - filter.tools_list_changed = capabilities.tools.as_ref().map(|_| true); - filter.resources_list_changed = capabilities.resources.as_ref().map(|_| true); - filter.prompts_list_changed = capabilities.prompts.as_ref().map(|_| true); + filter.tools_list_changed = capabilities + .tools + .as_ref() + .and_then(|c| c.list_changed) + .filter(|enabled| *enabled); + filter.resources_list_changed = capabilities + .resources + .as_ref() + .and_then(|c| c.list_changed) + .filter(|enabled| *enabled); + filter.prompts_list_changed = capabilities + .prompts + .as_ref() + .and_then(|c| c.list_changed) + .filter(|enabled| *enabled); filter } +fn subscription_filter_is_empty(filter: &McpSubscriptionFilter) -> bool { + filter.tools_list_changed != Some(true) + && filter.resources_list_changed != Some(true) + && filter.prompts_list_changed != Some(true) + && filter + .resource_subscriptions + .as_ref() + .is_none_or(Vec::is_empty) +} + fn client_lifecycle() -> ClientLifecycleMode { ClientLifecycleMode::Auto { // Auto must retry discovery at a mutually supported version before an diff --git a/crates/agentkit-mcp/tests/modern_http.rs b/crates/agentkit-mcp/tests/modern_http.rs index c56e2c8..530ddee 100644 --- a/crates/agentkit-mcp/tests/modern_http.rs +++ b/crates/agentkit-mcp/tests/modern_http.rs @@ -148,6 +148,22 @@ async fn rpc( } else { capabilities.build() }; + let capabilities = match token { + "Bearer caps-missing" => json!({}), + "Bearer caps-empty" => json!({"tools":{},"resources":{},"prompts":{}}), + "Bearer caps-false" => { + json!({"tools":{"listChanged":false},"resources":{"listChanged":false},"prompts":{"listChanged":false}}) + } + "Bearer caps-resource" => json!({"resources":{"subscribe":true}}), + "Bearer caps-mixed" => { + json!({"tools":{"listChanged":true},"resources":{"listChanged":false},"prompts":{}}) + } + "Bearer caps-true" => { + json!({"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}}) + } + _ => serde_json::to_value(capabilities).unwrap(), + }; + let capabilities = serde_json::from_value(capabilities).unwrap(); serde_json::to_value(DiscoverResult::new( vec![ProtocolVersion::V_2026_07_28], capabilities, @@ -155,6 +171,15 @@ async fn rpc( .unwrap() } "subscriptions/listen" => { + if matches!( + token, + "Bearer caps-missing" | "Bearer caps-empty" | "Bearer caps-false" + ) { + return Json(json!({"jsonrpc":"2.0", "id":id, "error":{ + "code":-32601, "message":"subscriptions not supported" + }})) + .into_response(); + } let (frames, receiver) = mpsc::unbounded_channel(); let filter = message["params"]["notifications"].clone(); if token != "Bearer hold" && !state.hold_ack.load(Ordering::SeqCst) { @@ -736,3 +761,100 @@ async fn auto_http_no_common_version_does_not_initialize() { }) .await; } + +#[tokio::test] +async fn advertised_catalog_flags_gate_listen_on_connect_discover_and_reconnect() { + bounded(async { + for profile in ["missing", "empty", "false", "mixed", "true"] { + let mut fixture = Fixture::start().await; + let token = format!("caps-{profile}"); + if let McpTransportBinding::StreamableHttp(binding) = &mut fixture.config.transport { + *binding = binding.clone().with_bearer_token(&token); + } + let connection = McpConnection::connect(&fixture.config).await.unwrap(); + let expected = match profile { + "mixed" => Some(json!({"toolsListChanged":true})), + "true" => Some(json!({"toolsListChanged":true,"resourcesListChanged":true,"promptsListChanged":true})), + _ => None, + }; + let initial = if let Some(filter) = &expected { + let stream = fixture.listen().await; + assert_eq!(&stream.filter, filter); + Some(stream) + } else { + assert!(connection.subscription_status().await.is_none()); + None + }; + connection.discover().await.unwrap(); + connection.discover().await.unwrap(); + connection.resolve_auth(auth(&token)).await.unwrap(); + connection.discover().await.unwrap(); + let replacement = if let Some(filter) = &expected { + let stream = fixture.listen().await; + assert_eq!(&stream.filter, filter); + Some(stream) + } else { + None + }; + let requests = fixture.requests.lock().await; + assert_eq!(requests.iter().filter(|(_, r)| r["method"] == "subscriptions/listen").count(), if expected.is_some() { 2 } else { 0 }, "{profile}"); + drop(requests); + connection.close().await.unwrap(); + drop((initial, replacement)); + } + }).await; +} + +#[tokio::test] +async fn resource_only_server_starts_modern_stream_lazily_and_replays_on_reconnect() { + bounded(async { + let mut fixture = Fixture::start().await; + if let McpTransportBinding::StreamableHttp(binding) = &mut fixture.config.transport { + *binding = binding.clone().with_bearer_token("caps-resource"); + } + let connection = McpConnection::connect(&fixture.config).await.unwrap(); + connection.discover().await.unwrap(); + connection + .unsubscribe_resource("memo:absent") + .await + .unwrap(); + assert!(connection.subscription_status().await.is_none()); + assert!( + !fixture + .requests + .lock() + .await + .iter() + .any(|(_, r)| r["method"] == "subscriptions/listen") + ); + // Reconnect before the first stream exists: lazy startup must use the + // replacement transport's active notification sink. + connection.resolve_auth(auth("caps-resource")).await.unwrap(); + let mut events = connection.subscribe_events(); + connection.subscribe_resource("memo:one").await.unwrap(); + let initial = fixture.listen().await; + assert_eq!( + initial.filter, + json!({"resourceSubscriptions":["memo:one"]}) + ); + initial.notify("notifications/resources/updated", json!({"uri":"memo:one"})); + assert!(matches!(events.recv().await.unwrap(), McpServerEvent::ResourceUpdated(params) if params.uri == "memo:one")); + connection + .resolve_auth(auth("caps-resource")) + .await + .unwrap(); + let replacement = fixture.listen().await; + assert_eq!( + replacement.filter, + json!({"resourceSubscriptions":["memo:one"]}) + ); + connection.unsubscribe_resource("memo:one").await.unwrap(); + assert!(!fixture.requests.lock().await.iter().any(|(_, r)| matches!( + r["method"].as_str(), + Some("resources/subscribe" | "resources/unsubscribe") + ))); + connection.close().await.unwrap(); + drop((initial, replacement)); + }) + .await; +}