Add design spec for Fortress stealth-browser MCP bridge

This commit is contained in:
EugeneTes
2026-09-23 19:23:00 +00:00
parent f9d84cccac
commit c078ce8add

View File

@@ -0,0 +1,191 @@
# 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<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`
```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`
```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:
```ts
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:
```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.