注記: 本記事は、Zennに掲載した記事(めいしばこMCPサーバー、Durable Objectは要らなかった)を、設計判断の手順とハマりどころ中心に再構成したものです。経緯や背景の読み物としては元記事のほうが詳しいです。
この記事で分かること
Cloudflare Workers上にリモートMCPサーバーを建てるとき、最初に突き当たる分岐が「McpAgent(Durable Object)で組むか、ステートレスで組むか」です。Cloudflareのテンプレートに乗るとMcpAgent=DOが“正解”に見えますが、要求応答で閉じるサーバーならDOは不要で、部品も壊れ方も減らせます。
本記事では、実際にMcpAgent(DO)で組んだリモートMCPサーバーをステートレスに移行した経験から、
- DOが要るかどうかの判断基準(チェックリスト)
- Workersでのステートレス Streamable HTTP の実装(トランスポート選定が非自明)
- 移行時のハマりどころ(DO migrationの累積式、旧
/sseの扱い)
をまとめます。対象は名刺管理サービスのMCPサーバー(search / get / create / update / delete のCRUD 5ツール、認証は @cloudflare/workers-oauth-provider、データはD1)です。
前提: トランスポートの現在地
MCPのリモートサーバーには通信方式の変遷があります。
- 旧: HTTP + SSE … Server-Sent Eventsで応答やサーバー発通知を流す。接続を張りっぱなしにする前提
- 現行: Streamable HTTP(2025年に標準化)… 普通のHTTPリクエストで完結でき、必要なときだけストリーミングする。接続を保持しなくてよい
Workersでこれを「セッションを持つ形」で実装する定番が agents ライブラリの McpAgent で、セッションごとにDurable Objectを1つ立ててMCPの接続状態を載せます。SSEの長時間接続やサーバー発通知が要るなら理にかなった構成です。
問題は、自分のサーバーがその状態を本当に使っているかです。
設計判断1: DO(McpAgent)は要るか
判断基準はシンプルで、次の3つのどれかを使うかどうかです。
- サーバー発の通知(リクエスト外・サーバー起点のイベント)を送るか
- 再開可能な長時間ストリームが要るか
- per-sessionのサーバー側メモリ(会話をまたいで保持する接続状態)が要るか
3つとも不要なら、DOは要りません。 ステートレスな Streamable HTTP で1リクエスト1完結にするほうが、課金対象(DOの呼び出し・実行時間・ストレージ・長時間接続のwall-clock)も障害点も少なくなります。
実例: DOが「空」だったケース
移行前のコードはこうでした。
// 旧: workers/mcp/src/index.ts(抜粋)
import { McpAgent } from 'agents/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
export class MeishibakoMcp extends McpAgent<Env, unknown, McpProps> {
server = new McpServer({ name: 'meishibako', version: '1.0.0' })
async init(): Promise<void> {
const props = this.props // ← トークンから復号された本人情報
if (!props) throw new Error('missing auth props')
registerTools(this.server, { DB: this.env.DB }, props)
}
}
init() がやっているのは「トークン由来の props(userId/orgId/lineUserId)を読んでツールを登録する」だけ。ツール本体も全部この形です。
// tools.ts(抜粋): 全ツールがこの形
async ({ id }) => {
const active = await requireActive(DB, props) // 毎回D1で実在&orgId確認
if (!active) return denied()
const row = await getCardById(DB, active.org.id, id) // 境界はprops.orgId
return ok(toPublic(row))
}
チェックリストに当てると、
- テナント境界 → アクセストークン由来の
props.orgIdが持っている(DOではない) - データ → 全部D1
- 操作 → 全ツールが要求→応答。途中経過もサーバー発通知も無い
つまりDOに載っていた“セッション状態”は実質ゼロ。状態を持たない部品を、状態を持てる高い部品の上に置いていただけでした。
判断のもう1つの軸: 移行タイミング
技術判断と同じくらい効いたのが時期です。旧トランスポート(/sse)は、依存するクライアントが増えてから廃止すると互換期間や告知が要る破壊的変更になります。依存が広がりきる前の早い段階なら、無停止で組み替えられます。ステートレス化のような破壊的になりうる変更は、早いほど安い——コスト削減というより「将来の強制的な移行コストを、先に小さくしておく」という発想です。
逆に、すでにクライアントが付いている本番サーバーなら、/sse の互換維持を含めて計画する必要があります。
設計判断2: トランスポートに何を使うか(ここが非自明)
DOを外すと決めたあと、唯一ハマりやすいのがここです。
MCP SDK標準の StreamableHTTPServerTransport は Node http(IncomingMessage/ServerResponse)前提で、Workersの fetch(Request) → Response モデルとは噛み合いません。McpAgentはこの橋渡しを(DOごと)引き受けてくれていたので、DOを外す以上、Web標準/fetchネイティブなトランスポートを別に用意する必要があります。
選択肢と判断:
| 選択肢 | 判断 |
|---|---|
SDK標準 StreamableHTTPServerTransport
|
Node http前提でWorkersに載らない(不採用) |
@hono/mcp の StreamableHTTPTransport
|
fetchネイティブ・ステートレス動作対応。Honoを既に使っていれば追加コスト最小(採用) |
| 自前の最小JSON-RPCハンドラ | 書けるが攻撃面が増えるだけで割に合わない(不採用) |
今回は同意画面や /callback を既にHonoで書いていたため、@hono/mcp の追加コストがほぼゼロでした。
移行手順
置き換えは実質1か所、index.ts の /mcp ハンドラだけです。
// 新: workers/mcp/src/index.ts(抜粋)
const mcpApp = new Hono<{ Bindings: Env }>()
mcpApp.all('/mcp', async (c) => {
// OAuthProviderがトークンから復号したpropsを実行コンテキストに注入する
const props = c.executionCtx.props as McpProps | undefined
if (!props) return c.text('unauthorized', 401)
// リクエストごとにMcpServerを生成。tools.ts(registerTools)は無改修
const server = new McpServer({ name: 'meishibako', version: '1.0.0' })
registerTools(server, { DB: c.env.DB }, props) // ← 境界は今までと同じ props.orgId
const transport = new StreamableHTTPTransport() // stateless(sessionIdGenerator無し)
await server.connect(transport)
const res = await transport.handleRequest(c) // 1リクエストで完結
return res ?? c.body(null, 204)
})
export default new OAuthProvider({
apiHandlers: { '/mcp': { fetch: (req, env, ctx) => mcpApp.fetch(req, env, ctx) } },
// '/sse' は廃止(旧SSE経路はMcpAgent=DOが担っていた)
/* authorize/token/register/CIMD は従来どおり */
})
変更の要点:
-
registerToolsは無改修。ツール実装は1行も変えない。props.orgIdで閉じるテナント境界もrequireActiveの退会後ガードも同一 -
認証層も無改修。
@cloudflare/workers-oauth-providerが/authorize・/token・トークン検証を担い、復号したpropsをハンドラのctxに注入する仕組みはMcpAgent時代と同じ -
クライアントの接続先URL(
/mcp)は不変。契約が変わらないのでMCPクライアントには透過。消えるのは/sseだけ -
削除するもの: DOクラス、
MCP_OBJECTバインディング、wrangler.tomlのdurable_objects、依存のagents
ハマりどころ
1. DO migrationは「消す」のではなく「積む」(累積式)
DOクラスを削除するとき、wrangler.toml から適用済みのmigrationタグ(v1の new_sqlite_classes)を消してはいけません。DO migrationは累積式で、適用済みタグを設定から消すとデプロイ時の検証に失敗します。
正しくは、v1を履歴としてそのまま残し、その上に v2(deleted_classes)を積んで旧DOクラスを削除します。
2. props の受け取り位置が変わる
McpAgentでは this.props でしたが、ステートレス構成では c.executionCtx.props から受け取ります(OAuthProviderの注入先は実行コンテキスト)。props 不在時は401でfail-closedにしておきます。
3. 旧 /sse を「残さない」判断
旧クライアント互換の保険として /sse を残す選択もありますが、依存クライアントがいないなら消すのが正解です(残すとMcpAgent=DOも残ってしまい、移行の意味が半減します)。
動作確認
デプロイ後、本番で次の3点を確認して移行完了としました。
-
/mcp… 未認証リクエストに 401(fail-closed) -
/sse… 404(廃止確認) - OAuth discovery … 200
ステートレス化で捨てるもの・残るもの
捨てるもの(原理的に不可能になる):
- サーバー発の通知
- 再開可能な長時間ストリーム
- per-sessionのサーバー側メモリ
残るもの: 認証・マルチテナント境界・ログ・可観測性・データ。これらはDOではなくトークン(props)とD1とOAuth層が持っていたので、トランスポートを差し替えても素通しでした。
将来サーバー発通知が欲しくなったら、そのときDO版へ戻せばよい——判断を今の要件に合わせるだけです。
まとめ
- リモートMCPサーバーでDO(McpAgent)が要るのは、サーバー発通知・長時間ストリーム・per-sessionメモリのどれかを使うときだけ。要求応答で閉じるCRUDならステートレスでよい
- Workersでステートレスにやるなら、トランスポートはfetchネイティブなもの(例:
@hono/mcpのStreamableHTTPTransport)を選ぶ。SDK標準はNode http前提で載らない - DO削除のmigrationは累積式。適用済みタグは消さず
deleted_classesを積む - 破壊的になりうる移行は、依存が広がりきる前の早い段階が一番安い
- 境界(テナント分離・認証)をDOでなくトークンとDBに持たせておくと、トランスポートの組み替えが素通しになる
この記事は、LINEで名刺を送ると自動整理され、Claude・ChatGPTなどMCP対応のAIから呼び出せる名刺管理サービス「めいしばこ」の開発記録です。MCPエンドポイントは https://mcp.meishibako.jp/mcp、詳細は meishibako.jp へ。