# Fortress stealth browser via MCP ## Goal Give the agent the [Fortress](https://github.com/tiliondev/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: ```ts export interface McpBridgeOptions { label: string; // "playwright" | "fortress", used in log lines command: string; // executable to spawn args: string[]; env?: Record; // merged over process.env for the child toolPrefix?: string; // e.g. "fortress_"; omitted = names unchanged logger: Logger; clientFactory?: (spawn: SpawnSpec) => Promise; // test seam } export interface SpawnSpec { command: string; args: string[]; env?: Record } export interface McpBridge { tools: CustomTools; dispose: () => Promise; } export async function createMcpBridge(opts: McpBridgeOptions): Promise; ``` - `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: `. - `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` ```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; // TILION_PROXY / TILION_REGION / CAPTCHA_API_KEY when set logger: Logger; clientFactory?: McpBridgeOptions["clientFactory"]; } export async function createFortressMcpBridge(opts: FortressMcpOptions): Promise; 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 `/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` ```ts export interface FortressConfig { enabled: true; command: string[]; headless: boolean; env: Record; } // 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: ```ts let fortressDispose: (() => Promise) | 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: ```dockerfile # 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.