Files
VolcanicSecretary/docs/superpowers/specs/2026-09-23-fortress-mcp-design.md

11 KiB

Fortress stealth browser via MCP

Goal

Give the agent the Fortress stealth-browser tools (fetch_protected_page, read_page, extract_page, search_web, screenshot_page, save_profile, run_browser_task, and the rest of its 29 tools) by running its MCP server, tilion-mcp, as a stdio subprocess and exposing each tool as a Pi custom tool. The existing Playwright bridge stays. The two are toggled independently.

Fortress is a Chromium fork that fixes the browser fingerprint inside the engine, so pages behind Cloudflare, DataDome, PerimeterX, or Akamai load instead of returning 403 or a challenge page. Its MCP tools also return bounded, clean markdown instead of raw HTML, which keeps token use down when the agent reads the web.

Non-goals

  • Fortress hosted mode (TILION_BASE_URL / TILION_API_KEY). Local engine only.
  • Per-tool allow/block lists. Every tool the server reports is exposed.
  • A Telegram command to toggle Fortress at runtime. It is configured with env vars and set at startup.
  • Pointing Playwright MCP at the Fortress engine over CDP (option B from brainstorming). This can be added later without touching this design.
  • macOS. The Fortress engine has no native macOS binary. The bot targets Docker (Linux x64) and Windows x64, and both are supported natively.

Architecture

1. Generic MCP bridge: src/agent/mcp-bridge.ts

The tool-wrapping and client code in playwright-mcp.ts moves into a generic module:

export interface McpBridgeOptions {
  label: string;                        // "playwright" | "fortress", used in log lines
  command: string;                      // executable to spawn
  args: string[];
  env?: Record<string, string>;         // merged over process.env for the child
  toolPrefix?: string;                  // e.g. "fortress_"; omitted = names unchanged
  logger: Logger;
  clientFactory?: (spawn: SpawnSpec) => Promise<McpClientLike>;  // test seam
}

export interface SpawnSpec { command: string; args: string[]; env?: Record<string, string> }

export interface McpBridge {
  tools: CustomTools;
  dispose: () => Promise<void>;
}

export async function createMcpBridge(opts: McpBridgeOptions): Promise<McpBridge>;
  • McpClientLike, McpToolDescriptor, and McpCallResult move here unchanged.
  • wrapTool takes the prefix. The Pi tool is named prefix + t.name, and callTool still receives the original t.name.
  • The default client factory builds StdioClientTransport({ command, args, env: { ...process.env, ...env }, stderr: "pipe" }). The child's stderr is logged at warn as ${label} mcp: <line>.
  • dispose closes the client and logs a warning on failure (same behavior as today).

2. Playwright wrapper: src/agent/playwright-mcp.ts

Keeps its public API (createPlaywrightMcpBridge, PlaywrightMcpOptions, _internals.buildArgs). It resolves the @playwright/mcp bin as it does today and calls createMcpBridge({ label: "playwright", command: process.execPath, args: [binAbs, ...buildArgs(opts)] }) with no prefix, so tool names (browser_*) do not change.

3. Fortress wrapper: src/agent/fortress-mcp.ts

export interface FortressMcpOptions {
  command: string[];                    // argv, e.g. ["tilion-mcp"] or ["python", "-m", "tilion.mcp"]
  headless: boolean;
  writeDir: string;                     // absolute, inside the workspace
  passthroughEnv: Record<string, string>;  // TILION_PROXY / TILION_REGION / CAPTCHA_API_KEY when set
  logger: Logger;
  clientFactory?: McpBridgeOptions["clientFactory"];
}

export async function createFortressMcpBridge(opts: FortressMcpOptions): Promise<McpBridge>;
export const _internals = { buildEnv };
  • buildEnv returns { ...passthroughEnv, TILION_MCP_HEADLESS: headless ? "1" : "0", TILION_MCP_WRITE_DIR: writeDir }.
  • Before spawning, the bridge creates writeDir (mkdir -p).
  • It calls createMcpBridge({ label: "fortress", command: command[0], args: command.slice(1), env, toolPrefix: "fortress_" }).

The prefix avoids clashes with Playwright tools and tells the model which engine a tool drives.

TILION_MCP_WRITE_DIR is set because tilion-mcp otherwise writes screenshots, saved pages, and downloads to <tmpdir>/tilion-mcp. The send tool only accepts workspace-relative paths, so files there could not be sent to the owner.

4. Config: src/config.ts

export interface FortressConfig {
  enabled: true;
  command: string[];
  headless: boolean;
  env: Record<string, string>;
}
// Config gains: fortress?: FortressConfig;
Variable Default Purpose
FORTRESS_ENABLED false Enable the Fortress MCP bridge
FORTRESS_COMMAND tilion-mcp Command that starts the server, shell-style parsed with parseArgsString. On Windows without the console script on PATH: python -m tilion.mcp
FORTRESS_HEADLESS true false opens a visible window (native hosts only)
TILION_PROXY Passed through: egress proxy http://user:pass@host:port
TILION_REGION Passed through: region hint, e.g. us
CAPTCHA_API_KEY Passed through: enables fortress_solve_captcha and auto-solve in fetch

Only non-blank pass-through values go into env. They are passed explicitly instead of relying on process.env inheritance so the config object documents what Fortress receives. An empty FORTRESS_COMMAND after parsing falls back to the default.

5. Wiring: src/main.ts

After the Playwright block, and with the same shape:

