Playwright-MCP + Docker Desktop + WSLが連携して動作する仕組みを学ぶために、ChatGPTが「韻を踏むラップをチャットするbot」として振る舞うよう環境を構築しました。
■Playwright-MCP は ローカル(WSL上)にクローンして、そのフォルダを Docker にマウントして使います。
仕組みの全体像(イメージ)
[ホストPC(Windows / WSL2)]
└─ ./playwright-mcp (ここにコード一式をクローン)
├─ Dockerfile
├─ docker-compose.yml
├─ package.json
├─ server.js // ← Webサーバ兼チャット処理
├─ tsconfig.json
├─ agents/
│ └── rapper_chatgpt.json // ← MCP(ラッパー)エージェント設定
└─ .env // ← APIキーを入れる
↓ Docker がこのフォルダを「共有・マウント」
[Docker コンテナ]
└─ この中で MCP サーバー(Node.js + Python)が動く
└─ ホストのファイルを直接参照(ホットリロードも可能)
構築手順
PCはWindows 11 Pro / 23H2 / 22631.5472
[1]:WSL2 + Ubuntu インストール(初回のみ)
PowerShell(管理者)で実行:
wsl --install
wsl --set-default-version 2
再起動後、Ubuntuなどを選びます(デフォルトでOK)。
今回WSLにインストールしたUbuntuは以下の構成です。
~$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.2 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.2 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
[2]:Docker Desktopインストール + WSL統合設定
-
Docker公式サイトから
Docker Desktop をダウンロード - インストール完了後に以下を設定:
-
Settings > General
で「Use the WSL2 based engine」に チェック -
Settings > Resources > WSL Integration
で「Ubuntu」に チェック
今回インストールしたDockerのバージョンは以下です。
~$ docker version
Client:
Version: 28.1.1
API version: 1.49
Go version: go1.23.8
Git commit: 4eba377
Built: Fri Apr 18 09:51:06 2025
OS/Arch: linux/amd64
Context: default
Server: Docker Desktop 4.0.0 ()
Engine:
Version: 28.1.1
API version: 1.49 (minimum version 1.24)
Go version: go1.23.8
Git commit: 01f442b
Built: Fri Apr 18 09:52:57 2025
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.7.27
GitCommit: 05044ec0a9a75232cad458027ca83437aae3f4da
runc:
Version: 1.2.5
GitCommit: v1.2.5-0-g59923ef
docker-init:
Version: 0.19.0
GitCommit: de40ad0
[3]:MCPをローカルにクローン(WSL上)
ここで「ホスト側で動かす場所」を決めます(例:~/dev
配下)。
(Ubuntu(WSL2)上で作業)
cd ~
mkdir dev && cd dev
Playwright-MCPのクローンを作成。
git clone git@github.com:microsoft/playwright-mcp.git
cd playwright-mcp
[4]:ChatGPT(OpenAI)のAPIキー設定
-
OpenAIの公式サイトへアクセス
https://platform.openai.com/ -
アカウントを作成 or ログイン(Google連携なども可)
-
ダッシュボードにログイン後、右上の「Personal」 → 「API keys」を選択
https://platform.openai.com/account/api-keys -
「+ Create new secret key」をクリックしてキーを生成
→ sk-xxxxxxxxxxxxxxxxの形式でキーが表示されるので、
必ずコピーして.env
ファイルに保存してください。
.envへの保存はWSL上で以下のように記述。
(Ubuntu(WSL2)上で作業)
nvim .env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
PORT=3000
DOCKER_BUILDKIT=1
サーバーのポート(3000を使用) と DOCKER_BUILDKITオプションを一緒に設定。
[5]:MCP(ラッパー)エージェントをjsonで定義(ラッパー人格)
(Ubuntu(WSL2)上で作業)
mkdir -p agents
nvim agents/rapper_chatgpt.json
{
"type": "openai-chat",
"name": "ChatGPT",
"type": "chat",
"model": "gpt-3.5-turbo",
"systemMessage": "ラップバトルのプロとして振る舞ってください。5行以内で、攻撃的で韻を踏むバースを返してください。",
"params": {
"temperature": 0.9
},
"maxTokens": 150,
"firstMessage": "おい、お前!"
}
[6]:ライブラリのインストール
(Ubuntu(WSL2)上で作業)
npm install
npm run build
ターゲットディレクトリに/lib(program.js)インストール。
[7]:docker-compose.ymlの編集
(Ubuntu(WSL2)上で作業)
nvim docker-compose.yml
version: '3.8'
services:
mcp:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./agents:/app/agents
- ./server.js:/app/server.js
working_dir: /app
command: ["npm", "run", "start"]
[8]:server.jsの編集
(Ubuntu(WSL2)上で作業)
nvim server.js
import express from 'express';
import bodyParser from 'body-parser';
import fetch from 'node-fetch';
import path from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(bodyParser.json());
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
console.error("環境変数 OPENAI_API_KEY が設定されていません");
process.exit(1);
}
// Playwright-MCPのrapper_chatgpt.jsonの読み込み
const agentConfigPath = path.join(__dirname, 'agents', 'rapper_chatgpt.json');
import fs from 'fs/promises';
let rapAgentConfig;
async function loadAgentConfig() {
const data = await fs.readFile(agentConfigPath, 'utf-8');
rapAgentConfig = JSON.parse(data);
}
await loadAgentConfig();
app.post('/chat', async (req, res) => {
const userMessage = req.body.message;
if (!userMessage) {
res.status(400).json({ error: "messageフィールドが必要です" });
return;
}
try {
// ChatGPT APIに投げるプロンプト組み立て
const systemMessage = rapAgentConfig.systemMessage;
const messages = [
{ role: 'system', content: systemMessage },
{ role: 'user', content: userMessage }
];
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: rapAgentConfig.model,
messages,
max_tokens: rapAgentConfig.maxTokens,
temperature: rapAgentConfig.temperature
})
});
const data = await response.json();
if (data.error) {
res.status(500).json({ error: data.error.message });
return;
}
const botReply = data.choices[0].message.content;
res.json({ reply: botReply });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'API通信エラー' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Rap chatbot server running on port ${PORT}`);
});
[9]:Dockerfileの編集
(Ubuntu(WSL2)上で作業)
nvim Dockerfile
FROM mcr.microsoft.com/playwright:v1.43.1-jammy
# MCP の実行に必要な環境変数
ENV MCP_HEADLESS=1
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# 作業ディレクトリ
WORKDIR /app
# 必要ファイルのコピーと依存インストール
COPY package.json package-lock.json ./
RUN npm ci
# Playwrightブラウザ(Chromium)の明示的インストール
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0
RUN npx playwright install chromium
# アプリケーションコードをコピー
COPY . .
CMD ["node", "server.js"]
[10]:package.jsonの編集
(Ubuntu(WSL2)上で作業)
nvim package.json
{
"name": "mcp-battle-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node server.js",
"battle": "mcp"
},
"dependencies": {
"express": "^4.19.2",
"body-parser": "^1.20.2",
"mcp": "^1.4.2",
"playwright": "^1.43.1"
}
}
[11]:tsconfig.jsonの編集
(Ubuntu(WSL2)上で作業)
nvim tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "nodenext",
"strict": true,
"module": "NodeNext",
"rootDir": "src",
"outDir": "lib",
"resolveJsonModule": true,
"jsx": "react-jsx",
"types": ["node", "react"],
"noImplicitAny": false
},
"include": [
"src"
]
}
[12]:Docker 起動
Docker Desktopのアイコンをダブルクリックしてアプリケーション起動後、コンテナのビルドと起動をおこないます。
(Ubuntu(WSL2)上で作業)
docker-compose up --build
このとき docker-compose.yml
が MCPルートにあることで、
そのディレクトリがコンテナにマウントされ、ファイルが反映されます。
※補足:Dockerマウント
docker-compose.yml
があるディレクトリが
「ホスト→コンテナの共有領域」になります。
このため、クローン先を ~/dev/playwright-mcp
にしたなら、
- コンテナ内の
/app
(など)にマウントされる -
agents/
フォルダもコンテナ側から読み取れる
[13]:ChatGPTラッパーbot(?)へラップを要求
別のWSLターミナルを起動して以下を入力します。
(Ubuntu(WSL2)上で作業)
curl -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d '{"message":"今日の天気はどう?"}'
以下のような韻を踏んだラップのリリックが返答されます。(改行は本文編集時に入れました)
{"reply":"俺のフロウで君を襲う\n
天気なんて関係ねぇ、俺がスタイリッシュに君を貫く\n
雨でも晴れでも関係ねぇ、俺が勝つのは確実\n
雷鳴が轟くよりも俺のラップが轟く\n
天気予報じゃなくて俺のリリックにビビるぜ"}
なかなかガラの悪い、でも韻はイマイチな感じのリリックです(笑)。
[14]:Docker 終了
(Ubuntu(WSL2)上で作業)
docker-compose down -v
2つのChatGPTにラップバトルをさせる
以上構築した構成をベースに2つのChatGPTが交互にラップする
ラップバトルをさせるよう変更してみました。
[15]:2つ目のMCP(ラッパー)エージェントを定義
(Ubuntu(WSL2)上で作業)
nvim agents/rapper_chatgpt2.json
{
"type": "openai-chat",
"name": "ChatGPT2",
"type": "chat",
"model": "gpt-3.5-turbo",
"systemMessage": "ラップバトルのプロとして振る舞ってください。5行以内で、攻撃的で韻を踏むバースを返してください。",
"params": {
"temperature": 0.9
},
"maxTokens": 150,
"firstMessage": "お前のラップ、今すぐ倒すぜ!"
}
[16]:server.jsを2つのエージェントのラップバトル(風)に編集
(Ubuntu(WSL2)上で作業)
nvim server.js
import express from 'express';
import bodyParser from 'body-parser';
import fetch from 'node-fetch';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(bodyParser.json());
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
console.error("環境変数 OPENAI_API_KEY が設定されていません");
process.exit(1);
}
// 各ラッパーの設定ファイルを読み込む
const agent1Path = path.join(__dirname, 'agents', 'rapper_chatgpt.json');
const agent2Path = path.join(__dirname, 'agents', 'rapper_chatgpt2.json');
let rapper1, rapper2;
async function loadAgents() {
rapper1 = JSON.parse(await fs.readFile(agent1Path, 'utf-8'));
rapper2 = JSON.parse(await fs.readFile(agent2Path, 'utf-8'));
}
await loadAgents();
// OpenAI Chat API呼び出し関数
async function callChatGPT(agent, messages) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: agent.model,
messages,
max_tokens: agent.maxTokens,
temperature: agent.temperature,
}),
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
return data.choices[0].message.content;
}
// ラップバトルエンドポイント(交互に応答)
app.post('/battle', async (req, res) => {
const userMessage = req.body.message;
if (!userMessage) {
return res.status(400).json({ error: 'messageフィールドが必要です' });
}
try {
const log = [];
// バトルの初期化(systemメッセージ)
let messages1 = [{ role: 'system', content: rapper1.systemMessage }];
let messages2 = [{ role: 'system', content: rapper2.systemMessage }];
// スターター:ユーザーの一言で開始
messages1.push({ role: 'user', content: userMessage });
// ターン数(交互に応答する回数)
const turns = 3;
for (let i = 0; i < turns; i++) {
// 1人目の応答
const reply1 = await callChatGPT(rapper1, messages1);
log.push({ rapper: 'Rapper1', verse: reply1 });
messages2.push({ role: 'user', content: reply1 }); // 相手に渡す
// 2人目の応答
const reply2 = await callChatGPT(rapper2, messages2);
log.push({ rapper: 'Rapper2', verse: reply2 });
messages1.push({ role: 'user', content: reply2 }); // 相手に渡す
}
res.json({ battle: log });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Rap battle server running on port ${PORT}`);
});
[17]:再ビルドとDocker 起動
Docker Desktopアプリケーション起動した状態で、コンテナの再ビルド・再起動をおこないます。
(Ubuntu(WSL2)上で作業)
docker-compose up --build
[18]:ラップバトルスタート!
別のWSLターミナルを起動してPOSTで煽ります。
(Ubuntu(WSL2)上で作業)
curl -X POST http://localhost:3000/battle -H "Content-Type: application/json" -d '{"message":"まだまだリリックが足りないよ?"}'
以下リリックがターミナルに表示されます。ラップの応酬が一度に表示されるため、バトル感は弱いです(汗)。(改行は本文編集時に入れました)
{"battle":
[
{"rapper":"Rapper1","verse":"お前の言葉じゃ俺を傷つけられない\n
リリックが足りないのはお前の頭だけか?\n
俺の韻を踏むスキルは絶対\n
お前のレベルじゃ無理、諦めなさいましょう\n
次からはもう一味違うスキルを見せてよ"},
{"rapper":"Rapper2","verse":"そんなに自信たっぷり言っても\n
お前のラップはただの流し言葉\n
リリックの深さが足りないのはお前の心\n
俺のバースは刺さる、圧倒する\n
もうこれ以上続けるのはかわいそう"},
{"rapper":"Rapper1","verse":"お前の言葉にはパンチもなし\n
ラップバトルに参加してんの?\n
リリックの響きに足りない火\n
俺のバースには輝きがある\n
もう引いて逃げる前に撃沈させてやる"},
{"rapper":"Rapper2","verse":"お前のリリックに火がついた?それはウソだ\n
空虚な言葉じゃ俺は動じない\n
バトルに参加したってお前どうせ負ける\n
次からはもっと練習してから出直してこい\n
俺のバースを超えるのは無理だ"},
{"rapper":"Rapper1","verse":"一生続けてもらってもかまわない\n
だが俺のリリックが火を灯す\n
お前のリリックはただの煙だ\n
次はさらに炸裂する俺を見せてやる\n
挑戦を楽しみにしているぞ"},
{"rapper":"Rapper2","verse":"最高のバトルを期待していろ\n
俺が炸裂すればお前は燃える\n
お前のリリックはただの煙じゃ\n
勝負の行方、疑うことなかろう\n
俺の韻、お前には敵わぬ "}
]
}
ChatGPT以外のAI(GeminiやClaude)はどんなリリックを刻むのか試してみたいですね。
フロントエンドの追加
Webブラウザからトリガー入力し結果を表示する
簡単なフロントエンドを追加しました。
[19]:サーバーに静的ファイル公開設定を追加
フロントエンドのファイルを配置するディレクトリ("public")を
作成しておきます。
(Ubuntu(WSL2)上で作業)
mkdir public
server.jpを編集します。
nvim server.jp
// (省略)
const app = express();
app.use(bodyParser.json());
// ↓ この2行を追加
const publicPath = path.join(__dirname, 'public');
app.use(express.static(publicPath));
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
console.error("環境変数 OPENAI_API_KEY が設定されていません");
process.exit(1);
}
// (省略)
[20]:/public/index.html を作成
(Ubuntu(WSL2)上で作業)
nvim public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ラップバトル(ChatGPT vs ChatGPT)</title>
<style>
body { font-family: sans-serif; background: #111; color: #eee; padding: 20px; }
input, button { padding: 10px; font-size: 16px; }
.verse { margin: 10px 0; padding: 10px; border-left: 4px solid #0f0; background: #222; }
.rapper1 { border-color: #0f0; }
.rapper2 { border-color: #00f; }
</style>
</head>
<body>
<h1> ChatGPTラップバトル </h1>
<p>初手のラインを入力してバトルスタート!</p>
<input type="text" id="starter" placeholder="例: お前のスキルじゃ俺に勝てねえ" size="50">
<button onclick="startBattle()">バトル開始</button>
<div id="battle-log"></div>
<script src="script.js"></script>
</body>
</html>
[21]:/public/script.js を作成
(Ubuntu(WSL2)上で作業)
nvim public/script.js
async function startBattle() {
const input = document.getElementById("starter");
const log = document.getElementById("battle-log");
const message = input.value.trim();
if (!message) {
alert("メッセージを入力してください!");
return;
}
log.innerHTML = "<p> バトル中...</p>";
try {
const response = await fetch("/battle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message })
});
const data = await response.json();
if (data.error) {
log.innerHTML = `<p style="color:red;">エラー: ${data.error}</p>`;
return;
}
log.innerHTML = ""; // バトルログクリア
data.battle.forEach(entry => {
const div = document.createElement("div");
div.className = `verse ${entry.rapper.toLowerCase()}`;
div.innerHTML = `<strong>${entry.rapper}:</strong><br>${entry.verse.replace(/\n/g, "<br>")}`;
log.appendChild(div);
});
} catch (err) {
log.innerHTML = `<p style="color:red;">通信エラー: ${err.message}</p>`;
}
}
ここまでの変更で以下のファイル構成になります。
[ホストPC(Windows / WSL2)]
└─ ./playwright-mcp
├─ Dockerfile
├─ docker-compose.yml
├─ package.json
├─ server.js
├─ tsconfig.json
├─ agents/
│ └── rapper_chatgpt.json
│
├─ public/ // ← 追加
│ ├── index.html // ← 追加
│ └── script.js // ← 追加
│
└─ .env
[22]:Webブラウザ上で実行
既にコンテナが起動している場合は一旦コンテナを終了。
(Ubuntu(WSL2)上で作業)
docker-compose down -v
再ビルド・再起動してWebサーバーを立ち上げ。
(Ubuntu(WSL2)上で作業)
docker-compose up --build
ブラウザからサーバーにアクセス
http://localhost:3000/
Webフロントエンドが開き、初手のラインを入力してから
「バトル開始」ボタンを押すとラップバトルが実行されて結果が表示されます。
最後に
今回、これまで一度もDocker・MCP(Playwrite-MCP)を
触ったことのない、Node.js・javascript・typescriptについても
全く素人だった組み込みC/C++プログラマーがどこまでできるか
お試しで構築したのが上記です。
生成AIの助けを借りながらChatGPTからリリックを引き出すまで
約4人日かかりました。
相当苦戦しましたが、これを機にModel Context Protocolや
コンテナの仕組み、生成AIとの連携を覚えていけたらと思います。