Effect 4.0 MCP 服务器日志能力:从 logging 能力广告到按客户端日志级别过滤的完整实现
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
导读:本篇文章围绕 Effect 仓库中 changeset
.changeset/pre/fair-logs-listen.md所记录的effect: patch——"MCP servers now advertise logging and honor each client's selected log level when sending log notifications"——深入剖析 Effect 4.0 中 MCP 服务器日志功能的完整实现链路。你将看到:MCP 协议中日志能力如何被声明、客户端如何通过logging/setLevel选择接收级别、服务器如何按客户端阈值过滤notifications/message通知,以及这一切如何与 Effect 自身的LogLevel体系衔接。读完本文,你将能在自己的 MCP 服务器中实现"按客户端定制日志输出"的生产级能力。
一次 patch 背后的能力闭环
changeset 是 Changesets 驱动的版本发布声明,fair-logs-listen.md描述的是effect包的一次patch级变更,其核心语义可以拆解为两个动作:
- advertise logging:MCP 服务器在
initialize握手阶段,向客户端声明自己支持日志发送能力(capabilities.logging)。 - honor each client's selected log level:服务器在发送日志通知时,会尊重每个客户端各自通过
logging/setLevel选择的日志级别阈值,只投递"达到或超过该阈值"的日志。
这两个动作虽然描述简短,但对应了仓库中一条完整的实现链路:协议 Schema(McpSchema.ts)→ 运行时生命周期(internal/mcpRuntime.ts)→ 会话级状态管理(internal/mcpStatefulRuntime.ts)→ 通知投影与投递(internal/mcpProtocol.ts与McpServer.ts),并有配套的 conformance 测试(packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts)逐条验证。下面沿这条链路展开。
MCP 日志的协议基础:三个 Schema 构件
MCP(Model Context Protocol)规范将日志定义为一组独立于具体传输层的能力原语,Effect 在 McpSchema.ts 中将其建模为三个部分。
LoggingLevel:映射 syslog 严重级别的 8 级字面量
export const LoggingLevel: Schema.Literals< "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" > = Schema.Literals([ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ])这 8 个级别直接对齐 RFC 5424 第 6.2.1 节定义的 syslog 严重级别(源码注释明确给出该规范出处)。从类型系统看,LoggingLevel是Schema.Literals构造的联合字面量 Schema,因此未知级别会在解码阶段就被拒绝——这为后文"拒绝未知级别"的测试行为提供了 Schema 层面的保证。
SetLevel:客户端到服务器的日志开关
export class SetLevel extends Rpc.make("logging/setLevel", { payload: { ...RequestMeta.fields, level: LoggingLevel // 客户端希望接收的最低级别 }, success: Schema.Struct({}), error: McpError }) {}logging/setLevel是客户端 → 服务器方向的请求,携带level字段。源码 JSDoc 明确了语义:服务器应把"等于该级别及更高(更严重)"的日志作为notifications/message发送给客户端。也就是说,这是一个阈值开关,而不是"只发某个级别"的精确匹配。
LoggingMessageNotification:服务器到客户端的日志载体
export class LoggingMessageNotification extends Rpc.make("notifications/message", { payload: Schema.Struct({ ...NotificationMeta.fields, level: LoggingLevel, // 该日志消息的严重级别 logger: optional(Schema.String), // 发出日志的 logger 名称(可选) data: Schema.Any // 任意 JSON 可序列化数据 }) }) {}notifications/message是服务器 → 客户端方向的通知,其 payload 由三部分构成:
| 字段 | 类型 | 说明 |
|---|---|---|
level | LoggingLevel | 日志消息的严重级别 |
logger | string \| undefined | 可选,发出日志的 logger 名称 |
data | Schema.Any | 日志数据,任何 JSON 可序列化类型(字符串、数字、对象、数组等) |
data: Schema.Any意味着日志内容极为灵活——一个纯字符串、一个结构化错误对象、甚至嵌套数组都可以直接投递,测试用例也专门验证了这一点(见下文"测试验证")。
能力广告:initialize 阶段声明 logging 支持
MCP 客户端只有在明确知道服务器支持日志时,才会发起logging/setLevel。这一能力声明发生在initialize握手阶段,由服务器在其capabilities中携带logging字段。
Effect 的ServerCapabilitiesSchema 在 McpSchema.ts 中为logging预留了位置:
export class ServerCapabilities extends Schema.Opaque<ServerCapabilities>()(Schema.Struct({ ... /** * Present if the server supports sending log messages to the client. */ logging: optional(Schema.Struct({})), ... })) {}真正的"广告"行为发生在运行时层。在 internal/mcpRuntime.ts 的initialize生命周期回调中,服务器会构造自己的能力集:
const capabilities: McpCore.CanonicalServerCapabilities = { completions: true, logging: true, // ← 无论是否注册了工具/资源/提示词,logging 总是被声明 ...(presence.tools ? { tools: { listChanged: true } } : {}), ...(presence.resources ? { resources: { listChanged: true, subscribe: httpRequest === undefined } } : {}), ...(presence.prompts ? { prompts: { listChanged: true } } : {}), ...(options.serverInfo.extensions ? { extensions: options.serverInfo.extensions } : {}) }注意这里的细节:logging: true是无条件的,不像tools、resources、prompts那样依赖注册情况。从源码结构看,这体现了日志能力是 MCP 服务器的"基础设施级"能力——只要用 Effect 运行 MCP 服务器,客户端就能在 initialize 响应中看到capabilities.logging,并据此决定是否启用日志接收。conformance 测试也对此给出了硬性断言(MUST advertise logging when log notifications are supported,见下文)。
客户端选择日志级别:setLevel 的会话级存储
当客户端决定接收日志时,会发送logging/setLevel请求。Effect 的服务端运行时在 internal/mcpRuntime.ts 将这一请求委托给有状态运行时的setLogLevel:
setLogLevel: stateful.setLogLevel,其实现位于 internal/mcpStatefulRuntime.ts:
setLogLevel: (level, clientId, headers) => Effect.sync(() => { const session = resolveSession(clientId, headers) if (session !== undefined) { session.logLevel = { _tag: "Mcp", level } } }),关键点在于:日志级别是按会话(session)存储的。resolveSession会根据客户端 ID(stdio/自定义传输)或Mcp-Session-Id头(HTTP 传输)解析到当前客户端对应的会话对象,然后把用户选择的级别写入该会话的logLevel字段。这意味着:
- 每个客户端的选择是相互隔离的;
- 服务器可以同时服务多个客户端,各自拥有不同的日志阈值。
这一点在测试should isolate background HTTP log levels when concurrent sessions select different thresholds中得到了显式验证:两个并发 HTTP 会话分别选择debug和emergency,同一服务器发出的同一条 debug 日志,只有选择了debug的会话能收到,选择了emergency的会话只能收到 emergency 级别的日志。
阈值过滤:canDeliver 的投递判定
advertise logging与"honor each client's selected log level"之间的桥梁,是canDeliver——服务器在把每条日志通知投递给某个客户端之前,都会先经过这一判定。其实现同样在 internal/mcpStatefulRuntime.ts:
canDeliver: (clientId, headers, notification, fallbackLogLevel) => { const session = resolveSession(clientId, headers) if (notification._tag === "LoggingMessage") { const minimum = session?.logLevel return minimum?._tag === "Mcp" ? McpProtocol.mcpLogLevels[notification.level].order >= McpProtocol.mcpLogLevels[minimum.level].order : LogLevel.isGreaterThanOrEqualTo( McpProtocol.mcpLogLevels[notification.level].effect, minimum?.level ?? fallbackLogLevel ) } return notification._tag !== "ResourceUpdated" || session?.resourceSubscriptions?.has(notification.uri) === true }逻辑分为两条路径:
- 客户端显式设置过 MCP 级别(
session.logLevel._tag === "Mcp"):比较两个级别的order数值。当前日志的order大于等于客户端所选级别的order才投递; - 客户端没有显式设置:退化为用 Effect 的
LogLevel.isGreaterThanOrEqualTo与服务器默认日志级别(fallbackLogLevel)比较。
这里的order来自 internal/mcpProtocol.ts 中维护的 MCP 级别 → Effect 日志级别的完整映射表:
| MCP 级别 | Effect LogLevel | order |
|---|---|---|
debug | Debug | 0 |
info | Info | 1 |
notice | Info | 2 |
warning | Warn | 3 |
error | Error | 4 |
critical | Fatal | 5 |
alert | Fatal | 6 |
emergency | Fatal | 7 |
这张表同时服务于两个目的:判定阈值(order比较)和级别转换(effect字段),后者用于把 MCP 级别映射为 Effect 自身的LogLevel,使 MCP 日志能与 Effect 的日志系统无缝衔接。注意notice被映射为Info、alert/emergency被映射为Fatal——这是 MCP 8 级 syslog 语义到 Effect 5 级(Debug/Info/Warn/Error/Fatal)的归并。
投递判定发生在通知发送路径上。服务器发出的日志通知首先进入notifications/message的投影管线(makeNotificationProjector在 internal/mcpProtocol.ts 中把内部LoggingMessage通知投影为协议层的LoggingMessageNotification),随后按目标客户端逐一分发,canDeliver就是分发前的闸门。
与 Effect 日志体系的衔接:CurrentLogLevel
除了通知投递的阈值过滤,本次变更还涉及另一个方向:当服务器在处理某个客户端的请求时,Effect 代码中的日志应该以何种级别输出。
在 McpServer.ts 中,服务器启动时会读取当前的默认日志级别:
const defaultLogLevel = yield* CurrentLogLevel这个默认值被传入运行时(defaultLogLevel字段),在 initialize 时作为会话的初始logLevel(见mcpRuntime.ts第 498 行的logLevel: options.defaultLogLevel),也是canDeliver的兜底比较基准。
更重要的是,在McpServer.ts的中间件层(第 860-934 行),每个来自客户端的请求都会被注入与客户端会话匹配的CurrentLogLevel服务:
return Effect.provideService( ..., CurrentLogLevel, runtime.effectLogLevel(client.id, headers, defaultLogLevel) )而effectLogLevel的实现(internal/mcpStatefulRuntime.ts)会将会话中存储的 MCP 级别转换为 Effect 级别:
const effectLogLevel = (clientId, headers, fallback): LogLevel.LogLevel => { const session = resolveSession(clientId, headers) return session?.logLevel._tag === "Mcp" ? McpProtocol.mcpLogLevels[session.logLevel.level].effect : session?.logLevel.level ?? fallback }由此形成完整闭环:客户端设置 MCP 日志级别 → 会话存储 → 请求处理时转换为 Effect 的 CurrentLogLevel → Effect 日志 API 在该请求上下文中按此级别输出。conformance 测试SHOULD update the minimum level for subsequent operations验证的正是这一点:调用logging/setLevel设置为debug后,后续工具调用中通过 Effect 读取到的当前级别为Debug。
现代协议(2026-07-28)的每请求日志级别
上述会话级阈值主要服务于 2024-11-05 至 2025-11-25 的"有状态"协议规范。而在更新的2026-07-28协议适配器(stateless modern)中,日志过滤还支持**请求级(request-scoped)**方式:客户端可以在单个请求的_meta中携带io.modelcontextprotocol/logLevel字段。
McpServer.ts的provideInvocationContext(第 163-186 行)处理了这一情形:
const requestMetadata = invocation.requestContext.requestMetadata const logLevel = Predicate.hasProperty(requestMetadata, "io.modelcontextprotocol/logLevel") ? requestMetadata["io.modelcontextprotocol/logLevel"] : undefined if (isLoggingLevel(logLevel)) { provided = Effect.provideService( provided, CurrentLogLevel, McpProtocolInternal.mcpLogLevels[logLevel].effect ) }即:如果请求元数据中声明了合法的 MCP 日志级别,就用它覆盖当前请求上下文的CurrentLogLevel;如果声明了未知级别,isLoggingLevel(基于Schema.is(McpSchema.LoggingLevel))判定失败,最终由InvalidParams错误拒绝——测试should reject a request when its request-scoped log level is unknown验证了这一点。
需要注意,在 stateless 现代协议下,日志通知不再像有状态协议那样按会话广播给所有已初始化客户端,而是跟随产生它的请求/订阅流投递,测试MUST not deliver logs from background or another request on an HTTP subscription stream专门约束了这一行为边界。
测试验证:LoggingTest 的 conformance 覆盖
本次变更的协议行为并非只停留在实现层面,仓库在 packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts 中提供了覆盖四个协议版本(2024-11-05、2025-03-26、2025-06-18、2025-11-25)与 stateless 现代协议的 conformance 测试套件。值得关注的断言包括:
- 能力广告:
MUST advertise logging when log notifications are supported——initialize 响应的capabilities必须包含logging。 - 级别合法性:
MUST accept every specified log level(8 个级别逐一通过)与MUST reject an unknown log level(如verbose返回INVALID_PARAMS_ERROR_CODE)。 - 阈值语义:
SHOULD send notifications at the selected level and higher与MUST not send notifications below the selected level——在 stdio 传输上设置warning后,warning级别的日志被投递,debug级别的日志被过滤。 - 通知格式:
MUST emit log messages as notifications without an identifier——notifications/message是纯通知,不带id与result字段;MUST allow arbitrary JSON-compatible log data——data字段接受字符串、数字、布尔、null、数组、嵌套对象。 - 传输完整性:
SCENARIO does not corrupt the stdio protocol stream with log output——日志通知以结构化帧发送,不会污染 stdio 上的 JSON-RPC 请求/响应流。 - 并发隔离:
should isolate background HTTP log levels when concurrent sessions select different thresholds——不同 HTTP 会话的日志阈值互不干扰。
这些测试同时覆盖了 HTTP 与 stdio 两种传输形态,说明日志能力在两种传输上行为一致。
小结:从 changeset 到可运行的服务器能力
回看这次patch,其价值可以总结为三层:
- 协议完备:8 级 syslog 级别的 Schema、
logging/setLevel请求与notifications/message通知,构成了完整的 MCP 日志协议面(McpSchema.ts); - 实现严谨:initialize 阶段无条件声明
capabilities.logging,会话级存储每个客户端的级别选择,canDeliver按阈值过滤投递,并利用mcpLogLevels映射表与 Effect 的CurrentLogLevel/LogLevel体系无缝衔接(internal/mcpRuntime.ts、internal/mcpStatefulRuntime.ts); - 行为可验证:跨四个协议版本的 conformance 测试把"广告能力""接受/拒绝级别""阈值投递""会话隔离"等行为固化为可回归的断言(LoggingTest.ts)。
对于使用 Effect 构建 MCP 服务器的开发者,这意味着:服务器天然支持客户端按需调节日志输出,无需在业务代码中手工维护每个客户端的日志开关——只需像测试中那样调用server.notifications["notifications/message"]发送日志,剩下的能力声明、阈值过滤与级别映射都由运行时完成。
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考