はじめに
AI クライアントが使用するアプリケーションごとに、専用の統合を用意する必要はありません。Model Context Protocol(MCP) を使うと、クライアントは標準的な方法でツールを検出し、入力仕様を確認して呼び出せます。この実験では、ツールの機能を意図的に小さくします。合成サポートケースを 1 件検索するだけで、データは一切変更できません。
Cloudflare の現在のステートレス MCP ハンドラーを使って、次のサーバーを構築します。
- 専用の Cloudflare KV namespace に、合成された業務レコードを保存します。KV は明示的なアプリケーションデータストアであり、MCP セッションメモリを隠れて保持するものではありません。
- 厳格な Zod スキーマで合成チケット ID だけを受け付け、余分なフィールドを拒否します。
McpServer.registerTool()で、読み取り専用かつ非破壊的なアノテーションを持つツールを 1 つ公開します。createMcpHandler()で、Streamable HTTP リクエストごとに新しいサーバーを作成します。- 公式 MCP TypeScript クライアントで、独立した接続からツールを検出して呼び出します。
- ローカル環境とデプロイ先で検証を行い、正しい検索、安全なレコード未検出時の動作、不正な引数の拒否、暗黙的な共有セッション状態が存在しないことを確認します。
このエンドポイントが認証なしで動作するのは、破棄可能な合成レコードを 1 件だけ読み取るためです。個人顧客データを公開するために、このパターンを使用しないでください。本番サーバーでテナントデータにアクセスする場合は、認証と認可を追加する必要があります。外部 OAuth プロバイダーについては、この初級者向け実験では扱いません。
MCP エコシステムでは、以前は SSE エンドポイントとステートフルなサーバーの定型コードが使われていました。この実験では、そのレガシー設計は扱いません。新しいリモートサーバー向けの現在の Cloudflare ガイダンスに従い、Streamable HTTP とリクエストごとのサーバーファクトリを使用します。
このコースを直接始める前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しく作成された LabEx VM では、それぞれに専用の Wrangler 認証が必要です。コース内の前の実験も推奨しますが、それらの VM やリソースがここで再利用されることはありません。
VM を認証し、専用カタログを作成する
このステップでは、新しい VM を認証し、学習用アカウントを選択して、破棄可能な KV namespace を 1 つ作成します。カタログを分離すると、所有者と削除対象が明確になります。
用意されたプロジェクトに移動し、固定されたツールのバージョンを確認します。
cd /home/labex/project/read-only-mcp-tool
node --version
npx wrangler --version
この VM を認証します。
npx wrangler login
ブラウザーで表示されたデバイスリンクを開き、要求された権限を確認して、専用の学習用アカウントを認証します。ターミナルに戻り、処理が完了するまで待ってから、構造化されたアカウント情報を確認します。

