結論:Issue を書いたら5分後にPRが立っていた
Issue を書いたら5分後にPRが立っていた──Claude Code とMCP自作サーバー3つで実現した完全自動開発フローを、コード全量つきで公開します。
この記事を読むと、以下が再現できます。
- MCP サーバーを TypeScript でゼロから自作する手順
- Claude Code から GitHub API・ファイル操作・通知を連携させるアーキテクチャ
- Issue 起票 → ブランチ作成 → コード生成 → PR 作成 → Slack 通知を全自動で回すフロー
日本語圏では MCP サーバー自作の実践ガイドがまだ少ない領域です。ハンズオン形式でコード全量を公開するので、ぜひ手元で動かしてみてください。
完成デモ:Issue 起票から PR マージまで
実際の動作フローは以下の通りです。
- GitHub 上で Issue を起票(タイトルと要件を自然言語で記述)
- Claude Code が Issue を読み取り、要件を解析
- feature ブランチを自動作成
- コードを生成・編集してコミット
- PR を自動作成(Issue へのリンク付き)
- 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: 最新版(
claudeCLI がインストール済み) -
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_request が 404 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つのガードレールが必須。 自動化の便利さとセキュリティのバランスを取ることが重要です