32 KiB
MCP client extension and Playwright browser tools — design
Date: 2026-09-26
Status: implemented (the Dockerfile version test, PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage, landed with 2026-09-26-docker-deployment-design.md)
Depends on: 2026-09-26-telegram-attachments-design.md (the send tool that delivers browser output);
2026-09-26-process-environment-hook-design.md (the environment every server process gets, and the redaction of
known secrets in tool output).
Deployed by: 2026-09-26-docker-deployment-design.md (preinstalled package and Chromium).
Problem
The agent can only use the built-in tools and whatever extensions implement by hand. A large set of useful tools
already exists as MCP servers, the most important being @playwright/mcp,
which lets the model drive a real browser (navigate, click, type, snapshot, screenshot). The TypeScript predecessor
(VolcanicSecretary, src/agent/mcp-bridge.ts + playwright-mcp.ts) proved the approach: spawn a stdio MCP server,
list its tools, and re-expose each one as an agent tool with its JSON Schema passed through untouched.
This port needs a generic MCP client: launch stdio MCP servers from a declarative list, register their tools as
ITools, and ship a Playwright preset whose screenshots land in the chat workspace so the send tool
(2026-09-26-telegram-attachments-design.md) can deliver them.
Don't confuse this with ClaudeCode.Bridge, which is an MCP server that exposes core tools to claude -p. The
two meet only in tool naming (see "Names").
Decisions
- An extension, public API only. New project
extensions/Volcanic.AgenticCore.Extensions.Mcpreferences onlyVolcanic.AgenticCore.Abstractions, like the provider extensions. It works registered from host code (o.AddExtension<McpExtension>()) or dropped into an extensions directory. A small contract gap is closed in Abstractions (see Components §8). No runtime slice changes. - Official SDK, not hand-rolled JSON-RPC. Use the
ModelContextProtocol.Corepackage (the client/transport core, without the hosting package). It handles the initialize handshake, protocol version negotiation, pagination oftools/list, content-block types and process shutdown (StdioClientTransport:Command,Arguments,WorkingDirectory,EnvironmentVariables,StandardErrorLines,ShutdownTimeout). TheClaudeCode.Bridgehand-rolls its protocol because it answers only three methods. A general client would have to reimplement much more. SDK types never leave the extension. ItsMicrosoft.Extensions.*dependencies resolve from the host when present and from the extension folder otherwise (ExtensionLoadContextalready falls back). - Processes are per session, tool catalogues are per process. Each
AgentSession(one Telegram chat) gets its own server process, so browser tabs, cookies, the profile and output files never leak between chats, and a crash only affects one conversation. Spawning one process per chat at startup would be wasteful, though, so:- the catalogue (the
tools/listresult) is discovered once per host process per server configuration and cached inMcpCatalogCache.Shared. It's explicit process-wide state, likeClaudeLoginCoordinator.Shared; - the session process starts lazily on the first tool call, in the session's working directory, and stops on
SessionShutdownEventor after an idle timeout.
- the catalogue (the
- Tools are registered during
ActivateAsync. Activation awaits the cached catalogue, which means one discovery spawn per host lifetime, run in a temp directory and disposed right away. It then registers oneIToolper MCP tool. That keeps registration where the docs require it, honoursAgentSessionOptions.ActiveTools, and means the cwd (unknown at activation) is needed only when the live process starts, fromToolInvocation.Context.Cwd. - Startup failure never breaks the session. If discovery fails or times out, the server is logged, recorded as
failed for
/mcp, and contributes no tools. Other servers and the session carry on. A failed discovery is cached for a 60 s cooldown so every new chat doesn't pay the timeout again. A live process that fails to start or dies turns into an error tool result, and the next call respawns it. - Sequential by default. MCP servers are usually stateful (Playwright drives one page), and the core runs a
batch of tool calls in parallel. MCP tools report
ExecutionMode = Sequentialunless the server config sets"parallel": true. - Server environments go through the process environment hook. Discovery and session processes get the
filtered environment from
IProcessSpawnPolicy(2026-09-26-process-environment-hook-design.md), so host secrets (TELEGRAM_BOT_TOKEN,OPENAI_API_KEY, …) never reach an MCP server by inheritance. A server's ownenventries are explicit and pass through even when deny-listed: that's how a server that needsGITHUB_TOKENgets it, and the only way.${env:NAME}in anenvvalue copies a host variable explicitly, so a secret set in Coolify can be handed to one server without writing it into a config file. - MCP output is redacted like any tool's. MCP tools are ordinary
ITools, so the core loop replaces known secret values (SecretValues) with[REDACTED]in their results, progress and error messages, including the<stderr tail>of the "MCP server exited" error (process-environment-hook spec §9). A server that was handed a secret on purpose and echoes it back doesn't put it in the transcript.McpTooldoes nothing itself. Text the extension produces outside a tool result goes throughapi.Redactortoo: stderr lines are redacted as they arrive (before they are logged or kept in the tail), and/mcpoutput is redacted before it is shown or sent. /newresets the browser. EverySessionShutdownEvent(quit,/new, resume) disposes the session's server processes, so tabs and in-memory browser state don't survive a new session. That is the intended behaviour: a new conversation starts with a fresh browser. Logins survive through the persistent profile (next decision).- The browser profile is persistent and lives in the workspace,
${workspace}/.volcanic/browser-profile, not--isolated. Chats keep their site logins across idle restarts,/newand bot restarts. Cookies and site data in that directory are readable by the agent'sbashandreadby design: it's the same trust model asbashitself (the agent already acts as the bot's OS user in that workspace), and a profile outside the workspace wouldn't be out of reach anyway. Users who don't want a chat's logins kept can add--isolatedtoExtraArgs. - Configuration. Host options (
Mcpsection in appsettings / DI) plus~/.volcanic/agent/settings.json"mcpServers". The first source wins on a server name. There's deliberately no<cwd>/.volcanic/settings.jsonsource: the cwd isn't known at activation, and a Telegram workspace is writable by the agent itself. The Playwright preset is a typed shortcut that expands into one server entry namedplaywright.
Components
1. Configuration — McpOptions, McpServerConfig
// appsettings.json ("Mcp" section) or ~/.volcanic/agent/settings.json ("mcpServers" at the root, same entry shape)
"Mcp": {
"Servers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@2025.9.1"],
"env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" }, // explicit: applied over the filtered environment, never filtered; null removes
"toolPrefix": "gh_", // optional; default none
"enabled": true,
"parallel": false,
"startupTimeoutSeconds": 60,
"callTimeoutSeconds": 120,
"idleTimeoutSeconds": 1800, // 0 = never stop an idle process
"stderrLevel": "Warning",
"promptSection": null // optional text added to the system prompt while its tools are active
}
},
"Playwright": { "Enabled": false, "Headless": true, "Browser": null, "ExtraArgs": [], "Package": null, "Command": null }
}
command,args,envvalues and an optionalcwdmay contain${workspace}, which is expanded to the session cwd when the live process starts (and to the temp discovery dir during discovery). The live process's working directory defaults to${workspace}.envvalues may also contain${env:NAME}, expanded from the host environment at spawn time (unset → empty). This is the sanctioned way to pass a deny-listed host variable to one server. The expansion happens in the extension, so the value is explicit when it reaches the policy.- The environment a server process gets is
IProcessSpawnPolicy's result for a request withPurpose = McpServer,Origin= the server name, the expanded command, args and cwd, and the expandedenventries as the explicit environment. It's passed toStdioClientTransportOptions.EnvironmentVariablesin full withInheritEnvironmentVariables = false(SDK 2.x), so the SDK doesn't merge the host environment back in. - The catalogue cache key is the server name plus a digest of the unexpanded command, args, env, cwd and tool
prefix (the catalogue holds exposed names, which depend on the prefix), so every chat shares one catalogue even
though their expanded args differ. Secrets from
${env:…}never enter the key. McpOptions.ApplyEnvironment(getEnv)works likeTelegramOptions.ApplyEnvironmentand fills in the preset fromPLAYWRIGHT_ENABLED,PLAYWRIGHT_HEADLESS(default true),PLAYWRIGHT_BROWSER,PLAYWRIGHT_EXTRA_ARGS(shell-style split honouring quotes, so--device "iPhone 15"is two argv entries) andPLAYWRIGHT_COMMAND. Values from config win over the environment, and an empty variable counts as unset (the Docker compose file passes them all through).- Validation (
McpOptions.Validate()) rejects empty commands, non-positive timeouts, and prefixes that don't match[A-Za-z0-9_-]*or that start withmcp__. - Order:
Mcp:Serversby name, then the Playwright preset (skipped with a warning when a server is explicitly namedplaywright), then the user settings in file order. The first source wins on a server name. Host servers are sorted by name (case-insensitive, then ordinal) becauseMicrosoft.Extensions.Configurationreturns child keys sorted anyway, so the order inappsettings.jsoncan't be kept; sorting inMcpServerSetgives options built in code the same rule. Server names are compared ordinally everywhere,/mcp <name>included./mcplists a disabled preset asplaywright: disabled (set PLAYWRIGHT_ENABLED=true). - The extension has two constructors,
McpExtension(McpOptions)for DI and a parameterless one that reads only the environment and user settings, so a dropped-in DLL works without host code. The agent dir isVOLCANIC_AGENT_DIRor~/.volcanic/agent, regardless ofAgenticCoreOptions.AgentDir(the extension API doesn't expose the host's agent dir; exposing it is a separate, additive change).
2. Playwright preset — PlaywrightPreset
When enabled, it expands to server playwright:
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
[--headless] --browser <b, default chromium>
--output-dir ${workspace}/browser
--user-data-dir ${workspace}/.volcanic/browser-profile
<ExtraArgs...>
cwd: ${workspace}
prefix: none (tools stay browser_*)
callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads the package)
-
The package version is pinned in
PlaywrightPreset.DefaultPackageso upgrades are deliberate, as with the TS project's pinned dependency. That constant is the single source of truth for the version: the Dockerfile'sARG PLAYWRIGHT_MCP_VERSIONdefault must equal it, and a unit test reads the Dockerfile and fails if they differ. -
Command. With
Playwright:Command(envPLAYWRIGHT_COMMAND) set, the preset runs that binary directly and drops-y <package>from the args; the rest of the argv is the same. Bare hosts leave it unset and usenpx(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally and compose setsPLAYWRIGHT_COMMAND=playwright-mcp, so a container never downloads anything and the first chat doesn't stall. -
Without
--browser,@playwright/mcpuses the Chrome channel, which the deployment doesn't install (playwright install chromiumprovides Chromium). The preset therefore passes--browser chromiumunlessBrowseris set. With--isolatedinExtraArgsthe preset leaves out--user-data-dir. -
Chromium sandbox. The preset doesn't add
--no-sandboxitself. Chromium's sandbox needs user namespaces, which Docker's default seccomp profile blocks for a non-root user, so the Docker compose file setsPLAYWRIGHT_EXTRA_ARGS=--no-sandboxand the container is the isolation boundary. Bare hosts keep the sandbox. -
--output-dirinside the workspace makes screenshots, PDFs and downloads reachable bysendthrough a workspace-relative path.@playwright/mcpresolves relative screenshotfilenames against its workspace root, which is the process cwd (the chat workspace), and by default refuses file access outside it. -
A per-workspace
--user-data-dirkeeps each chat's logins across idle restarts,/newand bot restarts, and stops concurrent chats from fighting over one shared profile. Its cookies are agent-readable by design (see Decisions).--isolatedinExtraArgsopts out. -
Browser binaries are not installed at runtime. The deployment has to provide Node and Chromium installed by the
playwrightversion that the pinned@playwright/mcpdepends on (npx playwright install --with-deps chromiumrun from that package, as the Docker spec does). The server process gets the host environment filtered by the process environment hook, which keepsPLAYWRIGHT_BROWSERS_PATH, so it applies. In the container that's/opt/ms-playwright, set in the image. A missing browser shows up as an error result from the firstbrowser_*call (the MCP server itself starts fine), and the model reports it. -
PromptSection(added throughBeforeAgentStartEvent→SystemPromptOptions.Sections["browser"]while any of the server's registered tools is active (for the preset, thebrowser_*tools); a server'spromptSectionworks the same way under its own name):## Browser - browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a page and find refs for click/type; it is cheaper and more reliable than screenshots. - Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back to you. - To give the user a screenshot, save it with `filename: "browser/<name>.png"` and then call `send` with that path. Files the browser saves (PDFs, downloads) are under browser/ too. - Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause. - Don't enter passwords or payment details unless the user explicitly asks for that specific site.The
sendline is included only when a tool namedsendis active (checked at prompt time throughGetActiveTools(); a registered but inactivesendisn't callable), so the preset still reads correctly on hosts without attachments. Withoutsend, that bullet reads: "Files the browser saves (screenshots with afilenamesuch asbrowser/<name>.png, PDFs, downloads) are under browser/ in the workspace."
3. Catalogue — McpCatalogCache
Task<McpCatalog> GetAsync(name, McpServerConfig, IProcessSpawnPolicy, envLookup, ILogger, CancellationToken) (internal;
the extension passes api.Processes and the host environment lookup), process-wide (Shared):
- Concurrent activations for the same key share one in-flight task, so ten chats starting at once make one spawn. The shared discovery runs detached from any caller's token; each caller waits with its own.
- Discovery spawns the server in a fresh temp dir, with its environment from
api.Processes.Prepare(activation has no session, so only host hooks run andTZisn't set), runsinitializeand a paginatedtools/listunderstartupTimeout, capturesserverInfo, then disposes the client and deletes the dir in the background. The catalogue is returned without waiting for that cleanup, because SDK 2.2's dispose waits the wholeShutdownTimeoutbefore it kills the process (see §5). - Success is cached for the process lifetime. Failure is cached for 60 s, then retried. The failure message includes the last 20 stderr lines, as in the TS bridge, because a server that dies during the handshake explains why only there.
McpCatalog= server name,serverInfo, and a list of(mcpName, exposedName, description, inputSchema).
4. Names — ToolNaming
The exposed name is toolPrefix + mcpName, sanitised so it works for every provider and for the Claude Code
provider's aliasing:
- Characters outside
[A-Za-z0-9_-]become_, and the name is truncated to 48 characters. Anthropic and OpenAI allow 64, butToolCataloggives up to 48 before switching to an opaquetool_<digest>alias, andmcp__volcanic__browser_navigateis only readable if the name survives. Duplicates within one server get_2,_3. - The MCP server is always called with the original
mcpName. - Collisions with other tools follow the existing
ToolRegistryrules. An MCP tool named like a built-in (read,bash, ...) would replace it, soMcpExtensionrefuses to register an exposed name thatapi.GetAllTools()already reports with sourcebuilt-in. It logs a warning suggesting atoolPrefix. The built-in check runs againstGetAllTools()at activation, so it covers every built-in, active or not. A clash with another extension's tool is already ignored and recorded by the runner. Between two MCP servers, the first in the order of §1 wins (host servers by name, then the preset, then the user settings), and a warning names both. - Through the Claude Code provider these tools reach the model as
mcp__volcanic__<exposedName>. Nothing else is needed: the Bridge advertises them from the catalogue like any other tool and never executes them. Prefixes that start withmcp__are rejected (they'd read asmcp__volcanic__mcp__x__y). Because Claude Code runs with--strict-mcp-config, a Playwright MCP configured in the user's own Claude Code settings can't shadow ours.
5. The session process — McpServerConnection
One per server per extension instance (that is, per session):
CallAsync(mcpName, JsonElement args, IExtensionContext ctx, CancellationToken)takes aSemaphoreSlim-guarded lazy start: expand${workspace}fromctx.Cwdand${env:…}, build the command, cwd and environment throughctx.PrepareProcessAsync(so session-levelProcessSpawnEventhandlers run andTZis the chat's zone), createStdioClientTransport+McpClientunderstartupTimeout, and routeStandardErrorLinestoapi.LoggeratstderrLevelwith the prefix"<name> mcp: ", keeping a 20-line tail.- Calls use a linked token: the tool's
CancellationToken(a session abort) plusCancelAfter(callTimeout). On an abort or a timeout the connection sendsnotifications/cancelledfor the request itself: SDK 2.2.0 doesn't send it when a request's token fires (checked against the fake server), so each call goes out with its own request id (volcanic-<n>) throughSendRequestAsyncand the notification is sent best effort. A timeout becomes"<tool> timed out after <n>s", and an abort propagates asOperationCanceledException, which the loop already handles. - If the process exits or the transport faults, the client is disposed and marked dead, and the call fails with
"<name> MCP server exited (<code>): <stderr tail>. It restarts on the next call; browser state was lost.". The next call respawns it. There's no automatic retry of the failed call, because it may have had side effects. An exit is noticed either when the next call finds the SDK session completed or when a call fails and the process ends within 2 s. The exit code comes from the SDK'sStdioClientCompletionDetails. - A JSON-RPC error from the server (e.g. invalid params) is an ordinary error result and doesn't mark the server dead.
- The idle timer (reset per call) disposes the client after
idleTimeoutwith no calls in flight. The in-flight count is taken under the start gate and the idle stop re-checks it under the same gate, so an idle stop never kills a process a call has just been handed. DisposeAsyncis idempotent: dispose the client. SDK 2.2 waits up toShutdownTimeoutfor the process to exit with stdin still open, then kills its tree, so disposing a live server always takes aboutShutdownTimeout. Only when the SDK reports no exit code does the connection kill the tree itself, and only if the process under that pid started while this server was being spawned (a pid can be reused by an unrelated process once ours is gone). After it, calls throwObjectDisposedException.StopAsyncdoes the same but leaves the connection usable, and resets a failed status tonot started(the last error stays for/mcp <name>). The extension stops connections onSessionShutdownEventfornew,resumeandfork(the same extension instance serves the next session) and disposes them forquitandreload, all servers concurrently. A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.- If the live server's
tools/listdiffers from the cached catalogue (a package upgrade in place), a warning is logged. Calls go by name either way.
6. The tool — McpTool : ITool
Name= exposed name;Description= MCP description, falling back to annotationstitleor the name;Parameters=inputSchemaverbatim, except that a missing or non-object schema becomes{"type":"object"}(RegisterToolrequires an object) and a top-level$schemais dropped. The core's lightArgumentValidatorchecksrequiredand primitive types, and everything else is left to the server.PromptSnippetis null (hosts get descriptions through the API, and 20–60 browser lines would bloat the prompt). Guidance comes from the server'spromptSection.ExecutionMode=Sequentialunlessparallel.ExecuteAsyncpassescall.Argumentsthrough and maps the result withMcpResultMapper.
7. Results — McpResultMapper
| MCP content | ToolResult.Content |
|---|---|
text |
TextContent. The joined text is capped at 2000 lines / 50 KB (head kept, the same limits and note wording as core OutputTruncation, reimplemented locally because that type is in the runtime) |
image |
ImageContent(data, mimeType). Images over 5 MB decoded are replaced by [image omitted: <mime>, <n> bytes] |
audio |
[audio omitted: <mime>, <n> bytes] |
resource (text) |
TextContent headed Resource <uri>: |
resource (blob) |
[resource <uri>: <mime>, <n> bytes omitted] |
resource_link |
Resource link: <uri> (<name>) |
no content but structuredContent |
its JSON as text |
Details={ server, tool, isError, truncated, images }.isError: truethrowsToolExecutionException(result), so the model sees the server's own error content, images included, marked as an error, which is the TS behaviour of forwarding it rather than crashing.- Tool-result images already reach the model through the Anthropic encoder and the Claude Code image store. The
OpenAI chat-completions encoder turns them into
[image](the API has no images in tool messages), so on that provider the model can't see screenshots. That's acceptable, andsendstill works.
8. Abstractions changes (small, contract-only)
- Move
ToolExecutionExceptionfromFeatures/Tools/BuiltIn/BashTool.cstoAbstractions/Tools/ITool.cs.docs/extensions.mdalready tells extension authors to use it, but today only the runtime can reach it. The catch inAgentSession.Tools.csgets a namespace change, nothing more. - No other contract change in this spec. Registering tools during activation and reading
ctx.CwdfromToolInvocationalready work.IExtensionApi.Processes,IExtensionContext.PrepareProcessAsyncandIExtensionContext.TimeZonecome from2026-09-26-process-environment-hook-design.mdand the scheduler spec (§8).
9. The command — /mcp
Lists each configured server for this session with its state and number of tools. It costs no model call. States:
not started / running / exited (restarts on the next call) (the process ended since the last call) /
failed: <reason> / disabled (the preset adds (set PLAYWRIGHT_ENABLED=true)). The SDK
doesn't expose a live server's pid, so /mcp shows none. /mcp <name> shows the unexpanded command, the names (never
the values) of its env entries, server info, tools, the last error and the stderr tail. With a UI the output is a
notification; without one it is a mcp custom message appended without a turn. Either way the text goes through
api.Redactor first (identity when the API has none), because the last error and stderr tail can hold a secret.
/mcp restart <name> disposes the session process so the next call starts fresh (clears a wedged browser).
10. Host wiring (Telegram)
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
// validate alongside TelegramOptions
builder.Services.AddSingleton(mcp);
o.AddExtension<McpExtension>();
At startup the log reads MCP: playwright enabled (headless, chromium) or
MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true).
11. Docs
- New
docs/mcp.md: configuration, the preset, env vars, naming and collisions, lifecycle,/mcp, and the deployment requirements (Node,npx playwright install --with-deps chromium, or the Docker image fromdocs/docker.md, which has everything). docs/extensions.md: a note thatToolExecutionExceptionnow lives in Abstractions.docs/telegram-bot.md: thePLAYWRIGHT_*variables.CLAUDE.md: addMcpto the extensions list.
Testing
No test touches the network, npm or a real browser.
- Fake stdio server
tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer: a small console app built with the SDK's server API (inModelContextProtocol.Core, no hosting package) and launched withdotnet <dll>. Its behaviour is chosen by env varFAKE_MCP_MODE(comma-separated, so modes combine):normal:echo,image(1×1 PNG),fail(returnsisError),slow(awaits cancellation and logs{"event":"cancelled"}to$FAKE_MCP_LOGwhen cancelled),weird.name/x,weird_name_x(collides withweird.name/xafter sanitising), a tool whose name is longer than 48 characters, and a tool with noinputSchema;crash-on-start: writes a traceback to stderr and exits 1;hang-on-start: never answersinitialize;die-after-first-call: about 200 ms after answering the first call, writesdying: $FAKE_MCP_DIE_MESSAGEto stderr and exits 3;env: adds a tool that returns the server's environment as text;builtin-clash: adds a tool namedread;count-starts: appends a line to$FAKE_MCP_LOGper start, to count spawns, with the pid, cwd, args and (withenv) the environment.- With
--output-dirin its argv it also offersbrowser_navigateandbrowser_take_screenshot(afilenameis written relative to its cwd), so the Playwright preset can run against the fake.
- Tests (
tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/):- catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is
applied;
weird.name/x→weird_name_x; >48 chars truncated; duplicates suffixed; a missing schema becomes{"type":"object"}; - one discovery for N concurrent activations (
count-starts); cooldown aftercrash-on-start, whose error contains the stderr traceback;hang-on-startgives up afterstartupTimeoutand the session still activates with its other tools; - lazy start: activation spawns no session process, the first call does, in
${workspace}with the expanded args; - environment (
FAKE_MCP_MODE=env, a tool that returns its environment): withOPENAI_API_KEYandGITHUB_TOKENin the injected host snapshot, neither reaches the server; a serverenventryGITHUB_TOKEN: ${env:GITHUB_TOKEN}delivers it (explicit beats the deny-list); discovery and the session process agree;TZis the session's zone for the session process (on Unix; the policy never setsTZon Windows, so there the test checks it's absent); - results: text, image →
ImageContent, oversized image replaced,isError→ error tool result with content, output truncation note; - redaction, end to end through the core: the
envtool's output with aGITHUB_TOKENconfigured as a secret value shows[REDACTED]in the next faux request, and so does adie-after-first-callexit error whose stderr tail contains it; - cancellation: aborting during
slowcancels the call and the server seesnotifications/cancelled; the call timeout gives an error result naming the timeout; - crash and restart:
die-after-first-call→ the second call's error mentions the exit and stderr, and the third call succeeds on a new process; idle timeout stops the process; - lifecycle:
SessionShutdownEventkills the process (checked by pid); dispose is idempotent; - collisions: a server tool named
readis refused and the built-in stays; two servers with the same tool → the first by name wins, and configuration binding deliversMcp:Serverssorted by name; - config:
ApplyEnvironment(quotedPLAYWRIGHT_EXTRA_ARGS), config over env,Validate, usersettings.jsonmerge and precedence; - Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned
package; with
PLAYWRIGHT_COMMANDthe binary is used and-y <package>is dropped; the Dockerfile'sPLAYWRIGHT_MCP_VERSIONdefault equalsDefaultPackage's version (Dockerfile_version_matches_DefaultPackage, added with the docker-deployment spec); the prompt section appears only withbrowser_*tools active and mentionssendonly when it exists; - end to end through the core with
FauxProvider: the scripted model calls the fakeimagetool, and the tool result message holds the image.
- catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is
applied;
- Claude Code: a
ToolCatalogtest thatbrowser_navigate-style names alias to themselves (no digest). - Manual E2E (documented, not CI):
PLAYWRIGHT_ENABLED=true, then "Open https://example.com and tell me the H1" →Example Domain; "Screenshot example.com and send it to me" → a photo in Telegram;/mcpshowsrunning; two chats browse at once without seeing each other's tabs.
Out of scope
- HTTP / SSE / streamable-HTTP transports (stdio only; the config shape leaves room for a
urlfield later). - MCP resources, prompts, sampling, elicitation and roots (tools only). Server-initiated
tools/list_changedis logged and ignored. - Per-server tool allow/deny lists and renaming beyond the prefix.
- Sharing one server process across sessions (a possible later
"scope": "process"for stateless servers). - Supervision beyond respawn-on-next-call, and orphan cleanup after a host crash.
- Installing Node, the npm package or browser binaries at runtime. The Dockerfile belongs to
2026-09-26-docker-deployment-design.md. - An approval UX for browser actions.
- Fortress (the TS project's
fortress-mcp) and itsfortress_*tools.