26 KiB
Fortress MCP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Expose the Fortress stealth-browser MCP server (tilion-mcp) to the agent as fortress_* Pi tools, next to the existing Playwright tools.
Architecture: Extract the stdio MCP client and tool wrapping from playwright-mcp.ts into a generic mcp-bridge.ts. Playwright and Fortress each become a thin wrapper that builds a command, args, and env, then calls createMcpBridge. Config, main.ts wiring, a conditional system-prompt section, the Dockerfile, and the docs complete the feature.
Tech Stack: TypeScript (Node 24, ESM), @modelcontextprotocol/sdk client, Pi defineTool, vitest, Python tilion[mcp] in a venv inside the Docker image.
Spec: docs/superpowers/specs/2026-09-23-fortress-mcp-design.md
File map
- Create
src/agent/mcp-bridge.ts: generic stdio MCP bridge (types,createMcpBridge,wrapTool, default client factory). - Create
src/agent/mcp-bridge.test.ts: the moved wrapping, execute, and dispose tests, plus the prefix and spawn-spec tests. - Modify
src/agent/playwright-mcp.ts: thin wrapper overcreateMcpBridge. Re-export theMcpClientLiketype. - Modify
src/agent/playwright-mcp.test.ts: keepbuildArgs, add one test for "no prefix, names unchanged". - Create
src/agent/fortress-mcp.tsandsrc/agent/fortress-mcp.test.ts. - Modify
src/config.tsandsrc/config.test.ts: addFortressConfig. - Modify
src/agent/system-prompt.tsandsrc/agent/system-prompt.test.ts: add an optional Fortress section. - Modify
src/agent/pi-backend.ts: add afortressEnabledoption. - Modify
src/main.ts: wiring and dispose. - Modify
Dockerfile,README.md,.env.example, andSETUP-WINDOWS.md.
Task 1: Generic MCP bridge
Files: Create src/agent/mcp-bridge.ts and src/agent/mcp-bridge.test.ts
- Step 1: Write the failing tests in
src/agent/mcp-bridge.test.ts:
import { describe, expect, it, vi } from "vitest";
import { createLogger } from "../log.js";
import { createMcpBridge, type McpClientLike, type SpawnSpec } from "./mcp-bridge.js";
const silentLogger = createLogger("error", () => {});
function fakeClient(overrides: Partial<McpClientLike> = {}) {
const state = { closed: 0, calls: [] as Array<{ name: string; arguments: Record<string, unknown> }> };
const client: McpClientLike = {
listTools: async () => ({
tools: [
{ name: "navigate", description: "Navigate to a URL", inputSchema: { type: "object", properties: { url: { type: "string" } } } },
{ name: "snapshot", description: "Capture snapshot", inputSchema: { type: "object", properties: {} } },
],
}),
callTool: async (req) => {
state.calls.push(req);
return { content: [{ type: "text", text: `called ${req.name}` }] };
},
close: async () => { state.closed += 1; },
...overrides,
};
return { client, state };
}
const base = { label: "test", command: "cmd", args: [] as string[], logger: silentLogger };
describe("createMcpBridge", () => {
it("wraps every MCP tool with matching name and description when no prefix is given", async () => {
const { client } = fakeClient();
const bridge = await createMcpBridge({ ...base, clientFactory: async () => client });
expect(bridge.tools.map((t) => t.name)).toEqual(["navigate", "snapshot"]);
expect(bridge.tools[0].description).toBe("Navigate to a URL");
});
it("prefixes Pi tool names but calls the MCP tool by its original name", async () => {
const { client, state } = fakeClient();
const bridge = await createMcpBridge({ ...base, toolPrefix: "fortress_", clientFactory: async () => client });
expect(bridge.tools.map((t) => t.name)).toEqual(["fortress_navigate", "fortress_snapshot"]);
const result = await bridge.tools[0].execute("c1", { url: "https://x" }, undefined, undefined, {} as never);
expect(state.calls).toEqual([{ name: "navigate", arguments: { url: "https://x" } }]);
expect(result.content).toEqual([{ type: "text", text: "called navigate" }]);
});
it("passes command, args, and env to the client factory", async () => {
const { client } = fakeClient();
const seen: SpawnSpec[] = [];
await createMcpBridge({
...base,
command: "tilion-mcp",
args: ["--x"],
env: { A: "1" },
clientFactory: async (spec) => { seen.push(spec); return client; },
});
expect(seen).toEqual([{ command: "tilion-mcp", args: ["--x"], env: { A: "1" } }]);
});
it("dispose closes the client", async () => {
const { client, state } = fakeClient();
const bridge = await createMcpBridge({ ...base, clientFactory: async () => client });
await bridge.dispose();
expect(state.closed).toBe(1);
});
it("dispose swallows close errors and logs a labelled warning", async () => {
const { client } = fakeClient({ close: async () => { throw new Error("boom"); } });
const warn = vi.fn();
const bridge = await createMcpBridge({ ...base, label: "fortress", logger: { ...silentLogger, warn }, clientFactory: async () => client });
await expect(bridge.dispose()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fortress mcp close failed"), expect.any(Error));
});
});
-
Step 2: Run
npx vitest run src/agent/mcp-bridge.test.ts. Expected: FAIL (module not found). -
Step 3: Implement
src/agent/mcp-bridge.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { Logger } from "../log.js";
import type { CustomTools } from "./pi-backend.js";
export interface SpawnSpec {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface McpBridgeOptions extends SpawnSpec {
// Used in log lines: "<label> mcp: ...".
label: string;
// Prepended to every Pi tool name; the MCP server still sees the original name.
toolPrefix?: string;
logger: Logger;
// Test seam: inject an already-connected MCP client. When omitted, the
// bridge spawns `command` as a stdio subprocess.
clientFactory?: (spec: SpawnSpec) => Promise<McpClientLike>;
}
export interface McpBridge {
tools: CustomTools;
dispose: () => Promise<void>;
}
// Minimal shape of the MCP Client we depend on. Kept narrow so tests can
// implement it in a few lines.
export interface McpClientLike {
listTools: () => Promise<{ tools: McpToolDescriptor[] }>;
callTool: (req: { name: string; arguments: Record<string, unknown> }) => Promise<McpCallResult>;
close: () => Promise<void>;
}
export interface McpToolDescriptor {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
annotations?: { title?: string };
}
export interface McpCallResult {
content: Array<Record<string, unknown>>;
isError?: boolean;
}
export async function createMcpBridge(opts: McpBridgeOptions): Promise<McpBridge> {
const spec: SpawnSpec = { command: opts.command, args: opts.args, env: opts.env };
const factory = opts.clientFactory ?? ((s) => defaultClientFactory(s, opts.label, opts.logger));
const client = await factory(spec);
const { tools: mcpTools } = await client.listTools();
const tools: CustomTools = mcpTools.map((t) => wrapTool(t, client, opts.toolPrefix ?? ""));
return {
tools,
dispose: async () => {
try {
await client.close();
} catch (err) {
opts.logger.warn(`${opts.label} mcp close failed`, err);
}
},
};
}
function wrapTool(t: McpToolDescriptor, client: McpClientLike, prefix: string) {
return defineTool({
name: prefix + t.name,
label: t.annotations?.title ?? t.name,
description: t.description ?? "",
parameters: Type.Unsafe<Record<string, unknown>>(t.inputSchema ?? { type: "object" }),
execute: async (_toolCallId, params) => {
const result = await client.callTool({ name: t.name, arguments: params as Record<string, unknown> });
// MCP callTool returns { content, isError? }; Pi tools return { content, details }.
// MCP's content shape (type: "text" | "image" | "resource"; text/data/uri) is compatible
// with what Pi passes through to the model.
return { content: result.content as never, details: { isError: result.isError ?? false } };
},
});
}
async function defaultClientFactory(spec: SpawnSpec, label: string, logger: Logger): Promise<McpClientLike> {
const env = spec.env
? { ...(Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record<string, string>), ...spec.env }
: undefined;
const transport = new StdioClientTransport({ command: spec.command, args: spec.args, env, stderr: "pipe" });
const client = new Client({ name: "volcanic-secretary", version: "0.1.0" });
await client.connect(transport);
transport.stderr?.on("data", (buf: Buffer | string) => {
const line = (typeof buf === "string" ? buf : buf.toString("utf8")).trimEnd();
if (line) logger.warn(`${label} mcp: ${line}`);
});
return client as unknown as McpClientLike;
}
env is undefined when the caller passes none. In that case StdioClientTransport uses its default environment, which is what Playwright gets today.
- Step 4: Run
npx vitest run src/agent/mcp-bridge.test.ts. Expected: PASS (5 tests). - Step 5: Commit:
git add src/agent/mcp-bridge.ts src/agent/mcp-bridge.test.ts && git commit -m "Extract generic stdio MCP bridge with optional tool-name prefix"
Task 2: Playwright on top of the generic bridge
Files: Modify src/agent/playwright-mcp.ts and src/agent/playwright-mcp.test.ts
- Step 1: Replace the test file. Keep the
buildArgstest verbatim and replace the other four tests (now covered inmcp-bridge.test.ts) with:
import { describe, expect, it } from "vitest";
import { createLogger } from "../log.js";
import type { McpClientLike } from "./mcp-bridge.js";
import { _internals, createPlaywrightMcpBridge } from "./playwright-mcp.js";
const silentLogger = createLogger("error", () => {});
describe("createPlaywrightMcpBridge", () => {
it("exposes MCP tools under their original browser_* names", async () => {
const client: McpClientLike = {
listTools: async () => ({ tools: [{ name: "browser_navigate" }, { name: "browser_snapshot" }] }),
callTool: async () => ({ content: [] }),
close: async () => {},
};
const bridge = await createPlaywrightMcpBridge({ headless: true, logger: silentLogger, clientFactory: async () => client });
expect(bridge.tools.map((t) => t.name)).toEqual(["browser_navigate", "browser_snapshot"]);
});
it("buildArgs assembles CLI flags: headless, browser, extraArgs", () => {
expect(_internals.buildArgs({ headless: true, logger: silentLogger })).toEqual(["--headless"]);
expect(_internals.buildArgs({ headless: false, browser: "firefox", logger: silentLogger })).toEqual(["--browser", "firefox"]);
expect(_internals.buildArgs({
headless: true,
browser: "chrome",
extraArgs: ["--device", "iPhone 15"],
logger: silentLogger,
})).toEqual(["--headless", "--browser", "chrome", "--device", "iPhone 15"]);
});
});
- Step 2: Rewrite
src/agent/playwright-mcp.ts:
import { createRequire } from "node:module";
import path from "node:path";
import type { Logger } from "../log.js";
import { createMcpBridge, type McpBridge, type McpBridgeOptions } from "./mcp-bridge.js";
export type { McpClientLike } from "./mcp-bridge.js";
export interface PlaywrightMcpOptions {
headless: boolean;
browser?: string;
extraArgs?: string[];
logger: Logger;
// Test seam, forwarded to createMcpBridge.
clientFactory?: McpBridgeOptions["clientFactory"];
}
export type PlaywrightMcpBridge = McpBridge;
export async function createPlaywrightMcpBridge(opts: PlaywrightMcpOptions): Promise<PlaywrightMcpBridge> {
// Only resolve the bin when we'll actually spawn it, so tests don't depend on node_modules layout.
const binAbs = opts.clientFactory ? "@playwright/mcp" : resolvePlaywrightMcpBin();
return createMcpBridge({
label: "playwright",
command: process.execPath, // the current node binary
args: [binAbs, ...buildArgs(opts)],
logger: opts.logger,
clientFactory: opts.clientFactory,
});
}
function buildArgs(opts: PlaywrightMcpOptions): string[] {
const args: string[] = [];
if (opts.headless) args.push("--headless");
if (opts.browser) args.push("--browser", opts.browser);
if (opts.extraArgs?.length) args.push(...opts.extraArgs);
return args;
}
// Resolve the bin from our own node_modules so we never depend on `npx` at
// runtime and always get the pinned @playwright/mcp version.
function resolvePlaywrightMcpBin(): string {
const require = createRequire(import.meta.url);
const pkgPath = require.resolve("@playwright/mcp/package.json");
const pkg = require("@playwright/mcp/package.json") as { bin?: string | Record<string, string> };
const binField = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin ?? {})[0];
if (!binField) throw new Error("@playwright/mcp package.json has no bin entry");
return path.resolve(path.dirname(pkgPath), binField);
}
// exported for tests
export const _internals = { buildArgs };
- Step 3: Run
npx vitest run src/agent/ && npm run typecheck. Expected: PASS, no type errors. - Step 4: Commit:
git commit -am "Build Playwright MCP bridge on the generic bridge"
Task 3: Fortress bridge
Files: Create src/agent/fortress-mcp.ts and src/agent/fortress-mcp.test.ts
- Step 1: Write failing tests:
import { mkdtemp, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createLogger } from "../log.js";
import { _internals, createFortressMcpBridge } from "./fortress-mcp.js";
import type { McpClientLike, SpawnSpec } from "./mcp-bridge.js";
const silentLogger = createLogger("error", () => {});
const client: McpClientLike = {
listTools: async () => ({ tools: [{ name: "read_page" }, { name: "fetch_protected_page" }] }),
callTool: async () => ({ content: [] }),
close: async () => {},
};
describe("createFortressMcpBridge", () => {
it("spawns the configured command with Fortress env, creates the write dir, and prefixes tools", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "fortress-"));
try {
const writeDir = path.join(dir, "ws", "fortress");
const seen: SpawnSpec[] = [];
const bridge = await createFortressMcpBridge({
command: ["python", "-m", "tilion.mcp"],
headless: true,
writeDir,
passthroughEnv: { TILION_PROXY: "http://p:1" },
logger: silentLogger,
clientFactory: async (s) => { seen.push(s); return client; },
});
expect(seen).toEqual([{
command: "python",
args: ["-m", "tilion.mcp"],
env: { TILION_PROXY: "http://p:1", TILION_MCP_HEADLESS: "1", TILION_MCP_WRITE_DIR: writeDir },
}]);
expect((await stat(writeDir)).isDirectory()).toBe(true);
expect(bridge.tools.map((t) => t.name)).toEqual(["fortress_read_page", "fortress_fetch_protected_page"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("buildEnv maps headless=false to TILION_MCP_HEADLESS=0", () => {
expect(_internals.buildEnv({ headless: false, writeDir: "/w", passthroughEnv: {} })).toEqual({
TILION_MCP_HEADLESS: "0",
TILION_MCP_WRITE_DIR: "/w",
});
});
});
- Step 2: Run
npx vitest run src/agent/fortress-mcp.test.ts. Expected: FAIL (module not found). - Step 3: Implement
src/agent/fortress-mcp.ts:
import { mkdir } from "node:fs/promises";
import type { Logger } from "../log.js";
import { createMcpBridge, type McpBridge, type McpBridgeOptions } from "./mcp-bridge.js";
export interface FortressMcpOptions {
// argv that starts the server, e.g. ["tilion-mcp"] or ["python", "-m", "tilion.mcp"].
command: string[];
headless: boolean;
// Absolute dir inside the workspace; screenshots, saved pages, downloads and profiles land here.
writeDir: string;
// TILION_PROXY / TILION_REGION / CAPTCHA_API_KEY when set.
passthroughEnv: Record<string, string>;
logger: Logger;
// Test seam, forwarded to createMcpBridge.
clientFactory?: McpBridgeOptions["clientFactory"];
}
export async function createFortressMcpBridge(opts: FortressMcpOptions): Promise<McpBridge> {
const [command, ...args] = opts.command;
if (!command) throw new Error("fortress command is empty");
// tilion-mcp defaults to <tmpdir>/tilion-mcp, which the send tool can't reach.
await mkdir(opts.writeDir, { recursive: true });
return createMcpBridge({
label: "fortress",
command,
args,
env: buildEnv(opts),
toolPrefix: "fortress_",
logger: opts.logger,
clientFactory: opts.clientFactory,
});
}
function buildEnv(opts: Pick<FortressMcpOptions, "headless" | "writeDir" | "passthroughEnv">): Record<string, string> {
return {
...opts.passthroughEnv,
TILION_MCP_HEADLESS: opts.headless ? "1" : "0",
TILION_MCP_WRITE_DIR: opts.writeDir,
};
}
// exported for tests
export const _internals = { buildEnv };
- Step 4: Run the tests again. Expected: PASS.
- Step 5: Commit:
git add src/agent/fortress-mcp.* && git commit -m "Add Fortress MCP bridge with fortress_ tool prefix and workspace write dir"
Task 4: Config
Files: Modify src/config.ts and src/config.test.ts
- Step 1: Add tests inside
describe("loadConfig"):
it("leaves fortress undefined unless FORTRESS_ENABLED is truthy", () => {
expect(loadConfig(base, "/p").fortress).toBeUndefined();
expect(loadConfig({ ...base, FORTRESS_ENABLED: "false" }, "/p").fortress).toBeUndefined();
});
it("enables fortress with defaults", () => {
expect(loadConfig({ ...base, FORTRESS_ENABLED: "true" }, "/p").fortress).toEqual({
enabled: true,
command: ["tilion-mcp"],
headless: true,
env: {},
});
});
it("reads FORTRESS_COMMAND, FORTRESS_HEADLESS, and non-blank pass-through vars", () => {
const cfg = loadConfig(
{
...base,
FORTRESS_ENABLED: "1",
FORTRESS_COMMAND: 'python -m "tilion.mcp"',
FORTRESS_HEADLESS: "false",
TILION_PROXY: "http://u:p@h:1",
TILION_REGION: " ",
CAPTCHA_API_KEY: "k",
},
"/p",
);
expect(cfg.fortress).toEqual({
enabled: true,
command: ["python", "-m", "tilion.mcp"],
headless: false,
env: { TILION_PROXY: "http://u:p@h:1", CAPTCHA_API_KEY: "k" },
});
});
it("falls back to tilion-mcp when FORTRESS_COMMAND is blank", () => {
expect(loadConfig({ ...base, FORTRESS_ENABLED: "1", FORTRESS_COMMAND: " " }, "/p").fortress?.command).toEqual(["tilion-mcp"]);
});
- Step 2: Run
npx vitest run src/config.test.ts. Expected: the new tests fail. - Step 3: Implement. In
src/config.ts, addfortress?: FortressConfig;toConfig, then:
export interface FortressConfig {
enabled: true;
command: string[];
headless: boolean;
// Fortress's own env vars, passed to the tilion-mcp subprocess when set.
env: Record<string, string>;
}
const FORTRESS_PASSTHROUGH = ["TILION_PROXY", "TILION_REGION", "CAPTCHA_API_KEY"] as const;
In loadConfig, after playwright:
const fortressCommand = parseArgsString(env.FORTRESS_COMMAND);
const fortress: FortressConfig | undefined = parseBool(env.FORTRESS_ENABLED)
? {
enabled: true,
command: fortressCommand.length ? fortressCommand : ["tilion-mcp"],
headless: parseBool(env.FORTRESS_HEADLESS, true),
env: Object.fromEntries(
FORTRESS_PASSTHROUGH.flatMap((k) => {
const v = blankToUndefined(env[k]);
return v ? [[k, v]] : [];
}),
),
}
: undefined;
Add fortress, to the returned object. Also update the parseArgsString comment so it mentions FORTRESS_COMMAND.
- Step 4: Run the tests. Expected: PASS.
- Step 5: Commit:
git commit -am "Add FORTRESS_* config"
Task 5: System prompt and backend option
Files: Modify src/agent/system-prompt.ts, src/agent/system-prompt.test.ts, and src/agent/pi-backend.ts
- Step 1: Add tests in
describe("secretarySystemPrompt"):
it("adds the Fortress section only when fortress is enabled", () => {
const off = secretarySystemPrompt("UTC");
expect(off).not.toContain("fortress_");
expect(secretarySystemPrompt("UTC", { fortress: false })).toBe(off);
const on = secretarySystemPrompt("UTC", { fortress: true });
expect(on.startsWith(off)).toBe(true);
expect(on).toContain("## Web access (Fortress)");
expect(on).toContain("fortress_fetch_protected_page");
expect(on).toContain("fortress/");
});
- Step 2: Run. Expected: FAIL.
- Step 3: Implement. Change the signature to
secretarySystemPrompt(timezone: string, opts: { fortress?: boolean } = {}): string. Assign the existing template literal toconst base = \...`, thenreturn opts.fortress ? base + FORTRESS_SECTION : base;`, with:
const FORTRESS_SECTION = `
## 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\`.`;
In pi-backend.ts, add fortressEnabled?: boolean; to PiBackendOptions and pass secretarySystemPrompt(opts.timezone, { fortress: opts.fortressEnabled }).
- Step 4: Run
npx vitest run src/agent/ && npm run typecheck. Expected: PASS. - Step 5: Commit:
git commit -am "Add Fortress section to the system prompt when enabled"
Task 6: Wire into main.ts
Files: Modify src/main.ts
- Step 1: Import
createFortressMcpBridgefrom./agent/fortress-mcp.js. Inshutdown, after the playwright dispose line, addawait fortressDispose?.().catch((err) => logger.warn("fortress dispose failed", err));. - Step 2: After the Playwright
if/else, add:
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)");
}
Pass fortressEnabled: fortressDispose !== undefined to createPiBackend.
- Step 3: Run
npm run typecheck && npm test. Expected: all pass. - Step 4: Commit:
git commit -am "Start the Fortress MCP bridge when FORTRESS_ENABLED=true"
Task 7: Local smoke test against the real tilion-mcp
- Install
tilion[mcp]into a scratch venv. Write a throwaway script in/tmp(not committed) that callscreateFortressMcpBridgewithcommand: ["/tmp/tilion-venv/bin/tilion-mcp"]and logs the tool count. Then callfortress_read_pagewith{ url: "https://quotes.toscrape.com/js/" }(check the argument name in the listed schema) and print the first 500 characters. - Expected: about 29 tools, and text that contains quotes. Record the result for the final report.
Task 8: Dockerfile
Files: Modify Dockerfile
- After the Playwright install line, add:
# Fortress stealth-browser MCP (FORTRESS_ENABLED=true). Isolated venv because
# Debian's system Python is externally managed (PEP 668). The engine bundle
# (~300 MB) is fetched at build time into FORTRESS_BROWSERS_PATH so the first
# run doesn't download it; tilion_fortress skips the download when the
# launcher is already there. _download is private API: if it disappears,
# drop the prefetch line and the engine downloads on first use instead.
ENV FORTRESS_BROWSERS_PATH=/opt/fortress-browsers
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]" \
&& /opt/tilion/bin/python -c "import tilion_fortress as t; tag = t._CHANNELS[t._DEFAULT_CHANNEL]['tag']; t._download('linux-x64', t._host(tag), tag)"
Append /opt/tilion/bin: to the PATH in the existing ENV block.
- If Docker is available, run
docker build. If not, record that the image build wasn't verified. - Commit:
git commit -am "Install tilion-mcp and prefetch the Fortress engine in the Docker image"
Task 9: Docs
Files: Modify README.md, .env.example, and SETUP-WINDOWS.md
- README env table: add rows for
FORTRESS_ENABLED,FORTRESS_COMMAND,FORTRESS_HEADLESS,TILION_PROXY,TILION_REGION, andCAPTCHA_API_KEY. Add a "Stealth browsing (Fortress)" section after Google Calendar covering what it is, how to enable it,fortress/in the workspace, and that Docker ships it preinstalled. .env.example: a Fortress block after the Playwright block.SETUP-WINDOWS.md: an optional step,pip install "tilion[mcp]", plus theFORTRESS_COMMAND=python -m tilion.mcpfallback.- Commit:
git commit -am "Document Fortress stealth-browser tools"