Files
EugeneTes 891a644590 feat(mcp): remote MCP servers per chat with sealed keys and owner-only commands
Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json.
Headers are sealed with AES-GCM; the chat and the visible fields are the
associated data, so only /mcp_add|remove|tools change servers. Tools
<server>__<tool> go live in the session. An unreachable server is skipped
and reported in the system prompt. Keys are redacted from tool results, and
Telegram deletes messages that carry one.
2026-10-09 21:11:01 +00:00

12 KiB
Raw Permalink Blame History

MCP servers and the Playwright browser

extensions/Volcanic.AgenticCore.Extensions.Mcp is an MCP client: it starts stdio MCP servers and exposes their tools to the agent as ordinary tools. Its second extension, RemoteMcpExtension, connects each chat to its own remote servers over HTTP (below). It uses only the public extension API and the official ModelContextProtocol.Core SDK. (ClaudeCode.Bridge is the opposite, an MCP server that shows core tools to claude -p.)

Configuration

Servers come from two places. The first one wins a server name:

  1. the host's Mcp section (appsettings.json, or Mcp__Servers__<name>__Command and friends in the environment), sorted by name (configuration binding sorts keys, so the order in appsettings.json doesn't count), then the Playwright preset;
  2. "mcpServers" in ~/.volcanic/agent/settings.json (VOLCANIC_AGENT_DIR moves it), with the same entry shape in camelCase, in file order. The extension reads this file itself, so a host that sets AgenticCoreOptions.AgentDir to another directory must also set VOLCANIC_AGENT_DIR for mcpServers to be found there.

There is no per-workspace source: a Telegram workspace is writable by the agent itself.

"Mcp": {
  "Servers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github@2025.9.1"],
      "env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" },
      "toolPrefix": "gh_",
      "enabled": true,
      "parallel": false,
      "startupTimeoutSeconds": 60,
      "callTimeoutSeconds": 120,
      "idleTimeoutSeconds": 1800,
      "stderrLevel": "Warning",
      "promptSection": null
    }
  },
  "Playwright": { "Enabled": false, "Headless": true, "Browser": null, "ExtraArgs": [], "Package": null, "Command": null }
}
  • ${workspace} in command, args, cwd and env values becomes the chat's workspace when the process starts. The process runs in ${workspace} unless cwd says otherwise.
  • ${env:NAME} in an env value copies a host variable (unset: empty). It's the only way a deny-listed variable such as GITHUB_TOKEN reaches a server, and it reaches only that server. A null value removes a variable (use the user settings file for that; configuration binding may turn null into an empty string).
  • Every server process gets the process policy's filtered environment (see extensions.md). Host secrets (TELEGRAM_BOT_TOKEN, OPENAI_API_KEY, ...) are never inherited, and known secret values in tool output become [REDACTED].
  • toolPrefix may contain letters, digits, _ and -, and may not start with mcp__. Timeouts must be positive; idleTimeoutSeconds: 0 never stops an idle process. The host refuses to start on invalid configuration.

The Playwright preset

PLAYWRIGHT_ENABLED=true (or Mcp:Playwright:Enabled) adds a server named playwright running @playwright/mcp:

npx -y @playwright/mcp@0.0.82 [--headless] --browser chromium
    --output-dir ${workspace}/browser --user-data-dir ${workspace}/.volcanic/browser-profile <extra args>

--user-data-dir is left out when the extra arguments contain --isolated.

Variable Setting Default Notes
PLAYWRIGHT_ENABLED Mcp:Playwright:Enabled false
PLAYWRIGHT_HEADLESS Mcp:Playwright:Headless true
PLAYWRIGHT_BROWSER Mcp:Playwright:Browser chromium Without it @playwright/mcp would look for Google Chrome
PLAYWRIGHT_EXTRA_ARGS Mcp:Playwright:ExtraArgs — Shell-style split: --device "iPhone 15" is two arguments. --isolated keeps no profile on disk
PLAYWRIGHT_COMMAND Mcp:Playwright:Command npx Run this binary (e.g. a global playwright-mcp) instead of npx -y <package>
— Mcp:Playwright:Package @playwright/mcp@0.0.82 npm spec used with npx. The pinned version is PlaywrightPreset.DefaultPackage

Configured values win over the variables; an empty variable counts as unset. The bot logs MCP: playwright enabled (headless, chromium) or MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true) at startup.

  • Screenshots saved with filename: "browser/<name>.png", PDFs and downloads land in the workspace's browser/ folder, where send can deliver them. A screenshot without filename comes back to the model as an image (the OpenAI chat-completions provider can't show tool-result images, so there it only gets [image]).
  • Each chat has its own browser profile under .volcanic/browser-profile, so site logins survive idle restarts, /new and bot restarts. The agent's bash can read that profile, like everything else in its workspace.
  • The system prompt gets a short "Browser" section while the browser tools are active. It mentions send only when a send tool is active.

Deployment requirements

The server needs Node and the Chromium build that the pinned @playwright/mcp expects: npx playwright install --with-deps chromium, run from that package so the versions match (PLAYWRIGHT_BROWSERS_PATH is honoured). The first npx run downloads the package, which is why the preset allows 120 s to start. The Docker image (docker.md, built with INSTALL_PLAYWRIGHT=true) preinstalls both and sets PLAYWRIGHT_COMMAND=playwright-mcp and PLAYWRIGHT_EXTRA_ARGS=--no-sandbox. Chromium's sandbox needs user namespaces, which Docker's default profile blocks, so there the container is the isolation boundary. A missing browser shows up as an error from the first browser_* call.

