1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code × MCPサーバーを自作して『GitHub Issue → ブランチ → PR』を全自動化した話

1
Posted at

結論:Issue を書いたら5分後にPRが立っていた

Issue を書いたら5分後にPRが立っていた──Claude Code とMCP自作サーバー3つで実現した完全自動開発フローを、コード全量つきで公開します。

この記事を読むと、以下が再現できます。

  • MCP サーバーを TypeScript でゼロから自作する手順
  • Claude Code から GitHub API・ファイル操作・通知を連携させるアーキテクチャ
  • Issue 起票 → ブランチ作成 → コード生成 → PR 作成 → Slack 通知を全自動で回すフロー

日本語圏では MCP サーバー自作の実践ガイドがまだ少ない領域です。ハンズオン形式でコード全量を公開するので、ぜひ手元で動かしてみてください。


完成デモ:Issue 起票から PR マージまで

実際の動作フローは以下の通りです。

  1. GitHub 上で Issue を起票(タイトルと要件を自然言語で記述)
  2. Claude Code が Issue を読み取り、要件を解析
  3. feature ブランチを自動作成
  4. コードを生成・編集してコミット
  5. PR を自動作成(Issue へのリンク付き)
  6. Slack に完了通知が飛ぶ

所要時間:約3〜5分。 人間が行うのは「Issue を書く」ことだけです。


アーキテクチャ全体像

このフローは Claude Code を中心に、3つの MCP サーバーが協調動作する構成です。

各 MCP サーバーの役割

MCP サーバー 役割 主なツール
MCP GitHub Server GitHub API との橋渡し get_issue, create_branch, create_pull_request
MCP Filesystem Server ローカルファイルの読み書き read_file, write_file, list_directory
MCP Notification Server 外部通知の送信 notify_slack, notify_email

環境・前提条件

  • Node.js: v20 以上
  • Claude Code: 最新版(claude CLI がインストール済み)
  • GitHub Personal Access Token: repo スコープ付き
  • Slack Incoming Webhook URL(通知を使う場合)
# Claude Code のインストール(未導入の場合)
npm install -g @anthropic-ai/claude-code

MCPサーバー自作の最小ステップ

ここが本記事の核心です。TypeScript SDK を使って MCP サーバーをゼロから作ります。

Step 1: プロジェクトの scaffold

mkdir mcp-github-server && cd mcp-github-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

tsconfig.json は以下を最低限設定します。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}

Step 2: ツール定義(GitHub Server の例)

// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const GITHUB_TOKEN = process.env.GITHUB_TOKEN!;
const server = new McpServer({
  name: "mcp-github-server",
  version: "1.0.0",
});

// --- Tool: get_issue ---
server.tool(
  "get_issue",
  "GitHub Issue の詳細を取得する",
  {
    owner: z.string().describe("リポジトリオーナー"),
    repo: z.string().describe("リポジトリ名"),
    issue_number: z.number().describe("Issue 番号"),
  },
  async ({ owner, repo, issue_number }) => {
    const res = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`,
      {
        headers: {
          Authorization: `Bearer ${GITHUB_TOKEN}`,
          Accept: "application/vnd.github.v3+json",
        },
      }
    );
    const issue = await res.json();
    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify(
            { title: issue.title, body: issue.body, labels: issue.labels },
            null,
            2
          ),
        },
      ],
    };
  }
);

// --- Tool: create_branch ---
server.tool(
  "create_branch",
  "新しいブランチを作成する",
  {
    owner: z.string(),
    repo: z.string(),
    branch_name: z.string().describe("作成するブランチ名"),
    base_branch: z.string().default("main").describe("ベースブランチ"),
  },
  async ({ owner, repo, branch_name, base_branch }) => {
    // ベースブランチの最新 SHA を取得
    const refRes = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${base_branch}`,
      {
        headers: { Authorization: `Bearer ${GITHUB_TOKEN}` },
      }
    );
    const refData = await refRes.json();
    const sha = refData.object.sha;

    // ブランチ作成
    const createRes = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/git/refs`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${GITHUB_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ ref: `refs/heads/${branch_name}`, sha }),
      }
    );
    const result = await createRes.json();
    return {
      content: [
        { type: "text" as const, text: `Branch created: ${result.ref}` },
      ],
    };
  }
);

// --- Tool: create_pull_request ---
server.tool(
  "create_pull_request",
  "Pull Request を作成する",
  {
    owner: z.string(),
    repo: z.string(),
    title: z.string(),
    body: z.string(),
    head: z.string().describe("PR のヘッドブランチ"),
    base: z.string().default("main"),
  },
  async ({ owner, repo, title, body, head, base }) => {
    const res = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/pulls`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${GITHUB_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, body, head, base }),
      }
    );
    const pr = await res.json();
    return {
      content: [
        {
          type: "text" as const,
          text: `PR created: ${pr.html_url}`,
        },
      ],
    };
  }
);

