Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions crates/agentkit-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions crates/agentkit-mcp/tests/modern_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,38 @@ 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,
))
.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) {
Expand Down Expand Up @@ -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;
}