effect-smol 中 McpServer.layerHttp 的 HTTP 语义完善:405 / 400 / 202 状态码处理详解
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
导读
本文聚焦 effect-smol 仓库(Effect 生态下的 MCP 服务器实现)中McpServer.layerHttp这一 HTTP 传输层的关键行为改进:对不支持的 HTTP 方法返回405、对不支持的MCP-Protocol-Version请求头返回400、对已接受的纯通知(notification)与响应(response)返回空202。通过本文,你将理解 Streamable HTTP 拓扑下 MCP 服务器应如何精确区分"方法不支持、版本协商失败、消息已受理"三类情况,并掌握layerHttp的完整请求校验链路与可配置参数,可直接用于在生产中构建行为规范、可被标准 MCP 客户端正确识别的 HTTP 版 MCP 服务器。
一、背景:layerHttp 在 MCP 服务器架构中的位置
effect-smol 的 MCP 服务器实现在 McpServer.ts 模块中。该模块既负责承载服务器状态(工具、资源、资源模板、提示词、完成器、已初始化的客户端与出站通知),也提供了三种形态的运行层(layer):
McpServer.layerStdio:基于标准输入输出的 MCP 服务器,适用于本地 CLI 类 MCP 客户端;McpServer.layerHttp:注册在既有HttpRouter上的 Streamable HTTP 端点,适用于远程/网络化 MCP 客户端;McpServer.layer:不含传输协议的纯服务器层,供上层自行接入传输。
项目文档 MCP.md 指出,正是由于采用了分层(Layer)架构,服务器实现可以轻松地在 stdio 与 HTTP 两种传输之间互换——只需将layerStdio替换为layerHttp,工具、资源、提示词的定义与实现层无需任何改动。本次变更所涉及的三个状态码行为,正是layerHttp对 MCP Streamable HTTP 传输规范的关键对齐。
二、变更总览:三个状态码,三类明确语义
原变更记录(calm-servers-share.md)对McpServer.layerHttp的语义做了如下收紧:
| 场景 | 返回状态码 | 语义 |
|---|---|---|
| 不支持的 HTTP 方法(如 GET、PUT、PATCH、DELETE、OPTIONS)访问 MCP 端点 | 405 Method Not Allowed | 端点存在,但该方法不被接受 |
携带不支持的MCP-Protocol-Version请求头 | 400 Bad Request | 版本协商失败,拒绝处理 |
| 已接受的纯通知(notification)与响应(response)消息 | 202 Accepted(空响应体) | 消息已受理,无需返回内容 |
这三条规则并非孤立补丁,而是与layerHttp的完整请求校验链路深度耦合。下面逐一从源码层面展开。
三、405:非 POST 方法统一拒绝,并声明 Allow 头
在 McpServer.ts 中,layerHttp首先预构造了一个 405 响应,并显式携带Allow: POST头,向客户端宣告该端点唯一合法的方法:
const methodNotAllowedResponse = HttpServerResponse.empty({ status: 405, headers: { allow: "POST" } }) const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => isAllowedMcpOrigin(request, options.allowedOrigins) ? Effect.succeed(methodNotAllowedResponse) : Effect.succeed(HttpServerResponse.empty({ status: 403 })) const routes = Layer.mergeAll( HttpRouter.add("GET", options.path, methodNotAllowed), HttpRouter.add("PUT", options.path, methodNotAllowed), HttpRouter.add("PATCH", options.path, methodNotAllowed), HttpRouter.add("DELETE", options.path, methodNotAllowed), HttpRouter.add("OPTIONS", options.path, methodNotAllowed) )要点分析:
- MCP Streamable HTTP 是单端点 POST 协议。所有 JSON-RPC 消息(请求、通知、响应)都通过 POST 发送到
options.path指定的单一端点,因此 GET/PUT/PATCH/DELETE/OPTIONS 都不构成合法调用。 - 405 与 404 的区别:这里选择 405 而非 404,意味着端点本身存在、只是方法不被允许,配合
Allow: POST头,符合 HTTP 语义,也便于客户端快速纠正请求方法。 - Origin 校验优先于方法校验:若请求携带的
Origin头不在allowedOrigins白名单内,即使方法非法也优先返回403(而非 405),避免向未知来源泄露端点能力信息。这一点在方法校验前统一应用。
从测试角度,ProtocolAdapters.test.ts 与 McpServer.test.ts 等测试套件会针对该 HTTP 层的行为做断言验证,配合 TestUtils/McpServerLayer.ts 提供的测试层构造,可对 405 场景进行端到端回归。
四、400:MCP-Protocol-Version 版本协商的严格化
MCP 客户端在请求头中通过MCP-Protocol-Version声明其支持的协议版本。layerHttp在 POST 处理函数(McpServer.ts)中对版本头做了严格校验:
const protocolVersion = request.headers[MCP_PROTOCOL_VERSION_HEADER] const sessionId = request.headers[MCP_SESSION_ID_HEADER] const session = sessionId === undefined ? undefined : state.sessions.bySessionId.get(sessionId) // ... const protocolVersionHeaderRejected = (protocolVersion !== undefined && !state.protocolRegistry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion)) || (session?.protocol.transport.requiresVersionHeader === true && protocolVersion !== session.protocol.protocolVersion)一旦判定protocolVersionHeaderRejected为真,对应请求即被400拒绝。这里有几个值得注意的细节:
- 版本必须落在已注册的协议适配器集合内。
protocols参数要求传入非空的协议适配器数组(Arr.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter>),服务器只接受这些适配器声明的protocolVersion。 - 会话固定的版本不可漂移。当客户端已通过
initialize建立会话,且该会话所选协议要求版本头(requiresVersionHeader === true)时,后续请求携带的版本必须与会话固定版本完全一致,否则同样 400。 - initialize 请求豁免版本头检查。源码注释明确说明:若对
initialize请求直接返回 400,客户端会把端点误判为旧版 HTTP+SSE 服务器并用 GET 重试初始化;因此版本拒绝逻辑对initialize消息做了放行(McpServer.ts),避免破坏协议协商流程。 - 批处理消息同样校验。对于 JSON-RPC 批量数组,若其中包含
initialize消息或当前无有效会话,同样返回 400;已建立会话时还要求所选协议支持 JSON-RPC 批处理(acceptsJsonRpcBatches),否则 400。
此外,POST 处理函数还包含完整的媒体类型与状态码矩阵:content-type非application/json时返回415;accept头未同时包含application/json与text/event-stream时返回406;携带未知sessionId时返回404(McpServer.ts)。这些行为共同构成了版本协商之外的协议合规防线。
五、202:已接受的纯通知与响应返回空 202
layerHttp的第三个关键语义是:对于"已受理、但无需回传内容"的消息返回202 Accepted。其实现并非在路由层直接书写,而是通过 Effect HTTP 的预处理钩子(appendPreResponseHandlerUnsafe)在响应出口统一改写(McpServer.ts):
appendPreResponseHandlerUnsafe(httpRequest, (_, response) => Effect.succeed( response.status === 200 && response.body._tag === "Uint8Array" && response.body.contentLength === 0 ? HttpServerResponse.empty({ headers: Headers.remove(response.headers, "content-type"), status: 202 }) : response ))改写条件为:内部生成了200状态、空字节体(Uint8Array且contentLength === 0)的响应。这类"空响应"对应的正是协议层面的两类消息:
- 纯通知(notification):如
notifications/initialized等服务器主动推送、客户端无需回复的通知。MCP 规范对这类消息不要求响应体,202 即表示"已收到并受理"。 - 响应(response):例如客户端发起的 ping 等无需结构化回包的消息。改写时还会剥离
content-type头,确保空响应体不被错误标注类型。
通过统一在响应出口改写,而非在每个消息分支各自处理,保证了 202 语义覆盖的一致性——只要最终产生空 200 响应,就自动收敛为 202。
六、配置项速查:如何启动一个 HTTP 版 MCP 服务器
layerHttp的完整签名(McpServer.ts)如下:
export const layerHttp = (options: { readonly name: string readonly version: string readonly description?: string | undefined readonly websiteUrl?: string | undefined readonly icons?: ReadonlyArray<McpSchema.Icon> | undefined readonly path: HttpRouter.PathInput readonly protocols: Arr.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter> readonly extensions?: ServerExtensions | undefined readonly allowedOrigins?: ReadonlyArray<string> | undefined }): Layer.Layer<McpServer | McpServerClient, Cause.IllegalArgumentError, HttpRouter.HttpRouter>| 参数 | 必填 | 说明 |
|---|---|---|
name | 是 | MCP 服务器名称,出现在 initialize 握手信息中 |
version | 是 | MCP 服务器版本号 |
description/websiteUrl/icons | 否 | 服务器描述、官网链接与图标集合,供客户端展示 |
path | 是 | 注册到HttpRouter的端点路径(如/mcp) |
protocols | 是 | 非空协议适配器数组,声明支持的 MCP 协议版本(顺序敏感) |
extensions | 否 | 服务器能力扩展声明 |
allowedOrigins | 否 | Origin 白名单;携带不在名单内的Origin头的请求一律 403,无 Origin 的非浏览器客户端不受影响 |
协议适配器目前支持McpProtocol.v2024_11_05、McpProtocol.v2025_03_26、McpProtocol.v2025_06_18三个版本(MCP.md)。需要注意:layerHttp始终实现单端点 Streamable HTTP 拓扑,即便选择v2024_11_05适配器,也只是复用该版本的 RPC schema 与单端点兼容传输,并不实现历史上双端点 HTTP+SSE 传输、GET SSE、事件续传、会话过期或客户端会话终止等能力(McpServer.ts)。
一个最小启动示例(参照 MCP.md 的结构,将 stdio 层替换为 HTTP 层):
import { Effect, Layer } from "effect" import { McpProtocol, McpServer, Tool, Toolkit } from "effect/unstable/ai" const DemoTool = Tool.make("DemoTool", { description: "A demo tool that echoes back the input", parameters: { message: Schema.String }, success: Schema.String }) const MyToolkit = Toolkit.make(DemoTool) const ServerLayer = Layer.mergeAll( McpServer.toolkit(MyToolkit).pipe( Layer.provideMerge( MyToolkit.toLayer({ DemoTool: ({ message }) => Effect.succeed(`Echo: ${message}`) }) ) ) ).pipe( Layer.provide( McpServer.layerHttp({ name: "Demo MCP Server", version: "1.0.0", path: "/mcp", protocols: [McpProtocol.v2025_06_18], allowedOrigins: ["https://client.example.com"] }) ) )该层仅依赖HttpRouter.HttpRouter作为输入,因此实际绑定端口、接口与鉴权由外层 HTTP 服务器负责——源码注释明确指出"surrounding HTTP server remains responsible for binding to an appropriate interface and installing authentication"(McpServer.ts),即layerHttp只负责端点语义,不越权处理网络层安全。
七、本次变更的意义与验证方式
综合来看,这三个状态码的收紧让layerHttp的 HTTP 行为完全可预期:
- 405 +
Allow: POST让任何误用 GET/OPTIONS 等方法的客户端立刻得到机器可读的纠正提示; - 400 版本拒绝在协议协商阶段就把不兼容客户端挡在门外,避免进入后续消息解析后才报错,同时通过 initialize 豁免避免与旧式 HTTP+SSE 客户端的误判纠缠;
- 202 空响应准确表达"已受理、无回包"的语义,与标准 200 携带 JSON-RPC 结果的情况严格区分,方便客户端/网关按状态码分流处理。
仓库内的测试基建可对这一行为做持续回归验证:McpServer.test.ts 覆盖服务器核心行为,ProtocolAdapters.test.ts 覆盖协议适配层,TestUtils/McpServerLayer.ts 提供可组合的测试层;此外McpConformance目录下的 McpConformance.ts 与 McpConformanceFixtures.ts 用于协议一致性测试。若要在自己的项目中验证这些行为,可按上述配置起一个 HTTP 层端点,然后用curl分别发送GET /mcp(期望 405)、携带非法MCP-Protocol-Version头的 POST(期望 400)以及一条纯通知 POST(期望 202 空响应体)。
八、小结
McpServer.layerHttp通过 405 / 400 / 202 三个状态码,为 Streamable HTTP 传输建立了清晰、符合 MCP 规范的错误与受理语义:方法层面用 405 + Allow 头纠正调用方式,协议层面用 400 严卡版本协商,消息层面用 202 表达"受理成功但无回包"。结合allowedOrigins的 403 防护、415/406/404 的媒体类型与会话校验,这一层构成了生产可用的远程 MCP 服务器入口。开发者只需对照上文参数表配置path、protocols与allowedOrigins,并将其挂载到自己负责端口与鉴权的外层 HTTP 服务器即可。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考