// --- サーバー起動 ---
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP GitHub Server running on stdio");
}

main().catch(console.error);

Step 3: Notification Server(Slack 通知)

// mcp-notification-server/src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!;
const server = new McpServer({
  name: "mcp-notification-server",
  version: "1.0.0",
});

server.tool(
  "notify_slack",
  "Slack にメッセージを送信する",
  {
    message: z.string().describe("送信するメッセージ"),
    channel: z.string().optional().describe("チャンネル名(省略時はWebhookデフォルト)"),
  },
  async ({ message, channel }) => {
    const payload: Record<string, string> = { text: message };
    if (channel) payload.channel = channel;

    await fetch(SLACK_WEBHOOK_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    return {
      content: [{ type: "text" as const, text: "Slack notification sent" }],
    };
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}
main().catch(console.error);

Step 4: Claude Code に MCP サーバーを登録

プロジェクトルートの .claude/claude_code_config.json(またはグローバル設定の ~/.claude/claude_code_config.json)に以下を追加します。

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["tsx", "/path/to/mcp-github-server/src/index.ts"],
      "env": {
        "GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
      }
    },
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"],
      "env": {}
    },
    "notification": {
      "command": "npx",
      "args": ["tsx", "/path/to/mcp-notification-server/src/index.ts"],
      "env": {
        "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/xxx/yyy/zzz"
      }
    }
  }
}

Step 5: テスト

# MCP Inspector で動作確認
npx @modelcontextprotocol/inspector npx tsx src/index.ts

MCP Inspector が起動したらブラウザで各ツールを呼び出し、正常にレスポンスが返ることを確認します。


Claude Code の Hooks 機能で Slack 通知を差し込む

Claude Code には Hooks という機能があり、特定のイベント(会話開始・終了、ツール実行前後など)にシェルコマンドを差し込めます。

PR 作成後に MCP サーバー経由ではなく、Hooks 経由でも Slack 通知を飛ばせます。

.claude/settings.json に以下を追記します。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "create_pull_request",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST -H 'Content-Type: application/json' -d '{\"text\":\"🎉 新しいPRが作成されました: '$CLAUDE_TOOL_RESULT'\"}' $SLACK_WEBHOOK_URL"
          }
        ]
      }
    ]
  }
}

MCP Notification Server との使い分け:

方式 メリット ユースケース
MCP Notification Server Claude が文脈に応じてメッセージを構成可能 通知内容を AI に任せたい場合
Hooks 確実に発火する(Claude の判断に依存しない) 必ず通知したい場合

本番では Hooks で確実に通知を飛ばしつつ、MCP Server でリッチな通知内容を生成する 二段構えがおすすめです。


ハマりどころ5選

1. 認証トークンのスコープ不足

GitHub Personal Access Token に repo(Full control of private repositories) が必要です。Fine-grained token の場合は以下を有効にしてください。

  • Contents: Read and write
  • Pull requests: Read and write
  • Issues: Read
  • Metadata: Read

症状: create_pull_request404 Not Found を返す。これは権限不足の典型的なサインです。

2. ファイルパスの正規化

MCP Filesystem Server は 許可されたディレクトリ外へのアクセスを拒否 します。相対パスや .. を含むパスはエラーになります。

# NG: 相対パスは解決されない場合がある
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./project"]

# OK: 絶対パスで指定
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/project"]

3. レート制限対策