この権限一覧がこの実験だけに必要な範囲より広いのは、Wrangler が Cloudflare の汎用開発 CLI だからです。承認する前に、ページに Wrangler と表示されていること、意図した学習用アカウントを使用していること、ターミナルにパスワードやトークンが表示されていないことを確認してください。
npx wrangler whoami --json
loggedIn: true と意図したアカウント名を確認します。出力にアカウントが 1 つしか表示されない場合も同じです。そのアカウントの実際の id をコピーします。固有のプレフィックスを 1 つ生成し、最初の Worker 設定を保存します。先にプレースホルダーを置き換えてください。
ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s07-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/server.ts",
"compatibility_date": "2026-09-19",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true }
}
JSON
Wrangler にファイルを自動編集させずに namespace を作成します。
npx wrangler kv namespace create "$RUN-cases" --update-config=false
Wrangler がバインディングを自動追加するか尋ねたら、No を選択します。次の編集でこの接続を明示的に設定します。出力に表示された 32 文字の namespace ID をコピーし、バインディングを 1 つだけ追加します。
NAMESPACE_ID="paste-the-created-namespace-id"
python3 - "$NAMESPACE_ID" <<'PY'
import json, sys
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data['kv_namespaces'] = [{'binding': 'SUPPORT_CASES', 'id': sys.argv[1]}]
path.write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler kv namespace list
python3 .labex/verify.py authorization
SUPPORT_CASES というバインディング名を、コードから使用します。namespace ID は、確認済みアカウント内の実際のリソースを指します。この時点ではまだ何もデプロイされていません。
明示的な合成業務データを投入する
このステップでは、指定された同じレコードをローカル KV とリモート KV の両方に登録します。データストアは明示的に指定されています。MCP リクエストがステートレスでも、アプリケーションはキーを使って永続的な業務データを読み取れます。
アップロードする前に、フィクスチャを確認します。
cat fixtures/case.json
T-SYNTH-101 プレフィックスと synthetic: true マーカーによって、このデモが合成データを扱っていることが分かります。このレコードには、実在する顧客名、メールアドレス、メッセージ、認証情報は含まれていません。
wrangler dev が使用するローカルストアに登録します。
npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --local
専用のクラウド namespace に登録します。
npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --remote
バインディングを通して、2 つのコピーを読み取ります。
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --local --text
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --remote --text
python3 .labex/verify.py catalog
検証スクリプトは、アカウント ID によって namespace を確認し、キーが正確に 1 つだけ存在することを要求し、リモート JSON が指定された合成フィクスチャと一致するか比較します。KV は場所によって結果が反映されるまで時間がかかる場合があります。書き込んだ直後の最初のリモート読み取りで一時的に値が見つからない場合は、コピーを繰り返し書き込むのではなく、数秒待ってから再試行してください。
厳格な読み取り専用 MCP ツールを登録する
このステップでは、MCP サーバーファクトリを 1 つ定義し、読み取り専用の検索ツールを 1 つ定義します。
McpServer はプロトコルの公開面を定義します。ファクトリは HTTP リクエストごとに新しいインスタンスを作成します。一方、SUPPORT_CASES バインディングは明示的な業務データのソースとして残ります。src/server.ts を作成します。
cat > src/server.ts <<'TS'
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
interface Env {
SUPPORT_CASES: KVNamespace;
}
const lookupInput = z.object({
ticketId: z.string().regex(/^T-SYNTH-[0-9]{3}$/, "use a synthetic ticket ID")
}).strict();
const storedCase = z.object({
ticketId: z.string(),
subject: z.string(),
status: z.string(),
priority: z.string(),
product: z.string(),
synthetic: z.literal(true)
}).strict();
function buildServer(env: Env): McpServer {
const requestInstance = crypto.randomUUID();
const server = new McpServer({
name: "synthetic-support-catalog",
version: "1.0.0"
});
server.registerTool("lookup_support_case", {
title: "Look up a synthetic support case",
description: "Read one synthetic demonstration case by its T-SYNTH identifier.",
inputSchema: lookupInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}, async ({ ticketId }) => {
const raw = await env.SUPPORT_CASES.get(`case:${ticketId}`, "json");
if (raw === null) {
return {
isError: true,
content: [{ type: "text", text: `Synthetic case ${ticketId} was not found.` }]
};
}
const record = storedCase.parse(raw);
const result = { ...record, requestInstance };
return {
structuredContent: result,
content: [{ type: "text", text: JSON.stringify(result) }]
};
});
return server;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json({
service: "synthetic-support-mcp",
transport: "streamable-http",
state: "stateless"
});
}
if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 });
const handler = createMcpHandler(
() => buildServer(env),
{ route: "/mcp", corsOptions: false, legacy: "stateless" }
);
return handler(request, env, ctx);
}
};
TS
npm run check
python3 .labex/verify.py server
ここでは、次の 3 つの境界が重要です。
.strict()により、宣言されていないフィールドを黙って受け入れずに拒否します。- アノテーションは、このツールが閉じた合成カタログを読み取り、破壊的な作用を持たないことをクライアントに伝えます。アノテーションは有用なメタデータですが、
put()やdelete()が存在しないことを確認するコードレビューの代わりにはなりません。 requestInstanceは、ファクトリがサーバーを構築するときに生成されます。異なるプロトコルリクエストでは異なるマーカーが返るため、セッションデータを保存せずにステートレスなライフサイクルを確認できます。
legacy: "stateless" の互換性設定でも、使用するのは Streamable HTTP です。2025 プロトコルファミリーをネゴシエートする現在のクライアントを利用しながら、すべてのリクエストに新しいサーバーインスタンスを割り当てられます。SSE ルートや永続的な MCP セッションは作成されません。
独立した MCP クライアントプローブを作成する
このステップでは、JSON-RPC を手作業で記述する代わりに、公式クライアントライブラリを使用します。実際のクライアントは、StreamableHTTPClientTransport を通してプロトコルの初期化、ツールの検出、呼び出しを実行します。
scripts/test-client.mjs を作成します。
cat > scripts/test-client.mjs <<'JS'
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
const endpoint = process.argv[2];
if (!endpoint) throw new Error("usage: node scripts/test-client.mjs <mcp-url>");
async function withClient(label, action) {
const transport = new StreamableHTTPClientTransport(new URL(endpoint));
const client = new Client({ name: `labex-${label}`, version: "1.0.0" });
try {
await client.connect(transport);
return await action(client);
} finally {
await client.close();
}
}
const tools = await withClient("discovery", (client) => client.listTools());
const tool = tools.tools.find((item) => item.name === "lookup_support_case");
if (!tool || tool.annotations?.readOnlyHint !== true) {
throw new Error("the read-only lookup tool was not discoverable");
}
console.log("DISCOVERED lookup_support_case");
async function lookup(ticketId) {
return withClient(`lookup-${ticketId.toLowerCase()}`, (client) => client.callTool({
name: "lookup_support_case",
arguments: { ticketId }
}));
}
const first = await lookup("T-SYNTH-101");
const second = await lookup("T-SYNTH-101");
const a = first.structuredContent;
const b = second.structuredContent;
if (!a || !b || a.synthetic !== true || a.status !== "investigating") {
throw new Error("the valid synthetic record was not returned");
}
console.log(`VALID synthetic=${a.synthetic} status=${a.status}`);
const missing = await lookup("T-SYNTH-404");
console.log(`MISSING isError=${missing.isError === true}`);
let invalidRejected = false;
try {
const invalid = await withClient("invalid", (client) => client.callTool({
name: "lookup_support_case",
arguments: { ticketId: "REAL-101", unexpected: "must-not-pass" }
}));
invalidRejected = invalid.isError === true;
} catch {
invalidRejected = true;
}
console.log(`INVALID_REJECTED ${invalidRejected}`);
const stateless = typeof a.requestInstance === "string"
&& typeof b.requestInstance === "string"
&& a.requestInstance !== b.requestInstance;
console.log(`STATELESS ${stateless}`);
if (missing.isError !== true || !invalidRejected || !stateless) process.exitCode = 1;
JS
python3 .labex/verify.py client
各ヘルパー呼び出しは、独自のクライアントトランスポートを作成して閉じます。検出処理により、サーバーがツールの仕様を公開していることを確認できます。2 回の正しい呼び出しは同じ KV レコードを読み取りますが、異なるリクエストインスタンスマーカーを返す必要があります。存在しないケースは通常のツールレベルのエラーです。一方、無効な識別子は、ハンドラーが KV を読み取る前に入力スキーマによって拒否されます。
MCP コントラクトをローカルで確認する
このステップでは、ローカル KV を使って Worker を起動し、デプロイ先のエンドポイントに接続する前に、クライアントプローブ全体を実行します。
開発サーバーを起動します。
npx wrangler dev --ip 127.0.0.1 --port 8787
このターミナルは起動したままにします。2 つ目のターミナルを開き、同じプロジェクトに移動して、簡単な health ルートを確認します。
cd /home/labex/project/read-only-mcp-tool
curl --fail --silent http://127.0.0.1:8787/health | python3 -m json.tool
transport: "streamable-http" と state: "stateless" が表示されることを確認します。次に、プロトコルクライアントを実行します。
node scripts/test-client.mjs http://127.0.0.1:8787/mcp
5 つの確認行に、検出、正しい合成レコードの結果、安全なケース未検出エラー、不正な入力の拒否、STATELESS true が表示されます。プローブが完了したら、最初のターミナルに戻り、Ctrl+C を押します。
独立したチェックを実行します。このチェックはポート 8791 で別の制限付きローカル Worker を起動し、同じインポート済みコードを実行して、自動的に停止します。
python3 .labex/verify.py local
リモート MCP エンドポイントをデプロイしてテストする
このステップでは、明示的な KV バインディングを持つ Worker をデプロイし、実際の workers.dev エンドポイントに同じクライアントを接続します。
プロジェクト設定からデプロイします。
npx wrangler deploy
表示されたデプロイ URL をコピーし、末尾のスラッシュを付けずに保存します。
WORKER_URL="https://your-generated-worker.your-subdomain.workers.dev"
health ルートを確認してから、MCP クライアントを /mcp に接続します。
curl --fail --silent "$WORKER_URL/health" | python3 -m json.tool
node scripts/test-client.mjs "$WORKER_URL/mcp"
python3 .labex/verify.py deployed
独立した検証スクリプトは、シェル変数をそのまま信頼せず、選択されたアカウントからエンドポイントを導出します。また、デプロイされた SUPPORT_CASES バインディング、正確なリモートレコード、MCP の 5 つの動作すべてを確認します。health ルートに到達できるだけでは不十分です。検出と呼び出しがプロトコルクライアントを通過して成功する必要があります。
Workers & Pages を開き、生成された Worker を選択します。概要には workers.dev ドメインがその Worker に接続されていることと、SUPPORT_CASES KV バインディングが 1 つ表示されます。以下の値はテスト実行時の例です。実際には、固有のリソース名と件数が異なります。

