Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/erato.template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions backend/erato/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<String, String>>,
// 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<Vec<String>>,
// Wildcard patterns selecting tools to remove after `allow_tools` is applied.
#[serde(default)]
pub exclude_tools: Vec<String>,
// Authentication settings for requests to this MCP server.
#[serde(default)]
pub authentication: McpServerAuthenticationConfig,
Expand Down
155 changes: 151 additions & 4 deletions backend/erato/src/services/mcp_session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(())
}
Expand All @@ -134,6 +134,59 @@ impl McpSession {
}
}

fn filter_tools_by_server_config(tools: Vec<Tool>, config: &McpServerConfig) -> Vec<Tool> {
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<String>;
type SessionKey = (Uuid, String, SessionAuthKey);

Expand Down Expand Up @@ -631,14 +684,18 @@ 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?;

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(())
Expand Down Expand Up @@ -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<Vec<&str>>, 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::<Vec<_>>();

assert_eq!(filtered_names, vec!["get_page", "list_pages"]);
}
}
8 changes: 8 additions & 0 deletions backend/erato/tests/integration_tests/api/assistants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ async fn test_update_assistant_endpoint(pool: Pool<Postgres>) {
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,
},
Expand Down Expand Up @@ -649,6 +651,8 @@ async fn test_create_assistant_endpoint(pool: Pool<Postgres>) {
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,
},
Expand All @@ -659,6 +663,8 @@ async fn test_create_assistant_endpoint(pool: Pool<Postgres>) {
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,
},
Expand Down Expand Up @@ -779,6 +785,8 @@ async fn test_create_assistant_rejects_unauthorized_mcp_server(pool: Pool<Postgr
transport_type: "streamable_http".to_string(),
url: "http://127.0.0.1:8123/mcp".to_string(),
http_headers: None,
allow_tools: None,
exclude_tools: vec![],
authentication: McpServerAuthenticationConfig::None,
max_session_idle_seconds: None,
},
Expand Down
2 changes: 2 additions & 0 deletions backend/erato/tests/integration_tests/api/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ fn mcp_server_config(
transport_type: "streamable_http".to_string(),
url: format!("{base_url}{path}"),
http_headers: None,
allow_tools: None,
exclude_tools: vec![],
authentication,
max_session_idle_seconds: None,
}
Expand Down
2 changes: 2 additions & 0 deletions backend/erato/tests/integration_tests/llm/mcp_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ fn mcp_server_config(
transport_type: "streamable_http".to_string(),
url: format!("{base_url}{path}"),
http_headers: None,
allow_tools: None,
exclude_tools: vec![],
authentication,
max_session_idle_seconds: None,
}
Expand Down
2 changes: 2 additions & 0 deletions backend/generated/config_reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@
"mcp_server_permissions.rules.<rule-name>.groups.[]": {},
"mcp_server_permissions.rules.<rule-name>.mcp_server_ids.[]": {},
"mcp_server_permissions.rules.<rule-name>.rule_type": {},
"mcp_servers.<server-id>.allow_tools.[]": {},
"mcp_servers.<server-id>.authentication": {},
"mcp_servers.<server-id>.authentication.fixed.api_key": {},
"mcp_servers.<server-id>.authentication.fixed.header_name": {},
Expand All @@ -422,6 +423,7 @@
"mcp_servers.<server-id>.authentication.oauth2.client_name": {},
"mcp_servers.<server-id>.authentication.oauth2.client_secret": {},
"mcp_servers.<server-id>.authentication.oauth2.scopes.[]": {},
"mcp_servers.<server-id>.exclude_tools.[]": {},
"mcp_servers.<server-id>.http_headers.<key>": {},
"mcp_servers.<server-id>.max_session_idle_seconds": {},
"mcp_servers.<server-id>.transport_type": {},
Expand Down
34 changes: 34 additions & 0 deletions site/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2734,6 +2734,40 @@ url = "https://mcp-server.example.com/sse"
http_headers = { "X-Tenant-ID" = "tenant-123", "X-Environment" = "production" }
```

#### `mcp_servers.<server-id>.allow_tools`

{/* erato_toml_config_key: mcp_servers.<server-id>.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<string> | None`

**Examples:**

```toml
# Expose only the exact get_page tool and tools beginning with "list_".
allow_tools = ["get_page", "list_*"]
```

#### `mcp_servers.<server-id>.exclude_tools`

{/* erato_toml_config_key: mcp_servers.<server-id>.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<string>`

**Default value:** `[]`

**Example:**

```toml
allow_tools = ["get_*"]
exclude_tools = ["get_secret_*"]
```

#### `mcp_servers.<server-id>.authentication`

{/* erato_toml_config_key: mcp_servers.<server-id>.authentication */}
Expand Down
9 changes: 9 additions & 0 deletions site/content/docs/features/mcp_servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
Loading