GitHub API は認証済みでも 5,000 req/hour の制限があります。大量の Issue を一括処理する場合はリトライロジックを入れましょう。

// 簡易リトライ付き fetch
async function githubFetch(url: string, options: RequestInit, retries = 3): Promise<Response> {
  for (let i = 0; i < retries; i++) {
    const res = await fetch(url, options);
    if (res.status === 403) {
      const resetTime = res.headers.get("x-ratelimit-reset");
      const waitMs = resetTime 
        ? Number(resetTime) * 1000 - Date.now() 
        : 60_000;
      console.error(`Rate limited. Waiting ${waitMs}ms...`);
      await new Promise((r) => setTimeout(r, Math.max(waitMs, 1000)));
      continue;
    }
    return res;
  }
  throw new Error("GitHub API rate limit exceeded after retries");
}

4. stdio トランスポートでの console.log 事故

MCP の stdio トランスポートは 標準出力を JSON-RPC メッセージのやり取りに使いますconsole.log でデバッグ出力すると通信が壊れます。

// NG: stdout に出力される → JSON-RPC が壊れる
console.log("debug info");

// OK: stderr に出力する
console.error("debug info");

5. ブランチ名の衝突

同じ Issue 番号で再実行すると Reference already exists エラーが出ます。既存ブランチの存在チェックを入れるか、タイムスタンプ付きの命名規則にしましょう。

const branchName = `feature/issue-${issue_number}-${Date.now()}`;

Before/After:手動フローとの所要時間比較

ステップ 手動(Before) 自動化(After)
Issue の要件読解 5分 10秒(Claude が解析)
ブランチ作成 1分 3秒
コード実装 30〜120分 2〜4分
コミット & プッシュ 2分 5秒
PR 作成(説明文記入含む) 5分 5秒
Slack 通知 1分 自動
合計 44〜134分 3〜5分

※ コード実装の時間は中規模の機能追加(関数2〜3個、テスト付き)を想定しています。複雑なアーキテクチャ変更は人間のレビューが不可欠です。


セキュリティ上の注意点と本番導入時のガードレール設計

自動化は強力ですが、ガードレールなしの本番投入は危険 です。

必須ガードレール一覧

1. トークン権限の最小化

  • Fine-grained Personal Access Token を使い、対象リポジトリのみに限定する
  • Contents の Write 権限は自動化対象リポジトリだけに付与する

2. PR は必ず Draft で作成

body: JSON.stringify({ title, body, head, base, draft: true }),

人間のレビューを必ず経由させます。

3. 自動化対象の限定

特定のラベル(例: automate)が付いた Issue のみを処理対象にします。全 Issue を自動処理するのは事故の元です。

4. シークレット管理

環境変数に直接トークンを書かず、シークレットマネージャー(1Password CLI、AWS Secrets Manager など)経由で注入しましょう。

{
  "env": {
    "GITHUB_TOKEN": "op://vault/github-token/credential"
  }
}

5. 実行ログの保持

Claude Code のセッションログは ~/.claude/projects/ 配下に保存されます。本番運用では、これをチームで閲覧可能な場所にエクスポートする仕組みを入れましょう。

6. 許可コマンドの制限

.claude/settings.json で実行可能なコマンドを制限します。

{
  "permissions": {
    "allow": [
      "Read",
      "Write",
      "mcp__github__get_issue",
      "mcp__github__create_branch",
      "mcp__github__create_pull_request",
      "mcp__notification__notify_slack"
    ],
    "deny": [
      "Bash(rm *)",
      "Bash(sudo *)"
    ]
  }
}

まとめ

  • MCP サーバーは TypeScript SDK を使えば100行程度で自作できる。 McpServer クラスにツールを登録し、stdio トランスポートで接続するだけのシンプルな構造です
  • Claude Code + MCP 3台構成で、Issue → PR の全自動フローが3〜5分で完了する。 手動作業と比較して最大97%の時間削減が可能です
  • 本番導入には Draft PR 強制・ラベルによる対象限定・トークン最小権限の3つのガードレールが必須。 自動化の便利さとセキュリティのバランスを取ることが重要です

参考リンク

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?