所有するリソースを確認して削除する
このステップでは、クラウド上で確認できる状態を調べた後、Wrangler の認証が有効な間に、この実行で作成した Worker と KV namespace だけを削除します。
Cloudflare Dashboard を開き、同じ学習用アカウントを選択します。Workers & Pages で、名前が labex-c11-s07- から始まる Worker を開きます。最新のデプロイが正常であること、Observability が有効であること、SUPPORT_CASES バインディングが wrangler.jsonc 内の namespace ID を指していることを確認します。
Storage & databases > KV を開き、対応する -cases namespace を選択して、case:T-SYNTH-101 を確認します。値は合成フィクスチャです。個人情報を追加しないでください。これらの Dashboard 画面は状態を把握するために役立ちますが、機能の正式な証拠はクライアントと検証スクリプトです。
KV Pairs ビューには、最初に正確なキーと JSON 値のプレビューが表示されます。

行を展開して、そのキーと MCP ツールが返すフィールドを対応付けます。テスト対象のフィクスチャでは、status: investigating、priority: medium、synthetic: true が設定されています。

Worker に戻り、Observability を開きます。成功した POST /mcp とトランスポートの GET /mcp イベントから、実際のリモート MCP クライアントがデプロイ済み Worker に到達したことを確認できます。テスト実行では、記録された 42 件のイベントがすべて成功し、Worker エラーはありませんでした。実際のリクエスト数は異なる場合があります。

