丹尼拉(Dayfing)
返回文章列表
3,052 字15 分钟

使用 TypeScript 构建 MCP 服务器:Streamable HTTP、OAuth 与最小权限

本文为一个笔记服务构建小型远程 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/jsontext/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/callresources/readprompts/getMcp-Name 对应 params.nameparams.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 请求创建新的 McpServertoNodeHandler 把它的 Web 标准 fetch 接口适配到 Node 的请求和响应对象。Express 适配器提供 JSON 解析、针对已配置主机的 host 和 origin 保护,以及 OAuth middleware。

MCP 服务器是 OAuth resource server。它验证 authorization server 签发的令牌,但不负责签发令牌。下面的 verifier 使用 RFC 7662 introspection,明确检查 activesubexp,解析以空格分隔的 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_scope403

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 导出 inputRequiredacceptedContentcreateRequestStateCodec 来实现这种模式:

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 放到传给 McpServerrequestState.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-After429。进程内计数器适合单个开发进程,但不是集群级限制,无界 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 时要明确设置 allowedHostsallowedOrigins

现代协议不需要 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/jsontext/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 会拒绝缺少 expiresAtAuthInfo403 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 架构

来源

更多文章