🗓

REST APIと同じCloudflare WorkersでリモートMCPサーバーを公開する(Hono + Zodでスキーマを共通化)

に公開

Japanese Calendar API(日本のカレンダー API) では、REST API とリモート MCP サーバーを 1 つの Cloudflare Workers 内で運用しています。

https://api.jp-calendar.com

GET  /v1/holidays/2026-05-03    → REST(祝日判定)
POST /mcp                       → MCP(同じロジックを LLM 向けに公開)

MCP を追加したかった理由は、AI エージェントが扱う日付計算を「モデルの推測」ではなく「公開データにもとづく確定的な計算」に寄せるためです。

そのうえで、MCP を別サブドメインや別 Worker に分けず同じ Worker に載せたのは、REST API と同じレスポンス定義・計算ロジック・データ取得経路を使い、REST と MCP の結果がズレる余地を小さくしたかったからです。

同居させた結果、

  • インフラもデプロイも 1 本(wrangler deploy ひとつ)
  • REST API 用の Zod スキーマを MCP の outputSchema にも流用できる
  • 動的エンドポイントの計算ロジック(祝日判定・営業日計算など)を MCP ツール側でもそのまま再利用できる

という運用の楽さが得られました。この記事では、この同居構成で意識した実装上のポイントを 3 つに絞ってまとめます。

この構成は、読み取り専用でステートレスな MCP tool を、既存の REST API と同じデータ・同じロジックで提供したい場合に向いています。一方で、ユーザーごとの長時間セッションや状態管理、書き込みを伴う操作が中心になる場合は、MCP 側を別 Worker や別サービスとして分ける選択肢も検討したほうがよいと思います。

サービス全体の紹介記事はこちら。
https://zenn.dev/kazuki_tam/articles/a9a037210b1d60

全体像

この API は Cloudflare Workers 上で動く Hono アプリとして実装しています。

祝日データの JSON や CSV などの静的ファイルは Static Assets から配信し、祝日判定や営業日計算のような動的な処理は同じ Worker 内の Hono アプリで処理しています。

MCP も別サービスにはせず、同じ Hono アプリに /mcp として載せています。MCP tool は REST API と同じデータ・同じ計算ロジックを使い、レスポンスの検証にも同じ Zod スキーマをできるだけ使います。

構成

REST と MCP の両方を、同じ Cloudflare Worker 内で同じモジュール群を共有させながら配信しています。リクエストの流れは次の通りです。

ファイルレイアウトは次のようになっています。

src/
├── worker.ts   # Hono 本体(REST API のルーティング)
├── mcp.ts      # MCP サーバー(POST /mcp)
├── env.ts      # 共通の Bindings & リポジトリキャッシュ
├── schema.ts   # Zod スキーマ(REST と MCP で共有)
└── lib/        # 祝日・営業日などの純粋ロジック

worker.tsapp.route("/mcp", mcpApp) するだけで、REST API と同じ Hono アプリツリーの中に MCP が収まります。

src/worker.ts
import { mcpApp } from "./mcp.ts";

export const app = new Hono<{ Bindings: Bindings }>();

app.use(
  "*",
  cors({
    origin: "*",
    allowMethods: ["GET", "POST", "OPTIONS"],
    maxAge: 86_400,
    credentials: false,
  }),
);

app.route("/mcp", mcpApp);

app.get("/v1/holidays/:date{\\d{4}-\\d{2}-\\d{2}}", async (c) => {
  // ...
});

ポイント 1: MCP をステートレスな読み取り専用ツールとして公開する

MCP の Streamable HTTP では、セッション ID や SSE レスポンスを扱う構成も取れます。一方、今回の用途は祝日判定や営業日計算のような読み取り専用 tool なので、長時間接続やサーバー側セッションに寄せず、request/response で完結させるほうが運用しやすいと判断しました。

ここでは MCP の HTTP 接続部分に @hono/mcp を使っています。@hono/mcp は Hono アプリ上で MCP Server を公開しやすくするためのパッケージで、Hono 向けの StreamableHTTPTransport を提供しています。

@hono/mcp のドキュメントでも、Hono アプリに /mcp エンドポイントを生やし、StreamableHTTPTransport を接続する基本形が紹介されています。
https://honohub.dev/docs/hono-mcp

MCP の実リクエストは POST /mcp に絞り、StreamableHTTPTransport を MCP セッションとしてはステートレス + JSON レスポンスで使います。MCP は読み取り専用の tools として公開し、書き込みや破壊的な操作は持たせません。

src/mcp.ts
import { StreamableHTTPTransport } from "@hono/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export const mcpApp = new Hono<{ Bindings: Bindings }>();

mcpApp.post("/", async (c) => {
  const parsedBody = await parseBoundedJsonBody(c);
  if (parsedBody instanceof Response) return parsedBody;

  const server = createCalendarMcpServer(c.env);
  const transport = new StreamableHTTPTransport({
    enableJsonResponse: true,
    sessionIdGenerator: undefined,
  });
  await server.connect(transport);
  const response = await transport.handleRequest(c, parsedBody);
  return response ?? c.body(null, 202);
});
  • enableJsonResponse: true — この用途ではレスポンスを JSON として扱えるようにする。通常の HTTP リクエスト/レスポンスとして運用しやすくなる。
  • sessionIdGenerator: undefined — セッションを発行しない。リクエストごとに McpServer を作り直す代わりに、祝日データを読み込んだオブジェクトは Worker のモジュールスコープにキャッシュし、同じ実行環境が再利用される間は計算コストを抑える。

