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:
+2
-1
@@ -15,6 +15,7 @@ use crate::config::prompts::{
|
||||
};
|
||||
use crate::graph::types::RagNode;
|
||||
use crate::graph::{Graph, GraphParser, NodeType};
|
||||
use crate::mcp::McpServerFeatures;
|
||||
use crate::rag::RagInitConfig;
|
||||
use crate::vault::SECRET_RE;
|
||||
use anyhow::{Context, Result};
|
||||
@@ -380,7 +381,7 @@ impl Agent {
|
||||
self.graph_rags.get(node_id).cloned()
|
||||
}
|
||||
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<String>) {
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<McpServerFeatures>) {
|
||||
self.functions.append_mcp_meta_functions(mcp_servers);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ impl AppState {
|
||||
|
||||
let mut functions = Functions::init(config.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
|
||||
if !mcp_registry.is_empty() && config.mcp_server_support {
|
||||
functions.append_mcp_meta_functions(mcp_registry.list_started_servers());
|
||||
functions.append_mcp_meta_functions(mcp_registry.server_features());
|
||||
}
|
||||
|
||||
let mcp_registry = if mcp_registry.is_empty() {
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) mod todo;
|
||||
mod tool_scope;
|
||||
mod update;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::agent::AgentConfig;
|
||||
pub use self::agent::{
|
||||
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
||||
list_agents_with_descriptions,
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::function::{
|
||||
user_interaction::USER_FUNCTION_PREFIX,
|
||||
};
|
||||
use crate::mcp::{
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, is_auth_required_error,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, is_auth_required_error,
|
||||
is_mcp_meta_function, mcp_meta_function_names,
|
||||
};
|
||||
use crate::rag::Rag;
|
||||
@@ -2167,8 +2167,8 @@ impl RequestContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item_invoke_name =
|
||||
format!("{}_{item}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX);
|
||||
let item_search_name =
|
||||
format!("{}_{item}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX);
|
||||
if let Some(values) = app.mapping_mcp_servers.get(item) {
|
||||
server_names.extend(
|
||||
values
|
||||
@@ -2176,7 +2176,7 @@ impl RequestContext {
|
||||
.flat_map(mcp_meta_function_names)
|
||||
.filter(|v| mcp_declaration_names.contains(v)),
|
||||
)
|
||||
} else if mcp_declaration_names.contains(&item_invoke_name) {
|
||||
} else if mcp_declaration_names.contains(&item_search_name) {
|
||||
server_names.extend(mcp_meta_function_names(item));
|
||||
}
|
||||
}
|
||||
@@ -3779,7 +3779,7 @@ impl RequestContext {
|
||||
functions.append_todo_functions();
|
||||
}
|
||||
if !mcp_runtime.is_empty() {
|
||||
functions.append_mcp_meta_functions(mcp_runtime.server_names());
|
||||
functions.append_mcp_meta_functions(mcp_runtime.server_features());
|
||||
}
|
||||
if app.function_calling_support && policy.skills_enabled {
|
||||
functions.append_skill_functions();
|
||||
@@ -4628,7 +4628,7 @@ mod tests {
|
||||
use crate::config::AppState;
|
||||
use crate::config::agent::AgentConfig;
|
||||
use crate::function::{ToolCall, skill};
|
||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||
use crate::mcp::{McpServer, McpServerFeatures, McpServersConfig, McpTransportType};
|
||||
use crate::utils;
|
||||
use crate::utils::get_env_name;
|
||||
use crate::vault::Vault;
|
||||
@@ -4689,6 +4689,15 @@ mod tests {
|
||||
RequestContext::new(default_app_state(), WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
fn tools_only_features(name: &str) -> McpServerFeatures {
|
||||
McpServerFeatures {
|
||||
name: name.to_string(),
|
||||
tools: true,
|
||||
resources: false,
|
||||
prompts: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_clean_state() {
|
||||
let ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||
@@ -5696,9 +5705,10 @@ mod tests {
|
||||
#[test]
|
||||
fn select_enabled_mcp_servers_all_returns_all_mcp_functions() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope
|
||||
.functions
|
||||
.append_mcp_meta_functions(vec!["github".into(), "slack".into()]);
|
||||
ctx.tool_scope.functions.append_mcp_meta_functions(vec![
|
||||
tools_only_features("github"),
|
||||
tools_only_features("slack"),
|
||||
]);
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["all".to_string()]));
|
||||
@@ -5713,9 +5723,10 @@ mod tests {
|
||||
#[test]
|
||||
fn select_enabled_mcp_servers_comma_filters() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope
|
||||
.functions
|
||||
.append_mcp_meta_functions(vec!["github".into(), "slack".into()]);
|
||||
ctx.tool_scope.functions.append_mcp_meta_functions(vec![
|
||||
tools_only_features("github"),
|
||||
tools_only_features("slack"),
|
||||
]);
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["github".to_string()]));
|
||||
@@ -5726,6 +5737,28 @@ mod tests {
|
||||
assert!(!names.contains(&"mcp_invoke_slack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_enabled_mcp_servers_keeps_resources_only_server() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope
|
||||
.functions
|
||||
.append_mcp_meta_functions(vec![McpServerFeatures {
|
||||
name: "res".to_string(),
|
||||
tools: false,
|
||||
resources: true,
|
||||
prompts: false,
|
||||
}]);
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["res".to_string()]));
|
||||
|
||||
let fns = ctx.select_enabled_mcp_servers(&role);
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"mcp_search_res"));
|
||||
assert!(names.contains(&"mcp_describe_res"));
|
||||
assert!(!names.contains(&"mcp_invoke_res"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_empty_context_has_no_context_flags() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
+99
-12
@@ -1,5 +1,5 @@
|
||||
use crate::function::{Functions, ToolCallTracker};
|
||||
use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry};
|
||||
use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, McpServerFeatures};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use bm25::{Document, Language, SearchEngineBuilder};
|
||||
@@ -49,8 +49,20 @@ impl McpRuntime {
|
||||
self.servers.get(name)
|
||||
}
|
||||
|
||||
pub fn server_names(&self) -> Vec<String> {
|
||||
self.servers.keys().cloned().collect()
|
||||
pub fn server_features(&self) -> Vec<McpServerFeatures> {
|
||||
let mut features: Vec<McpServerFeatures> = self
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(name, handle)| {
|
||||
let info = handle.peer_info();
|
||||
McpServerFeatures::from_capabilities(
|
||||
name.as_str(),
|
||||
info.as_ref().map(|info| &info.capabilities),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
features.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
features
|
||||
}
|
||||
|
||||
pub fn sync_from_registry(&mut self, registry: &McpRegistry) {
|
||||
@@ -65,12 +77,14 @@ impl McpRuntime {
|
||||
.get(server)
|
||||
.cloned()
|
||||
.with_context(|| format!("{server} MCP server not found in runtime"))?;
|
||||
let capabilities = server_handle
|
||||
.peer_info()
|
||||
.map(|info| info.capabilities.clone());
|
||||
let info = server_handle.peer_info();
|
||||
let features = McpServerFeatures::from_capabilities(
|
||||
server,
|
||||
info.as_ref().map(|info| &info.capabilities),
|
||||
);
|
||||
let mut items = HashMap::new();
|
||||
|
||||
if capabilities.as_ref().is_none_or(|c| c.tools.is_some()) {
|
||||
if features.tools {
|
||||
match server_handle.list_all_tools().await {
|
||||
Ok(tools) => merge_catalog_items(
|
||||
&mut items,
|
||||
@@ -82,7 +96,7 @@ impl McpRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.as_ref().is_some_and(|c| c.resources.is_some()) {
|
||||
if features.resources {
|
||||
match server_handle.list_all_resources().await {
|
||||
Ok(resources) => merge_catalog_items(
|
||||
&mut items,
|
||||
@@ -105,7 +119,7 @@ impl McpRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.as_ref().is_some_and(|c| c.prompts.is_some()) {
|
||||
if features.prompts {
|
||||
match server_handle.list_all_prompts().await {
|
||||
Ok(prompts) => merge_catalog_items(
|
||||
&mut items,
|
||||
@@ -349,8 +363,9 @@ pub(crate) mod test_fixtures {
|
||||
use rmcp::{RoleServer, ServerHandler, ServiceExt};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FixtureServer {
|
||||
pub(crate) tools_capability: bool,
|
||||
pub(crate) resources_capability: bool,
|
||||
pub(crate) prompts_capability: bool,
|
||||
pub(crate) fail_resource_listings: bool,
|
||||
@@ -358,9 +373,26 @@ pub(crate) mod test_fixtures {
|
||||
pub(crate) list_prompts_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Default for FixtureServer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools_capability: true,
|
||||
resources_capability: false,
|
||||
prompts_capability: false,
|
||||
fail_resource_listings: false,
|
||||
list_resources_calls: Arc::default(),
|
||||
list_prompts_calls: Arc::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for FixtureServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let mut capabilities = ServerCapabilities::builder().enable_tools().build();
|
||||
let mut capabilities = if self.tools_capability {
|
||||
ServerCapabilities::builder().enable_tools().build()
|
||||
} else {
|
||||
ServerCapabilities::builder().build()
|
||||
};
|
||||
capabilities.resources = self.resources_capability.then(ResourcesCapability::default);
|
||||
capabilities.prompts = self.prompts_capability.then(PromptsCapability::default);
|
||||
ServerInfo::new(capabilities)
|
||||
@@ -493,7 +525,7 @@ mod tests {
|
||||
fn mcp_runtime_new_is_empty() {
|
||||
let runtime = McpRuntime::new();
|
||||
assert!(runtime.is_empty());
|
||||
assert!(runtime.server_names().is_empty());
|
||||
assert!(runtime.server_features().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -508,6 +540,61 @@ mod tests {
|
||||
assert!(runtime.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_features_reports_fixture_capabilities() {
|
||||
let (runtime, _server) = fixture_runtime(FixtureServer {
|
||||
resources_capability: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let features = runtime.server_features();
|
||||
assert_eq!(
|
||||
features,
|
||||
vec![McpServerFeatures {
|
||||
name: "fixture".to_string(),
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: false,
|
||||
}]
|
||||
);
|
||||
|
||||
let mut functions = Functions::default();
|
||||
functions.append_mcp_meta_functions(features);
|
||||
assert_eq!(functions.declarations().len(), 3);
|
||||
assert!(functions.contains("mcp_invoke_fixture"));
|
||||
assert!(functions.contains("mcp_search_fixture"));
|
||||
assert!(functions.contains("mcp_describe_fixture"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_features_without_tools_capability_gates_invoke() {
|
||||
let (runtime, _server) = fixture_runtime(FixtureServer {
|
||||
tools_capability: false,
|
||||
resources_capability: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let features = runtime.server_features();
|
||||
assert_eq!(
|
||||
features,
|
||||
vec![McpServerFeatures {
|
||||
name: "fixture".to_string(),
|
||||
tools: false,
|
||||
resources: true,
|
||||
prompts: false,
|
||||
}]
|
||||
);
|
||||
|
||||
let mut functions = Functions::default();
|
||||
functions.append_mcp_meta_functions(features);
|
||||
assert_eq!(functions.declarations().len(), 2);
|
||||
assert!(!functions.contains("mcp_invoke_fixture"));
|
||||
assert!(functions.contains("mcp_search_fixture"));
|
||||
assert!(functions.contains("mcp_describe_fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_scope_default_has_empty_mcp_runtime() {
|
||||
let scope = ToolScope::default();
|
||||
|
||||
Reference in New Issue
Block a user