MCP Integration
How Aleph integrates with the Model Context Protocol to connect external tool servers
What is MCP?
The Model Context Protocol (MCP) is an open standard defining how AI applications communicate with external tool servers. It gives an LLM a structured way to discover and invoke tools, read resources, and retrieve prompts from external processes.
Aleph acts as an MCP client, connecting to one or more MCP servers that expose tools, resources, and prompts. This architecture lets you extend Aleph's capabilities without modifying its core — just run an MCP server alongside Aleph.
MCP is one of three extension types (Skill, Plugin, Mcp). This page
covers the underlying MCP client mechanism — the extension store is the
day-to-day surface.
Architecture
Three layers:
McpClient(src/mcp/client.rs) — manages connections to external MCP servers, tool discovery, andtools/callinvocation.McpManager(src/mcp/manager/) — an actor orchestrating server lifecycle with health monitoring and circuit breaking.McpHandler(src/tools/handlers/mcp.rs) — wraps each discovered tool as aLoopTool, registered in the sharedLoopToolRegistryunder<server>__<tool>.
A fourth component — McpToolBridge (src/mcp/tool_bridge.rs) —
subscribes to the manager's event broadcast and incrementally syncs tools
into the registry. It is also the only place that registers the
capability-gated builtins (mcp_read_resource / mcp_list_resources /
mcp_list_resource_templates / mcp_get_prompt / mcp_list_prompts /
mcp_login).
Aleph Core
├── McpManager (Actor)
│ ├── McpClient (per server)
│ ├── health monitoring + circuit breaker
│ └── auto-restart
│
├── McpToolBridge
│ ├── ToolsChanged event → register / unregister McpHandler
│ └── capability aggregation → reconcile_capability_tools()
│
└── LoopToolRegistry
├── Builtin handlers
├── <server>__<tool> (per server)
└── Capability-gated builtin toolsTransport types
| Transport | Use case | Connection |
|---|---|---|
| Stdio | Local process | Spawns subprocess; communicates over stdin/stdout |
| HTTP | Remote server | Standard HTTP request/response |
| SSE | Remote server with streaming | Server-Sent Events for real-time updates |
Server configuration
Server config lives in ~/.aleph/mcp_config.json:
{
"version": 1,
"servers": {
"filesystem": {
"id": "filesystem",
"name": "Filesystem Server",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-filesystem", "/Users/me/projects"],
"env": {},
"requires_runtime": "node",
"auto_start": true,
"timeout_seconds": 300
},
"github": {
"id": "github",
"name": "GitHub Server",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
},
"requires_runtime": "node",
"auto_start": true
}
}
}Field reference
| Field | Required | Default | Description |
|---|---|---|---|
id | Yes | — | Unique server identifier |
name | No | same as id | Display name |
transport | No | stdio | stdio / http / sse |
command | stdio only | — | Executable path |
args | No | [] | Command arguments |
url | http / sse only | — | Server URL |
env | No | {} | Environment variables |
requires_runtime | No | null | node / python / bun |
auto_start | No | true | Start on server boot |
timeout_seconds | No | 60 | Connection timeout (seconds) |
Environment variable expansion
Values support ${VAR} expansion. Unknown variables are left as-is, so you
can safely reference variables that may not be set everywhere.
Restart rules
The config file is not auto-reloaded and there is no MCP-management
RPC surface. After editing you must aleph-server stop && aleph-server start for changes to take effect.
Tool registration and naming
When a server starts, the bridge calls
src/tools/handlers/registration.rs::register_mcp_tools, which
instantiates a McpHandler for each discovered tool and places it in the
shared registry. The registered name is composed by
McpHandler::qualified_name (src/tools/handlers/mcp.rs), the single
source of naming truth:
pub fn qualified_name(&self) -> String {
let short = self.tool_name
.strip_prefix(&format!("{}:", self.server_id))
.unwrap_or(&self.tool_name);
sanitize_tool_name(&format!("{}__{}", self.server_id, short))
}Concretely:
- An inner name
get_weatherbecomes<server>__get_weather(weather__get_weather). - Any character outside
[A-Za-z0-9_-]is mapped to_. - The name is truncated to 64 characters (the alphabet OpenAI function calling / Anthropic / Gemini all accept).
The __ is a namespace separator — the model sees it as part of the name.
Don't put colons or dots in your inner tool names (they map to _ and can
produce confusing collisions).
Capability-gated builtins
These tools appear in the registry only while at least one connected server advertises the matching capability:
| Tool | Capability that triggers it |
|---|---|
mcp_read_resource | Any server with resource_count > 0 or resource_template_count > 0 |
mcp_list_resources | Same as above (paired with mcp_read_resource) |
mcp_list_resource_templates | Same as above (a template-only server also triggers it) |
mcp_get_prompt | Any server with prompt_count > 0 |
mcp_list_prompts | Same as above (paired with mcp_get_prompt) |
mcp_login | Any server whose transport is not Stdio (remote OAuth) |
reconcile_capability_tools re-syncs the capability set on every manager
event (ToolsChanged / ServerStarted / ServerStopped / ServerCrashed)
and after a broadcast Lagged. The resource reader and its discovery twin
share one gate: both are present or both are absent — otherwise the
model either has a reader with no way to discover URIs or a discovery tool
with nothing to read.
resolve_server_qualified — longest-prefix match
URIs arriving at mcp_read_resource / mcp_get_prompt are in
<server>:<rest> form. src/builtin_tools/mcp_resource.rs::resolve_server_qualified
uses longest-prefix match to strip exactly one server prefix:
fn server_prefix_candidates(qualified: &str) -> Vec<(&str, &str)> {
let mut out: Vec<(&str, &str)> = qualified
.match_indices(':')
.map(|(idx, _)| (&qualified[..idx], &qualified[idx + 1..]))
.collect();
out.reverse(); // longest first
out
}The previous shortest/first-match implementation mis-routed when one
server id was a colon-prefix of another: a resource on a server literally
named gh:sub also satisfies get_client("gh"), so it resolved to the
gh server and stripped the wrong layer. Iterating right-to-left (longest
first), get_client returns None for any non-server prefix (e.g., the
file:// inside a file:///… URI), so the first hit is the longest
registered server id.
qualified_id re-prefixes the already-server-qualified id with the server
id again, producing a doubled segment
(github:github:file:///R.md). This is load-bearing: mcp_read_resource
strips the leading server: layer, and the client's own prefix resolution
strips the second, so a verbatim round-trip lands the bare URI at the
server. Presenting the id as opaque keeps the model from "helpfully"
collapsing the doubled segment and breaking the round-trip.
Annotation-driven metadata
MCP servers advertise tool metadata via the annotations field in
tools/list. McpHandler::with_flags maps them onto
ToolDefinitionMetadata:
| Annotation | Field | Meaning |
|---|---|---|
readOnlyHint: true | concurrent_safe | Auto-retry |
idempotentHint: true | idempotent | Tier treats as safe |
destructiveHint: true | requires_approval | Ask tier still prompts |
ScopedToolService (src/tools/scoped/) reads these two fields, not
tool names. Undeclared = false = non-idempotent = mutating = Ask tier
holds fail-closed. By design — no name-glob override is allowed on top of
this.
Schema quarantine
unusable_tool_schema_reason (src/tools/handlers/registration.rs) flags
schemas that every provider would reject:
- The top-level schema is not a JSON object.
- The
typeis not"object"and not an array containing"object".
A missing type is tolerated (providers default to object-shaped tool
parameters). Individual unsupported keywords ($ref, format, …) are
intentionally left alone — keyword stripping is provider-specific and
handled elsewhere (Gemini schema cleaner).
A quarantined tool is skipped with a warning; it never poisons the turn with a 400.
McpManager actor
McpManager uses the actor pattern for safe concurrent server management:
let (actor, handle) = McpManagerActor::new(None).await?;
tokio::spawn(actor.run());
handle.add_server(config).await?;
handle.list_servers().await?;
handle.shutdown().await?;Server lifecycle commands
| Command | Description |
|---|---|
AddServer | Register a new server config |
RemoveServer | Remove a server by ID |
StartServer / StopServer / RestartServer | Lifecycle control |
ListServers | List servers with status |
GetStatus | Detailed status for one server |
AggregateTools / AggregateResources / AggregatePrompts | Cross-server aggregation |
ReloadConfig | Reload config from disk |
Shutdown | Gracefully shut down all servers |
Events
The manager broadcasts these events for real-time sync (the bridge consumes them):
pub enum McpManagerEvent {
ServerStarted { server_id, tool_count, .. },
ServerStopped { server_id, .. },
ServerCrashed { server_id, error, .. },
ServerRestarting { server_id, .. },
ServerRemoved { server_id, .. },
ToolsChanged { server_id, .. },
// ...
}A Lagged reconnect triggers a full resync (resync_all) — the bridge
never tries to guess at missed transitions from memory.
Health monitoring
McpManager implements a circuit-breaker pattern:
Healthy → Degraded (2+ failures) → Unhealthy (5+ failures)
↓
Restarting
↓
Dead (max restarts exceeded)Health tracking includes: consecutive-failure count, restart-window management (to prevent restart storms), last-error retention, and time since last health check.
Tool invocation
When the agent calls an MCP tool:
LoopToolRegistryresolves<server>__<tool>to aMcpHandler.McpHandler::invokecallsMcpClient::call_tool(using the inner namespaced short name).- The server executes the tool and returns a result.
- The result is forwarded to the LLM as a
ToolOutput.
Error mapping: NetworkError / IoError → ToolError::Transport;
McpTimeout / Timeout → ToolError::Timeout; everything else →
ToolError::Execution. All error messages pass through
mcp::redact_mcp_error before display — a server that echoes back a
secret-bearing argument or URL cannot leak it into conversation history.
OAuth and remote servers
Remote MCP servers use OAuth. The mcp_login builtin tool (present only
when at least one server's transport is non-stdio) drives the browser
OAuth flow and persists tokens under mcp/auth/.
Remote server URLs must pass SSRF validation (mcp/transport/http.rs uses
ssrf::validate_url) — private network, link-local, and DNS-rebinding
targets are rejected.
Runtime checks
requires_runtime lets the manager verify Node / Python / Bun availability
before starting a server. A missing dependency skips that one server with
a warning — it does not fail the whole boot.
Security notes
- Process isolation: stdio MCP servers run as separate processes.
- Environment variables: use
${VAR}expansion for sensitive values (API keys, etc.); never hardcode. - OAuth token storage: remote-server OAuth tokens are written to the
encrypted vault by the Rust core; the
AlephBridgeand any other process must not read or write any~/.aleph/path. - Tool approval: make approval work via
destructiveHintdeclarations or explicit entries in[policies.tool_permissions]. - Network boundary: remote MCP servers use HTTPS with appropriate auth headers.
- Runtime validation: required runtimes are checked before server start, preventing arbitrary command execution.
Best practices
- Set
auto_start: truefor foundational servers that must always be up. - Set reasonable timeouts to prevent hangs.
- Set
requires_runtimefor clear errors when a dependency is missing. - Use environment variables for secrets — never hardcode API keys.
- Subscribe to manager events to monitor server health and tool changes.
- Group related tools into a single MCP server to reduce connection overhead.