From 57aabac9d6fb676be3b46601f954e2379a589a26 Mon Sep 17 00:00:00 2001 From: Max Goisser Date: Wed, 29 Jul 2026 10:32:22 +0200 Subject: [PATCH] Add MCP server allow/exclude tool filtering Related Linear issue: ERMAIN-510 --- backend/erato.template.toml | 2 + backend/erato/src/config.rs | 7 + .../erato/src/services/mcp_session_manager.rs | 155 +++++++++++++++++- .../tests/integration_tests/api/assistants.rs | 8 + .../tests/integration_tests/api/messages.rs | 2 + .../tests/integration_tests/llm/mcp_auth.rs | 2 + backend/generated/config_reference.json | 2 + site/content/docs/configuration.mdx | 34 ++++ site/content/docs/features/mcp_servers.mdx | 9 + 9 files changed, 217 insertions(+), 4 deletions(-) diff --git a/backend/erato.template.toml b/backend/erato.template.toml index ef8081a8..fd65e10a 100644 --- a/backend/erato.template.toml +++ b/backend/erato.template.toml @@ -87,6 +87,8 @@ chat_provider_ids = ["test-token-limit"] # [mcp_servers.file_provider] # transport_type = "sse" # url = "http://127.0.0.1:63490/sse" +# allow_tools = ["get_*", "list_pages"] +# exclude_tools = ["get_secret_*"] # [mcp_servers.file_provider.authentication] # mode = "none" # # Optional per-server override (seconds) diff --git a/backend/erato/src/config.rs b/backend/erato/src/config.rs index 145de1f7..9a1090ac 100644 --- a/backend/erato/src/config.rs +++ b/backend/erato/src/config.rs @@ -2083,6 +2083,13 @@ pub struct McpServerConfig { // Optional static HTTP headers to be sent with every request. // This is useful for non-authentication headers that should accompany MCP requests. pub http_headers: Option>, + // Optional wildcard patterns selecting the tools exposed by this MCP server. + // When omitted, all tools are allowed. An explicitly empty list allows no tools. + #[serde(default)] + pub allow_tools: Option>, + // Wildcard patterns selecting tools to remove after `allow_tools` is applied. + #[serde(default)] + pub exclude_tools: Vec, // Authentication settings for requests to this MCP server. #[serde(default)] pub authentication: McpServerAuthenticationConfig, diff --git a/backend/erato/src/services/mcp_session_manager.rs b/backend/erato/src/services/mcp_session_manager.rs index e69ab815..edaab8b5 100644 --- a/backend/erato/src/services/mcp_session_manager.rs +++ b/backend/erato/src/services/mcp_session_manager.rs @@ -84,7 +84,7 @@ impl McpSession { _service: service, // Keep the service alive! peer, server_id, - tools: tools_result.tools, + tools: filter_tools_by_server_config(tools_result.tools, config), last_activity: SystemTime::now(), max_idle_duration: Duration::from_secs( config @@ -109,14 +109,14 @@ impl McpSession { } /// Refresh the tools list from the server - async fn refresh_tools(&mut self) -> Result<(), Report> { + async fn refresh_tools(&mut self, config: &McpServerConfig) -> Result<(), Report> { let tools_result = self .peer .list_tools(Default::default()) .await .map_err(|e| eyre!("Failed to refresh tools: {}", e))?; - self.tools = tools_result.tools; + self.tools = filter_tools_by_server_config(tools_result.tools, config); self.touch(); Ok(()) } @@ -134,6 +134,59 @@ impl McpSession { } } +fn filter_tools_by_server_config(tools: Vec, config: &McpServerConfig) -> Vec { + tools + .into_iter() + .filter(|tool| is_tool_allowed_by_server_config(&tool.name, config)) + .collect() +} + +fn is_tool_allowed_by_server_config(tool_name: &str, config: &McpServerConfig) -> bool { + let allowed = config.allow_tools.as_ref().is_none_or(|patterns| { + patterns + .iter() + .any(|pattern| wildcard_matches(pattern, tool_name)) + }); + + allowed + && !config + .exclude_tools + .iter() + .any(|pattern| wildcard_matches(pattern, tool_name)) +} + +fn wildcard_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let mut pattern_index = 0; + let mut value_index = 0; + let mut last_star_index = None; + let mut star_match_value_index = 0; + + while value_index < value.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + last_star_index = Some(pattern_index); + pattern_index += 1; + star_match_value_index = value_index; + } else if let Some(star_index) = last_star_index { + pattern_index = star_index + 1; + star_match_value_index += 1; + value_index = star_match_value_index; + } else { + return false; + } + } + + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + + pattern_index == pattern.len() +} + type SessionAuthKey = Option; type SessionKey = (Uuid, String, SessionAuthKey); @@ -631,6 +684,10 @@ impl McpSessionManager { server_id: &str, auth_context: &McpRequestAuthContext<'_>, ) -> Result<(), Report> { + let config = self + .server_configs + .get(server_id) + .ok_or_else(|| eyre!("MCP server '{}' not found in configuration", server_id))?; let key = self .get_or_create_session(chat_id, server_id, auth_context) .await?; @@ -638,7 +695,7 @@ impl McpSessionManager { let mut sessions_guard = self.sessions.write().await; if let Some(session) = sessions_guard.get_mut(&key) { - session.refresh_tools().await?; + session.refresh_tools(config).await?; } Ok(()) @@ -764,3 +821,93 @@ pub struct ManagedTool { pub server_id: String, pub tool: Tool, } + +#[cfg(test)] +mod tests { + use super::{ + filter_tools_by_server_config, is_tool_allowed_by_server_config, wildcard_matches, + }; + use crate::config::{McpServerAuthenticationConfig, McpServerConfig}; + use rmcp::model::Tool; + + fn server_config(allow_tools: Option>, exclude_tools: Vec<&str>) -> McpServerConfig { + McpServerConfig { + transport_type: "streamable_http".to_string(), + url: "http://localhost/mcp".to_string(), + http_headers: None, + allow_tools: allow_tools + .map(|patterns| patterns.into_iter().map(str::to_string).collect()), + exclude_tools: exclude_tools.into_iter().map(str::to_string).collect(), + authentication: McpServerAuthenticationConfig::None, + max_session_idle_seconds: None, + } + } + + #[test] + fn wildcard_patterns_match_tool_names() { + assert!(wildcard_matches("*", "get_page")); + assert!(wildcard_matches("get_*", "get_page")); + assert!(wildcard_matches("*_page", "get_page")); + assert!(wildcard_matches("get*page", "get_cached_page")); + assert!(!wildcard_matches("get_*", "list_pages")); + assert!(!wildcard_matches("get_page", "get_pages")); + } + + #[test] + fn omitted_allowlist_is_equivalent_to_wildcard() { + let omitted_allowlist = server_config(None, vec![]); + let wildcard_allowlist = server_config(Some(vec!["*"]), vec![]); + + for tool_name in ["get_page", "delete_page"] { + assert!(is_tool_allowed_by_server_config( + tool_name, + &omitted_allowlist + )); + assert!(is_tool_allowed_by_server_config( + tool_name, + &wildcard_allowlist + )); + } + } + + #[test] + fn explicitly_empty_allowlist_allows_no_tools() { + let config = server_config(Some(vec![]), vec![]); + + assert!(!is_tool_allowed_by_server_config("get_page", &config)); + } + + #[test] + fn allowlist_is_applied_before_excludelist() { + let config = server_config( + Some(vec!["get_*", "list_pages"]), + vec!["*_secret", "get_raw*"], + ); + + assert!(is_tool_allowed_by_server_config("get_page", &config)); + assert!(is_tool_allowed_by_server_config("list_pages", &config)); + assert!(!is_tool_allowed_by_server_config("delete_page", &config)); + assert!(!is_tool_allowed_by_server_config("get_secret", &config)); + assert!(!is_tool_allowed_by_server_config("get_raw_page", &config)); + } + + #[test] + fn server_tool_list_is_filtered_in_original_order() { + let config = server_config(Some(vec!["get_*", "list_pages"]), vec!["get_secret"]); + let tools = ["delete_page", "get_page", "get_secret", "list_pages"] + .into_iter() + .map(|name| { + let mut tool = Tool::default(); + tool.name = name.to_string().into(); + tool + }) + .collect(); + + let filtered_names = filter_tools_by_server_config(tools, &config) + .into_iter() + .map(|tool| tool.name.into_owned()) + .collect::>(); + + assert_eq!(filtered_names, vec!["get_page", "list_pages"]); + } +} diff --git a/backend/erato/tests/integration_tests/api/assistants.rs b/backend/erato/tests/integration_tests/api/assistants.rs index a6232837..ad70422d 100644 --- a/backend/erato/tests/integration_tests/api/assistants.rs +++ b/backend/erato/tests/integration_tests/api/assistants.rs @@ -257,6 +257,8 @@ async fn test_update_assistant_endpoint(pool: Pool) { transport_type: "streamable_http".to_string(), url: "http://127.0.0.1:8123/mcp/server1".to_string(), http_headers: None, + allow_tools: None, + exclude_tools: vec![], authentication: McpServerAuthenticationConfig::None, max_session_idle_seconds: None, }, @@ -649,6 +651,8 @@ async fn test_create_assistant_endpoint(pool: Pool) { transport_type: "streamable_http".to_string(), url: "http://127.0.0.1:8123/mcp/server1".to_string(), http_headers: None, + allow_tools: None, + exclude_tools: vec![], authentication: McpServerAuthenticationConfig::None, max_session_idle_seconds: None, }, @@ -659,6 +663,8 @@ async fn test_create_assistant_endpoint(pool: Pool) { transport_type: "streamable_http".to_string(), url: "http://127.0.0.1:8123/mcp/server2".to_string(), http_headers: None, + allow_tools: None, + exclude_tools: vec![], authentication: McpServerAuthenticationConfig::None, max_session_idle_seconds: None, }, @@ -779,6 +785,8 @@ async fn test_create_assistant_rejects_unauthorized_mcp_server(pool: Pool.groups.[]": {}, "mcp_server_permissions.rules..mcp_server_ids.[]": {}, "mcp_server_permissions.rules..rule_type": {}, + "mcp_servers..allow_tools.[]": {}, "mcp_servers..authentication": {}, "mcp_servers..authentication.fixed.api_key": {}, "mcp_servers..authentication.fixed.header_name": {}, @@ -422,6 +423,7 @@ "mcp_servers..authentication.oauth2.client_name": {}, "mcp_servers..authentication.oauth2.client_secret": {}, "mcp_servers..authentication.oauth2.scopes.[]": {}, + "mcp_servers..exclude_tools.[]": {}, "mcp_servers..http_headers.": {}, "mcp_servers..max_session_idle_seconds": {}, "mcp_servers..transport_type": {}, diff --git a/site/content/docs/configuration.mdx b/site/content/docs/configuration.mdx index 2b11cfdd..1617acf0 100644 --- a/site/content/docs/configuration.mdx +++ b/site/content/docs/configuration.mdx @@ -2734,6 +2734,40 @@ url = "https://mcp-server.example.com/sse" http_headers = { "X-Tenant-ID" = "tenant-123", "X-Environment" = "production" } ``` +#### `mcp_servers..allow_tools` + +{/* erato_toml_config_key: mcp_servers..allow_tools.[] */} + +Optional list of wildcard patterns selecting which tools from this MCP server Erato exposes. Patterns match the unqualified MCP tool name, and `*` matches any sequence of characters. + +If this option is omitted, all tools are allowed, which is equivalent to `allow_tools = ["*"]`. An explicitly empty list allows no tools. + +**Type:** `array | None` + +**Examples:** + +```toml +# Expose only the exact get_page tool and tools beginning with "list_". +allow_tools = ["get_page", "list_*"] +``` + +#### `mcp_servers..exclude_tools` + +{/* erato_toml_config_key: mcp_servers..exclude_tools.[] */} + +List of wildcard patterns selecting tools to remove from the tools permitted by `allow_tools`. Exclusions are applied after the allowlist, and both filters are applied before request-level assistant, facet, and permission filtering. + +**Type:** `array` + +**Default value:** `[]` + +**Example:** + +```toml +allow_tools = ["get_*"] +exclude_tools = ["get_secret_*"] +``` + #### `mcp_servers..authentication` {/* erato_toml_config_key: mcp_servers..authentication */} diff --git a/site/content/docs/features/mcp_servers.mdx b/site/content/docs/features/mcp_servers.mdx index 351794a4..ad962571 100644 --- a/site/content/docs/features/mcp_servers.mdx +++ b/site/content/docs/features/mcp_servers.mdx @@ -133,6 +133,7 @@ MCP servers are configured in your `erato.toml` file under the `[mcp_servers]` s 3. Server URL endpoint 4. Authentication configuration 5. Optional per-server idle timeout override +6. Optional per-server tool allow/exclude filters **Example:** @@ -144,6 +145,8 @@ max_session_idle_seconds = 1200 [mcp_servers.file_provider] transport_type = "sse" url = "http://127.0.0.1:63490/sse" +allow_tools = ["list_*", "read_file"] +exclude_tools = ["list_private_*"] [mcp_servers.file_provider.authentication] mode = "none" @@ -177,6 +180,12 @@ See the [Configuration Reference](../configuration#mcp_servers) for detailed con **Session timeout guidance:** If an MCP server allocates a lot of resources per session, set `mcp_servers_global.max_session_idle_seconds` (or that server's `max_session_idle_seconds`) to a low value so idle sessions are cleaned up quickly. +### Tool filtering + +Each MCP server can define `allow_tools` and `exclude_tools` wildcard patterns over its unqualified tool names. Erato first keeps tools matching `allow_tools`, then removes tools matching `exclude_tools`. This happens before request-level permission filtering. + +Omitting `allow_tools` permits every tool and is equivalent to `allow_tools = ["*"]`. Setting `allow_tools = []` permits none. An omitted or empty `exclude_tools` removes nothing. + ## Access to MCP servers Authentication controls how Erato connects to an MCP server. Access control decides which users are allowed to see and use that server in the first place.