let fortressDispose: (() => Promise<void>) | undefined;
if (config.fortress?.enabled) {
  try {
    const bridge = await createFortressMcpBridge({
      command: config.fortress.command,
      headless: config.fortress.headless,
      writeDir: path.join(config.workspaceDir, "fortress"),
      passthroughEnv: config.fortress.env,
      logger,
    });
    customTools.push(...bridge.tools);
    fortressDispose = bridge.dispose;
    logger.info(`fortress tools enabled (${bridge.tools.length} tools)`);
  } catch (err) {
    logger.error("fortress bridge failed to start, continuing without stealth browser tools", err);
  }
} else {
  logger.info("fortress tools disabled (set FORTRESS_ENABLED=true to enable)");
}

Shutdown calls fortressDispose next to playwrightDispose, and logs failures as warnings.

createPiBackend receives fortressEnabled: boolean, which is true only if the bridge actually started, and passes it to secretarySystemPrompt.

6. System prompt: src/agent/system-prompt.ts

secretarySystemPrompt(timezone, { fortress }). When fortress is true it appends:

## Web access (Fortress)
- For reading or extracting web content, prefer fortress_read_page, fortress_extract_page, and fortress_search_web. They return clean, bounded markdown.
- When a site blocks you (403, Cloudflare/captcha page, empty JS shell), retry with fortress_fetch_protected_page.
- Use the browser_* tools for step-by-step interactive browsing when you need to watch the page.
- Fortress writes screenshots, saved pages, and downloads under fortress/ in the workspace. Send them with `send`.

When fortress is false the prompt stays byte-for-byte the same as today.

Failure mode

A missing tilion-mcp or Python, a failed engine download, or an MCP handshake error rejects createFortressMcpBridge. main.ts logs the error and continues without Fortress tools, and the system prompt omits the Fortress section. The bot never fails to start because of Fortress. Tool-level errors come back from the server as isError: true results and are forwarded to the model as they are today.

Docker

In the Dockerfile, after the Playwright install:

# Fortress stealth-browser MCP (FORTRESS_ENABLED=true). Isolated venv because
# Debian's system Python is externally managed (PEP 668).
RUN apt-get update && apt-get install -y --no-install-recommends python3-venv \
  && rm -rf /var/lib/apt/lists/* \
  && python3 -m venv /opt/tilion \
  && /opt/tilion/bin/pip install --no-cache-dir "tilion[mcp]"
ENV PATH="/opt/tilion/bin:${PATH}"

The engine binary (about 300 MB) downloads on first run and is cached by tilion-fortress. During implementation, find the cache location:

  • If a build-time fetch is available, pre-fetch in the image.
  • Otherwise, set the cache to a path under a volume so the download survives container restarts.

Record which one was used in the Dockerfile comment. Chromium runtime libraries are already present from playwright install --with-deps chromium. If the engine needs more, add them to the same apt-get line.

Docs

  • README env table: the three FORTRESS_* rows plus the pass-through rows. Add a short "Stealth browsing (Fortress)" section.
  • .env.example: a commented Fortress block.
  • SETUP-WINDOWS.md: pip install "tilion[mcp]" as an optional step, and the FORTRESS_COMMAND=python -m tilion.mcp fallback.

Testing

  • mcp-bridge.test.ts: the moved Playwright tests (wrapping, execute forwarding, dispose, dispose error). New cases:
    • With toolPrefix, the Pi name is prefixed and callTool still gets the unprefixed name.
    • clientFactory receives the command, args, and env it was given.
  • playwright-mcp.test.ts: buildArgs tests stay. One test that the bridge is created without a prefix.
  • fortress-mcp.test.ts:
    • buildEnv covers headless 0/1, the write dir, and pass-through merge.
    • writeDir is created.
    • command[0] / command.slice(1) split correctly.
    • Tools come out prefixed.
  • config.test.ts:
    • Disabled by default.
    • FORTRESS_COMMAND parsing and default.
    • FORTRESS_HEADLESS.
    • Pass-through includes only non-blank vars.
  • system-prompt.test.ts: the Fortress section is present when enabled and absent otherwise.
  • Manual smoke test: in the Docker image with FORTRESS_ENABLED=true, the startup log shows fortress tools enabled (29 tools) and a Telegram request "read https://quotes.toscrape.com/js" returns the quotes.

Implementation notes (2026-09-23)

The real tilion-mcp (tilion 0.1.14, tilion-fortress 151.0.7908.0.post5) surfaced four things the design didn't anticipate:

  • mcp<2 pin. tilion imports mcp.server.fastmcp, which the Python MCP SDK 2.x removed, and it doesn't pin the dependency. Install with pip install "tilion[mcp]" "mcp<2".
  • FORTRESS_CHANNEL=latest default. The stable bundle (v149) unpacks to tillion-fortress/tillion, but tilion_fortress looks for tilion-fortress/tilion, so it re-downloads and never launches. The bridge sets latest (v151, correct layout), and FORTRESS_CHANNEL in .env still overrides it. The Docker image prefetches the latest bundle.
  • Bridge hardening, which Playwright also gets:
    • callTimeoutMs: Fortress uses 180 s. The SDK default of 60 s timed out the first call while the engine warmed up.
    • Pi's abort signal is forwarded to callTool, so /stop cancels a running call.
    • stderr is subscribed before connect, and a failed handshake rethrows with the server's last 20 stderr lines.
    • stderrLevel: Fortress logs at debug, because it writes a line per request.
  • Screenshot guidance. screenshot_page without a path returns images under about 150 KB as base64 text. The system prompt tells the agent to always pass a file name.

The server reports 33 tools, not 29.