Files
Playa/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md

32 KiB
Raw Blame History

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.Mcp references only Volcanic.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.Core package (the client/transport core, without the hosting package). It handles the initialize handshake, protocol version negotiation, pagination of tools/list, content-block types and process shutdown (StdioClientTransport: Command, Arguments, WorkingDirectory, EnvironmentVariables, StandardErrorLines, ShutdownTimeout). The ClaudeCode.Bridge hand-rolls its protocol because it answers only three methods. A general client would have to reimplement much more. SDK types never leave the extension. Its Microsoft.Extensions.* dependencies resolve from the host when present and from the extension folder otherwise (ExtensionLoadContext already 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/list result) is discovered once per host process per server configuration and cached in McpCatalogCache.Shared. It's explicit process-wide state, like ClaudeLoginCoordinator.Shared;
    • the session process starts lazily on the first tool call, in the session's working directory, and stops on SessionShutdownEvent or after an idle timeout.
  • 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 one ITool per MCP tool. That keeps registration where the docs require it, honours AgentSessionOptions.ActiveTools, and means the cwd (unknown at activation) is needed only when the live process starts, from ToolInvocation.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 = Sequential unless 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 own env entries are explicit and pass through even when deny-listed: that's how a server that needs GITHUB_TOKEN gets it, and the only way. ${env:NAME} in an env value 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. McpTool does nothing itself. Text the extension produces outside a tool result goes through api.Redactor too: stderr lines are redacted as they arrive (before they are logged or kept in the tail), and /mcp output is redacted before it is shown or sent.
  • /new resets the browser. Every SessionShutdownEvent (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, /new and bot restarts. Cookies and site data in that directory are readable by the agent's bash and read by design: it's the same trust model as bash itself (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 --isolated to ExtraArgs.
  • Configuration. Host options (Mcp section in appsettings / DI) plus ~/.volcanic/agent/settings.json "mcpServers". The first source wins on a server name. There's deliberately no <cwd>/.volcanic/settings.json source: 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 named playwright.

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, env values and an optional cwd may 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}.
  • env values 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 with Purpose = McpServer, Origin = the server name, the expanded command, args and cwd, and the expanded env entries as the explicit environment. It's passed to StdioClientTransportOptions.EnvironmentVariables in full with InheritEnvironmentVariables = 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 like TelegramOptions.ApplyEnvironment and fills in the preset from PLAYWRIGHT_ENABLED, PLAYWRIGHT_HEADLESS (default true), PLAYWRIGHT_BROWSER, PLAYWRIGHT_EXTRA_ARGS (shell-style split honouring quotes, so --device "iPhone 15" is two argv entries) and PLAYWRIGHT_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 with mcp__.
  • Order: Mcp:Servers by name, then the Playwright preset (skipped with a warning when a server is explicitly named playwright), 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) because Microsoft.Extensions.Configuration returns child keys sorted anyway, so the order in appsettings.json can't be kept; sorting in McpServerSet gives options built in code the same rule. Server names are compared ordinally everywhere, /mcp <name> included. /mcp lists a disabled preset as playwright: 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 is VOLCANIC_AGENT_DIR or ~/.volcanic/agent, regardless of AgenticCoreOptions.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.DefaultPackage so upgrades are deliberate, as with the TS project's pinned dependency. That constant is the single source of truth for the version: the Dockerfile's ARG PLAYWRIGHT_MCP_VERSION default must equal it, and a unit test reads the Dockerfile and fails if they differ.

  • Command. With Playwright:Command (env PLAYWRIGHT_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 use npx (hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally and compose sets PLAYWRIGHT_COMMAND=playwright-mcp, so a container never downloads anything and the first chat doesn't stall.

  • Without --browser, @playwright/mcp uses the Chrome channel, which the deployment doesn't install (playwright install chromium provides Chromium). The preset therefore passes --browser chromium unless Browser is set. With --isolated in ExtraArgs the preset leaves out --user-data-dir.

  • Chromium sandbox. The preset doesn't add --no-sandbox itself. Chromium's sandbox needs user namespaces, which Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets PLAYWRIGHT_EXTRA_ARGS=--no-sandbox and the container is the isolation boundary. Bare hosts keep the sandbox.

  • --output-dir inside the workspace makes screenshots, PDFs and downloads reachable by send through a workspace-relative path. @playwright/mcp resolves relative screenshot filenames 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-dir keeps each chat's logins across idle restarts, /new and bot restarts, and stops concurrent chats from fighting over one shared profile. Its cookies are agent-readable by design (see Decisions). --isolated in ExtraArgs opts out.

  • Browser binaries are not installed at runtime. The deployment has to provide Node and Chromium installed by the playwright version that the pinned @playwright/mcp depends on (npx playwright install --with-deps chromium run from that package, as the Docker spec does). The server process gets the host environment filtered by the process environment hook, which keeps PLAYWRIGHT_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 first browser_* call (the MCP server itself starts fine), and the model reports it.

  • PromptSection (added through BeforeAgentStartEvent → SystemPromptOptions.Sections["browser"] while any of the server's registered tools is active (for the preset, the browser_* tools); a server's promptSection works 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 send line is included only when a tool named send is active (checked at prompt time through GetActiveTools(); a registered but inactive send isn't callable), so the preset still reads correctly on hosts without attachments. Without send, that bullet reads: "Files the browser saves (screenshots with a filename such as browser/<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 and TZ isn't set), runs initialize and a paginated tools/list under startupTimeout, captures serverInfo, 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 whole ShutdownTimeout before 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, but ToolCatalog gives up to 48 before switching to an opaque tool_<digest> alias, and mcp__volcanic__browser_navigate is 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 ToolRegistry rules. An MCP tool named like a built-in (read, bash, ...) would replace it, so McpExtension refuses to register an exposed name that api.GetAllTools() already reports with source built-in. It logs a warning suggesting a toolPrefix. The built-in check runs against GetAllTools() 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 with mcp__ are rejected (they'd read as mcp__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 a SemaphoreSlim-guarded lazy start: expand ${workspace} from ctx.Cwd and ${env:…}, build the command, cwd and environment through ctx.PrepareProcessAsync (so session-level ProcessSpawnEvent handlers run and TZ is the chat's zone), create StdioClientTransport + McpClient under startupTimeout, and route StandardErrorLines to api.Logger at stderrLevel with the prefix "<name> mcp: ", keeping a 20-line tail.
  • Calls use a linked token: the tool's CancellationToken (a session abort) plus CancelAfter(callTimeout). On an abort or a timeout the connection sends notifications/cancelled for 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>) through SendRequestAsync and the notification is sent best effort. A timeout becomes "<tool> timed out after <n>s", and an abort propagates as OperationCanceledException, 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's StdioClientCompletionDetails.
  • 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 idleTimeout with 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.
  • DisposeAsync is idempotent: dispose the client. SDK 2.2 waits up to ShutdownTimeout for the process to exit with stdin still open, then kills its tree, so disposing a live server always takes about ShutdownTimeout. 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 throw ObjectDisposedException. StopAsync does the same but leaves the connection usable, and resets a failed status to not started (the last error stays for /mcp <name>). The extension stops connections on SessionShutdownEvent for new, resume and fork (the same extension instance serves the next session) and disposes them for quit and reload, 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/list differs 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 annotations title or the name; Parameters = inputSchema verbatim, except that a missing or non-object schema becomes {"type":"object"} (RegisterTool requires an object) and a top-level $schema is dropped. The core's light ArgumentValidator checks required and primitive types, and everything else is left to the server.
  • PromptSnippet is null (hosts get descriptions through the API, and 20–60 browser lines would bloat the prompt). Guidance comes from the server's promptSection.
  • ExecutionMode = Sequential unless parallel.
  • ExecuteAsync passes call.Arguments through and maps the result with McpResultMapper.

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: true throws ToolExecutionException(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, and send still works.

8. Abstractions changes (small, contract-only)

  • Move ToolExecutionException from Features/Tools/BuiltIn/BashTool.cs to Abstractions/Tools/ITool.cs. docs/extensions.md already tells extension authors to use it, but today only the runtime can reach it. The catch in AgentSession.Tools.cs gets a namespace change, nothing more.
  • No other contract change in this spec. Registering tools during activation and reading ctx.Cwd from ToolInvocation already work. IExtensionApi.Processes, IExtensionContext.PrepareProcessAsync and IExtensionContext.TimeZone come from 2026-09-26-process-environment-hook-design.md and 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 from docs/docker.md, which has everything).
  • docs/extensions.md: a note that ToolExecutionException now lives in Abstractions.
  • docs/telegram-bot.md: the PLAYWRIGHT_* variables.
  • CLAUDE.md: add Mcp to 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 (in ModelContextProtocol.Core, no hosting package) and launched with dotnet <dll>. Its behaviour is chosen by env var FAKE_MCP_MODE (comma-separated, so modes combine):
    • normal: echo, image (1×1 PNG), fail (returns isError), slow (awaits cancellation and logs {"event":"cancelled"} to $FAKE_MCP_LOG when cancelled), weird.name/x, weird_name_x (collides with weird.name/x after sanitising), a tool whose name is longer than 48 characters, and a tool with no inputSchema;
    • crash-on-start: writes a traceback to stderr and exits 1;
    • hang-on-start: never answers initialize;
    • die-after-first-call: about 200 ms after answering the first call, writes dying: $FAKE_MCP_DIE_MESSAGE to stderr and exits 3;
    • env: adds a tool that returns the server's environment as text;
    • builtin-clash: adds a tool named read;
    • count-starts: appends a line to $FAKE_MCP_LOG per start, to count spawns, with the pid, cwd, args and (with env) the environment.
    • With --output-dir in its argv it also offers browser_navigate and browser_take_screenshot (a filename is 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 after crash-on-start, whose error contains the stderr traceback; hang-on-start gives up after startupTimeout and 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): with OPENAI_API_KEY and GITHUB_TOKEN in the injected host snapshot, neither reaches the server; a server env entry GITHUB_TOKEN: ${env:GITHUB_TOKEN} delivers it (explicit beats the deny-list); discovery and the session process agree; TZ is the session's zone for the session process (on Unix; the policy never sets TZ on 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 env tool's output with a GITHUB_TOKEN configured as a secret value shows [REDACTED] in the next faux request, and so does a die-after-first-call exit error whose stderr tail contains it;
    • cancellation: aborting during slow cancels the call and the server sees notifications/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: SessionShutdownEvent kills the process (checked by pid); dispose is idempotent;
    • collisions: a server tool named read is refused and the built-in stays; two servers with the same tool → the first by name wins, and configuration binding delivers Mcp:Servers sorted by name;
    • config: ApplyEnvironment (quoted PLAYWRIGHT_EXTRA_ARGS), config over env, Validate, user settings.json merge and precedence;
    • Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned package; with PLAYWRIGHT_COMMAND the binary is used and -y <package> is dropped; the Dockerfile's PLAYWRIGHT_MCP_VERSION default equals DefaultPackage's version (Dockerfile_version_matches_DefaultPackage, added with the docker-deployment spec); the prompt section appears only with browser_* tools active and mentions send only when it exists;
    • end to end through the core with FauxProvider: the scripted model calls the fake image tool, and the tool result message holds the image.
  • Claude Code: a ToolCatalog test that browser_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; /mcp shows running; 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 url field later).
  • MCP resources, prompts, sampling, elicitation and roots (tools only). Server-initiated tools/list_changed is 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 its fortress_* tools.