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.
12 KiB
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:
- the host's
Mcpsection (appsettings.json, orMcp__Servers__<name>__Commandand friends in the environment), sorted by name (configuration binding sorts keys, so the order inappsettings.jsondoesn't count), then the Playwright preset; "mcpServers"in~/.volcanic/agent/settings.json(VOLCANIC_AGENT_DIRmoves it), with the same entry shape in camelCase, in file order. The extension reads this file itself, so a host that setsAgenticCoreOptions.AgentDirto another directory must also setVOLCANIC_AGENT_DIRformcpServersto 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}incommand,args,cwdandenvvalues becomes the chat's workspace when the process starts. The process runs in${workspace}unlesscwdsays otherwise.${env:NAME}in anenvvalue copies a host variable (unset: empty). It's the only way a deny-listed variable such asGITHUB_TOKENreaches a server, and it reaches only that server. Anullvalue removes a variable (use the user settings file for that; configuration binding may turnnullinto 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]. toolPrefixmay contain letters, digits,_and-, and may not start withmcp__. Timeouts must be positive;idleTimeoutSeconds: 0never 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'sbrowser/folder, wheresendcan deliver them. A screenshot withoutfilenamecomes 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,/newand bot restarts. The agent'sbashcan 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
sendonly when asendtool 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 atoolPrefix. - 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
toolPrefixto 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
/mcpas 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
idleTimeoutSecondswithout calls, and on/new,/resumeor 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./stopcancels 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 ofa-z,0-9,-.url: absolute http(s) without user info.transport:auto(Streamable HTTP, falling back to SSE),httporsse.tools:nullfor every tool, else an allowlist of the server's names.sealedholds{"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 ownbash, for example) or copied from another chat therefore doesn't open. It is skipped, and/mcp_listand the agent's system prompt say so. The agent can delete the file, but it can't add or redirect a server.authisheaderstoday. 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/newis needed, after the commands either. Calls run one at a time per tool, up to 120 s each;/stopsendsnotifications/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:
- "Open https://example.com and tell me the H1" →
Example Domain. - "Screenshot example.com and send it to me" → a photo arrives in Telegram (needs the
sendtool). /mcpshowsplaywright: running.- Two chats browse at the same time without seeing each other's tabs.