Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,69 @@ iptables -L OUTPUT -n --line-numbers # ルールを確認
- 一時的に切りたいときは `devcontainer.json` の `postStartCommand` 行をコメントアウト
(非推奨)。次回起動から firewall 無しになる。

## Playwright MCP (devpod 内外どちらでも動く headless 構成)

リポジトリの `.mcp.json` は `playwright` MCP サーバを **committed 済み**で、devpod 内でも
ホストでも**追加設定なしで headless 動作**する。実体は薄いラッパー
`tools/mcp/playwright-mcp.mjs` を経由する:

```json
"playwright": {
"type": "stdio",
"command": "node",
"args": ["tools/mcp/playwright-mcp.mjs"],
"env": {}
}
```

### なぜ素の `npx @playwright/mcp@latest` ではダメか (Issue #999)

- `@playwright/mcp@latest` は既定で **headful** 起動する → **display の無い devpod
コンテナではブラウザ起動に失敗**する。
- `--browser chromium --headless` を足しても、その MCP 版がバンドルする playwright-core は
**自分専用の新しい chromium リビジョン**を要求し、未導入なら**起動時に
`cdn.playwright.dev` からダウンロード**しようとする。このホストは **egress allowlist
で `cdn.playwright.dev` を遮断**しているため**ダウンロードに失敗**する
(「Playwright was just installed or updated. Please run npx playwright install」で停止)。

### ラッパーが何をするか

`tools/mcp/playwright-mcp.mjs` は、**リポジトリの `playwright`(root `node_modules`)が
イメージビルド時(firewall 適用前)に既に導入済みの chromium** の実行ファイルパスを
`require('playwright').chromium.executablePath()` で解決し、それを
`@playwright/mcp` に `--executable-path <path> --headless` で渡す。

- **devpod 内**: ビルド時導入済み chromium を使うので**追加ダウンロード不要**→ 動く。
- **ホスト**: 同じ解決でホストの chromium を使う(未導入でもホストは egress 制限が
無いので通常どおり取得される)→ 動く。
- 解決できない場合は `--executable-path` を付けず、MCP 既定挙動へフォールバックする。

疎通確認(コンテナ内、root):

```bash
node --test tools/mcp/playwright-mcp.test.mjs # ラッパーの単体テスト
claude mcp list # playwright が ✓ connected
```

### ホストで headful に見たい場合 (opt-in・committed 設定は変えない)

headful はホスト固有なので committed `.mcp.json` には入れない。ホストで
**HTTP server mode の Playwright MCP** を起動し、コンテナ側は **local scope** で
上書きする(local が project scope より優先されるので `.mcp.json` は変更不要):

```bash
# ホスト側 (iTerm2)。ヘルパースクリプトが用意してある:
tools/host-playwright-mcp.sh
# コンテナ側で一度だけ local scope を張る:
claude mcp add --scope local --transport http playwright http://host.docker.internal:8931/mcp
# /mcp で Reconnect
```

`--host 0.0.0.0` 必須・`--allowed-hosts` はポート込みカンマ区切り 1 引数など、
ハマりどころは `tools/host-playwright-mcp.sh` の先頭コメントに集約してある。
ホスト Chrome を直接操作する一般的な背景は `.claude/rules/scratch-gui/e2e-test.md`
(→ memory `reference_host_playwright_mcp.md`)も参照。

## tmux

