
WebアプリをAIから操作できるようにした話(第2回)— Next.jsでMCP Streamable HTTPサーバーを実装する
このシリーズ: 全4回
概要
MCP Streamable HTTP の実装は、突き詰めると 「JSON-RPC 2.0 を受け取って処理するHTTPエンドポイント」 を作ることです。Next.js の API Route として、以下の3つを実装します:
POST /api/mcp— メインのリクエスト処理GET /api/mcp— SSEハンドシェイク(Lambda向け特殊対応)OPTIONS /api/mcp— CORS プリフライト
こんな人向け
- Next.js App Router でバックエンドAPIを書いたことがある
- JSON-RPC という言葉を聞いたことがある
- AWS Lambda + CloudFront の制約(ステートレス、接続時間制限)を把握している
型定義から始める
まず src/lib/mcp/types.ts に型を定義します。
// JSON-RPC 2.0 の基本型
export interface JsonRpcRequest {
jsonrpc: '2.0';
id?: string | number | null; // Notification は id なし(undefined)
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
// MCPツールの定義
export interface McpToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
}
export interface McpToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
export interface McpTool {
definition: McpToolDefinition;
handler: (args: Record<string, unknown>, userId: string) => Promise<McpToolResult>;
}
// エラーコード
export const RPC_ERROR = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
} as const;ポイント: id は undefined(フィールドなし)が Notification を意味します。null は有効なIDです。ここを id === undefined || id === null と書くと、null IDのリクエストをNotificationと誤判定してしまいます。
JSON-RPC ハンドラー
src/lib/mcp/handler.ts に JSON-RPC メソッドのディスパッチロジックを実装します。
import type { JsonRpcRequest, JsonRpcResponse, McpTool } from './types';
import { RPC_ERROR } from './types';
function ok(id: string | number | null | undefined, result: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', id: id ?? null, result };
}
function err(
id: string | number | null | undefined,
code: number,
message: string
): JsonRpcResponse {
return { jsonrpc: '2.0', id: id ?? null, error: { code, message } };
}
export async function handleMcpRequest(
req: JsonRpcRequest,
tools: McpTool[],
userId: string
): Promise<JsonRpcResponse | null> {
// Notification(id フィールドが存在しない)はレスポンス不要
if (req.id === undefined) {
return null;
}
const { method, params, id } = req;
try {
switch (method) {
case 'initialize':
return ok(id, {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'team-task-scheduler', version: '1.0.0' },
});
case 'ping':
return ok(id, {});
case 'tools/list':
return ok(id, { tools: tools.map((t) => t.definition) });
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string | undefined;
const args = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<string, unknown>;
if (!toolName) {
return err(id, RPC_ERROR.INVALID_PARAMS, 'tools/call requires "name" parameter');
}
const tool = tools.find((t) => t.definition.name === toolName);
if (!tool) {
return err(id, RPC_ERROR.INVALID_PARAMS, `Unknown tool: ${toolName}`);
}
try {
const result = await tool.handler(args, userId);
return ok(id, result);
} catch (e) {
const message = e instanceof Error ? e.message : 'Tool execution failed';
return ok(id, {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
});
}
}
default:
return err(id, RPC_ERROR.METHOD_NOT_FOUND, `Method not found: ${method}`);
}
} catch (e) {
const message = e instanceof Error ? e.message : 'Internal error';
return err(id, RPC_ERROR.INTERNAL_ERROR, message);
}
}ツール実行でエラーが発生した場合は、JSON-RPC レベルのエラーではなく isError: true のレスポンスを返します。MCP の仕様では、ツール実行の失敗は「正常なレスポンス内のエラー情報」として返すのが正しい作法です。
MCPエンドポイント(route.ts)
src/app/api/mcp/route.ts が本体です。
import { NextRequest, NextResponse } from 'next/server';
import { authenticateMcpRequest } from '@/lib/mcp/auth';
import { handleMcpRequest } from '@/lib/mcp/handler';
import { allTools } from '@/lib/mcp/server';
import type { JsonRpcRequest } from '@/lib/mcp/types';
function unauthorized(): NextResponse {
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32600, message: 'Unauthorized' }, id: null },
{ status: 401 }
);
}
// POST: メインのMCP処理
export async function POST(request: NextRequest): Promise<NextResponse> {
const auth = await authenticateMcpRequest(request.headers.get('authorization'));
if (!auth) return unauthorized();
let body: Record<string, unknown> | JsonRpcRequest | Array<Record<string, unknown> | JsonRpcRequest>;
try {
body = (await request.json()) as typeof body;
} catch {
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null },
{ status: 400 }
);
}
// バッチリクエスト(配列)対応
if (Array.isArray(body)) {
const responses = await Promise.all(
body.map((req) => handleMcpRequest(req as JsonRpcRequest, allTools, auth.userId))
);
const filtered = responses.filter(Boolean);
if (filtered.length === 0) {
return new NextResponse(null, { status: 202 }); // 全部 Notification の場合
}
return NextResponse.json(filtered);
}
const response = await handleMcpRequest(body as JsonRpcRequest, allTools, auth.userId);
if (response === null) {
return new NextResponse(null, { status: 202 }); // Notification
}
return NextResponse.json(response);
}
// GET: SSEハンドシェイク(Claude Code が最初に試みる接続)
export async function GET(request: NextRequest): Promise<NextResponse> {
const auth = await authenticateMcpRequest(request.headers.get('authorization'));
if (!auth) return unauthorized();
// Lambda はステートレスなので長時間接続できない
// 空のSSEストリームを即座に閉じることでハンドシェイクを完了させる
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(': connected\n\n'));
controller.close();
},
});
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
});
}
// OPTIONS: CORSプリフライト
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}ツールの集約
src/lib/mcp/server.ts で各ツールファイルをまとめます。
import { projectTools } from './tools/project';
import { taskTools } from './tools/task';
import { itemTools } from './tools/item';
import { commentTools } from './tools/comment';
import { tagTools } from './tools/tag';
import { milestoneTools } from './tools/milestone';
import { baselineTools } from './tools/baseline';
import { workReportTools } from './tools/work-report';
import { dependencyTools } from './tools/dependency';
import { miscTools } from './tools/misc';
import type { McpTool } from './types';
export const allTools: McpTool[] = [
...projectTools,
...taskTools,
...itemTools,
...commentTools,
...tagTools,
...milestoneTools,
...baselineTools,
...workReportTools,
...dependencyTools,
...miscTools,
];ツールの実装パターン
各ツールは McpTool 型に従います。project_list を例に見てみましょう:
// src/lib/mcp/tools/project.ts
import type { McpTool, McpToolResult } from '../types';
function json<T>(obj: T): McpToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }],
};
}
function errorResult(message: string): McpToolResult {
return {
content: [{ type: 'text', text: message }],
isError: true,
};
}
const projectList: McpTool = {
definition: {
name: 'project_list',
description: '指定チームのプロジェクト一覧を取得します',
inputSchema: {
type: 'object',
properties: {
teamId: { type: 'string', description: 'チームID(必須)' },
includeArchived: {
type: 'boolean',
description: 'アーカイブ済みプロジェクトを含めるか(デフォルト: false)',
},
},
required: ['teamId'],
},
},
async handler(args, userId) {
const teamId = args.teamId as string | undefined;
if (!teamId) return errorResult('teamId は必須です');
// 認可チェック:チームメンバーでなければ拒否
const isMember = await teamRepository.isMember(teamId, userId);
if (!isMember) return errorResult('このチームへのアクセス権がありません');
const projects = await projectRepository.findByTeamId(teamId);
return json({ projects, count: projects.length });
},
};
export const projectTools: McpTool[] = [projectList, /* ... */];ポイント: handler は常に McpToolResult を返します。例外をスローしてもハンドラー側でキャッチされますが、認可エラーは errorResult() で明示的に返す方が意図が明確になります。
Lambda 向けの GET/SSE 対応
通常の MCP サーバーは GET リクエストに対して SSE ストリームを開きっぱなし にして、サーバー側からのプッシュ通知に使います。しかし Lambda は以下の制約があります:
- 実行時間制限: API Gateway 経由は最大 29 秒
- ステートレス: 次のリクエストは別のコンテナで処理される可能性がある
そのため、今回は「ストリームをすぐ閉じる」実装にしました。Claude Code はこれを見て「サーバーからのプッシュはない」と判断し、以降は POST のみで通信します。
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(': connected\n\n'));
controller.close(); // すぐ閉じる
},
});重要: GET を実装しないと(または 405 を返すと)、Claude Code はセッション開始時の接続確立に失敗し、ツールが一切ロードされません。詳細は第4回で解説します。
curlで動作確認する
デプロイ後の動作確認は curl で行えます:
# initialize
curl -X POST https://your-app.cloudfront.net/api/mcp \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# tools/list でツール数を確認
curl -X POST https://your-app.cloudfront.net/api/mcp \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# GETエンドポイント
curl -X GET https://your-app.cloudfront.net/api/mcp \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: text/event-stream"
# → HTTP 200 が返れば OKまとめ
- MCP Streamable HTTP は POST でリクエストを受け取る普通の HTTP API
- JSON-RPC 2.0 の
idがundefined(フィールドなし)の場合は Notification → 202 を返す - バッチリクエスト(配列)にも対応する
- Lambda + CloudFront では GET/SSE を「空ストリームを即閉じ」で対応する
バイブコーディングで実装する
Next.js 15 App Router の API Route として MCP Streamable HTTP エンドポイントを実装してください。
ファイル: src/app/api/mcp/route.ts
【POST ハンドラー】
- Authorization ヘッダーの Bearer トークンを検証(別途 authenticateMcpRequest 関数を使用)
- リクエストボディが配列ならバッチ処理(各要素を handleMcpRequest で処理)
- 単一リクエストなら handleMcpRequest で処理
- handleMcpRequest が null を返した場合(Notification)は 202 No Content
- 全バッチ要素が null だった場合も 202 No Content
【GET ハンドラー】
- 同様に認証チェック
- Content-Type: text/event-stream で空のSSEストリームを返す
- ': connected\n\n' を送信してすぐ閉じる(Lambda がステートレスなため長時間接続不可)
【handleMcpRequest の仕様】
- req.id === undefined(フィールドが存在しない)の場合は null を返す
- req.id === null は有効なID(Notificationではない)
- switch(method) で initialize / ping / tools/list / tools/call を処理
- initialize は { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: {...} } を返す
型に any/unknown は使わないこと。AIに指示するときのポイント
- Notification の判定を明示する:
id === undefinedのみが Notification。nullは有効なID。「idフィールドが存在しない場合」と書くと正確 - バッチの全Notification ケースを忘れやすい: バッチで全リクエストが Notification だった場合に
[]を返すのか202を返すのかを明示する(MCP仕様は後者) - ツール実行エラーは JSON-RPC エラーではない: ツール内で例外が起きても
errorフィールドではなくisError: trueの result として返す
次回: 第3回: Bearer Token認証とDynamoDBトークン管理 では、安全なトークン管理の実装方法を解説します。