削除する前に、もう一度独立した状態確認を実行します。
python3 .labex/verify.py observed
cat wrangler.jsonc
固有の Worker 名と namespace ID が正しいことを確認してから、Worker を削除します。
npx wrangler delete
確認を求められたら、表示された Worker 名を確認して y と入力します。SUPPORT_CASES バインディングが選択している namespace だけを削除します。
npx wrangler kv namespace delete --binding SUPPORT_CASES
npx wrangler kv namespace list
python3 .labex/verify.py deleted
Dashboard の Worker 一覧と KV 一覧を更新します。labex-c11-s07-... の 2 つのリソースが表示されなくなり、無関係なリソースは残っているはずです。エンドポイントへのリクエストが失敗しただけでは、削除の証拠になりません。検証スクリプトは、認証済みアカウントのインベントリを直接確認します。
生成された正確な Worker 名を検索します。結果が空であれば、Dashboard に削除したラボ Worker が表示されていないことを確認できます。

Workers KV で、正確な -cases namespace を検索します。空の状態と現在のストレージ 0 B により、このクリーンなテストアカウントから破棄可能なカタログも削除されたことを確認できます。

この VM の認証を取り消す
このステップでは、リソースが存在しないことを確認した後、一時的な VM の認証を取り消します。
npx wrangler logout
npx wrangler whoami --json || true
構造化された結果に loggedIn: false と表示されるはずです。または、Wrangler が未認証を示すゼロ以外の終了コードを返す場合もあります。ログアウトを最後に行うのは意図的です。削除確認には選択したアカウントへの読み取りアクセスが必要ですが、破棄可能な VM にはそのアクセスが不要だからです。
まとめ
Cloudflare 上で、範囲を限定した読み取り専用 MCP サービスを公開し、削除しました。次のことを実施しました。
- 暗黙的な MCP セッション状態ではなく、専用 KV namespace に合成業務データを保存した。
- 厳格な入力検証と読み取り専用アノテーションを備えた、検出可能なツールを登録した。
- 現在のステートレスな Streamable HTTP ハンドラーでツールを提供した。
- 実際の MCP クライアントを使って、検出、正しい検索、レコード未検出、不正入力のテストを行った。
- 独立したリクエストが同じ明示的データを読み取りながら、新しいサーバーインスタンスを受け取ることを確認した。
- Worker と KV の状態を確認し、所有する 2 つのリソースを削除して、VM の認証を取り消した。
重要な設計上のポイントは、ステートレスなトランスポートが、データのないアプリケーションを意味するわけではないということです。ステートレスとは、プロトコルリクエストが隠れたセッションメモリに依存しないという意味です。永続的な業務データは、明示的に指定し、範囲を限定し、独立して管理します。