### 設定の構成
Expand Down
5 changes: 2 additions & 3 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@
},
"playwright": {
"type": "stdio",
"command": "npx",
"command": "node",
"args": [
"-y",
"@playwright/mcp@latest"
"tools/mcp/playwright-mcp.mjs"
],
"env": {}
},
Expand Down
122 changes: 122 additions & 0 deletions tools/mcp/playwright-mcp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Playwright MCP launcher — committed, works both inside devpod and on the host.
*
* なぜ薄いラッパーが要るか(Issue #999):
* `.mcp.json` から素の `npx -y @playwright/mcp@latest` を起動すると、その版が
* バンドルする playwright-core が要求する **新しい chromium リビジョンを起動時に
* ダウンロード**しようとする。devpod コンテナは egress allowlist で
* `cdn.playwright.dev` を遮断しているためダウンロードに失敗し、ブラウザが起動しない
* (headful 既定なので display 不足でも失敗する)。
*
* そこで、リポジトリの `playwright`(root node_modules)が **イメージビルド時に
* 既に導入済みの chromium** の実行ファイルパスを解決し、`--executable-path` で
* その既存 chromium を headless 起動する。これで:
* - devpod 内: 追加ダウンロード不要(ビルド時導入分を使う)→ 動く
* - ホスト: 同じ解決で host の chromium を使う(無ければ host は egress 制限が
* 無いので通常どおり取得できる)→ 動く
* 単一の committable 設定で両環境が動作する。
*
* ホストで headful に見たい場合は committed 設定を変えず local-scope の HTTP MCP で
* opt-in する(`tools/host-playwright-mcp.sh` と `.devcontainer/README.md` の
* 「ホストで headful に見たい場合」参照)。
*
* `.mcp.json` からは `command: "node", args: ["tools/mcp/playwright-mcp.mjs"]` で呼ぶ。
* stdio transport はこのプロセスの stdin/stdout を子 (`npx` → MCP) にそのまま
* 引き継ぐ(`stdio: 'inherit'`)ため、JSON-RPC バイト列はラッパーの JS を経由しない。
*/

import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';

const DEFAULT_MCP_SPEC = '@playwright/mcp@latest';

/**
* npx に渡す引数配列を組み立てる(純粋関数)。
* @param {object} opts - オプション
* @param {?string} opts.chromiumPath - 使う chromium 実行ファイルパス(null なら付けない)
* @param {string[]} [opts.extraArgs] - 末尾へ透過する追加引数
* @param {string} [opts.mcpSpec] - `@playwright/mcp` の npm 指定(既定は latest)
* @returns {string[]} `npx` に渡す引数配列
*/
export function buildMcpArgs({ chromiumPath, extraArgs = [], mcpSpec = DEFAULT_MCP_SPEC }) {
const args = ['-y', mcpSpec, '--headless'];
if (chromiumPath) {
args.push('--executable-path', chromiumPath);
}
args.push(...extraArgs);
return args;
}

/**
* リポジトリの playwright が導入済みの chromium 実行ファイルパスを解決する(純粋関数)。
* 解決できない/未導入なら null を返す(呼び出し側は `--executable-path` を付けず、
* MCP 既定のダウンロード挙動へフォールバックする)。
* @param {() => object} loadPlaywright - playwright モジュールを返す関数(テスト用に注入)
* @returns {?string} chromium 実行ファイルの絶対パス、または null
*/
export function resolveChromiumExecutable(loadPlaywright) {
try {
const playwright = loadPlaywright();
const p = playwright?.chromium?.executablePath?.();
return typeof p === 'string' && p.length > 0 ? p : null;
} catch {
return null;
}
}

/**
* このファイルの位置を起点に `playwright` を require で解決する。
* worktree では root の node_modules が main へ symlink されているため、
* script 位置(tools/mcp/)から上方向に辿ってリポジトリ root の playwright に届く。
* @returns {object} playwright モジュール
*/
function loadRepoPlaywright() {
const require = createRequire(import.meta.url);
return require('playwright');
}

/**
* エントリポイント。chromium を解決して MCP を起動し、子プロセスの終了コードで終わる。
* @returns {void}
*/
function main() {
const log = (m) => process.stderr.write(`[playwright-mcp] ${m}\n`);
const chromiumPath = resolveChromiumExecutable(loadRepoPlaywright);
if (chromiumPath) {
log(`using repo chromium: ${chromiumPath}`);
} else {
log('repo chromium not resolved; falling back to @playwright/mcp default browser');
}

const args = buildMcpArgs({ chromiumPath, extraArgs: process.argv.slice(2) });
// stdio: 'inherit' で stdin/stdout/stderr を子へ透過(JSON-RPC はここを通らない)。
const child = spawn('npx', args, { stdio: 'inherit' });

const forward = (sig) => {
try {
child.kill(sig);
} catch {
/* 子が既に終了している場合は無視 */
}
};
process.on('SIGINT', () => forward('SIGINT'));
process.on('SIGTERM', () => forward('SIGTERM'));

child.on('error', (e) => {
log(`failed to start npx: ${e.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}

// テスト(import)時は main を実行しない。直接起動されたときだけ実行する。
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
main();
}
75 changes: 75 additions & 0 deletions tools/mcp/playwright-mcp.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* tools/mcp/playwright-mcp.mjs の純粋ロジックのユニットテスト。
*
* `node --test tools/mcp/playwright-mcp.test.mjs` で実行する(外部依存なし)。
*/

import { test } from 'node:test';
import assert from 'node:assert';

import { buildMcpArgs, resolveChromiumExecutable } from './playwright-mcp.mjs';

test('buildMcpArgs: chromiumPath があれば --executable-path を付ける', () => {
const args = buildMcpArgs({ chromiumPath: '/browsers/chrome' });
assert.deepStrictEqual(args, [
'-y',
'@playwright/mcp@latest',
'--headless',
'--executable-path',
'/browsers/chrome',
]);
});

test('buildMcpArgs: chromiumPath が無ければ --executable-path を付けない(既定 download 挙動へフォールバック)', () => {
const args = buildMcpArgs({ chromiumPath: null });
assert.deepStrictEqual(args, ['-y', '@playwright/mcp@latest', '--headless']);
});

test('buildMcpArgs: 呼び出し側の追加引数を末尾に透過する', () => {
const args = buildMcpArgs({
chromiumPath: '/browsers/chrome',
extraArgs: ['--viewport-size', '1280x720'],
});
assert.deepStrictEqual(args, [
'-y',
'@playwright/mcp@latest',
'--headless',
'--executable-path',
'/browsers/chrome',
'--viewport-size',
'1280x720',
]);
});

test('buildMcpArgs: mcpSpec を上書きできる(ピン止め用)', () => {
const args = buildMcpArgs({ chromiumPath: null, mcpSpec: '@playwright/mcp@0.0.78' });
assert.deepStrictEqual(args, ['-y', '@playwright/mcp@0.0.78', '--headless']);
});

test('resolveChromiumExecutable: playwright が解決でき executablePath があればそれを返す', () => {
const loadPlaywright = () => ({ chromium: { executablePath: () => '/browsers/chrome-1217' } });
assert.strictEqual(resolveChromiumExecutable(loadPlaywright), '/browsers/chrome-1217');
});

test('resolveChromiumExecutable: playwright 解決が throw したら null(フォールバック)', () => {
const loadPlaywright = () => {
throw new Error('Cannot find module playwright');
};
assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null);
});

test('resolveChromiumExecutable: executablePath が空文字なら null', () => {
const loadPlaywright = () => ({ chromium: { executablePath: () => '' } });
assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null);
});

test('resolveChromiumExecutable: executablePath 自体が throw しても null', () => {
const loadPlaywright = () => ({
chromium: {
executablePath: () => {
throw new Error('no browser');
},
},
});
assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null);
});
Loading