Tool names

The agent sees toolPrefix + name, with characters outside [A-Za-z0-9_-] replaced by _, cut to 48 characters, and _2, _3 added to duplicates. The server is always called with its own name. Through the Claude Code provider the tools appear as mcp__volcanic__<name>.

  • A tool named like a built-in (read, bash, ...) is not registered, and a warning suggests a toolPrefix.
  • If two servers offer the same name, the first in the order above wins (host servers by name) and a warning names both. Give one of them a toolPrefix to keep both.
  • MCP tools run one at a time unless the server sets "parallel": true: most servers (Playwright included) are stateful.

Lifecycle

  • The tool list is discovered once per bot process per server configuration, in a temp directory, while the first session activates. Concurrent chats share that one discovery. A server that fails to start is logged, shows up in /mcp as failed, contributes no tools, and is retried after 60 s. Other servers and the chat carry on.
  • Each chat gets its own server process, started by the first call in the chat's workspace. It stops after idleTimeoutSeconds without calls, and on /new, /resume or shutdown, so a new conversation gets a fresh browser.
  • A server that dies turns the call into an error ("… MCP server exited (code): . It restarts on the next call; browser state was lost."), and the next call starts a new one. Failed calls are never retried automatically.
  • A call runs at most callTimeoutSeconds. /stop cancels it and the server is told (notifications/cancelled).

Remote servers per chat

RemoteMcpExtension (mcp-remote, in Remote/) connects a chat to remote MCP servers over Streamable HTTP, or the older SSE transport. It never starts a process. The Telegram host adds it to every private chat session (not to A2A server sessions).

The file

<workspace>/.volcanic/mcp/servers.json, written only by the commands below:

{
  "version": 1,
  "servers": [
    { "name": "notes", "url": "https://mcp.example.com/mcp", "transport": "auto", "tools": ["search", "get"],
      "auth": "headers", "sealed": "<base64>" }
  ]
}
  • name: 1–20 characters of a-z, 0-9, -. url: absolute http(s) without user info. transport: auto (Streamable HTTP, falling back to SSE), http or sse. tools: null for every tool, else an allowlist of the server's names.
  • sealed holds {"headers": {...}} encrypted with AES-256-GCM. The key is derived (HKDF-SHA256) from a host secret the agent's processes never see (Telegram: MCP_ENCRYPTION_SECRET, else the bot token). The chat's session scope and every visible field are the associated data. An entry written or edited by anyone without the secret (the agent's own bash, for example) or copied from another chat therefore doesn't open. It is skipped, and /mcp_list and the agent's system prompt say so. The agent can delete the file, but it can't add or redirect a server.
  • auth is headers today. OAuth would be another value whose tokens live in the same sealed part.
  • A new secret makes every entry unreadable: add the servers again.

Commands (no model call; nothing enters the conversation)

Command
/mcp_list Each server: url without query, transport, status (connecting, connected · N tools, unavailable: <why>), header names only, allowlist, active tools; then skipped entries
/mcp_add <name> <url> [--tools a,b] [--transport auto|http|sse] [--bearer <token>] [--header "Name: value"]... [--no-key] [--force] Without a key option the bot asks for the key in a separate message (UiRequestOptions.Secret; Telegram deletes the answer). A bare token becomes Authorization: Bearer <token>; Name: value lines set any headers; - means none. The server is tried first (handshake and tools/list); a failure is reported and nothing is saved unless --force. The same name replaces the old entry
/mcp_remove <name> Deletes the entry; its tools are deactivated at once
/mcp_tools <name> [a,b|all] Without a list: offered and allowed tools. With one: sets the allowlist

Telegram also deletes a /mcp_add message that contains --bearer or --header.

Tools and lifecycle

  • The agent sees <server>__<tool>, sanitised and capped at 48 characters like the stdio tools (through the Claude Code provider: mcp__volcanic__notes__search). A name that some other tool already has is skipped with a warning.
  • When the session starts, every server is connected in the background (handshake plus tools/list, at most 15 s each). The first run waits for those connects, and tools are added to the live session; no restart or /new is needed, after the commands either. Calls run one at a time per tool, up to 120 s each; /stop sends notifications/cancelled.
  • An unreachable server doesn't block the session: it has no tools, and the system prompt's "Remote MCP servers" section tells the agent which server is unavailable and why ("401 (Unauthorized)", "no answer within 15s", ...). It is retried before later runs, after 1, 2, 4 ... up to 15 minutes. A call that fails in the transport drops the connection; the next call reconnects. Failed calls are never retried, since the server may have run them.
  • Header values (and the credential after Bearer ) are replaced with [REDACTED] in every tool result of the chat, in errors and in command replies, so a server that echoes the key doesn't leak it.

/mcp

Command
/mcp Each server: not started, running, exited, failed: <reason> or disabled, and its tool count
/mcp <name> Command line (without env values), env names, server version, tools, last error, stderr tail
/mcp restart <name> Stop the chat's process; the next call starts a fresh one (clears a wedged browser)

No model call is made. Known secret values in the output (a server's stderr, say) are shown as [REDACTED].

Manual check (not in CI)

With PLAYWRIGHT_ENABLED=true and Chromium installed:

  1. "Open https://example.com and tell me the H1" → Example Domain.
  2. "Screenshot example.com and send it to me" → a photo arrives in Telegram (needs the send tool).
  3. /mcp shows playwright: running.
  4. Two chats browse at the same time without seeing each other's tabs.