Files
2026-07-10 15:02:09 +01:00

649 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MCP (Model Context Protocol)
## Workspace Location
The MCP protocol layer lives in the standalone crate `crates/mcp-client`:
- `McpServer` — stdio subprocess client
- `McpHttpServer` — streamable HTTP client
- `McpServerClient` trait, `McpTool`, `McpServerConfig`, `McpTransport`
`McpManager` (`src/core/mcp/mod.rs`) remains in the main crate because it owns the `SqlitePool` and calls `crate::db::mcp_events` / `crate::db::mcp_servers`.
---
## What MCP Is Here
MCP allows external processes or HTTP services to expose tools to the LLM. The app connects to MCP servers at startup (or on demand via `register_mcp`), discovers their tools, and makes them available alongside built-in tools.
---
## McpManager Internals
```rust
McpManager {
pool: Arc<SqlitePool>
servers: RwLock<HashMap<String, Arc<dyn McpServerClient>>> // running servers
errors: RwLock<HashMap<String, String>> // startup failures
}
```
Initialization runs in a background `tokio::spawn` task. The manager is available immediately; servers connect asynchronously. A server failing to start is recorded in `errors` and does not block the app.
---
## Transports
| Transport | When to use | Required fields |
| --- | --- | --- |
| `stdio` | Local process (spawn subprocess) | `command`, optionally `args`, `env` |
| `http` | Remote HTTP server (streamable MCP) | `url`, optionally `api_key` |
| `sse` | Alias for `http` (backward compat) | same as `http` |
`${VAR}` interpolation is supported in `env` values and `api_key`.
### stdio process lifecycle
stdio subprocesses are spawned with `kill_on_drop(true)` and, on Unix, in their
own process group (`process_group(0)`). The new process group detaches them from
the terminal's foreground group, so a terminal Ctrl+C (SIGINT to the whole group)
does not reach them directly — otherwise Python-based servers would catch it and
dump a `KeyboardInterrupt` traceback. They are instead reaped via `kill_on_drop`:
when the app shuts down and the per-server reader task is dropped, the child gets
a silent SIGKILL.
The child's **stderr is captured** (`Stdio::piped()`, not inherited) and drained
into `tracing` at `debug` level under the `mcp_client` target, prefixed with the
server name. This keeps startup banners, deprecation warnings and INFO logs from
servers like FastMCP off the console at the default log level, while still making
them available for diagnostics via `RUST_LOG=mcp_client=debug`. Each stderr line is
**also** forwarded to that server's per-server log file — see [Per-server logs](#per-server-logs).
---
## Protocol version, header & pagination
**Protocol version** is a single shared constant — `PROTOCOL_VERSION` in
`crates/mcp-client/src/lib.rs` (currently **`2025-11-25`**, the revision Skald
targets). Both transports advertise it in their `initialize` request, so they can
never drift apart. **Capabilities are per-transport**, not shared: stdio declares
`{ "elicitation": {}, "experimental": { "tasks": {} } }` (form mode — see
Elicitation below); HTTP declares `{ "experimental": { "tasks": {} } }` because it
does not service the `ElicitationHandler` (stdio-only) and must not claim a
capability it can't honour. The `experimental.tasks` marker signals Skald is
task-*aware* (it recognises a deferred `CreateTaskResult`) without claiming the
full `tasks` capability — see *Cancellation & Tasks* below.
**Version negotiation is tolerant.** Skald reads `protocolVersion` from the
`initialize` response and, if the server negotiates a different (older) version,
logs a `warn!` and proceeds rather than disconnecting.
**`MCP-Protocol-Version` header (HTTP only).** Per the Streamable HTTP spec, every
*post-initialize* request must carry `MCP-Protocol-Version: <negotiated>`. The HTTP
transport captures the negotiated version into `protocol_version: Mutex<Option<String>>`
(mirroring how `session_id` is captured) and `request_headers()` injects it on every
`request`/`notify`. It is `None` only during the `initialize` call itself, so the
header is naturally omitted there (the spec scopes it to post-initialize requests).
**`tools/list` pagination.** Both transports follow the cursor: `tools/list` is
requested with `{ "cursor": <nextCursor> }` until the response omits `nextCursor`,
accumulating every page (previously only the first page was read, silently
truncating large servers). A `MAX_TOOL_PAGES` (50) cap guards against a server that
never clears the cursor. The per-tool field mapping lives in one place,
`McpTool::from_json`, shared by both transports.
---
## Structured tool results
MCP tools with an `outputSchema` return `structuredContent` (a JSON object) in
addition to (or instead of) text. Skald preserves the type end-to-end instead of
flattening everything to a string:
- `McpCallResult` (`crates/mcp-client/src/lib.rs`) — the transport-level result:
`Text(String)`, `Json(Value)`, or `Media { text, structured, items }` (see
*Media tool results* below). `extract_call_result` **prefers
`structuredContent`** when present (canonical per spec) and falls back to the
joined `text` items — which also fixes the silent empty-result case for servers
that return only `structuredContent` without the recommended text mirror.
> Tradeoff: when a server returns *both* a text mirror and `structuredContent`,
> the LLM sees the (compact) JSON, not the text. With a single `result` column +
> a type tag this is the correct one-representation choice (JSON is lossless and
> LLM-readable; the mirror is usually just `JSON.stringify` of the same object).
- `McpManager::call` maps `McpCallResult``ToolResult` (`crates/core-api/src/tool.rs`),
the host-side equivalent (`Text`/`Json`). `ToolResult::to_wire()` is the string
persisted in `chat_llm_tools.result` and replayed to the LLM (Json → compact JSON
string); `ToolResult::kind()` is the `"string"`/`"json"` tag.
- The tag is persisted in `chat_llm_tools.result_type` (schema **v19**, `DEFAULT
'string'`, `CHECK IN ('string','json')`) and sent to the frontend both live
(`ServerEvent::ToolDone.result_type`) and on history replay / approval-resolve
(`/api/sessions` items + `ResolveToolResponse`).
- Frontend: `copilot-render.js` renders a `result_type === 'json'` result as
pretty-printed JSON (`.copilot-tool-pre--json`); everything else stays plain text.
**In-repo example:** `scripts/weather_mcp_server.py`'s `get_air_quality` tool
emits `structuredContent` with a declared `outputSchema` — the payload carries a
human-readable `summary` string (emoji-formatted, the text mirror) **plus** the
raw numeric AQI and pollutant fields (`european_aqi`, `us_aqi`,
`pollutants_ug_m3`, …) for machine consumption. The other weather tools
(`get_current_weather`, `get_forecast`, `status`) return plain text. This is the
minimal in-repo reference for a server that emits structured results: it builds
the dict in the handler and the JSON-RPC layer wraps it as `structuredContent`
(`_structured_result`); error paths still return plain `Error:` text.
---
## Media tool results
MCP tool results can carry non-text content blocks — `image`, `audio`, embedded
`resource` (base64 `blob`), and `resource_link` (a remote URI). Previously these
were dropped: `extract_text` read only `content[].text`, so an MCP server that
generated an image returned an empty result unless it also mirrored the bytes in
`structuredContent`.
Now they are preserved end-to-end, **saved to disk and surfaced as a URL** (the
same model as the built-in `image_generate` tool — the bytes never enter the LLM
wire format):
- `crates/mcp-client/` stays a generic transport: `classify_content` walks
`content[]`, decodes the base64 of `image`/`audio`/`resource` blocks into bytes
and passes through `resource_link` URIs, producing `McpCallResult::Media { text,
structured, items: Vec<McpMedia> }`. The `Media` variant is emitted **only** when
at least one media block is present; pure text/JSON results are unchanged (no
regression).
- `McpManager::persist_media` (`src/core/mcp/mod.rs`) writes each inline item to
`data/mcp_media/<id>.<ext>` (`<ext>` from the MIME via `ext_for_mime`) and
composes a **markdown** `ToolResult::Text` referencing each item by URL
(`![image](/api/mcp-media/<id>.png) (image/png, 412 KB)`, `[audio]()`,
`[file]()`); `resource_link`s become `[<mime>](<uri>)`. Markdown (not JSON) so
the model can relay the URL into its message, where `renderMarkdown` displays it.
- Serving: `GET /api/mcp-media/{file}` (`src/frontend/api/mcp_media.rs`) reads from
`McpManager::media_dir()` with the `Content-Type` inferred by
`content_type_for_ext`; the filename is path-sanitized (flat `<id>.<ext>` only).
- **Not** sent to the model as a multimodal content block (the model does not
"see" the pixels) and **not** rendered inline in the tool card yet — both are
possible future enhancements.
`McpTool` also captures `title`, `output_schema`, `annotations` (2025-06-18+), and
`task_support` (`execution.taskSupport`, 2025-11-25). These are stored but **not
yet** validated/surfaced (output-schema validation, `readOnlyHint`/`destructiveHint`
UI hints, and per-tool task negotiation are future work).
---
## Cancellation & Tasks
Two 2025-11-25 base utilities the client now covers (`crates/mcp-client/`):
**`notifications/cancelled`.** When an in-flight `tools/call` is abandoned, the
client tells the server to stop instead of silently leaving it working. Both
transports arm a drop-guard **only for `tools/call`** (the spec forbids cancelling
`initialize`):
- `CancelOnDrop` (`server.rs`, stdio) / `HttpCancelOnDrop` (`http_server.rs`, HTTP)
hold the request `id`; if dropped while still *armed* they emit
`notifications/cancelled { requestId, reason }` (built by the shared
`cancelled_notification` helper in `lib.rs`, so the two transports can't drift).
- Fires in exactly two cases: a `/stop` drops the work future (`SimpleExecution`
→ `mcp.call` → `request()` future), and the 120 s `CALL_TIMEOUT_SECS` elapses
(reason `"timeout"`). Disarmed once the server replies or disconnects, where
cancelling is pointless.
- stdio also drops the now-orphaned `pending` entry (a small leak fix). HTTP is
**best-effort**: `requestId`↔POST correlation is weaker over Streamable HTTP, and
a non-timeout send error (server never received the request) disarms instead.
**Tasks (experimental, block-and-poll).** A server MAY defer an expensive
`tools/call` and return a `CreateTaskResult` (durable `taskId`, `status`,
`pollInterval`, `ttl`) instead of blocking. The client drives it to completion
synchronously:
- **Opt-in per request.** `call_tool` adds a `task` field to `tools/call` when the
tool advertises `execution.taskSupport` as `required`/`optional` (`McpTool.task_support`,
captured in `from_json`). Adding the field is the spec's opt-in for `tools/call`
(the client is the *requestor*), so no extra capability declaration is needed —
the `tasks` marker stays under `capabilities.experimental`.
- **Recognise.** `CreateTaskResult::parse` (top-level or nested under `task`) runs in
`extract_call_result` **before** the media/text logic, yielding `McpCallResult::Task`
so a handle isn't mistaken for an empty result.
- **Poll (`poll_task`, per transport).** Sleep `clamp_poll_interval` (server
`pollInterval`, clamped to [500 ms, 30 s]), then `tasks/get` until a terminal
status: `completed` → fetch the real result via `tasks/result` (parsed like any
normal result); `failed`/`cancelled` → error; `input_required` → error (mid-task
input is a follow-up). Bounded by `poll_deadline` (the task's `ttl`, else a 1 h
cap). **Each poll request is a normal short `request()`; only the overall wait is
unbounded — so a task-mode call no longer hits the 120 s `CALL_TIMEOUT_SECS` wall.**
- **Cooperative cancel.** A `TaskCancelOnDrop` / `HttpTaskCancelOnDrop` guard sends
`tasks/cancel { taskId }` (shared `tasks_cancel_request` helper) if the poll future
is dropped (a `/stop`, between polls) or the deadline is hit; disarmed on a terminal
status. Server-caps captured at `initialize` (`server_capabilities()`) remain for a
future poller to gate on.
**Trade-offs (v1, block-and-poll).** The session holds its `processing` lock for the
whole task (like any long tool call), and polling does **not** survive a Skald
self-restart. A *detached/durable* variant (DB-persisted tasks + a background poller
delivering via `inject_async_result`/`resume_turn`, surviving restart and freeing the
session) is the tracked follow-up. `McpManager::call`'s `McpCallResult::Task` arm is
now only a **defensive fallback** (polling normally resolves the task in `call_tool`).
---
## Tool Naming Convention
MCP tools are exposed to the LLM as **`mcp__<server_name>__<tool_name>`**.
Examples:
- Server `tavily`, tool `search` → `mcp__tavily__search`
- Server `fetch`, tool `get` → `mcp__fetch__get`
`parse_mcp_tool_name(name)` in `src/core/mcp/mod.rs` splits on `__` to extract server and tool names. This is how `run_agent_turn` routes MCP calls.
---
## Registering a Server
All MCP servers are stored in the **`mcp_servers` table** in SQLite. There is no static config file.
**Live registration** via `register_mcp` tool:
- LLM calls `register_mcp` with name, transport, connection details, and optionally `description` and `friendly_name`
- `McpManager::register()` does DB upsert + live `start_one()` connect
- Server is immediately available without a restart
**Tool parameters:**
| Parameter | Required | Type | Description |
| --- | --- | --- | --- |
| `name` | yes | string | Unique name for this MCP server (used to reference it in tool calls) |
| `transport` | yes | string | `stdio`, `http`, or `sse` |
| `command` | stdio only | string | Executable to spawn |
| `args` | stdio only | string[] | Command-line arguments |
| `env` | stdio only | object | Extra environment variables |
| `url` | http/sse only | string | Base URL of the remote server |
| `api_key` | http/sse only | string | API key (sent as `Authorization: Bearer <key>`) |
| `description` | no | string | Short description of what the server provides (shown in `list_items` type=mcp) |
| `friendly_name` | no | string | Human-readable display name for UI (e.g. "Google Calendar") |
**Startup timeout**: **`SERVER_START_TIMEOUT_SECS = 120`**. Servers that don't respond within 120 s are recorded as errors.
---
## Enabling / Disabling Servers
Use the built-in tool **`toggle_item`** (kind=mcp) to enable or disable an MCP server by name:
```text
toggle_item(kind="mcp", id="gcal", enabled=false) # disable
toggle_item(kind="mcp", id="gcal", enabled=true) # enable
```
**Important:** Toggling updates the `enabled` flag in the database, but **a restart is required** for the change to take effect on running servers. Disabled servers won't connect on next restart.
Use `list_items` (type=mcp) to see current server names and statuses.
## Deleting Servers
Use the built-in tool **`delete_mcp`** to permanently remove a server. It calls `McpManager::unregister`, which deletes the `mcp_servers` row and disconnects the running client in one step — no restart needed:
```text
delete_mcp(name="gcal")
```
This is **irreversible** (the configuration is discarded). To turn a server off while keeping its configuration, use `toggle_item(kind="mcp", enabled=false)` instead. Like `delete_cron_job`, `delete_mcp` is kept separate from the reversible `toggle_item` so it can carry its own approval rule.
---
## Example: Google Calendar MCP Server
A custom Python MCP server (`scripts/gcal_mcp_server.py`) provides full read/write access to Google Calendar:
| Tool | Description |
| --- | --- |
| `list_calendars` | Lists all calendars accessible to the authenticated user |
| `list_events` | Lists events with filters: `calendar_id`, `start_time`, `end_time`, `max_results`, `full_text`, `time_zone` |
| `get_event` | Returns a single event by `event_id` |
| `create_event` | Creates a new event (`summary`, `start`, `end`, optional description/location/attendees/recurrence) |
| `update_event` | Updates an existing event — only fields provided are changed |
| `delete_event` | Permanently deletes an event by `event_id` |
| `respond_to_event` | Sets RSVP status (`accepted`, `declined`, `tentative`, `needsAction`) |
**Credentials:** Stored in `./secrets/google_creds.json`. Run `python3 scripts/gcal_oauth_setup.py` to authenticate (requires `https://www.googleapis.com/auth/calendar` scope). Token refresh is handled automatically.
**Register:**
```text
register_mcp(name="gcal", transport="stdio", command="python3", args=["scripts/gcal_mcp_server.py"])
```
**Disable when not needed:**
```text
toggle_item(kind="mcp", id="gcal", enabled=false)
restart
```
---
## Push Notifications from MCP Servers
MCP servers can send **unsolicited events** to the app by writing JSON-RPC notification messages (no `id` field) to stdout. The app persists them to SQLite and processes them in batches via the TIC background agent.
### Protocol
A notification is a JSON-RPC 2.0 message without `id`:
```json
{"jsonrpc": "2.0", "method": "event/new_email", "params": {"subject": "...", "from": "..."}}
```
### How it flows
```text
MCP server writes notification to stdout
→ McpServer reader loop detects msg with no "id"
→ sends (server_name, msg) over notification_tx channel
→ McpManager::notification_consumer persists to mcp_events table
→ TicManager (every `tic.interval_secs`, default 900 s) fetches pending events, runs TIC agent
→ TIC calls notify(briefing) if user action is needed
```
**Exception — `notifications/message`.** The MCP *logging* utility method
`notifications/message` (`{ level, logger?, data }`) is **not** a business event: it
carries a diagnostic log record. The reader loop diverts it to the server's
[per-server log file](#per-server-logs) via `log_tx` instead of `notification_tx`, so
log records never reach `mcp_events`/TIC. Every other method (the custom `event/*`
notifications below) flows as shown above. Verified against production data: business
events use `event/*`; the only server observed emitting `notifications/message` was
`firecrawl`, and it was pure logging.
### Implementing notifications in an MCP server
**Node.js (WhatsApp)**:
```js
function notify(method, params) {
process.stdout.write(JSON.stringify({jsonrpc:'2.0', method, params}) + '\n');
}
client.on('message', async (msg) => {
if (msg.fromMe) return;
notify('event/whatsapp_message', { from: msg.from, body: msg.body });
});
```
**Python (Gmail, GCal)** — use a lock to avoid interleaving with MCP responses:
```python
import threading
_stdout_lock = threading.Lock()
def _emit_notification(method, params):
msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
with _stdout_lock:
sys.stdout.write(msg + "\n")
sys.stdout.flush()
```
Start a daemon polling thread in `main()` before entering the MCP serve loop. The MCP serve loop must also acquire `_stdout_lock` before writing responses.
### Implemented notification sources
| Source | Method | Trigger | Poll interval |
| --- | --- | --- | --- |
| `whatsapp` | `event/whatsapp_message` | Inbound WhatsApp message | Real-time (event) |
| `gmail` | `event/new_email` | New email in INBOX | 60 s (History API) |
| `gcal` | `event/new_calendar_event` | New calendar event created | 300 s (Events API) |
---
## Per-server logs
Each MCP server gets its own diagnostic log file at **`logs/mcp/<name>.log`** (the
server name is sanitized: non-`[A-Za-z0-9._-]` characters become `_`). This is a plain
append-only file — no SQLite — meant to be scanned later (e.g. by a diagnostics agent)
for `[error]`/`[warning]` lines.
**Sources captured** (all keyed by server name, all routed over the crate's `log_tx`
channel to `McpManager`'s `logs::log_consumer`):
| Tag | Source | Transports |
| --- | --- | --- |
| `[stderr]` | Raw child-process stderr line | stdio |
| `[<level>]` | `notifications/message` log record — tag is the MCP level (`debug`..`emergency`) | stdio |
| `[lifecycle]` | Start failure, startup timeout, connection, disconnect | all (incl. HTTP/SSE) |
**Line format** — ISO-8601 UTC timestamp, padded level tag, then text:
```text
2026-07-03T12:34:56.789Z [stderr] INFO: server ready
2026-07-03T12:34:56.789Z [warning] scraper: rate limit approaching
2026-07-03T12:34:56.789Z [lifecycle] failed to start: command not found
```
**Rotation:** when a file would exceed **5 MB** it is rotated to `<name>.log.1` (one
backup kept) so a chatty server can't grow the file without bound.
**Why stderr is primary.** The MCP *logging* utility (`notifications/message` +
`logging/setLevel`) is **deprecated** from the 2026-07-28 draft (SEP-2577); the official
migration path is `stderr` for stdio transports plus OpenTelemetry. So `stderr` is the
future-proof primary source and `notifications/message` is captured only for interop with
servers that still emit it. HTTP/SSE servers have no stderr and no async notification
listener, so their files contain lifecycle lines only.
**Code:** emission lives in `crates/mcp-client/src/log.rs` (`McpLogLine`, `McpLogTx`) and
the stdio reader loop in `crates/mcp-client/src/server.rs`; file writing lives in
`src/core/mcp/logs.rs` (`log_consumer`), spawned from `McpManager::new`. Lifecycle lines
are emitted by `McpManager::log_lifecycle`.
---
## Elicitation — server-initiated input (spec 2025-06-18)
An MCP server can ask the user for input **during** a tool call — a server→client
request, distinct from the unsolicited notifications above. Primary use case: the
SSH MCP asking for a sudo password on demand (see `data/mcp_ssh.md`). The value
never reaches the LLM, is never logged, and is never persisted.
Skald advertises the capability on the **stdio** transport's `initialize`
(`"capabilities": { "elicitation": {} }`) and surfaces requests in the Agent Inbox.
The `protocolVersion` is the shared `PROTOCOL_VERSION` const (see *Protocol version,
header & pagination*); `{ "elicitation": {} }` is form mode, which is what Skald
supports (URL-mode elicitation, new in 2025-11-25, is not yet handled).
### Elicitation protocol
A server→client request has **both** `method` and `id`:
```json
{"jsonrpc":"2.0","id":"e1","method":"elicitation/create","params":{
"message":"Enter sudo password",
"requestedSchema":{"type":"object","properties":{
"password":{"type":"string","format":"password"}}}}}
```
Skald replies on the same stdin: `{action: "accept"|"decline"|"cancel", content?: {…}}`.
### Elicitation flow
```text
MCP server writes elicitation/create (method + id) to stdout
→ McpServer reader loop routes it to handle_server_request (BEFORE the id/response
branch, since it has both method and id) and spawns a task (the user may take minutes)
→ ElicitationHandler bridge (src/core/elicitation) → ElicitationManager::register
→ ServerEvent::ElicitationRequested → Agent Inbox card ("Secrets" section)
→ user enters a value (masked if sensitive) and confirms / rejects
→ POST /api/inbox/elicitations/{id}/resolve → ElicitationManager::resolve
→ bridge maps the outcome → reader loop writes the JSON-RPC reply to the server's stdin
```
While an elicitation is in flight, the underlying `tools/call` does **not** time out
(`pending_elicitations` counter re-arms the call timeout). On a 5-min user-response
deadline, channel drop, or `decline`/`cancel`, the server receives a non-accept reply.
The schema is **v1-scoped**: a single field (masked when `format: password`,
`writeOnly: true`, or the name contains `password`/`passphrase`/`secret`/`token`),
or an empty `properties` ⇒ a yes/no confirmation. Elicitation is **stdio-only**.
A dependency-free demo server lives at `scripts/elicitation_demo_mcp.py`.
---
## SSH MCP Server
`scripts/ssh_mcp_server.py` is a stdio MCP server (depends on `paramiko>=3.4`) that
operates on remote hosts. Its filesystem tools intentionally produce the **same output
format** as Skald's native fs tools (`read_file`, `list_files`, `grep_files`, `edit_file`,
`replace_lines`) so the LLM treats local and remote uniformly — the only difference is a
leading `alias` argument. Full design notes: `data/mcp_ssh.md`.
**13 tools** (bare names; Skald prefixes `mcp__ssh__`):
- Aliases: `list_aliases`, `add_alias`, `remove_alias`.
- Filesystem (SFTP, login user): `read_file`, `list_files`, `grep_files`, `edit_file`, `replace_lines`.
- Exec/transfer: `exec` (with `sudo`/`sudo_user`), `upload`, `download` (both recursive on directories).
- Diagnostics: `sysinfo`, `systemd`.
**Aliases** live in `secrets/ssh_aliases.json` (auto-managed by the tools, written
atomically at `0600`, gitignored). The file holds **no secrets** (no password, ever).
Host keys are checked against `~/.ssh/known_hosts`; unknown hosts are rejected unless the
alias was added with `accept_new_host_key=true`. Connections are pooled per alias with
lazy TTL eviction (`SSH_MCP_POOL_TTL`, default 300s).
**Login auth** (per-alias `auth`, default `key`):
- `key` → SSH key / ssh-agent. If the chosen private key is encrypted, its passphrase is
requested **lazily** via elicitation (only when paramiko reports the key needs one).
`SSH_MCP_KEY_PASSPHRASE` still works as a non-interactive override.
- `password` → login password requested on demand via **elicitation** (a masked field in
the Agent Inbox); agent/key probing is skipped so paramiko goes straight to the password.
Elicited login secrets are cached only in the server's RAM (`SSH_MCP_LOGIN_PW_TTL`, default
300s), never sent to the LLM, never written to disk, and dropped on an authentication
failure so the next attempt re-prompts.
**sudo** (per-alias `sudo.method`, default `prompt`):
- `nopasswd` → `sudo -n`: non-interactive, **never prompts**. Pick this **only** when the alias user has a `NOPASSWD:` rule in the remote sudoers; on a normal host every sudo call fails fast with "a password is required" (no hung channel). When in doubt use `prompt`.
- `prompt` → `sudo -S`: the password is requested on demand via **elicitation** (see above),
a masked single field; fed to sudo's stdin, cached only in the server's RAM
(`SSH_MCP_SUDO_PW_TTL`, default 300s), never sent to the LLM, never written to disk.
- `none`: sudo disabled.
SFTP tools run as the login user (no root). For privileged writes the LLM is told to use
`exec(..., sudo=true)` with `tee`/`install`.
`upload` follows scp/rsync destination semantics for a single file: a `remote_path` ending
in `/` (or that is an existing remote directory) uploads the file **into** that directory
keeping its basename; otherwise `remote_path` is the exact destination file path. Missing
parent directories are created either way. (paramiko's `sftp.put` alone would reject a
directory-style path with a generic `Failure`.)
Register like any stdio server: `command: python3`, `args: ["scripts/ssh_mcp_server.py"]`.
---
## Lazy MCP Tool Loading
By default, injecting all MCP tool definitions into every LLM turn is expensive — 30+ tools can consume 10,000+ tokens per turn. Lazy loading solves this by only including tools for servers that have been explicitly activated.
### How It Works
1. At the start of each turn, `build_agent_config` reads `session_mcp_grants` for the current `session_id` and populates `active_mcp_grants` in memory.
2. **MCP tools are no longer part of `base_tool_defs`**. Instead, `AgentRunConfig::all_tool_defs()` re-queries `mcp.tools_for(active_mcp_grants)` on **every LLM round**. This means an `activate_tools` call in round N makes those tools available from round N+1 within the same turn — no cross-turn delay.
3. The system prompt contains a `<!-- MCP_LIST -->` tag (in `AGENT.md`) which is replaced at request time with a dynamic two-section block:
```text
## MCP servers
**Available** — call `activate_tools(["name"])` to load tools:
| Server | Description |
|------------|------------------------------------------|
| `tavily` | Web search and content extraction |
| `whatsapp` | Send and receive WhatsApp messages |
**Active** — tools callable as `mcp__<name>__<tool>`:
- `gmail`
```
### `<!-- MCP_LIST -->` Tag
Add this tag anywhere in an `AGENT.md` to inject the dynamic MCP availability block at that position. Agents that do not include the tag receive no MCP list injection.
Currently used in: `agents/main/AGENT.md`, `agents/tic/AGENT.md`.
Resolution pipeline:
- `agents::resolve_includes()` — replaces `<!-- MCP_LIST -->` with the `__MCP_LIST__` sentinel.
- `ChatSessionHandler::build_openai_messages()` — replaces `__MCP_LIST__` with the rendered block (via `render_mcp_list()`).
### `activate_tools` Tool
A synthetic interface tool (not in the global `ToolRegistry`):
```text
activate_tools(groups: ["server_name", ..., "config"])
```
- Takes an array of **tool-group** names. A group is either an MCP server name **or** the reserved keyword `config` (see [Config tool group](#config-tool-group) below).
- Updates the in-memory `active_mcp_grants` set immediately (the set holds server names and/or `"config"`).
- **Root agents** (`stack_id = None`): persists grants to `session_mcp_grants` — survives across turns and restarts.
- **Sub-agents** (`stack_id = Some(id)`): persists grants to `stack_mcp_grants` — survives restarts, but **deleted when the stack frame terminates** (no session leak).
- Returns a confirmation string listing which groups were activated and their scope (`session` or `stack <id>`).
### Config tool group
The same lazy mechanism gates the built-in **`Config`-category** tools (`set_secret`, `list_secrets`, `register_mcp`, `delete_mcp`, `configure_plugin`, `cron_jobs` delete, `toggle_item`). They are hidden from `base_tool_defs` and loaded on demand via `activate_tools(["config"])`:
- `build_agent_config` splits the registry with `ToolRegistry::openai_definitions_excluding_config()` (the always-on base) and `openai_definitions_config_only()` (carried as `AgentRunConfig.config_tool_defs`).
- `all_tool_defs()` appends `config_tool_defs` only when `active_mcp_grants` contains `"config"`. The `config_tool_defs` go through the same interactive-only and approval-visibility filters as `base_tool_defs`, so activating `config` never bypasses access control.
- The grant string `"config"` is persisted in `session_mcp_grants` / `stack_mcp_grants` exactly like an MCP server name, so it survives restarts (root) or is dropped on frame exit (sub-agent).
- Discoverability is a short static hint in `agents/main/AGENT.md` and `agents/project-coordinator/AGENT.md` (the orchestrators); the `<!-- MCP_LIST -->` block stays MCP-only.
- Not affected: `image_generate` is registered via the image-generator manager (not the `ToolRegistry`), so its `Config` category is inert and it stays always-on.
**Root agents**: injected in `build_agent_config` as an `InterfaceTool`.
**Sub-agents**: injected in `dispatch_sub_agent` — sub-agents always start with zero grants and activate what they need.
### Sub-Agent MCP Isolation
Sub-agents have a fully isolated MCP grant state:
| Aspect | Root agent | Sub-agent |
| --- | --- | --- |
| Initial grants | Loaded from `session_mcp_grants` DB | Empty (starts from zero) |
| `activate_tools` persists to | `session_mcp_grants` | `stack_mcp_grants` |
| Grants survive restart? | Yes | Yes (re-loaded by `dispatch_sub_agent`) |
| Grants cleaned up? | No (session lifetime) | Yes (on frame termination) |
| Session contamination? | N/A | None |
Sub-agents that don't include `<!-- MCP_LIST -->` in their `AGENT.md` receive no MCP list injection in the system prompt. The tool definitions are still included dynamically in `all_tool_defs()` based on grants, so they can call tools without the descriptive list — useful for agents with a narrow, pre-known tool set.
### `tic` Agent
`tic` uses lazy loading like any other root agent — it calls `activate_tools` for the servers it needs based on the pending events it receives. This avoids loading all MCP tool definitions on every tick when there may be nothing to process.
### Token Savings
| Situation | Approximate tokens |
| --- | --- |
| All MCP tools always loaded (old behaviour) | ~10,00020,000 |
| Lazy mode, no grants yet | ~50100 (compact list only) |
| Lazy mode, gmail + gcal granted | ~2,0004,000 |
---
## When to Update This File
- A new transport type is added
- `PROTOCOL_VERSION` is bumped, the `MCP-Protocol-Version` header logic changes, or version-negotiation handling changes
- `tools/list` pagination (cursor loop, `MAX_TOOL_PAGES`) or `McpTool::from_json` changes
- The structured-result pipeline changes (`McpCallResult`/`ToolResult`, `result_type` column/event, `extract_call_result` preference, or the frontend JSON rendering)
- The tool naming convention changes
- `SERVER_START_TIMEOUT_SECS` changes
- `register_mcp` or `delete_mcp` tool parameters change (schema, required fields, description, friendly_name)
- `list_items` (type=mcp) return format changes (McpServerInfo fields)
- A new notification source is implemented
- The elicitation flow changes (capability/protocol version, schema parsing, the resolve route, or the in-flight timeout behaviour)
- Cancellation (`notifications/cancelled`, the `CancelOnDrop`/`HttpCancelOnDrop` guards) or Tasks (`CreateTaskResult` parsing, `McpCallResult::Task`, the block-and-poll `poll_task`, `wants_task` opt-in, `TaskCancelOnDrop`/`tasks/cancel`, `clamp_poll_interval`/`poll_deadline`, `server_capabilities`, the `experimental.tasks` marker) changes
- The SSH MCP server changes (tools, alias schema, sudo methods, or pooling/host-key behaviour in `scripts/ssh_mcp_server.py`)
- Lazy loading logic changes (`build_agent_config`, `dispatch_sub_agent`, `activate_tools`, grant tables)
- `ClientMessage` loses or gains fields relevant to MCP