简介
AI 客户端不应该为它使用的每个应用都编写一套自定义集成。Model Context Protocol(MCP) 为客户端提供了标准方式,用于发现工具、查看工具的输入契约并调用工具。在本实验中,工具的功能被刻意限制得很小:它只查询一个合成支持案例,不能修改任何内容。
你将使用 Cloudflare 当前的无状态 MCP 处理程序构建服务器:
- 专用的 Cloudflare KV 命名空间保存合成业务记录。KV 是明确的应用数据存储,不是隐藏的 MCP 会话内存。
- 严格的 Zod 模式只接受合成工单标识符,并拒绝额外字段。
McpServer.registerTool()发布一个带有只读、非破坏性注解的工具。createMcpHandler()为每个 Streamable HTTP 请求创建全新的服务器。- 官方 MCP TypeScript 客户端通过独立连接发现并调用该工具。
- 本地和已部署的探针会验证有效查询、安全的缺失记录处理、无效参数拒绝,以及不存在隐式共享会话状态。
此端点之所以刻意不进行身份验证,仅仅是因为它只公开一个可丢弃的合成只读记录。不要使用这种模式发布真实的客户私有数据。生产服务器在访问租户数据前应添加身份验证和授权;外部 OAuth 提供商不属于本初学者实验的范围。
MCP 生态系统过去使用 SSE 端点和有状态服务器样板代码。本实验不讲解这种旧设计,而是使用 Streamable HTTP 和按请求创建服务器的工厂。这是 Cloudflare 针对新建远程服务器的当前指导方式。
在直接进入本课程之前,请先完成 将 LabEx 连接到你的 Cloudflare 账户。 每个全新的 LabEx VM 都需要单独进行 Wrangler 授权。建议先完成本课程之前的实验,但本实验不会复用之前实验的 VM 和资源。
为 VM 授权并创建专用目录
在此步骤中,你将为这个全新的 VM 授权,选择学习账户,并创建一个可丢弃的 KV 命名空间。将目录分开可以明确资源归属,也便于清理。
进入准备好的项目并检查固定版本的工具:
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 和目标账户名称,即使输出中只列出了一个账户也要确认。复制该账户的实际 id。生成一个唯一前缀并保存初始 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 自动修改配置文件:
npx wrangler kv namespace create "$RUN-cases" --update-config=false
如果 Wrangler 询问是否自动添加绑定,请选择 No;下一步会手动明确配置连接。复制输出中的 32 字符命名空间 ID,并添加且仅添加一个绑定:
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 是代码中使用的标识符。命名空间 ID 指向已确认账户中的真实资源。此时还没有部署任何内容。
写入明确的合成业务数据
在此步骤中,你将把同一条提供的记录写入本地和远程 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
写入专用云端命名空间:
npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --remote
通过绑定读取这两个副本:
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 检查命名空间,要求恰好存在一个键,并将远程 JSON 与提供的合成测试数据进行比较。KV 在不同位置之间可能存在最终一致性,因此如果刚写入的值在第一次远程读取时暂时缺失,请等待几秒后重试,不要重复写入副本。
注册严格的只读 MCP 工具
在此步骤中,你将定义一个 MCP 服务器工厂和一个只读查询工具。
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
这里有三个重要边界:
.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
每次辅助函数调用都会创建并关闭自己的客户端传输。工具发现证明服务器公布了工具契约。两次有效调用必须读取同一条 KV 记录,但返回不同的请求实例标记。缺失案例属于正常的工具级错误,而无效标识符会在处理程序读取 KV 之前被输入模式拒绝。
在本地验证 MCP 契约
在此步骤中,你将让 Worker 使用本地 KV 启动,并在访问已部署端点之前运行完整的客户端探针。
启动开发服务器:
npx wrangler dev --ip 127.0.0.1 --port 8787
保持该终端运行。打开第二个终端,进入同一个项目,并检查这个简单的健康检查路由:
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
五行证明输出应显示工具发现、有效的合成结果、安全的缺失案例错误、无效输入拒绝和 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"
检查健康检查路由,然后让 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
独立验证器会根据所选账户推导端点,而不是盲目使用 shell 变量。它还会检查已部署的 SUPPORT_CASES 绑定、准确的远程记录,以及全部五项 MCP 行为。仅能访问健康检查路由还不够:工具发现和调用必须通过协议客户端完成。
打开 Workers & Pages,选择生成的 Worker。在概览页面中,workers.dev 域名应连接到该 Worker,并显示一个 SUPPORT_CASES KV 绑定。以下数值来自测试运行,仅供参考;你的唯一资源名称和计数会有所不同。

检查并删除所属资源
在此步骤中,你将检查云端的可观察状态,然后在 Wrangler 仍然处于授权状态时,仅删除本次运行创建的 Worker 和 KV 命名空间。
打开 Cloudflare Dashboard 并选择同一个学习账户。在 Workers & Pages 中,打开名称以 labex-c11-s07- 开头的 Worker。确认其最新部署运行正常、可观察性已启用,并确认 SUPPORT_CASES 绑定指向 wrangler.jsonc 中的命名空间 ID。
打开 Storage & databases > KV,选择匹配的 -cases 命名空间,并检查 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 名称和命名空间 ID,然后删除 Worker:
npx wrangler delete
如果出现提示,请核对显示的 Worker 名称并输入 y。只删除 SUPPORT_CASES 绑定所选中的命名空间:
npx wrangler kv namespace delete --binding SUPPORT_CASES
npx wrangler kv namespace list
python3 .labex/verify.py deleted
刷新 Dashboard 中的 Worker 和 KV 列表。两个 labex-c11-s07-... 资源都应不存在,而无关资源仍然保留。端点请求失败不能证明资源已删除;验证器会直接检查已授权账户中的资源清单。
搜索准确的生成 Worker 名称。搜索结果为空,表示 Dashboard 已不再列出该 Worker:

在 Workers KV 中搜索准确的 -cases 命名空间。空状态和 0 B 当前存储量表明,可丢弃的目录也已从这个干净的测试账户中删除:

撤销此 VM 的授权
在此步骤中,你将在确认资源已不存在后,撤销临时 VM 授权。
npx wrangler logout
npx wrangler whoami --json || true
结构化结果应报告 loggedIn: false,或者 Wrangler 可能返回未通过身份验证的非零结果。注销操作特意放在最后:删除验证需要读取所选账户的权限,而这个临时 VM 已不再需要这些权限。
总结
你已经在 Cloudflare 上发布并删除了一个范围受限的只读 MCP 服务。你完成了以下工作:
- 将合成业务数据保存在专用 KV 命名空间中,而不是依赖隐式的 MCP 会话状态;
- 注册了一个可发现的工具,并启用了严格输入验证和只读注解;
- 通过当前的无状态 Streamable HTTP 处理程序提供服务;
- 使用真实 MCP 客户端完成工具发现、有效查询、缺失记录和无效输入测试;
- 证明独立请求会读取相同的明确数据,同时获得全新的服务器实例;
- 检查了 Worker 和 KV 状态,删除了两个所属资源,并撤销了 VM 授权。
这里的关键设计经验是:无状态传输不等于应用没有数据。它表示协议请求不依赖隐藏的会话内存。持久化业务数据仍然应当明确、范围清晰,并由独立机制管理。



