本文为一个笔记服务构建小型远程 MCP 服务器。示例使用实现 MCP 2026-07-28 修订版的稳定 v2 TypeScript SDK。服务器接收现代 Streamable HTTP 请求,把自己作为资源服务器验证 OAuth access token,提供读取和删除工具,用 Zod 验证参数,并在受保护的操作旁边执行权限检查。代码是可执行的示例,但本文不声称这些代码已经在你的环境中运行。
协议版本不能被忽略。v2 将包拆分为 @modelcontextprotocol/server、@modelcontextprotocol/client、@modelcontextprotocol/core 和运行时适配器。新服务不要照搬导入 @modelcontextprotocol/sdk/server/mcp.js 的旧示例。MCP 2026-07-28 迁移指南详细介绍了线协议和包的变化。
先理解现代传输模型
Streamable HTTP 只有一个 MCP 端点,通常是 /mcp,客户端的每一个 JSON-RPC 请求都使用独立的 HTTP POST。服务器响应可以是一个 JSON 对象,也可以是只属于本次请求的 SSE 流。通知被接受时返回 202 且没有响应体。客户端必须在 Accept 中声明 application/json 和 text/event-stream,并以 Content-Type: application/json 发送 JSON。
2026-07-28 修订版删除了旧的 GET 流、协议级会话标识符和通过 Last-Event-ID 恢复流的机制,也删除了服务器向客户端发送独立 JSON-RPC 请求的通道。如果工具需要确认或额外输入,就返回 input_required 结果。客户端完成内嵌请求后重试原始调用。长期的列表变更通知通过 subscriptions/listen 响应流传递,而不是通过通用 GET 连接。
每个现代 POST 都带有 MCP-Protocol-Version,其值必须等于 JSON body 中 _meta.io.modelcontextprotocol/protocolVersion 的值。Mcp-Method 对应每个请求的 JSON-RPC 方法。对于 tools/call、resources/read 和 prompts/get,Mcp-Name 对应 params.name 或 params.uri。网关可以根据这些请求头路由,但应用仍然必须拒绝请求头与 body 不一致的请求。body 才是事实来源。
一个最小的现代调用如下:
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
安装 v2 SDK
创建使用 ESM 的 Node.js 20 或更高版本项目,安装 TypeScript 6 和 SDK 包,并在应用的 lockfile 中锁定版本。本文检查时,稳定的 server 包以及 Node 和 Express 适配器都是 2.0.0。使用 Zod schema 时,server 包需要 Zod 4。
npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx
TypeScript 6 不再自动包含 Node.js 类型。如果配置还没有包含它们,请显式添加:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"]
}
}
构建服务器和资源服务器网关
createMcpHandler 接收 factory,并为每个 HTTP 请求创建新的 McpServer。toNodeHandler 把它的 Web 标准 fetch 接口适配到 Node 的请求和响应对象。Express 适配器提供 JSON 解析、针对已配置主机的 host 和 origin 保护,以及 OAuth middleware。
MCP 服务器是 OAuth resource server。它验证 authorization server 签发的令牌,但不负责签发令牌。下面的 verifier 使用 RFC 7662 introspection,明确检查 active、sub 和 exp,解析以空格分隔的 scope 字段,并返回 SDK 的 AuthInfo。只有在实现并测试 issuer、audience、签名、密钥、时钟策略和密钥轮换后,才应改为本地 JWT 验证。
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth,
type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
createMcpHandler,
McpServer,
OAuthError,
OAuthErrorCode,
type AuthInfo,
type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
issuer: required("OAUTH_ISSUER"),
authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
response_types_supported: ["code"],
scopes_supported: scopes,
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true
};
const notes = [
{ id: "1", text: "Rotate the signing key after the release." },
{ id: "2", text: "Review the MCP gateway rate limit." }
];
async function verifyAccessToken(token: string): Promise<AuthInfo> {
const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
method: "POST",
headers: {
authorization: `Basic ${credentials}`,
"content-type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({ token }).toString()
});
if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
const payload = await response.json() as {
active?: unknown;
sub?: unknown;
client_id?: unknown;
scope?: unknown;
exp?: unknown;
};
if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
}
const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
return {
token,
clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
scopes: tokenScopes,
expiresAt: payload.exp
};
}
const verifier: OAuthTokenVerifier = { verifyAccessToken };
function buildServer(): McpServer {
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"notes.search",
{
title: "Search notes",
description: "Find notes containing a phrase.",
inputSchema: z.object({
query: z.string().trim().min(1).max(200),
limit: z.number().int().min(1).max(20).default(10)
})
},
async ({ query, limit }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
}
const needle = query.toLocaleLowerCase();
const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
return { content: [{ type: "text", text: JSON.stringify(matches) }] };
}
);
server.registerTool(
"notes.delete",
{
title: "Delete a note",
description: "Delete one note by id.",
inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
},
async ({ id }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
}
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
const [removed] = notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
}
);
return server;
}
const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);
mcp scope 是端点级别的门槛。两个工具内部的检查提供了更细的分离。只有 mcp 的客户端可以发现端点,但调用受保护操作时会得到 isError: true 的普通工具结果,模型可以看到拒绝。如果整个端点都需要某个 scope,把它加入 requiredScopes。middleware 会返回带 insufficient_scope 的 403。
Metadata router 发布 RFC 9728 Protected Resource Metadata,并转发 RFC 8414 authorization-server metadata。客户端可以沿着 WWW-Authenticate challenge 中的 resource_metadata URL 发现 issuer 和 endpoints,取得令牌,再重试请求。TypeScript SDK 不会替你实现 identity provider。新服务应使用受支持的 identity provider 或 OAuth server。v1 的 authorization-server helpers 位于 @modelcontextprotocol/server-legacy/auth,它们是冻结的迁移接口,不是新代码的首选。
2026-07-28 的授权规则也强化了客户端。authorization server 应在授权响应中返回 iss。客户端在把 code 发送到 token endpoint 之前,将其与 discovery 时记录的 issuer 比较。Client credentials 和 token 必须按 issuer 分区保存。Client ID Metadata Documents 优先于 Dynamic Client Registration,后者只为兼容性保留。这样可以防止 authorization-server mix-up 把一个服务器的 code 或 token 当成另一个服务器的 credential。
明确实现无状态
factory 为每个请求创建一个服务器实例,因此不要把已认证的 caller、workflow 阶段或权限决定放入 instance,再期待下一个 POST 能取回。现代请求可以通过普通 round-robin load balancer 到达任意 replica。把持久业务状态放在数据库或队列中,把经过校验的短期 handle 作为普通工具参数传递。
确认流程应返回 input_required,并保护其中的状态。SDK 导出 inputRequired、acceptedContent 和 createRequestStateCodec 来实现这种模式:
import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
function buildServerWithConfirmation(): McpServer {
const key = process.env.REQUEST_STATE_KEY;
if (!key) throw new Error("Missing REQUEST_STATE_KEY");
type DeleteState = { noteId: string; step: "confirm" };
const stateCodec = createRequestStateCodec<DeleteState>({
key,
ttlSeconds: 300,
bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
});
const server = new McpServer(
{ name: "notes-confirmation", version: "1.0.0" },
{ requestState: { verify: stateCodec.verify } }
);
const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
const confirmationSchema = z.object({ confirm: z.boolean() });
server.registerTool(
"notes.delete",
{ inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
async ({ id }, ctx) => {
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
if (!confirmed) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: "Delete this note?",
requestedSchema: confirmationSchema
})
},
requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
});
}
if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${id}` }] };
}
);
return server;
}
把 stateCodec.verify 放到传给 McpServer 的 requestState.verify 选项中。这个状态只有签名没有加密,SDK 在 hook 验证前会把客户端回传的状态视为不可信。将状态绑定到 principal 和操作,设置过期时间,不要把秘密放进 payload。所有可能接收 retry 的 replica 必须共享 HMAC 密钥。
校验、限流和部署
Zod 校验是应用的第一道边界,而不是完整的安全模型。限制字符串、数组、数字、ID、URL、路径和响应大小。在下游 API 边界再次验证,因为 schema 只能证明形状,不能证明权限或副作用安全。不要允许模型任意选择 HTTP method、host、filesystem path、SQL 片段或 shell 参数。使用 allowlist 和参数化 API。
Rate limiting 应以已认证的 clientId 或 tenant 为主要键,并分别限制未认证失败、discovery、昂贵工具和并发操作数。在 edge 或 gateway 返回带 Retry-After 的 429。进程内计数器适合单个开发进程,但不是集群级限制,无界 map 还可能造成内存压力。多副本部署应使用 Redis 等共享 limiter 或网关策略。使用经过验证的 headers 把 method 和 tool name 写入指标,但绝不要记录 bearer token。
在公共端点终止 TLS,或使用端到端 TLS,保持 SSE 响应流,并在代理支持时用 X-Accel-Buffering: no 关闭缓冲。明确配置 body、header、idle 和 upstream timeout。本地服务器应绑定 127.0.0.1,而不是 0.0.0.0,除非已有 allowlist 和 authentication 保护。Express factory 只对配置的 localhost 类地址启用 host 和 origin 保护。公共 bind 时要明确设置 allowedHosts 和 allowedOrigins。
现代协议不需要 sticky sessions。subscriptions/listen 流有所不同,如果变更通知需要跨副本传递,就在 pub/sub 系统之上提供共享 ServerEventBus。把数据库连接池和缓存放在模块级,但把 caller 专属的决定保留在 request context 中。优雅关闭时调用 handler.close(),让正在进行的现代交换在进程退出前停止。
测试线协议而不只是函数
v2 SDK 提供 in-process 测试方式,通过自定义 fetch 函数让 StreamableHTTPClientTransport 调用 createMcpHandler。使用真实的 request/response 路径,覆盖 version negotiation、必需 headers、JSON 与 SSE 响应、authentication 传递、schema 错误、取消以及 input_required retry。为无 token 的 401、inactive token 的 401、缺少 endpoint scope 的 403,以及工具缺少 scope 时的 in-band isError 分别写测试。
现代线协议测试使用 mode: { pin: "2026-07-28" }。兼容性测试使用 mode: "auto",并对一个确实使用旧协议的 fixture 断言 legacy fallback。使用不同 caller 发送两次请求,确认工具可见性和授权不会在请求之间泄露。多节点测试应把 retry 发给另一个进程,确认共享 state codec 和持久存储能够保留 workflow。
从最先失败的层开始排查
带 HeaderMismatch 的 HTTP 400 表示 protocol header 与 _meta claim 不一致,或者缺少必需的 routing header。修复 client 或 gateway。body 已经是可识别的现代错误时,不要静默退回旧协议。HTTP 415 表示 POST 的 media type 不是 application/json。text/plain; a=application/json 这样的字符串也无效。现代端点对 GET 返回 404 是预期行为。POST 的 404 可能表示未知 method,或请求根本没有到达 MCP handler。
401 应带有 WWW-Authenticate challenge。检查 metadata URL、token issuer、audience 或 resource、签名或 introspection,以及数字形式的 expiration claim。SDK 的 bearer gate 会拒绝缺少 expiresAt 的 AuthInfo。403 insufficient_scope 表示 endpoint 级 scope challenge,客户端可能因此开始 step-up flow。工具返回 isError: true 是应用结果,不会自动授予更宽的 token。
如果客户端卡住,检查 URL、reverse proxy 是否缓冲 SSE、idle timeout,以及是否返回了相同 JSON-RPC ID 的响应。如果 modern client 把服务器判断为 legacy,检查 versionNegotiation 是否为 auto 或 pin,SDK 版本是否一致,以及 gateway 是否删除了 _meta 或必需 headers。如果确认流程无限重复,检查 embedded response key,验证 inputResponses,限制 rounds,并检查签名 state 的阶段。迁移背景和兼容性矩阵见 MCP 2026-07-28 迁移指南。关于 MCP 周边的 injection、tool poisoning 和 authorization 威胁,见提示注入与 MCP 安全。关于服务边界、队列和故障隔离,见生产环境 AI agent 架构。