0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPT + Playwright-MCP + Docker Desktop + WSLの組み合わせでラップバトルbotを構築

Last updated at Posted at 2025-07-04

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統合設定

  1. Docker公式サイトから
    Docker Desktop をダウンロード
  2. インストール完了後に以下を設定:
  • 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キー設定

  1. OpenAIの公式サイトへアクセス
    https://platform.openai.com/

  2. アカウントを作成 or ログイン(Google連携なども可)

  3. ダッシュボードにログイン後、右上の「Personal」 → 「API keys」を選択
    https://platform.openai.com/account/api-keys

  4. + 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フロントエンドが開き、初手のラインを入力してから
「バトル開始」ボタンを押すとラップバトルが実行されて結果が表示されます。

front.png

最後に

今回、これまで一度もDocker・MCP(Playwrite-MCP)を
触ったことのない、Node.js・javascript・typescriptについても
全く素人だった組み込みC/C++プログラマーがどこまでできるか
お試しで構築したのが上記です。

生成AIの助けを借りながらChatGPTからリリックを引き出すまで
約4人日かかりました。
相当苦戦しましたが、これを機にModel Context Protocolや
コンテナの仕組み、生成AIとの連携を覚えていけたらと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?