Aleph
Tools & Extensions

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:

  1. McpClient (src/mcp/client.rs) — manages connections to external MCP servers, tool discovery, and tools/call invocation.
  2. McpManager (src/mcp/manager/) — an actor orchestrating server lifecycle with health monitoring and circuit breaking.
  3. McpHandler (src/tools/handlers/mcp.rs) — wraps each discovered tool as a LoopTool, registered in the shared LoopToolRegistry under <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 tools

Transport types

TransportUse caseConnection
StdioLocal processSpawns subprocess; communicates over stdin/stdout
HTTPRemote serverStandard HTTP request/response
SSERemote server with streamingServer-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

FieldRequiredDefaultDescription
idYesUnique server identifier
nameNosame as idDisplay name
transportNostdiostdio / http / sse
commandstdio onlyExecutable path
argsNo[]Command arguments
urlhttp / sse onlyServer URL
envNo{}Environment variables
requires_runtimeNonullnode / python / bun
auto_startNotrueStart on server boot
timeout_secondsNo60Connection 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_weather becomes <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:

ToolCapability that triggers it
mcp_read_resourceAny server with resource_count > 0 or resource_template_count > 0
mcp_list_resourcesSame as above (paired with mcp_read_resource)
mcp_list_resource_templatesSame as above (a template-only server also triggers it)
mcp_get_promptAny server with prompt_count > 0
mcp_list_promptsSame as above (paired with mcp_get_prompt)
mcp_loginAny 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:

AnnotationFieldMeaning
readOnlyHint: trueconcurrent_safeAuto-retry
idempotentHint: trueidempotentTier treats as safe
destructiveHint: truerequires_approvalAsk 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 type is 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

CommandDescription
AddServerRegister a new server config
RemoveServerRemove a server by ID
StartServer / StopServer / RestartServerLifecycle control
ListServersList servers with status
GetStatusDetailed status for one server
AggregateTools / AggregateResources / AggregatePromptsCross-server aggregation
ReloadConfigReload config from disk
ShutdownGracefully 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:

  1. LoopToolRegistry resolves <server>__<tool> to a McpHandler.
  2. McpHandler::invoke calls McpClient::call_tool (using the inner namespaced short name).
  3. The server executes the tool and returns a result.
  4. The result is forwarded to the LLM as a ToolOutput.

Error mapping: NetworkError / IoErrorToolError::Transport; McpTimeout / TimeoutToolError::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 AlephBridge and any other process must not read or write any ~/.aleph/ path.
  • Tool approval: make approval work via destructiveHint declarations 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

  1. Set auto_start: true for foundational servers that must always be up.
  2. Set reasonable timeouts to prevent hangs.
  3. Set requires_runtime for clear errors when a dependency is missing.
  4. Use environment variables for secrets — never hardcode API keys.
  5. Subscribe to manager events to monitor server health and tool changes.
  6. Group related tools into a single MCP server to reduce connection overhead.

On this page