これで「MCP の仕様に沿いつつ、Workers のリクエストモデルに乗せやすい」構成になります。

MCP リクエストの本文サイズにもアプリ側の上限を置き、StreamableHTTPTransport に渡す前に大きすぎるリクエストを拒否します。Content-Length がある場合は本文を読む前に判定し、ない場合も読み込んだ後の文字数で上限を確認します。

src/mcp.ts
const MAX_MCP_BODY_CHARS = 64 * 1024;

async function parseBoundedJsonBody(c: {
  req: {
    header: (name: string) => string | undefined;
    text: () => Promise<string>;
  };
}): Promise<unknown | Response> {
  const contentLength = c.req.header("Content-Length");
  if (contentLength && Number(contentLength) > MAX_MCP_BODY_CHARS) {
    return jsonRpcError(413, -32000, "MCP request body is too large");
  }
  const raw = await c.req.text();
  if (raw.length > MAX_MCP_BODY_CHARS) {
    return jsonRpcError(413, -32000, "MCP request body is too large");
  }
  try {
    return JSON.parse(raw) as unknown;
  } catch {
    return jsonRpcError(400, -32700, "Parse error");
  }
}

ポイント 2: REST API と MCP のスキーマを共通化する

ステートレスにできたら、次は「同じロジックを REST と MCP の両方から無理なく呼ぶ」やり方です。同居構成で一番恩恵があったのがここで、REST API が返す Zod スキーマを MCP の outputSchema にも使うことで、MCP の tool 定義として戻り値の構造を明示できます。

たとえば、指定日の祝日判定は REST API と MCP tool で同じレスポンス形状なので、同じ HolidayCheckResponse を使っています。

src/schema.ts
export const HolidayDate = z
  .string()
  .regex(/^\d{4}-\d{2}-\d{2}$/u, "date must be in YYYY-MM-DD format");

export const HolidayCheckResponse = z.object({
  date: HolidayDate,
  is_holiday: z.boolean(),
  name: z.string().nullable(),
});
src/mcp.ts
server.registerTool(
  "get_holiday",
  {
    title: "Get Holiday",
    description:
      "指定日が日本の祝日かどうかを判定し、祝日の場合は名称を返します。",
    inputSchema: z.object({ date: IsoDateSchema }),
    outputSchema: HolidayCheckResponse,
    annotations: { readOnlyHint: true },
  },
  async ({ date }) => {
    const repo = await getRepository(env);
    const name = repo.lookup(date);
    return result(
      HolidayCheckResponse.parse({ date, is_holiday: name !== null, name }),
    );
  },
);

REST API と MCP tool は入り口こそ別ですが、このように返すデータの形が同じものは同じ Zod スキーマで検証しています。

  • REST → 同じ Zod スキーマで検証して JSON 返却
  • MCP → 同じ Zod スキーマで検証して structuredContent 返却

これにより、REST だけ・MCP だけでレスポンス形式がズレることを防ぎやすくなります。仕様変更時も、どのスキーマを直せばよいかが分かりやすくなります。

ポイント 3: ミドルウェアで MCP だけ Cache-Control: no-store にする

REST API はキャッシュ可能なレスポンスとして扱いたい一方で、MCP は同じ POST /mcp でも JSON-RPC の methodparams によってレスポンスが変わります。そのため、MCP の応答はキャッシュ対象にしないようにしています。

ミドルウェアでパスを見て分岐させます。

src/worker.ts
app.use("*", async (c, next) => {
  await next();
  c.res.headers.set("X-Content-Type-Options", "nosniff");

  if (c.req.path === "/mcp" || c.req.path.startsWith("/mcp/")) {
    c.res.headers.set("Cache-Control", "no-store");
    return;
  }

  const status = c.res.status;
  if (status >= 200 && status < 300) {
    if (!c.res.headers.has("Cache-Control")) {
      c.res.headers.set(
        "Cache-Control",
        "public, max-age=3600, s-maxage=86400",
      );
    }
  } else {
    c.res.headers.set("Cache-Control", "no-store");
  }
});

まとめ

REST API と同じ Cloudflare Worker にリモート MCP を同居させるうえで、特に意識したのは次の 3 点でした。

  1. MCP をステートレスな読み取り専用 tool として公開する
  2. Zod スキーマを REST のレスポンス検証と MCP の outputSchema でできるだけ兼用して、レスポンス定義のズレを減らす
  3. ミドルウェアで /mcp 配下だけ Cache-Control: no-store にして、JSON-RPC 応答がキャッシュされる余地を減らす

公開 API に MCP を足すと、単に「LLM から呼べる」だけでなく、モデルが曖昧に推測しがちな領域を外部の確定的な計算に委ねられます。祝日・営業日・暦注のような公共性のあるデータほど、こうした参照先を用意しておく価値があると感じています。

REST API の仕様は https://api.jp-calendar.com/docs/ から確認できます。MCP については、公開サイトの MCP セクション にある curl 例で initialize / tools/list / tools/call を試せます。

Discussion