Files
VolcanicSecretary/docs/superpowers/plans/2026-09-23-fortress-mcp.md
2026-09-23 19:28:23 +00:00

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 over createMcpBridge. Re-export the McpClientLike type.
  • Modify src/agent/playwright-mcp.test.ts: keep buildArgs, add one test for "no prefix, names unchanged".
  • Create src/agent/fortress-mcp.ts and src/agent/fortress-mcp.test.ts.
  • Modify src/config.ts and src/config.test.ts: add FortressConfig.
  • Modify src/agent/system-prompt.ts and src/agent/system-prompt.test.ts: add an optional Fortress section.
  • Modify src/agent/pi-backend.ts: add a fortressEnabled option.
  • Modify src/main.ts: wiring and dispose.
  • Modify Dockerfile, README.md, .env.example, and SETUP-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 buildArgs test verbatim and replace the other four tests (now covered in mcp-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, add fortress?: FortressConfig; to Config, 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 to const base = \...`, then return 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 createFortressMcpBridge from ./agent/fortress-mcp.js. In shutdown, after the playwright dispose line, add await 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 calls createFortressMcpBridge with command: ["/tmp/tilion-venv/bin/tilion-mcp"] and logs the tool count. Then call fortress_read_page with { 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, and CAPTCHA_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 the FORTRESS_COMMAND=python -m tilion.mcp fallback.
  • Commit: git commit -am "Document Fortress stealth-browser tools"