fix(mcp): annotate declared-but-empty prompt/resource capabilities in .info mcp-server
This commit is contained in:
+101
-10
@@ -798,15 +798,31 @@ impl RequestContext {
|
|||||||
let info = handle.peer_info();
|
let info = handle.peer_info();
|
||||||
let features =
|
let features =
|
||||||
McpServerFeatures::from_capabilities(name, info.as_ref().map(|i| &i.capabilities));
|
McpServerFeatures::from_capabilities(name, info.as_ref().map(|i| &i.capabilities));
|
||||||
let capabilities: Vec<&str> = [
|
let mut capabilities: Vec<String> = vec![];
|
||||||
("tools", features.tools),
|
if features.tools {
|
||||||
("resources", features.resources),
|
capabilities.push("tools".to_string());
|
||||||
("prompts", features.prompts),
|
}
|
||||||
]
|
if features.resources {
|
||||||
.iter()
|
let resources = handle.list_all_resources().await;
|
||||||
.filter(|(_, supported)| *supported)
|
let templates = handle.list_all_resource_templates().await;
|
||||||
.map(|(label, _)| *label)
|
capabilities.push(if resources.is_err() && templates.is_err() {
|
||||||
.collect();
|
"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;
|
const INFO_LABEL_WIDTH: usize = 15;
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -8676,7 +8692,11 @@ mod tests {
|
|||||||
info.contains("server fixture (stdio, connected)"),
|
info.contains("server fixture (stdio, connected)"),
|
||||||
"got:\n{info}"
|
"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("global (mcp.json):"), "got:\n{info}");
|
||||||
assert!(info.contains("get_* | list_*"), "got:\n{info}");
|
assert!(info.contains("get_* | list_*"), "got:\n{info}");
|
||||||
assert!(info.contains("role (reviewer):"), "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]
|
#[test]
|
||||||
fn info_mcp_server_errors_when_unconfigured_or_not_running() {
|
fn info_mcp_server_errors_when_unconfigured_or_not_running() {
|
||||||
let ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
let ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||||
|
|||||||
@@ -646,6 +646,8 @@ pub(crate) mod test_fixtures {
|
|||||||
pub(crate) hostile_prompt: bool,
|
pub(crate) hostile_prompt: bool,
|
||||||
pub(crate) fail_resource_listings: bool,
|
pub(crate) fail_resource_listings: bool,
|
||||||
pub(crate) fail_prompt_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) fail_get_prompt: bool,
|
||||||
pub(crate) prompt_delay: Option<Duration>,
|
pub(crate) prompt_delay: Option<Duration>,
|
||||||
pub(crate) tool_result: Option<CallToolResult>,
|
pub(crate) tool_result: Option<CallToolResult>,
|
||||||
@@ -665,6 +667,8 @@ pub(crate) mod test_fixtures {
|
|||||||
hostile_prompt: false,
|
hostile_prompt: false,
|
||||||
fail_resource_listings: false,
|
fail_resource_listings: false,
|
||||||
fail_prompt_listings: false,
|
fail_prompt_listings: false,
|
||||||
|
empty_resource_listings: false,
|
||||||
|
empty_prompt_listings: false,
|
||||||
fail_get_prompt: false,
|
fail_get_prompt: false,
|
||||||
prompt_delay: None,
|
prompt_delay: None,
|
||||||
tool_result: None,
|
tool_result: None,
|
||||||
@@ -732,6 +736,9 @@ pub(crate) mod test_fixtures {
|
|||||||
if self.fail_resource_listings {
|
if self.fail_resource_listings {
|
||||||
return Err(ErrorData::internal_error("resource listing exploded", None));
|
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![
|
Ok(ListResourcesResult::with_all_items(vec![
|
||||||
Resource::new("dup", "dup-resource")
|
Resource::new("dup", "dup-resource")
|
||||||
.with_description("Duplicate-named resource")
|
.with_description("Duplicate-named resource")
|
||||||
@@ -752,6 +759,9 @@ pub(crate) mod test_fixtures {
|
|||||||
if self.fail_resource_listings {
|
if self.fail_resource_listings {
|
||||||
return Err(ErrorData::internal_error("template listing exploded", None));
|
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![
|
Ok(ListResourceTemplatesResult::with_all_items(vec![
|
||||||
ResourceTemplate::new("file:///{path}/{name}", "file-template")
|
ResourceTemplate::new("file:///{path}/{name}", "file-template")
|
||||||
.with_description("Read a file")
|
.with_description("Read a file")
|
||||||
@@ -807,6 +817,9 @@ pub(crate) mod test_fixtures {
|
|||||||
if self.fail_prompt_listings {
|
if self.fail_prompt_listings {
|
||||||
return Err(ErrorData::internal_error("prompt listing exploded", None));
|
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(
|
let mut prompts = vec![Prompt::new(
|
||||||
"summarize",
|
"summarize",
|
||||||
Some("Summarize a document"),
|
Some("Summarize a document"),
|
||||||
|
|||||||
Reference in New Issue
Block a user