メインコンテンツへスキップ
三田工場 技術サイト
WebアプリをAIから操作できるようにした話(第2回)— Next.jsでMCP Streamable HTTPサーバーを実装する

WebアプリをAIから操作できるようにした話(第2回)— Next.jsでMCP Streamable HTTPサーバーを実装する

HowTo12分で読めます

このシリーズ: 全4回

  1. 第1回: MCPサーバー化の動機とアーキテクチャ
  2. 第2回: MCP Streamable HTTPの実装 ← 今ここ
  3. 第3回: Bearer Token認証とDynamoDBトークン管理
  4. 第4回: Claude Codeから繋げるときの落とし穴

概要

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 に型を定義します。

typescript
// 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;

ポイント: idundefined(フィールドなし)が Notification を意味します。null は有効なIDです。ここを id === undefined || id === null と書くと、null IDのリクエストをNotificationと誤判定してしまいます。

JSON-RPC ハンドラー

src/lib/mcp/handler.ts に JSON-RPC メソッドのディスパッチロジックを実装します。

typescript
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 が本体です。

typescript
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 で各ツールファイルをまとめます。

typescript
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 を例に見てみましょう:

typescript
// 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 のみで通信します。

typescript
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue(new TextEncoder().encode(': connected\n\n'));
    controller.close(); // すぐ閉じる
  },
});

重要: GET を実装しないと(または 405 を返すと)、Claude Code はセッション開始時の接続確立に失敗し、ツールが一切ロードされません。詳細は第4回で解説します。

curlで動作確認する

デプロイ後の動作確認は curl で行えます:

bash
# 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 の idundefined(フィールドなし)の場合は Notification → 202 を返す
  • バッチリクエスト(配列)にも対応する
  • Lambda + CloudFront では GET/SSE を「空ストリームを即閉じ」で対応する

バイブコーディングで実装する

text
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トークン管理 では、安全なトークン管理の実装方法を解説します。

関連記事