1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as McpServer from "effect/ai/McpServer"
5import * as Context from "effect/Context"
6import * as Deferred from "effect/Deferred"
7import * as Effect from "effect/Effect"
8import * as Fiber from "effect/Fiber"
9import * as Layer from "effect/Layer"
10import * as Schema from "effect/Schema"
11import { initializeHttpSession, makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
12import { makeMcpSseReader, readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
13import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
14import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts"
15import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
16
17const levels = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] as const
18const effectLevels = {
19 debug: "Debug",
20 info: "Info",
21 notice: "Info",
22 warning: "Warn",
23 error: "Error",
24 critical: "Fatal",
25 alert: "Fatal",
26 emergency: "Fatal"
27} as const
28
29const setLevel = (level: string) =>
30 Effect.gen(function*() {
31 const test = yield* McpConformance
32 const initialized = yield* test.initialize({ server: "features" })
33 yield* test.notifyInitialized(initialized)
34 const response = yield* test.send(initialized, {
35 jsonrpc: "2.0",
36 id: 2,
37 method: "logging/setLevel",
38 params: { level }
39 })
40 return { initialized, response, test }
41 })
42
43export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
44 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
45 describe("Logging", () => {
46 // Logging has the same protocol surface in the legacy specifications.
47 describe("Capabilities", () => {
48 it.effect("MUST advertise logging when log notifications are supported", () =>
49 Effect.gen(function*() {
50 const test = yield* McpConformance
51 const initialized = yield* test.initialize({ server: "features" })
52 assert.property(initialized.message.result.capabilities, "logging")
53 }))
54 })
55
56 describe("Setting Log Level", () => {
57 // https://modelcontextprotocol.io/specification/2024-11-05/server/utilities/logging#setting-log-level
58 // https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging#setting-log-level
59 // https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#setting-log-level
60 // https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#setting-log-level
61 // These HTTP cases exercise the shared logging threshold contract. The 2024 adapter run checks
62 // Effect compatibility through this HTTP harness, not the original 2024 HTTP+SSE transport.
63 for (const level of ["debug", "emergency"] as const) {
64 it.effect(`should filter background HTTP logs when the destination session selects ${level}`, () =>
65 Effect.gen(function*() {
66 const release = yield* Deferred.make<void>()
67 const serverReady = yield* Deferred.make<McpServer.McpServer["Service"]>()
68 const harness = yield* makeHttpHarness(
69 Layer.effectDiscard(Effect.gen(function*() {
70 const server = yield* McpServer.McpServer
71 yield* server.addTool({
72 tool: new McpSchema.Tool({ name: "Wait", inputSchema: { type: "object" } }),
73 annotations: Context.empty(),
74 handle: () =>
75 Effect.gen(function*() {
76 yield* server.notifications["notifications/message"]({ level: "emergency", data: "ready" })
77 yield* Deferred.await(release)
78 return new McpSchema.CallToolResult({ content: [] })
79 })
80 })
81 yield* Deferred.succeed(serverReady, server)
82 })).pipe(Layer.provideMerge(makeServerLayer({ name: "BackgroundLogging", protocols: [protocol] })))
83 )
84 const headers = yield* initializeHttpSession(harness, protocol)
85 const configured = yield* harness.post({
86 jsonrpc: "2.0",
87 id: "set-level",
88 method: "logging/setLevel",
89 params: { level }
90 }, headers).pipe(Effect.flatMap(readMcpHttpResponse))
91 assert.deepInclude(configured, { id: "set-level", result: {} })
92 const response = makeMcpSseReader(
93 yield* harness.post({
94 jsonrpc: "2.0",
95 id: "wait",
96 method: "tools/call",
97 params: { name: "Wait", arguments: {} }
98 }, headers)
99 )
100 yield* Effect.addFinalizer(() => response.cancel)
101 assert.deepInclude(yield* response.take(), {
102 method: "notifications/message",
103 params: { level: "emergency", data: "ready" }
104 })
105 const server = yield* Deferred.await(serverReady)
106 yield* server.notifications["notifications/message"]({ level: "debug", data: "debug-log" })
107 yield* server.notifications["notifications/message"]({ level: "error", data: "error-log" })
108 yield* server.notifications["notifications/message"]({ level: "emergency", data: "sentinel" })
109 const messages: Array<unknown> = []
110 while (true) {
111 const message = yield* response.take()
112 assert.strictEqual(message.method, "notifications/message")
113 messages.push(message.params)
114 if ((message.params as { data: string }).data === "sentinel") break
115 }
116 assert.deepStrictEqual(
117 messages,
118 level === "debug" ?
119 [
120 { level: "debug", data: "debug-log" },
121 { level: "error", data: "error-log" },
122 { level: "emergency", data: "sentinel" }
123 ] :
124 [{ level: "emergency", data: "sentinel" }]
125 )
126 yield* Deferred.succeed(release, undefined)
127 assert.deepInclude(yield* response.take(), { id: "wait", result: { content: [] } })
128 assert.deepStrictEqual(yield* response.drain(), [])
129 }))
130 }
131
132 it.effect("should isolate background HTTP log levels when concurrent sessions select different thresholds", () =>
133 Effect.gen(function*() {
134 const release = yield* Deferred.make<void>()
135 const serverReady = yield* Deferred.make<McpServer.McpServer["Service"]>()
136 const harness = yield* makeHttpHarness(
137 Layer.effectDiscard(Effect.gen(function*() {
138 const server = yield* McpServer.McpServer
139 yield* server.addTool({
140 tool: new McpSchema.Tool({ name: "Wait", inputSchema: { type: "object" } }),
141 annotations: Context.empty(),
142 handle: () =>
143 Effect.gen(function*() {
144 yield* server.notifications["notifications/message"]({ level: "emergency", data: "ready" })
145 yield* Deferred.await(release)
146 return new McpSchema.CallToolResult({ content: [] })
147 })
148 })
149 yield* Deferred.succeed(serverReady, server)
150 })).pipe(
151 Layer.provideMerge(makeServerLayer({ name: "IsolatedBackgroundLogging", protocols: [protocol] }))
152 )
153 )
154 const responses = yield* Effect.forEach(["debug", "emergency"] as const, (level) =>
155 Effect.gen(function*() {
156 const headers = yield* initializeHttpSession(harness, protocol)
157 const configured = yield* harness.post({
158 jsonrpc: "2.0",
159 id: "set-level",
160 method: "logging/setLevel",
161 params: { level }
162 }, headers).pipe(Effect.flatMap(readMcpHttpResponse))
163 assert.deepInclude(configured, { id: "set-level", result: {} })
164 const response = makeMcpSseReader(
165 yield* harness.post({
166 jsonrpc: "2.0",
167 id: "wait",
168 method: "tools/call",
169 params: { name: "Wait", arguments: {} }
170 }, headers)
171 )
172 yield* Effect.addFinalizer(() => response.cancel)
173 assert.deepInclude(yield* response.take(), {
174 method: "notifications/message",
175 params: { level: "emergency", data: "ready" }
176 })
177 return { level, response }
178 }))
179 const server = yield* Deferred.await(serverReady)
180 yield* server.notifications["notifications/message"]({ level: "debug", data: "debug-log" })
181 yield* server.notifications["notifications/message"]({ level: "error", data: "error-log" })
182 yield* server.notifications["notifications/message"]({ level: "emergency", data: "sentinel" })
183 for (const { level, response } of responses) {
184 const messages: Array<unknown> = []
185 while (true) {
186 const message = yield* response.take()
187 assert.strictEqual(message.method, "notifications/message")
188 messages.push(message.params)
189 if ((message.params as { data: string }).data === "sentinel") break
190 }
191 assert.deepStrictEqual(
192 messages,
193 level === "debug" ?
194 [
195 { level: "debug", data: "debug-log" },
196 { level: "error", data: "error-log" },
197 { level: "emergency", data: "sentinel" }
198 ] :
199 [{ level: "emergency", data: "sentinel" }]
200 )
201 }
202 yield* Deferred.succeed(release, undefined)
203 for (const { response } of responses) {
204 assert.deepInclude(yield* response.take(), { id: "wait", result: { content: [] } })
205 assert.deepStrictEqual(yield* response.drain(), [])
206 }
207 }))
208
209 it.effect("MUST accept every specified log level", () =>
210 Effect.forEach(levels, (level) =>
211 Effect.gen(function*() {
212 const { response, test } = yield* setLevel(level)
213 const result = yield* test.decodeResult(response)
214 assert.deepStrictEqual(result.result, {})
215 }), { concurrency: 1 }))
216 it.effect("MUST reject an unknown log level", () =>
217 Effect.gen(function*() {
218 const { response, test } = yield* setLevel("verbose")
219 const error = yield* test.decodeError(response)
220 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
221 }))
222 it.effect("SHOULD update the minimum level for subsequent operations", () =>
223 Effect.gen(function*() {
224 const { initialized, test } = yield* setLevel("debug")
225 const response = yield* test.send(initialized, {
226 jsonrpc: "2.0",
227 id: 3,
228 method: "tools/call",
229 params: { name: "LogLevelTool", arguments: {} }
230 })
231 const result = yield* test.decodeResult(response).pipe(
232 Effect.flatMap((message) => Schema.decodeUnknownEffect(McpSchema.CallToolResult)(message.result))
233 )
234 assert.deepStrictEqual(result.content, [{ type: "text", text: "Debug" }])
235 }))
236 it.effect("SHOULD send notifications at the selected level and higher", () =>
237 Effect.gen(function*() {
238 const fixture = yield* makeMcpStdioHarness(protocol)
239 yield* fixture.initialize()
240 const response = yield* fixture.sendRequest("logging/setLevel", { level: "warning" }, 2)
241 assert.deepStrictEqual(response.result, {})
242
243 yield* fixture.server.notifications["notifications/message"]({
244 level: "warning",
245 logger: "conformance",
246 data: "at-threshold"
247 })
248 const notification = yield* fixture.awaitOutboundMethod("notifications/message")
249 const payload = yield* Schema.decodeUnknownEffect(
250 McpSchema.LoggingMessageNotification.payloadSchema
251 )(notification.params)
252 assert.deepStrictEqual(payload, {
253 level: "warning",
254 logger: "conformance",
255 data: "at-threshold"
256 })
257 }))
258 it.effect("MUST not send notifications below the selected level", () =>
259 Effect.gen(function*() {
260 const fixture = yield* makeMcpStdioHarness(protocol)
261 yield* fixture.initialize()
262 const response = yield* fixture.sendRequest("logging/setLevel", { level: "warning" }, 2)
263 assert.deepStrictEqual(response.result, {})
264
265 yield* fixture.server.notifications["notifications/message"]({
266 level: "debug",
267 logger: "conformance",
268 data: "below-threshold"
269 })
270 yield* fixture.server.notifications["notifications/message"]({
271 level: "warning",
272 logger: "conformance",
273 data: "allowed-sentinel"
274 })
275 const notification = yield* fixture.awaitOutboundMethod("notifications/message")
276 assert.deepNestedInclude(notification, {
277 params: {
278 level: "warning",
279 logger: "conformance",
280 data: "allowed-sentinel"
281 }
282 })
283 }))
284 })
285
286 describe("Log Message Notifications", () => {
287 it.effect("SCHEMA preserves the log level, logger name, and data", () =>
288 Effect.gen(function*() {
289 const payload = yield* Schema.decodeUnknownEffect(
290 McpSchema.LoggingMessageNotification.payloadSchema
291 )({
292 level: "warning",
293 logger: "database",
294 data: {
295 message: "slow query",
296 durationMs: 120
297 }
298 })
299
300 assert.deepStrictEqual(payload, {
301 level: "warning",
302 logger: "database",
303 data: {
304 message: "slow query",
305 durationMs: 120
306 }
307 })
308 }))
309 it.effect("MUST allow arbitrary JSON-compatible log data", () =>
310 Effect.forEach([
311 "message",
312 42,
313 true,
314 null,
315 ["one", { nested: "two" }],
316 { nested: { value: 1 } }
317 ], (data) =>
318 Schema.decodeUnknownEffect(
319 McpSchema.LoggingMessageNotification.payloadSchema
320 )({
321 level: "info",
322 data
323 }).pipe(
324 Effect.map((payload) => assert.deepStrictEqual(payload.data, data))
325 )))
326 it.effect("MUST emit log messages as notifications without an identifier", () =>
327 Effect.gen(function*() {
328 const fixture = yield* makeMcpStdioHarness(protocol)
329 yield* fixture.initialize()
330 yield* fixture.server.notifications["notifications/message"]({
331 level: "warning",
332 logger: "database",
333 data: { message: "slow query" }
334 })
335
336 const notification = yield* fixture.awaitOutboundMethod("notifications/message")
337 assert.strictEqual(notification.jsonrpc, "2.0")
338 assert.strictEqual(notification.method, "notifications/message")
339 yield* Schema.decodeUnknownEffect(
340 McpSchema.LoggingMessageNotification.payloadSchema
341 )(notification.params)
342 assert.notProperty(notification, "id")
343 assert.notProperty(notification, "result")
344 }))
345 it.effect("SCENARIO does not corrupt the stdio protocol stream with log output", () =>
346 Effect.gen(function*() {
347 const fixture = yield* makeMcpStdioHarness(protocol)
348 yield* fixture.initialize()
349 yield* fixture.takeFrame
350 yield* fixture.server.notifications["notifications/message"]({
351 level: "info",
352 logger: "conformance",
353 data: "stdio-integrity-diagnostic"
354 })
355 const ping = yield* fixture.sendRequest("ping", {}, 2).pipe(Effect.forkChild)
356
357 const notificationFrame = yield* fixture.takeFrame
358 const responseFrame = yield* fixture.takeFrame
359 assert.isObject(notificationFrame)
360 assert.isObject(responseFrame)
361 assert.deepInclude(notificationFrame, {
362 method: "notifications/message"
363 })
364 assert.deepNestedInclude(notificationFrame, {
365 "params.data": "stdio-integrity-diagnostic"
366 })
367 assert.deepInclude(responseFrame, { jsonrpc: "2.0", id: 2, result: {} })
368 yield* Fiber.join(ping)
369 }))
370 })
371 })
372 })
373
374export const statelessModernSuite = (
375 protocol: McpProtocol.ProtocolAdapter,
376 layer: McpConformanceLayer
377) =>
378 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
379 const callLogLevelTool = Effect.fnUntraced(function*(level?: string) {
380 const test = yield* McpConformance
381 const discovered = yield* test.initialize({ server: "features" })
382 const response = yield* test.send(discovered, {
383 jsonrpc: "2.0",
384 id: 2,
385 method: "tools/call",
386 params: {
387 name: "LogLevelTool",
388 arguments: {},
389 ...(level === undefined
390 ? {}
391 : { _meta: { "io.modelcontextprotocol/logLevel": level } })
392 }
393 })
394 return { response, test }
395 })
396
397 describe("Logging > Stateless modern", () => {
398 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level
399 for (const source of ["background", "another request"] as const) {
400 it.effect(`MUST not deliver logs from ${source} on an HTTP subscription stream`, () =>
401 Effect.gen(function*() {
402 const serverReady = yield* Deferred.make<McpServer.McpServer["Service"]>()
403 const harness = yield* makeHttpHarness(
404 Layer.effectDiscard(
405 Effect.gen(function*() {
406 const server = yield* McpServer.McpServer
407 yield* server.addTool({
408 tool: new McpSchema.Tool({ name: "EmitLogs", inputSchema: { type: "object" } }),
409 annotations: Context.empty(),
410 handle: () =>
411 server.notifications["notifications/message"]({ level: "error", data: "private-log" }).pipe(
412 Effect.as(new McpSchema.CallToolResult({ content: [] }))
413 )
414 })
415 yield* Deferred.succeed(serverReady, server)
416 })
417 ).pipe(Layer.provideMerge(makeServerLayer({ name: "LoggingConformance", protocols: [protocol] })))
418 )
419 const response = yield* harness.post({
420 jsonrpc: "2.0",
421 id: "logs-forbidden",
422 method: "subscriptions/listen",
423 params: {
424 notifications: { toolsListChanged: true },
425 _meta: {
426 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
427 "io.modelcontextprotocol/clientCapabilities": {}
428 }
429 }
430 }, {
431 "MCP-Protocol-Version": protocol.protocolVersion,
432 "Mcp-Method": "subscriptions/listen"
433 })
434 const stream = makeMcpSseReader(response)
435 yield* Effect.addFinalizer(() => stream.cancel)
436 assert.strictEqual((yield* stream.take()).method, "notifications/subscriptions/acknowledged")
437 const server = yield* Deferred.await(serverReady)
438 if (source === "background") {
439 yield* server.notifications["notifications/message"]({ level: "error", data: "private-log" })
440 } else {
441 const result = yield* harness.post({
442 jsonrpc: "2.0",
443 id: "originating-request",
444 method: "tools/call",
445 params: {
446 name: "EmitLogs",
447 arguments: {},
448 _meta: {
449 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
450 "io.modelcontextprotocol/clientCapabilities": {},
451 "io.modelcontextprotocol/logLevel": "warning"
452 }
453 }
454 }, {
455 "MCP-Protocol-Version": protocol.protocolVersion,
456 "Mcp-Method": "tools/call",
457 "Mcp-Name": "EmitLogs"
458 })
459 const originatingStream = makeMcpSseReader(result)
460 yield* Effect.addFinalizer(() => originatingStream.cancel)
461 assert.deepInclude(yield* originatingStream.take(), {
462 method: "notifications/message",
463 params: { level: "error", data: "private-log" }
464 })
465 const completed = yield* originatingStream.take()
466 assert.deepInclude(completed, { id: "originating-request" })
467 assert.property(completed, "result")
468 assert.deepStrictEqual(yield* originatingStream.drain(), [])
469 }
470 // Delivery is acknowledged before publishing the sentinel, so no timing window is needed.
471 yield* server.notifications["notifications/tools/list_changed"]({})
472 assert.strictEqual((yield* stream.take()).method, "notifications/tools/list_changed")
473 }))
474 }
475
476 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#capabilities
477 it.effect("should advertise logging when request-scoped log filtering is supported", () =>
478 Effect.gen(function*() {
479 const test = yield* McpConformance
480 const discovered = yield* test.initialize({ server: "features" })
481
482 assert.property(discovered.message.result.capabilities, "logging")
483 }))
484
485 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-levels
486 it.effect("should apply every specified log level to the request that declares it", () =>
487 Effect.forEach(levels, (level) =>
488 Effect.gen(function*() {
489 const { response, test } = yield* callLogLevelTool(level)
490 const result = yield* test.decodeResult(response).pipe(
491 Effect.flatMap((message) => Schema.decodeUnknownEffect(McpSchema.CallToolResult)(message.result))
492 )
493 assert.deepStrictEqual(result.content, [{ type: "text", text: JSON.stringify(effectLevels[level]) }])
494 }), { concurrency: 1 }))
495
496 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-levels
497 it.effect("should reject a request when its request-scoped log level is unknown", () =>
498 Effect.gen(function*() {
499 const { response, test } = yield* callLogLevelTool("verbose")
500 const error = yield* test.decodeError(response)
501
502 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
503 }))
504
505 // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-message-notifications
506 it.effect("should preserve the level, logger, and JSON data when decoding a log notification", () =>
507 Effect.gen(function*() {
508 const payload = yield* Schema.decodeUnknownEffect(
509 McpSchema.LoggingMessageNotification.payloadSchema
510 )({
511 level: "warning",
512 logger: "database",
513 data: { message: "slow query", durationMs: 120 }
514 })
515
516 assert.deepStrictEqual(payload, {
517 level: "warning",
518 logger: "database",
519 data: { message: "slow query", durationMs: 120 }
520 })
521 }))
522 })
523 })