# 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](#remote-servers-per-chat)). 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____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. ```jsonc "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](extensions.md#spawning-processes)). 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 ``` `--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 ` | | — | `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/.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](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__`. - 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 `/.volcanic/mcp/servers.json`, written only by the commands below: ```json { "version": 1, "servers": [ { "name": "notes", "url": "https://mcp.example.com/mcp", "transport": "auto", "tools": ["search", "get"], "auth": "headers", "sealed": "" } ] } ``` - `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: `), header names only, allowlist, active tools; then skipped entries | | `/mcp_add [--tools a,b] [--transport auto\|http\|sse] [--bearer ] [--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 `; `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 ` | Deletes the entry; its tools are deactivated at once | | `/mcp_tools [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 `__`, 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: ` or `disabled`, and its tool count | | `/mcp ` | Command line (without env values), env names, server version, tools, last error, stderr tail | | `/mcp restart ` | 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.