Add design spec for Fortress stealth-browser MCP bridge
This commit is contained in:
191
docs/superpowers/specs/2026-09-23-fortress-mcp-design.md
Normal file
191
docs/superpowers/specs/2026-09-23-fortress-mcp-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user