feat(mcp): gate meta-function emission on advertised server capabilities
Per-server McpServerFeatures (tools fail-open, resources/prompts fail-closed) now drive which meta-functions are declared, with gated_meta_function_prefixes as the single gating seam; read/prompt declarations land together with their handlers. The server-enablement sentinel keys on the always-emitted search name so resources-only servers survive role filtering. Implements plans/mcp-resources-prompts-design.md §4.4/D7 (T5).
This commit is contained in:
+220
-54
@@ -16,7 +16,9 @@ use crate::config::ensure_parent_exists;
|
||||
use crate::config::paths;
|
||||
use crate::mcp::{
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpServersConfig, is_mcp_meta_function,
|
||||
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpServerFeatures,
|
||||
McpServersConfig, is_mcp_meta_function,
|
||||
};
|
||||
use crate::parsers::{bash, python, typescript};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
@@ -409,6 +411,18 @@ impl ToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn gated_meta_function_prefixes(features: &McpServerFeatures) -> Vec<&'static str> {
|
||||
MCP_META_FUNCTION_PREFIXES
|
||||
.into_iter()
|
||||
.filter(|&prefix| match prefix {
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX => features.tools,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX => features.resources,
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX => features.prompts,
|
||||
_ => true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Functions {
|
||||
declarations: Vec<FunctionDeclaration>,
|
||||
@@ -624,7 +638,7 @@ impl Functions {
|
||||
.retain(|f| !f.name.starts_with(RAG_FUNCTION_PREFIX));
|
||||
}
|
||||
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<String>) {
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<McpServerFeatures>) {
|
||||
let mut invoke_function_properties = IndexMap::new();
|
||||
invoke_function_properties.insert(
|
||||
"tool".to_string(),
|
||||
@@ -682,59 +696,72 @@ impl Functions {
|
||||
},
|
||||
);
|
||||
|
||||
for server in mcp_servers {
|
||||
for features in mcp_servers {
|
||||
let server = &features.name;
|
||||
let search_function_name = format!("{}_{server}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX);
|
||||
let describe_function_name =
|
||||
format!("{}_{server}", MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX);
|
||||
let invoke_function_name = format!("{}_{server}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX);
|
||||
let invoke_function_declaration = FunctionDeclaration {
|
||||
name: invoke_function_name.clone(),
|
||||
description: formatdoc!(
|
||||
r#"
|
||||
for prefix in gated_meta_function_prefixes(&features) {
|
||||
match prefix {
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX => {
|
||||
self.declarations.push(FunctionDeclaration {
|
||||
name: invoke_function_name.clone(),
|
||||
description: formatdoc!(
|
||||
r#"
|
||||
Invoke the specified tool on the {server} MCP server. Always call {describe_function_name} first to
|
||||
find the correct invocation schema for the given tool.
|
||||
"#
|
||||
),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(invoke_function_properties.clone()),
|
||||
required: Some(vec!["tool".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
};
|
||||
let search_functions_declaration = FunctionDeclaration {
|
||||
name: search_function_name.clone(),
|
||||
description: formatdoc!(
|
||||
r#"
|
||||
Find candidate tools by keywords for the {server} MCP server. Returns small suggestions; fetch
|
||||
schemas with {describe_function_name}.
|
||||
"#
|
||||
),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(search_function_properties.clone()),
|
||||
required: Some(vec!["query".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
};
|
||||
let describe_functions_declaration = FunctionDeclaration {
|
||||
name: describe_function_name.clone(),
|
||||
description: "Get the full schema or metadata for exactly one MCP catalog item: \
|
||||
a tool, resource, resource template, or prompt."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(describe_function_properties.clone()),
|
||||
required: Some(vec!["tool".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
};
|
||||
self.declarations.push(invoke_function_declaration);
|
||||
self.declarations.push(search_functions_declaration);
|
||||
self.declarations.push(describe_functions_declaration);
|
||||
),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(invoke_function_properties.clone()),
|
||||
required: Some(vec!["tool".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
});
|
||||
}
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX => {
|
||||
self.declarations.push(FunctionDeclaration {
|
||||
name: search_function_name.clone(),
|
||||
description: formatdoc!(
|
||||
r#"
|
||||
Find candidate tools by keywords for the {server} MCP server. Returns small suggestions; fetch
|
||||
schemas with {describe_function_name}.
|
||||
"#
|
||||
),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(search_function_properties.clone()),
|
||||
required: Some(vec!["query".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
});
|
||||
}
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX => {
|
||||
self.declarations.push(FunctionDeclaration {
|
||||
name: describe_function_name.clone(),
|
||||
description: "Get the full schema or metadata for exactly one MCP \
|
||||
catalog item: a tool, resource, resource template, or \
|
||||
prompt."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(describe_function_properties.clone()),
|
||||
required: Some(vec!["tool".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
});
|
||||
}
|
||||
// The declaration is added alongside its handler.
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX => {}
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX => {}
|
||||
_ => debug_assert!(false, "unhandled MCP meta-function prefix: {prefix}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1859,6 +1886,19 @@ mod tests {
|
||||
ToolCall::new(name.to_string(), args, Some("id1".to_string()))
|
||||
}
|
||||
|
||||
fn mcp_features(name: &str, tools: bool, resources: bool, prompts: bool) -> McpServerFeatures {
|
||||
McpServerFeatures {
|
||||
name: name.to_string(),
|
||||
tools,
|
||||
resources,
|
||||
prompts,
|
||||
}
|
||||
}
|
||||
|
||||
fn tools_only(name: &str) -> McpServerFeatures {
|
||||
mcp_features(name, true, false, false)
|
||||
}
|
||||
|
||||
fn run_async<F: Future>(f: F) -> F::Output {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -2231,7 +2271,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_append_mcp_meta_creates_three_per_server() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["github".to_string()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("github")]);
|
||||
assert_eq!(f.declarations().len(), 3);
|
||||
assert!(f.contains("mcp_invoke_github"));
|
||||
assert!(f.contains("mcp_search_github"));
|
||||
@@ -2241,7 +2281,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_append_mcp_meta_multiple_servers() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["github".into(), "slack".into()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("github"), tools_only("slack")]);
|
||||
assert_eq!(f.declarations().len(), 6);
|
||||
assert!(f.contains("mcp_invoke_github"));
|
||||
assert!(f.contains("mcp_invoke_slack"));
|
||||
@@ -2254,6 +2294,132 @@ mod tests {
|
||||
assert!(f.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_append_mcp_meta_resources_only_omits_invoke() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec![mcp_features("res", false, true, false)]);
|
||||
assert_eq!(f.declarations().len(), 2);
|
||||
assert!(!f.contains("mcp_invoke_res"));
|
||||
assert!(f.contains("mcp_search_res"));
|
||||
assert!(f.contains("mcp_describe_res"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_append_mcp_meta_all_capabilities_emits_three() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec![mcp_features("srv", true, true, true)]);
|
||||
assert_eq!(f.declarations().len(), 3);
|
||||
assert!(f.contains("mcp_invoke_srv"));
|
||||
assert!(f.contains("mcp_search_srv"));
|
||||
assert!(f.contains("mcp_describe_srv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_from_missing_capabilities_fail_open_for_tools() {
|
||||
let features = McpServerFeatures::from_capabilities("srv", None);
|
||||
assert!(features.tools);
|
||||
assert!(!features.resources);
|
||||
assert!(!features.prompts);
|
||||
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec![features]);
|
||||
assert!(f.contains("mcp_invoke_srv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_tools_only() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", true, false, false)),
|
||||
vec![
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_tools_and_resources_include_read() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", true, true, false)),
|
||||
vec![
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_tools_and_prompts_include_prompt() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", true, false, true)),
|
||||
vec![
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_all_capabilities_include_all() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", true, true, true)),
|
||||
MCP_META_FUNCTION_PREFIXES.to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_resources_only_omit_invoke() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", false, true, false)),
|
||||
vec![
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_prompts_only_omit_invoke_and_read() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", false, false, true)),
|
||||
vec![
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_resources_and_prompts_omit_invoke() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", false, true, true)),
|
||||
vec![
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gated_prefixes_no_capabilities_keep_search_and_describe() {
|
||||
assert_eq!(
|
||||
gated_meta_function_prefixes(&mcp_features("srv", false, false, false)),
|
||||
vec![
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_find_returns_declaration() {
|
||||
let mut f = Functions::default();
|
||||
@@ -2285,7 +2451,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_mcp_invoke_declaration_has_tool_and_arguments_params() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["srv".to_string()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("srv")]);
|
||||
let decl = f.find("mcp_invoke_srv").unwrap();
|
||||
let props = decl.parameters.properties.as_ref().unwrap();
|
||||
assert!(props.contains_key("tool"));
|
||||
@@ -2297,7 +2463,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_mcp_search_declaration_has_query_and_top_k_params() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["srv".to_string()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("srv")]);
|
||||
let decl = f.find("mcp_search_srv").unwrap();
|
||||
let props = decl.parameters.properties.as_ref().unwrap();
|
||||
assert!(props.contains_key("query"));
|
||||
@@ -2307,7 +2473,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_mcp_describe_declaration_has_tool_param() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["srv".to_string()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("srv")]);
|
||||
let decl = f.find("mcp_describe_srv").unwrap();
|
||||
let props = decl.parameters.properties.as_ref().unwrap();
|
||||
assert!(props.contains_key("tool"));
|
||||
@@ -2316,7 +2482,7 @@ mod tests {
|
||||
#[test]
|
||||
fn functions_mcp_describe_declaration_has_optional_kind_param() {
|
||||
let mut f = Functions::default();
|
||||
f.append_mcp_meta_functions(vec!["srv".to_string()]);
|
||||
f.append_mcp_meta_functions(vec![tools_only("srv")]);
|
||||
let decl = f.find("mcp_describe_srv").unwrap();
|
||||
let props = decl.parameters.properties.as_ref().unwrap();
|
||||
let kind = props.get("kind").unwrap();
|
||||
|
||||
@@ -630,14 +630,14 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri
|
||||
}
|
||||
|
||||
fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
|
||||
let server_names = ctx.tool_scope.mcp_runtime.server_names();
|
||||
let server_features = ctx.tool_scope.mcp_runtime.server_features();
|
||||
let functions = {
|
||||
let agent = ctx
|
||||
.agent
|
||||
.as_mut()
|
||||
.with_context(|| "Agent should be initialized")?;
|
||||
if !server_names.is_empty() {
|
||||
agent.append_mcp_meta_functions(server_names);
|
||||
if !server_features.is_empty() {
|
||||
agent.append_mcp_meta_functions(server_features);
|
||||
}
|
||||
agent.functions().clone()
|
||||
};
|
||||
@@ -1453,7 +1453,8 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use crate::config::{AgentConfig, AppState, WorkingMode};
|
||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
@@ -1510,6 +1511,27 @@ mod tests {
|
||||
.block_on(f)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_agent_functions_gates_meta_functions_on_live_capabilities() {
|
||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig::default()));
|
||||
let (runtime, _server) = fixture_runtime(FixtureServer {
|
||||
tools_capability: false,
|
||||
resources_capability: true,
|
||||
..FixtureServer::default()
|
||||
})
|
||||
.await;
|
||||
ctx.tool_scope.mcp_runtime = runtime;
|
||||
|
||||
sync_agent_functions_to_ctx(&mut ctx).unwrap();
|
||||
|
||||
let functions = &ctx.tool_scope.functions;
|
||||
assert_eq!(functions.declarations().len(), 2);
|
||||
assert!(functions.contains("mcp_search_fixture"));
|
||||
assert!(functions.contains("mcp_describe_fixture"));
|
||||
assert!(!functions.contains("mcp_invoke_fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_list_running_empty_supervisor() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
|
||||
Reference in New Issue
Block a user