diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 1ad5d46..af710ef 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -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 = 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); diff --git a/src/config/tool_scope.rs b/src/config/tool_scope.rs index 3cdaac4..b6230f2 100644 --- a/src/config/tool_scope.rs +++ b/src/config/tool_scope.rs @@ -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, pub(crate) tool_result: Option, @@ -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"),