fix(mcp): annotate declared-but-empty prompt/resource capabilities in .info mcp-server

This commit is contained in:
2026-08-28 10:48:43 -06:00
parent 2d9829106d
commit c707aaf3ee
2 changed files with 114 additions and 10 deletions
+101 -10
View File
@@ -798,15 +798,31 @@ impl RequestContext {
let info = handle.peer_info();
let features =
McpServerFeatures::from_capabilities(name, info.as_ref().map(|i| &i.capabilities));
let capabilities: Vec<&str> = [
("tools", features.tools),
("resources", features.resources),
("prompts", features.prompts),
]
.iter()
.filter(|(_, supported)| *supported)
.map(|(label, _)| *label)
.collect();
let mut capabilities: Vec<String> = vec![];
if features.tools {
capabilities.push("tools".to_string());
}
if features.resources {
let resources = handle.list_all_resources().await;
let templates = handle.list_all_resource_templates().await;
capabilities.push(if resources.is_err() && templates.is_err() {
"resources (declared, list failed)".to_string()
} else {
let count = resources.map(|r| r.len()).unwrap_or_default()
+ templates.map(|t| t.len()).unwrap_or_default();
match count {
0 => "resources (declared, none served)".to_string(),
n => format!("resources ({n})"),
}
});
}
if features.prompts {
capabilities.push(match handle.list_all_prompts().await {
Ok(prompts) if prompts.is_empty() => "prompts (declared, none served)".to_string(),
Ok(prompts) => format!("prompts ({})", prompts.len()),
Err(_) => "prompts (declared, list failed)".to_string(),
});
}
const INFO_LABEL_WIDTH: usize = 15;
let mut out = String::new();
@@ -8676,7 +8692,11 @@ mod tests {
info.contains("server fixture (stdio, connected)"),
"got:\n{info}"
);
assert!(info.contains("capabilities tools"), "got:\n{info}");
let cap_line = info
.lines()
.find(|line| line.starts_with("capabilities"))
.unwrap();
assert_eq!(cap_line, "capabilities tools", "got:\n{info}");
assert!(info.contains("global (mcp.json):"), "got:\n{info}");
assert!(info.contains("get_* | list_*"), "got:\n{info}");
assert!(info.contains("role (reviewer):"), "got:\n{info}");
@@ -8704,6 +8724,77 @@ mod tests {
);
}
#[test]
fn info_mcp_server_counts_served_prompts_and_resources() {
let mut ctx = RequestContext::new(mcp_app_state(&["fixture"]), WorkingMode::Cmd);
let info = run_async(async {
let (runtime, _server) = fixture_runtime(FixtureServer {
resources_capability: true,
prompts_capability: true,
..Default::default()
})
.await;
ctx.tool_scope.mcp_runtime = runtime;
ctx.mcp_server_info("fixture").await.unwrap()
});
assert!(
info.contains("capabilities tools, resources (3), prompts (1)"),
"got:\n{info}"
);
}
#[test]
fn info_mcp_server_annotates_declared_but_empty_capabilities() {
let mut ctx = RequestContext::new(mcp_app_state(&["fixture"]), WorkingMode::Cmd);
let info = run_async(async {
let (runtime, _server) = fixture_runtime(FixtureServer {
resources_capability: true,
prompts_capability: true,
empty_resource_listings: true,
empty_prompt_listings: true,
..Default::default()
})
.await;
ctx.tool_scope.mcp_runtime = runtime;
ctx.mcp_server_info("fixture").await.unwrap()
});
assert!(
info.contains(
"capabilities tools, resources (declared, none served), prompts (declared, none served)"
),
"got:\n{info}"
);
}
#[test]
fn info_mcp_server_annotates_failed_capability_listings() {
let mut ctx = RequestContext::new(mcp_app_state(&["fixture"]), WorkingMode::Cmd);
let info = run_async(async {
let (runtime, _server) = fixture_runtime(FixtureServer {
resources_capability: true,
prompts_capability: true,
fail_resource_listings: true,
fail_prompt_listings: true,
..Default::default()
})
.await;
ctx.tool_scope.mcp_runtime = runtime;
ctx.mcp_server_info("fixture").await.unwrap()
});
assert!(
info.contains(
"capabilities tools, resources (declared, list failed), prompts (declared, list failed)"
),
"got:\n{info}"
);
}
#[test]
fn info_mcp_server_errors_when_unconfigured_or_not_running() {
let ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
+13
View File
@@ -646,6 +646,8 @@ pub(crate) mod test_fixtures {
pub(crate) hostile_prompt: bool,
pub(crate) fail_resource_listings: bool,
pub(crate) fail_prompt_listings: bool,
pub(crate) empty_resource_listings: bool,
pub(crate) empty_prompt_listings: bool,
pub(crate) fail_get_prompt: bool,
pub(crate) prompt_delay: Option<Duration>,
pub(crate) tool_result: Option<CallToolResult>,
@@ -665,6 +667,8 @@ pub(crate) mod test_fixtures {
hostile_prompt: false,
fail_resource_listings: false,
fail_prompt_listings: false,
empty_resource_listings: false,
empty_prompt_listings: false,
fail_get_prompt: false,
prompt_delay: None,
tool_result: None,
@@ -732,6 +736,9 @@ pub(crate) mod test_fixtures {
if self.fail_resource_listings {
return Err(ErrorData::internal_error("resource listing exploded", None));
}
if self.empty_resource_listings {
return Ok(ListResourcesResult::with_all_items(vec![]));
}
Ok(ListResourcesResult::with_all_items(vec![
Resource::new("dup", "dup-resource")
.with_description("Duplicate-named resource")
@@ -752,6 +759,9 @@ pub(crate) mod test_fixtures {
if self.fail_resource_listings {
return Err(ErrorData::internal_error("template listing exploded", None));
}
if self.empty_resource_listings {
return Ok(ListResourceTemplatesResult::with_all_items(vec![]));
}
Ok(ListResourceTemplatesResult::with_all_items(vec![
ResourceTemplate::new("file:///{path}/{name}", "file-template")
.with_description("Read a file")
@@ -807,6 +817,9 @@ pub(crate) mod test_fixtures {
if self.fail_prompt_listings {
return Err(ErrorData::internal_error("prompt listing exploded", None));
}
if self.empty_prompt_listings {
return Ok(ListPromptsResult::with_all_items(vec![]));
}
let mut prompts = vec![Prompt::new(
"summarize",
Some("Summarize a document"),