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, andMcpCallResultmove here unchanged.wrapTooltakes the prefix. The Pi tool is namedprefix + t.name, andcallToolstill receives the originalt.name.- The default client factory builds
StdioClientTransport({ command, args, env: { ...process.env, ...env }, stderr: "pipe" }). The child's stderr is logged atwarnas${label} mcp: <line>. disposecloses 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 };
buildEnvreturns{ ...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 theFORTRESS_COMMAND=python -m tilion.mcpfallback.
Testing
mcp-bridge.test.ts: the moved Playwright tests (wrapping, execute forwarding, dispose, dispose error). New cases:- With
toolPrefix, the Pi name is prefixed andcallToolstill gets the unprefixed name. clientFactoryreceives thecommand,args, andenvit was given.
- With
playwright-mcp.test.ts:buildArgstests stay. One test that the bridge is created without a prefix.fortress-mcp.test.ts:buildEnvcovers headless 0/1, the write dir, and pass-through merge.writeDiris created.command[0]/command.slice(1)split correctly.- Tools come out prefixed.
config.test.ts:- Disabled by default.
FORTRESS_COMMANDparsing 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 showsfortress 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<2pin. tilion importsmcp.server.fastmcp, which the Python MCP SDK 2.x removed, and it doesn't pin the dependency. Install withpip install "tilion[mcp]" "mcp<2".FORTRESS_CHANNEL=latestdefault. Thestablebundle (v149) unpacks totillion-fortress/tillion, but tilion_fortress looks fortilion-fortress/tilion, so it re-downloads and never launches. The bridge setslatest(v151, correct layout), andFORTRESS_CHANNELin.envstill overrides it. The Docker image prefetches thelatestbundle.- 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/stopcancels a running call. - stderr is subscribed before
connect, and a failed handshake rethrows with the server's last 20 stderr lines. stderrLevel: Fortress logs atdebug, because it writes a line per request.
- Screenshot guidance.
screenshot_pagewithout apathreturns 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.