1import { assert, describe, it } from "@effect/vitest"
2import { assertTrue, strictEqual } from "@effect/vitest/utils"
3import * as AiError from "effect/ai/AiError"
4import * as McpCore from "effect/ai/internal/mcpCore"
5import type * as McpProtocolInternal from "effect/ai/internal/mcpProtocol"
6import * as McpProtocol2026 from "effect/ai/internal/mcpProtocol/v2026_07_28"
7import * as McpSchema2026 from "effect/ai/internal/mcpSchema/v2026_07_28"
8import * as McpProtocol from "effect/ai/McpProtocol"
9import * as McpSchema from "effect/ai/McpSchema"
10import * as McpServer from "effect/ai/McpServer"
11import * as Tool from "effect/ai/Tool"
12import * as Toolkit from "effect/ai/Toolkit"
13import * as Cause from "effect/Cause"
14import * as Context from "effect/Context"
15import * as Deferred from "effect/Deferred"
16import * as Effect from "effect/Effect"
17import * as ErrorReporter from "effect/ErrorReporter"
18import * as Fiber from "effect/Fiber"
19import * as FetchHttpClient from "effect/http/FetchHttpClient"
20import * as HttpClient from "effect/http/HttpClient"
21import * as HttpClientRequest from "effect/http/HttpClientRequest"
22import * as HttpRouter from "effect/http/HttpRouter"
23import * as Layer from "effect/Layer"
24import * as Logger from "effect/Logger"
25import * as Option from "effect/Option"
26import * as PubSub from "effect/PubSub"
27import * as Queue from "effect/Queue"
28import * as Ref from "effect/Ref"
29import { RpcSerialization } from "effect/rpc"
30import * as Rpc from "effect/rpc/Rpc"
31import * as RpcClient from "effect/rpc/RpcClient"
32import type * as RpcMessage from "effect/rpc/RpcMessage"
33import { RequestId } from "effect/rpc/RpcMessage"
34import * as RpcServer from "effect/rpc/RpcServer"
35import * as Schema from "effect/Schema"
36import * as Sink from "effect/Sink"
37import * as Stdio from "effect/Stdio"
38import * as Stream from "effect/Stream"
39import * as TestClock from "effect/testing/TestClock"
40import { collectGarbage } from "../../utils/gc.ts"
41import { initializeHttpSession, makeHttpHarness } from "./TestUtils/McpHttpHarness.ts"
42import { makeMcpSseReader, readMcpHttpResponse } from "./TestUtils/McpHttpResponse.ts"
43import { makeServerLayer } from "./TestUtils/McpServerLayer.ts"
44import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts"
45
46const OptionalStringTool = Tool.make("OptionalStringTool", {
47 parameters: Schema.Struct({ signature: Schema.optional(Schema.String) }),
48 success: Schema.String
49})
50
51const StrictObjectTool = Tool.make("StrictObjectTool", {
52 parameters: Schema.Struct({
53 config: Schema.Struct({ value: Schema.FiniteFromString })
54 }).annotate({ identifier: "Strict / ~ % Input" }),
55 success: Schema.String,
56 failureMode: "return"
57}).annotate(Tool.Strict, true)
58
59const PublicFailureTool = Tool.make("PublicFailureTool", {
60 success: Schema.String,
61 failure: Schema.ErrorInstance()
62})
63
64const StructuredFailureTool = Tool.make("StructuredFailureTool", {
65 success: Schema.Struct({ answer: Schema.String }),
66 failure: Schema.Struct({
67 reason: Schema.String,
68 retryAfter: Schema.FiniteFromString,
69 detail: Schema.optional(Schema.Unknown)
70 }),
71 failureMode: "error"
72})
73
74const InternalAiErrorTool = Tool.make("InternalAiErrorTool", {
75 success: Schema.String
76})
77
78const DefectTool = Tool.make("DefectTool", {
79 success: Schema.String
80})
81
82const UnserializableResultTool = Tool.make("UnserializableResultTool", {
83 success: Schema.Unknown
84})
85
86const UntypedTool = Tool.make("UntypedTool")
87
88const StructuredResultTool = Tool.make("StructuredResultTool", {
89 success: Schema.Struct({ answer: Schema.String })
90})
91
92const AnnotatedVoidTool = Tool.make("AnnotatedVoidTool", {
93 success: Schema.Void.annotate({ description: "No output" })
94})
95
96const NullableResultTool = Tool.make("NullableResultTool", {
97 success: Schema.NullOr(Schema.Struct({ answer: Schema.String }))
98})
99
100const ArrayResultTool = Tool.make("ArrayResultTool", {
101 success: Schema.Array(Schema.String)
102})
103
104const TestToolkit = Toolkit.make(
105 OptionalStringTool,
106 StrictObjectTool,
107 PublicFailureTool,
108 StructuredFailureTool,
109 InternalAiErrorTool,
110 DefectTool,
111 UnserializableResultTool,
112 UntypedTool,
113 StructuredResultTool,
114 AnnotatedVoidTool,
115 NullableResultTool,
116 ArrayResultTool
117)
118type TestToolkitHandlers = Toolkit.HandlersFrom<Toolkit.Tools<typeof TestToolkit>>
119
120const publicFailure = new Error("Public failure")
121const internalAiError = AiError.make({
122 module: "TestToolkit",
123 method: "InternalAiErrorTool",
124 reason: new AiError.RateLimitError({})
125})
126const privateDefect = new Error("private defect details")
127
128const testToolkitHandlers = TestToolkit.of({
129 OptionalStringTool: ({ signature }) => Effect.succeed(signature ?? "omitted"),
130 StrictObjectTool: ({ config }) => Effect.succeed(config.value.toFixed(0)),
131 PublicFailureTool: () => Effect.fail(publicFailure),
132 StructuredFailureTool: () => Effect.fail({ reason: "busy", retryAfter: 5 }),
133 InternalAiErrorTool: () => Effect.fail(internalAiError),
134 DefectTool: () => Effect.die(privateDefect),
135 UntypedTool: () => Effect.void,
136 StructuredResultTool: () => Effect.succeed({ answer: "result" }),
137 AnnotatedVoidTool: () => Effect.void,
138 NullableResultTool: () => Effect.succeed(null),
139 ArrayResultTool: () => Effect.succeed(["first", "second"]),
140 UnserializableResultTool: () => Effect.succeed(1n)
141})
142
143const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error."
144
145const TestServerLayer = makeServerLayer({ name: "TestServer" })
146
147const LatestProtocolServerLayer = makeServerLayer({ name: "TestServer", protocols: [McpProtocol.v2025_11_25] })
148
149const initializePayload = {
150 protocolVersion: "2025-06-18",
151 capabilities: {},
152 clientInfo: {
153 name: "TestClient",
154 version: "1.0.0"
155 }
156}
157
158const pingBody = {
159 jsonrpc: "2.0",
160 method: "ping",
161 params: {},
162 id: 0
163}
164
165const directClient = McpSchema.McpServerClient.of({
166 clientId: 1,
167 protocolVersion: "2025-06-18",
168 clientCapabilities: {},
169 clientInfo: initializePayload.clientInfo,
170 initializePayload,
171 getClient: Effect.die("not used")
172})
173
174const makeTestClientWith = Effect.fnUntraced(function*<A, E>(
175 serverLayer: Layer.Layer<A, E, HttpRouter.HttpRouter>,
176 options?: {
177 readonly routerLayer?: Layer.Layer<never, never, HttpRouter.HttpRouter> | undefined
178 } | undefined
179) {
180 const harness = yield* makeHttpHarness(serverLayer, options)
181
182 const clientLayer = RpcClient.layerProtocolHttp({
183 url: "http://localhost/mcp",
184 transformClient: HttpClient.mapRequest(
185 HttpClientRequest.setHeader("accept", "application/json, text/event-stream")
186 )
187 }).pipe(
188 Layer.provideMerge([FetchHttpClient.layer, RpcSerialization.layerJsonRpc()]),
189 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch))
190 )
191 const client = yield* RpcClient.make(McpSchema.ClientRpcs).pipe(
192 Effect.provide(clientLayer)
193 )
194
195 const httpClient = yield* HttpClient.HttpClient.pipe(
196 Effect.provide(clientLayer)
197 )
198
199 return { client, responses: harness.responses, httpClient }
200})
201
202const makeTestClient = makeTestClientWith(TestServerLayer)
203
204const makeRouterTestClient = (
205 router: Layer.Layer<never, never, HttpRouter.HttpRouter>
206) => makeTestClientWith(TestServerLayer, { routerLayer: router })
207
208const makeToolkitTestClient = Effect.fnUntraced(function*(
209 handlers: TestToolkitHandlers = testToolkitHandlers,
210 options?: { readonly reportErrors?: boolean }
211) {
212 const reported: Array<Cause.Cause<unknown>> = []
213 const logged: Array<Cause.Cause<unknown>> = []
214 const loggerLayer = Logger.layer([Logger.make(({ cause, logLevel }) => {
215 if (logLevel === "Error") logged.push(cause)
216 })])
217 const reporterLayer = ErrorReporter.layer(
218 options?.reportErrors === false ? [] : [ErrorReporter.make(({ cause }) => {
219 reported.push(cause)
220 })]
221 )
222 const serverLayer = McpServer.toolkit(TestToolkit).pipe(
223 Layer.provideMerge(TestToolkit.toLayer(handlers)),
224 Layer.provide(Layer.mergeAll(TestServerLayer, loggerLayer)),
225 Layer.provide(reporterLayer)
226 )
227 const { client } = yield* makeTestClientWith(serverLayer)
228 yield* client.initialize({
229 protocolVersion: "9999-01-01",
230 capabilities: {},
231 clientInfo: {
232 name: "TestClient",
233 version: "1.0.0"
234 }
235 })
236 return { client, reported, logged }
237})
238
239const toolResultText = (result: McpSchema.CallToolResult): string => {
240 const content = result.content[0]
241 assertTrue(content?.type === "text", "Expected text tool-result content")
242 return content.text
243}
244
245describe("McpServer", () => {
246 // https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle
247 // Pings are allowed before the initialize response and require an empty result.
248 for (
249 const protocol of [
250 McpProtocol.v2024_11_05,
251 McpProtocol.v2025_03_26,
252 McpProtocol.v2025_06_18,
253 McpProtocol.v2025_11_25
254 ]
255 ) {
256 for (
257 const protocols of [
258 [protocol],
259 [McpProtocol.v2026_07_28, protocol],
260 [protocol, McpProtocol.v2026_07_28]
261 ] as const
262 ) {
263 it.effect(`answers stdio pings throughout initialization with ${protocols.map((p) => p.protocolVersion)}`, () =>
264 Effect.gen(function*() {
265 const fixture = yield* makeMcpStdioHarness(protocol, protocols)
266 assert.deepStrictEqual(yield* fixture.sendRequest("ping", undefined, 0), {
267 jsonrpc: "2.0",
268 id: 0,
269 result: {}
270 })
271 // A ping must not create a session or enable ordinary requests.
272 assert.property(yield* fixture.sendRequest("tools/list"), "error")
273 const initialized = yield* fixture.sendRequest("initialize", {
274 protocolVersion: protocol.protocolVersion,
275 capabilities: {},
276 clientInfo: { name: "ping-client", version: "1.0.0" }
277 })
278 assert.deepInclude(initialized.result, { protocolVersion: protocol.protocolVersion })
279 assert.deepStrictEqual(yield* fixture.sendRequest("ping", {}, "before-initialized"), {
280 jsonrpc: "2.0",
281 id: "before-initialized",
282 result: {}
283 })
284 yield* fixture.sendNotification("notifications/initialized")
285 assert.deepStrictEqual(yield* fixture.sendRequest("ping", undefined, "after-initialized"), {
286 jsonrpc: "2.0",
287 id: "after-initialized",
288 result: {}
289 })
290 }))
291 }
292 }
293
294 it.effect("does not route explicitly stateless pings to a stateful adapter", () =>
295 Effect.gen(function*() {
296 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2026_07_28, [
297 McpProtocol.v2026_07_28,
298 McpProtocol.v2025_11_25
299 ])
300 assert.deepInclude((yield* fixture.sendRequest("ping")).error, { code: -32601 })
301 }))
302
303 // Effect delivery contract: an unavailable destination must not leave the caller waiting.
304 it.effect("should return from completion notification when the target client is absent", () =>
305 Effect.gen(function*() {
306 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2025_11_25)
307 yield* fixture.initialize()
308 yield* fixture.server.notifyElicitationComplete({ clientId: 999, elicitationId: "not-connected" })
309 assert.deepInclude(yield* fixture.sendRequest("ping", {}), { result: {} })
310 }))
311
312 // URL completion is unavailable in June; this checks that the Effect call returns, not wire suppression.
313 it.effect("should return from completion notification when the target protocol does not support it", () =>
314 Effect.gen(function*() {
315 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2025_06_18)
316 yield* fixture.initialize()
317 yield* fixture.server.notifyElicitationComplete({ clientId: 0, elicitationId: "unsupported" })
318 assert.deepInclude(yield* fixture.sendRequest("ping", {}), { result: {} })
319 }))
320
321 // https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation
322 // MCP defines the completion notification and its recipient. Awaiting delivery before returning is the Effect API contract.
323 it.effect("should deliver URL elicitation completion before the tool result when the tool awaits notification delivery", () =>
324 Effect.gen(function*() {
325 const authorizationCompleted = yield* Deferred.make<void>()
326 const registration = Layer.effectDiscard(Effect.gen(function*() {
327 const server = yield* McpServer.McpServer
328 yield* server.addTool({
329 tool: new McpSchema.Tool({ name: "Authorize", inputSchema: { type: "object" } }),
330 annotations: Context.empty(),
331 handle: () =>
332 Effect.gen(function*() {
333 const client = yield* Effect.serviceOption(McpSchema.McpServerClient).pipe(
334 Effect.flatMap(Effect.fromOption)
335 )
336 yield* client.getClient.pipe(
337 Effect.flatMap((reverseClient) =>
338 reverseClient.elicit(
339 Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)({
340 mode: "url",
341 message: "Authorize access",
342 url: "https://example.com/authorize",
343 elicitationId: "authorization-1"
344 })
345 )
346 ),
347 Effect.scoped
348 )
349 yield* Deferred.await(authorizationCompleted)
350 yield* server.notifyElicitationComplete({
351 clientId: client.clientId,
352 elicitationId: "authorization-1"
353 })
354 return new McpSchema.CallToolResult({ content: [{ type: "text", text: "Authorized" }] })
355 }).pipe(Effect.orDie)
356 })
357 }))
358 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(
359 makeServerLayer({ name: "UrlElicitationCompletion", protocols: [McpProtocol.v2025_11_25] })
360 )))
361 const initialized = yield* harness.post({
362 jsonrpc: "2.0",
363 id: "initialize",
364 method: "initialize",
365 params: {
366 protocolVersion: "2025-11-25",
367 capabilities: { elicitation: { url: {} } },
368 clientInfo: { name: "authorization-client", version: "1.0.0" }
369 }
370 })
371 yield* readMcpHttpResponse(initialized)
372 const sessionId = initialized.headers.get("Mcp-Session-Id")
373 assert.isNotNull(sessionId)
374 const headers = { "Mcp-Session-Id": sessionId, "Mcp-Protocol-Version": "2025-11-25" }
375 yield* harness.post({ jsonrpc: "2.0", method: "notifications/initialized" }, headers)
376 const response = yield* harness.post({
377 jsonrpc: "2.0",
378 id: "authorize-tool",
379 method: "tools/call",
380 params: { name: "Authorize", arguments: {} }
381 }, headers)
382 const stream = makeMcpSseReader(response)
383 yield* Effect.addFinalizer(() => stream.cancel)
384 const elicitation = yield* stream.take()
385 assert.strictEqual(elicitation.method, "elicitation/create")
386 assert.isDefined(elicitation.id)
387 const accepted = yield* harness.post({
388 jsonrpc: "2.0",
389 id: elicitation.id,
390 result: { action: "accept" }
391 }, headers)
392 assert.strictEqual(accepted.status, 202)
393 yield* Deferred.succeed(authorizationCompleted, undefined)
394
395 const completed = yield* stream.take()
396 assert.strictEqual(completed.method, "notifications/elicitation/complete")
397 assert.deepStrictEqual(completed.params, { elicitationId: "authorization-1" })
398 const result = yield* stream.take()
399 assert.strictEqual(result.id, "authorize-tool")
400 assert.deepStrictEqual(result.result, { content: [{ type: "text", text: "Authorized" }] })
401 assert.deepStrictEqual(yield* stream.drain(), [])
402 }))
403
404 // Request metadata retention is a server lifecycle concern, not an MCP wire requirement.
405 // This collection hook is Node-specific; other runtimes still run the request lifecycle tests below.
406 it.effect.skipIf(process.versions.bun !== undefined || process.versions.deno !== undefined)(
407 "should release request metadata when HTTP requests complete without server notifications",
408 () =>
409 Effect.gen(function*() {
410 const references: Array<WeakRef<object>> = []
411 const controls: Array<WeakRef<object>> = []
412 const harness = yield* makeHttpHarness(
413 Layer.effectDiscard(Effect.gen(function*() {
414 const server = yield* McpServer.McpServer
415 yield* server.addTool({
416 tool: new McpSchema.Tool({ name: "Capture", inputSchema: { type: "object" } }),
417 annotations: Context.empty(),
418 handle: () =>
419 Effect.gen(function*() {
420 const context = yield* McpSchema.McpRequestContext
421 references.push(new WeakRef(context.requestMetadata!))
422 controls.push(new WeakRef({ unrelated: true }))
423 return new McpSchema.CallToolResult({ content: [] })
424 })
425 })
426 })).pipe(Layer.provideMerge(makeServerLayer({
427 name: "HttpRequestLifecycle",
428 protocols: [McpProtocol.v2026_07_28]
429 })))
430 )
431 for (let id = 0; id < 4; id++) {
432 const response = yield* harness.post({
433 jsonrpc: "2.0",
434 id,
435 method: "tools/call",
436 params: {
437 name: "Capture",
438 arguments: {},
439 _meta: {
440 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
441 "io.modelcontextprotocol/clientCapabilities": {},
442 marker: `request-${id}`
443 }
444 }
445 }, { "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "Capture" })
446 assert.strictEqual(response.status, 200)
447 yield* Effect.promise(() => response.text())
448 }
449 assert.strictEqual(references.length, 4)
450 yield* collectGarbage
451 assert.isTrue(controls.every((ref) => ref.deref() === undefined), "GC must collect unreachable controls")
452 assert.isTrue(
453 references.every((ref) => ref.deref() === undefined),
454 "completed request metadata must be collectable"
455 )
456 })
457 )
458
459 // Cancelled reverse requests may never receive a reply. Releasing their routing state is
460 // a server lifecycle concern, so this GC regression belongs here rather than conformance.
461 // https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements
462 it.effect.skipIf(process.versions.bun !== undefined || process.versions.deno !== undefined)(
463 "should release disconnected clients when a reverse request is cancelled without a reply",
464 () =>
465 Effect.gen(function*() {
466 const refs = new Map<number, WeakRef<object>>()
467 const connected = new Set([1, 2])
468 const outbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
469 const disconnects = yield* Queue.unbounded<number>()
470 type Receive = (
471 clientId: number,
472 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded
473 ) => Effect.Effect<void>
474 const ready = yield* Deferred.make<Receive>()
475 const cancelled = yield* Deferred.make<void>()
476 const transport = yield* RpcServer.Protocol.make((write) =>
477 Deferred.succeed(ready, write as Receive).pipe(Effect.as({
478 disconnects,
479 send: (_clientId, message) => Queue.offer(outbound, message).pipe(Effect.asVoid),
480 end: () => Effect.void,
481 clientIds: Effect.succeed(connected),
482 initialMessage: Effect.succeedNone,
483 supportsAck: false,
484 supportsTransferables: false,
485 supportsSpanPropagation: false,
486 supportsNotifications: true,
487 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
488 }))
489 )
490 const toolkit = Toolkit.make(Tool.make("Probe", {
491 success: Schema.String,
492 dependencies: [McpSchema.McpServerClient]
493 }))
494 yield* Layer.build(
495 McpServer.toolkit(toolkit).pipe(
496 Layer.provide(toolkit.toLayer({
497 Probe: () =>
498 Effect.gen(function*() {
499 const client = yield* McpSchema.McpServerClient
500 refs.set(client.clientId, new WeakRef(client.clientInfo))
501 if (client.clientId === 2) return "control"
502 yield* McpServer.elicit({ message: "Approve", schema: Schema.Struct({ approved: Schema.Boolean }) })
503 .pipe(
504 Effect.orDie,
505 Effect.onInterrupt(() => Deferred.succeed(cancelled, undefined))
506 )
507 return "done"
508 })
509 })),
510 Layer.provide(
511 McpServer.layer({ name: "ReverseLifetime", version: "1", protocols: [McpProtocol.v2025_11_25] }).pipe(
512 Layer.provide(Layer.succeed(RpcServer.Protocol, transport))
513 )
514 )
515 )
516 )
517 const send = yield* Deferred.await(ready)
518 for (const clientId of [1, 2]) {
519 yield* send(clientId, {
520 _tag: "Request",
521 id: clientId,
522 tag: "initialize",
523 payload: {
524 protocolVersion: "2025-11-25",
525 capabilities: { elicitation: { form: {} } },
526 clientInfo: { name: `client-${clientId}`, version: "1" }
527 },
528 headers: []
529 })
530 assert.strictEqual((yield* Queue.take(outbound))._tag, "Exit")
531 yield* send(clientId, {
532 _tag: "Request",
533 id: 10 + clientId,
534 tag: "tools/call",
535 payload: { name: "Probe" },
536 headers: []
537 })
538 const outboundMessage = yield* Queue.take(outbound)
539 assert.strictEqual(outboundMessage._tag, clientId === 1 ? "Request" : "Exit")
540 if (clientId === 1) {
541 yield* send(clientId, {
542 _tag: "Request",
543 id: "",
544 tag: "notifications/cancelled",
545 isNotification: true,
546 payload: { requestId: 11 },
547 headers: []
548 })
549 yield* Deferred.await(cancelled)
550 // The reverse client emits a control Interrupt; consume it before the next connection.
551 assert.strictEqual((yield* Queue.take(outbound))._tag, "Interrupt")
552 }
553 yield* send(clientId, { _tag: "Eof" })
554 connected.delete(clientId)
555 yield* Queue.offer(disconnects, clientId)
556 }
557 // Allow the reverse-client cache to expire its 10-second idle entries.
558 yield* TestClock.adjust("11 seconds")
559 yield* collectGarbage
560 assert.strictEqual(refs.get(2)!.deref(), undefined, "control client must be collectible")
561 assert.strictEqual(refs.get(1)!.deref(), undefined, "cancelled reverse client must be collectible")
562 })
563 )
564
565 // This server interrupts non-resumable HTTP work on disconnect; this is not an MCP conformance requirement.
566 it.effect("should interrupt tool work when its non-resumable HTTP response is disconnected", () =>
567 Effect.gen(function*() {
568 const interrupted = yield* Deferred.make<void>()
569 const protocol = McpProtocol.v2025_03_26
570 const registration = Layer.effectDiscard(McpServer.McpServer.use((server) =>
571 server.addTool({
572 tool: new McpSchema.Tool({ name: "Wait", inputSchema: { type: "object" } }),
573 annotations: Context.empty(),
574 handle: () =>
575 server.notifications["notifications/message"]({ level: "error", data: "running" }).pipe(
576 Effect.andThen(Effect.never),
577 Effect.onInterrupt(() => Deferred.succeed(interrupted, void 0))
578 )
579 })
580 ))
581 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(
582 makeServerLayer({ name: "HttpDisconnect", protocols: [protocol] })
583 )))
584 const headers = yield* initializeHttpSession(harness, protocol)
585 const response = yield* harness.post({
586 jsonrpc: "2.0",
587 id: "wait",
588 method: "tools/call",
589 params: { name: "Wait", arguments: {} }
590 }, headers)
591 const stream = makeMcpSseReader(response)
592 yield* Effect.addFinalizer(() => stream.cancel)
593 assert.strictEqual((yield* stream.take()).method, "notifications/message")
594 yield* stream.cancel
595 yield* Deferred.await(interrupted)
596 }))
597
598 it.effect("should frame HTTP batch notifications and results as separate SSE events", () =>
599 Effect.gen(function*() {
600 const harness = yield* makeHttpHarness(
601 Layer.effectDiscard(McpServer.McpServer.use((server) =>
602 server.addTool({
603 tool: new McpSchema.Tool({ name: "Emit", inputSchema: { type: "object" } }),
604 annotations: Context.empty(),
605 handle: () =>
606 server.notifications["notifications/message"]({ level: "error", data: "batch-log" }).pipe(
607 Effect.as(new McpSchema.CallToolResult({ content: [] }))
608 )
609 })
610 )).pipe(Layer.provideMerge(makeServerLayer({
611 name: "BatchNotificationFraming",
612 protocols: [McpProtocol.v2025_03_26]
613 })))
614 )
615 const initialized = yield* harness.post({
616 jsonrpc: "2.0",
617 id: 1,
618 method: "initialize",
619 params: {
620 protocolVersion: "2025-03-26",
621 capabilities: {},
622 clientInfo: { name: "legacy", version: "1.0.0" }
623 }
624 })
625 const sessionId = initialized.headers.get("mcp-session-id")
626 assert.isNotNull(sessionId)
627 const response = yield* harness.post([{
628 jsonrpc: "2.0",
629 id: 2,
630 method: "tools/call",
631 params: { name: "Emit" }
632 }], { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-03-26" })
633 assert.strictEqual(response.status, 200)
634 const reader = makeMcpSseReader(response)
635 yield* Effect.addFinalizer(() => reader.cancel)
636 const messages: ReadonlyArray<unknown> = yield* reader.drain()
637 assert.deepStrictEqual(messages, [
638 { jsonrpc: "2.0", method: "notifications/message", params: { level: "error", data: "batch-log" } },
639 [{ jsonrpc: "2.0", id: 2, result: { content: [] } }]
640 ])
641 }))
642
643 it.effect("should honor the legacy HTTP session log level when delivering notifications", () =>
644 Effect.gen(function*() {
645 const harness = yield* makeHttpHarness(
646 Layer.effectDiscard(McpServer.McpServer.use((server) =>
647 server.addTool({
648 tool: new McpSchema.Tool({ name: "Emit", inputSchema: { type: "object" } }),
649 annotations: Context.empty(),
650 handle: () =>
651 Effect.gen(function*() {
652 yield* server.notifications["notifications/message"]({ level: "warning", data: "filtered" })
653 yield* server.notifications["notifications/message"]({ level: "error", data: "delivered" })
654 return new McpSchema.CallToolResult({ content: [] })
655 })
656 })
657 )).pipe(Layer.provideMerge(makeServerLayer({
658 name: "SessionLogLevel",
659 protocols: [McpProtocol.v2025_11_25]
660 })))
661 )
662 const initialized = yield* harness.post({
663 jsonrpc: "2.0",
664 id: 1,
665 method: "initialize",
666 params: {
667 protocolVersion: "2025-11-25",
668 capabilities: {},
669 clientInfo: { name: "legacy", version: "1.0.0" }
670 }
671 })
672 const sessionId = initialized.headers.get("mcp-session-id")
673 assert.isNotNull(sessionId)
674 const headers = { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-11-25" }
675 const configured = yield* harness.post({
676 jsonrpc: "2.0",
677 id: 2,
678 method: "logging/setLevel",
679 params: { level: "error" }
680 }, headers)
681 assert.strictEqual(configured.status, 200)
682 const response = makeMcpSseReader(
683 yield* harness.post({
684 jsonrpc: "2.0",
685 id: 3,
686 method: "tools/call",
687 params: { name: "Emit" }
688 }, headers)
689 )
690 yield* Effect.addFinalizer(() => response.cancel)
691 const messages = yield* response.drain()
692 assert.deepStrictEqual(
693 messages.filter((message) => message.method === "notifications/message").map(
694 (message) => message.params
695 ),
696 [{ level: "error", data: "delivered" }]
697 )
698 assert.deepInclude(messages.at(-1), { id: 3, result: { content: [] } })
699 }))
700
701 for (const transport of ["http", "stdio"] as const) {
702 it.effect(`should reject stateless requests inside a legacy ${transport} batch`, () =>
703 Effect.gen(function*() {
704 const calls = yield* Ref.make(0)
705 const protocols = [McpProtocol.v2026_07_28, McpProtocol.v2025_03_26] as const
706 const registrations = Layer.effectDiscard(McpServer.McpServer.use((server) =>
707 server.addTool({
708 tool: new McpSchema.Tool({ name: "Count", inputSchema: { type: "object" } }),
709 annotations: Context.empty(),
710 handle: () =>
711 Ref.update(calls, (n) => n + 1).pipe(
712 Effect.as(new McpSchema.CallToolResult({ content: [] }))
713 )
714 })
715 ))
716 const batch = [{
717 jsonrpc: "2.0",
718 id: 2,
719 method: "tools/call",
720 params: {
721 name: "Count",
722 _meta: {
723 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
724 "io.modelcontextprotocol/clientCapabilities": {}
725 }
726 }
727 }, { jsonrpc: "2.0", id: 3, method: "ping" }]
728 if (transport === "stdio") {
729 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2025_03_26, protocols, registrations)
730 yield* fixture.initialize()
731 yield* fixture.takeFrame
732 yield* fixture.sendRaw(batch)
733 assert.deepInclude(yield* fixture.takeFrame, {
734 id: null,
735 error: { code: -32600, message: "JSON-RPC batches are not supported" }
736 })
737 } else {
738 const harness = yield* makeHttpHarness(registrations.pipe(Layer.provideMerge(makeServerLayer({
739 name: "BatchAdmission",
740 protocols
741 }))))
742 const initialized = yield* harness.post({
743 jsonrpc: "2.0",
744 id: 1,
745 method: "initialize",
746 params: {
747 protocolVersion: "2025-03-26",
748 capabilities: {},
749 clientInfo: { name: "legacy", version: "1.0.0" }
750 }
751 })
752 const sessionId = initialized.headers.get("mcp-session-id")
753 assert.isNotNull(sessionId)
754 const response = yield* harness.post(batch, {
755 "mcp-session-id": sessionId,
756 "mcp-protocol-version": "2025-03-26"
757 })
758 assert.strictEqual(response.status, 400)
759 }
760 assert.strictEqual(yield* Ref.get(calls), 0)
761 }))
762 }
763
764 it.effect("should isolate request notifications across mixed HTTP protocols", () =>
765 Effect.gen(function*() {
766 const holding = yield* Deferred.make<void>()
767 const release = yield* Deferred.make<void>()
768 const ready = yield* Deferred.make<McpServer.McpServer["Service"]>()
769 const harness = yield* makeHttpHarness(
770 Layer.effectDiscard(Effect.gen(function*() {
771 const server = yield* McpServer.McpServer
772 for (const name of ["Hold", "Emit"]) {
773 yield* server.addTool({
774 tool: new McpSchema.Tool({ name, inputSchema: { type: "object" } }),
775 annotations: Context.empty(),
776 handle: () =>
777 (name === "Hold"
778 ? Deferred.succeed(holding, undefined).pipe(Effect.andThen(Deferred.await(release)))
779 : Effect.gen(function*() {
780 yield* server.notifications["notifications/message"]({ level: "error", data: "request-log" })
781 yield* server.notifications["notifications/progress"]({ progressToken: "token", progress: 1 })
782 })).pipe(Effect.as(new McpSchema.CallToolResult({ content: [] })))
783 })
784 }
785 yield* Deferred.succeed(ready, server)
786 })).pipe(Layer.provideMerge(makeServerLayer({
787 name: "NotificationOwnership",
788 protocols: [McpProtocol.v2026_07_28, McpProtocol.v2025_11_25]
789 })))
790 )
791 const server = yield* Deferred.await(ready)
792 const initialized = yield* harness.post({
793 jsonrpc: "2.0",
794 id: 1,
795 method: "initialize",
796 params: {
797 protocolVersion: "2025-11-25",
798 capabilities: {},
799 clientInfo: { name: "legacy", version: "1.0.0" }
800 }
801 })
802 const sessionId = initialized.headers.get("mcp-session-id")
803 assert.isNotNull(sessionId)
804 const held = yield* harness.post({
805 jsonrpc: "2.0",
806 id: 2,
807 method: "tools/call",
808 params: { name: "Hold" }
809 }, { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-11-25" }).pipe(Effect.forkChild)
810 yield* Deferred.await(holding)
811 const postModern = (id: number, method: string, params: Record<string, unknown>) =>
812 harness.post({
813 jsonrpc: "2.0",
814 id,
815 method,
816 params: {
817 ...params,
818 _meta: {
819 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
820 "io.modelcontextprotocol/clientCapabilities": {},
821 "io.modelcontextprotocol/logLevel": "warning",
822 progressToken: "token"
823 }
824 }
825 }, { "mcp-protocol-version": "2026-07-28", "mcp-method": method, "mcp-name": "Emit" })
826 const subscription = makeMcpSseReader(
827 yield* postModern(3, "subscriptions/listen", {
828 notifications: { toolsListChanged: true }
829 })
830 )
831 yield* Effect.addFinalizer(() => subscription.cancel)
832 assert.strictEqual((yield* subscription.take()).method, "notifications/subscriptions/acknowledged")
833 const response = makeMcpSseReader(yield* postModern(4, "tools/call", { name: "Emit" }))
834 yield* Effect.addFinalizer(() => response.cancel)
835 const messages = yield* response.drain()
836 assert.deepStrictEqual(messages.map((message) => message.method ?? message.id), [
837 "notifications/message",
838 "notifications/progress",
839 4
840 ])
841 yield* Deferred.succeed(release, undefined)
842 const unrelated = yield* Fiber.join(held)
843 assert.match(unrelated.headers.get("content-type") ?? "", /^application\/json/)
844 assert.deepInclude(yield* Effect.promise(() => unrelated.json()), { id: 2, result: { content: [] } })
845 yield* server.notifications["notifications/tools/list_changed"]({})
846 assert.strictEqual((yield* subscription.take()).method, "notifications/tools/list_changed")
847 }))
848
849 it.effect("should deliver notifications and results to other clients when one client stops reading", () =>
850 Effect.gen(function*() {
851 const outbound = yield* Queue.unbounded<{ clientId: number; message: RpcMessage.FromServerEncoded }>()
852 const disconnects = yield* Queue.unbounded<number>()
853 const blocked = yield* Deferred.make<void>()
854 const release = yield* Deferred.make<void>()
855 const ready = yield* Deferred.make<
856 (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect<void>
857 >()
858 const transport = yield* RpcServer.Protocol.make((write) =>
859 Deferred.succeed(ready, write).pipe(Effect.as({
860 disconnects,
861 send: (clientId, message) =>
862 Effect.gen(function*() {
863 if (
864 clientId === 1 && message._tag === "Request" && (message.payload as { data?: string }).data === "first"
865 ) {
866 yield* Deferred.succeed(blocked, undefined)
867 yield* Deferred.await(release)
868 }
869 yield* Queue.offer(outbound, { clientId, message })
870 }),
871 end: () => Effect.void,
872 clientIds: Effect.succeed(new Set([1, 2])),
873 initialMessage: Effect.succeedNone,
874 supportsAck: false,
875 supportsTransferables: false,
876 supportsSpanPropagation: false,
877 supportsNotifications: true,
878 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
879 }))
880 )
881 const context = yield* Layer.build(
882 McpServer.layer({ name: "NotificationIsolation", version: "1.0.0", protocols: [McpProtocol.v2025_11_25] })
883 .pipe(Layer.provide(Layer.succeed(RpcServer.Protocol, transport)))
884 )
885 const server = Context.get(context, McpServer.McpServer)
886 yield* server.addTool({
887 tool: new McpSchema.Tool({ name: "Log", inputSchema: { type: "object" } }),
888 annotations: Context.empty(),
889 handle: () =>
890 Effect.gen(function*() {
891 yield* Effect.forEach(["first", "second"], (data) =>
892 server.notifications["notifications/message"]({ level: "error", data }), { concurrency: "unbounded" })
893 if ((yield* McpSchema.McpRequestContext).clientId === 2) {
894 yield* server.addTool({
895 tool: new McpSchema.Tool({ name: "Dynamic", inputSchema: { type: "object" } }),
896 annotations: Context.empty(),
897 handle: () =>
898 Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
899 })
900 }
901 return new McpSchema.CallToolResult({ content: [] })
902 })
903 })
904 const take = Effect.fnUntraced(function*(clientId: number) {
905 const event = yield* Queue.take(outbound)
906 assert.strictEqual(event.clientId, clientId)
907 return event.message
908 })
909 const send = yield* Deferred.await(ready)
910 for (const clientId of [1, 2]) {
911 yield* send(clientId, {
912 _tag: "Request",
913 id: "initialize",
914 tag: "initialize",
915 headers: [],
916 payload: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "client", version: "1" } }
917 })
918 assert.deepInclude(yield* take(clientId), { _tag: "Exit", requestId: "initialize" })
919 yield* send(clientId, {
920 _tag: "Request",
921 id: "",
922 tag: "notifications/initialized",
923 payload: {},
924 headers: [],
925 isNotification: true
926 })
927 }
928 const request = { _tag: "Request", id: "log", tag: "tools/call", payload: { name: "Log" }, headers: [] } as const
929 yield* send(1, request)
930 yield* Deferred.await(blocked)
931 yield* send(2, request)
932 for (const data of ["first", "second"]) {
933 assert.deepInclude(yield* take(2), {
934 _tag: "Request",
935 tag: "notifications/message",
936 payload: { level: "error", data }
937 })
938 }
939 assert.deepInclude(yield* take(2), { _tag: "Request", tag: "notifications/tools/list_changed" })
940 assert.deepInclude(yield* take(2), { _tag: "Exit", requestId: "log" })
941 yield* Deferred.succeed(release, undefined)
942 for (const data of ["first", "second"]) {
943 assert.deepInclude(yield* take(1), {
944 _tag: "Request",
945 tag: "notifications/message",
946 payload: { level: "error", data }
947 })
948 }
949 const remaining = yield* Effect.all([take(1), take(1)])
950 assert.strictEqual(remaining.filter((message) => message._tag === "Exit").length, 1)
951 assert.deepInclude(remaining.find((message) => message._tag === "Exit"), { requestId: "log" })
952 assert.deepInclude(remaining.find((message) => message._tag === "Request"), {
953 tag: "notifications/tools/list_changed"
954 })
955 }))
956
957 it.effect("should suppress cancelled responses when a custom transport handles MCP messages", () =>
958 Effect.gen(function*() {
959 const outbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
960 const disconnects = yield* Queue.unbounded<number>()
961 const ready = yield* Deferred.make<
962 (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect<void>
963 >()
964 const transport = yield* RpcServer.Protocol.make((write) =>
965 Deferred.succeed(ready, write).pipe(Effect.as({
966 disconnects,
967 send: (_clientId, message) => Queue.offer(outbound, message).pipe(Effect.asVoid),
968 end: () => Effect.void,
969 clientIds: Effect.succeed(new Set([1])),
970 initialMessage: Effect.succeedNone,
971 supportsAck: false,
972 supportsTransferables: false,
973 supportsSpanPropagation: false,
974 supportsNotifications: true,
975 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
976 }))
977 )
978 const context = yield* Layer.build(
979 McpServer.layer({
980 name: "CustomCancellation",
981 version: "1.0.0",
982 protocols: [McpProtocol.v2025_03_26]
983 }).pipe(Layer.provide(Layer.succeed(RpcServer.Protocol, transport)))
984 )
985 const entered = yield* Deferred.make<void>()
986 const interrupted = yield* Deferred.make<void>()
987 yield* Context.get(context, McpServer.McpServer).addTool({
988 tool: new McpSchema.Tool({ name: "Wait", inputSchema: { type: "object" } }),
989 annotations: Context.empty(),
990 handle: () =>
991 Deferred.succeed(entered, void 0).pipe(
992 Effect.andThen(Effect.never),
993 Effect.onInterrupt(() => Deferred.succeed(interrupted, void 0))
994 )
995 })
996 const send = yield* Deferred.await(ready)
997 yield* send(1, {
998 _tag: "Request",
999 id: "initialize",
1000 tag: "initialize",
1001 payload: {
1002 protocolVersion: "2025-03-26",
1003 capabilities: {},
1004 clientInfo: { name: "client", version: "1.0.0" }
1005 },
1006 headers: []
1007 })
1008 assert.deepInclude(yield* Queue.take(outbound), { _tag: "Exit", requestId: "initialize" })
1009 yield* send(1, { _tag: "Request", id: "wait", tag: "tools/call", payload: { name: "Wait" }, headers: [] })
1010 yield* Deferred.await(entered)
1011 yield* send(1, {
1012 _tag: "Request",
1013 id: "",
1014 tag: "notifications/cancelled",
1015 payload: { requestId: "wait" },
1016 headers: [],
1017 isNotification: true
1018 })
1019 yield* Deferred.await(interrupted)
1020 yield* send(1, { _tag: "Request", id: "ping", tag: "ping", payload: {}, headers: [] })
1021 assert.deepInclude(yield* Queue.take(outbound), { _tag: "Exit", requestId: "ping" })
1022 }))
1023
1024 it.effect("should match reverse responses to their originating connection", () =>
1025 Effect.gen(function*() {
1026 const outbound = yield* Queue.unbounded<{
1027 readonly clientId: number
1028 readonly message: RpcMessage.FromServerEncoded
1029 }>()
1030 const disconnects = yield* Queue.unbounded<number>()
1031 type Receive = (
1032 clientId: number,
1033 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded
1034 ) => Effect.Effect<void>
1035 const ready = yield* Deferred.make<Receive>()
1036 const transport = yield* RpcServer.Protocol.make((write) =>
1037 // MCP also receives reverse replies through the ordinary RPC client-message boundary.
1038 Deferred.succeed(ready, write as Receive).pipe(Effect.as({
1039 disconnects,
1040 send: (clientId, message) => Queue.offer(outbound, { clientId, message }).pipe(Effect.asVoid),
1041 end: () => Effect.void,
1042 clientIds: Effect.succeed(new Set([1, 2])),
1043 initialMessage: Effect.succeedNone,
1044 supportsAck: false,
1045 supportsTransferables: false,
1046 supportsSpanPropagation: false,
1047 supportsNotifications: true,
1048 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
1049 }))
1050 )
1051 const toolkit = Toolkit.make(Tool.make("Approve", {
1052 success: Schema.Struct({ approved: Schema.Boolean }),
1053 dependencies: [McpSchema.McpServerClient]
1054 }))
1055 yield* Layer.build(
1056 McpServer.toolkit(toolkit).pipe(
1057 Layer.provide(toolkit.toLayer({
1058 Approve: () =>
1059 McpServer.elicit({
1060 message: "Approve",
1061 schema: Schema.Struct({ approved: Schema.Boolean })
1062 }).pipe(Effect.orDie)
1063 })),
1064 Layer.provide(
1065 McpServer.layer({
1066 name: "ResponseOwnership",
1067 version: "1.0.0",
1068 protocols: [McpProtocol.v2025_11_25]
1069 }).pipe(Layer.provide(Layer.succeed(RpcServer.Protocol, transport)))
1070 )
1071 )
1072 )
1073 const send = yield* Deferred.await(ready)
1074 for (const clientId of [1, 2]) {
1075 yield* send(clientId, {
1076 _tag: "Request",
1077 id: clientId,
1078 tag: "initialize",
1079 payload: {
1080 protocolVersion: "2025-11-25",
1081 capabilities: { elicitation: { form: {} } },
1082 clientInfo: { name: `client-${clientId}`, version: "1.0.0" }
1083 },
1084 headers: []
1085 })
1086 assert.strictEqual((yield* Queue.take(outbound)).message._tag, "Exit")
1087 }
1088 yield* send(1, { _tag: "Request", id: 10, tag: "tools/call", payload: { name: "Approve" }, headers: [] })
1089 const reverse = yield* Queue.take(outbound)
1090 assert.strictEqual(reverse.clientId, 1)
1091 if (reverse.message._tag !== "Request") return assert.fail("Expected an elicitation request")
1092 assert.strictEqual(reverse.message.tag, "elicitation/create")
1093 const requestId = reverse.message.id
1094 for (const clientId of [2, 1]) {
1095 yield* send(clientId, {
1096 _tag: "Exit",
1097 requestId,
1098 exit: { _tag: "Success", value: { action: "accept", content: { approved: clientId === 1 } } }
1099 })
1100 }
1101 const result = yield* Queue.take(outbound)
1102 assert.strictEqual(result.clientId, 1)
1103 assert.deepInclude(result.message, { _tag: "Exit", requestId: 10 })
1104 if (result.message._tag !== "Exit" || result.message.exit._tag !== "Success") {
1105 return assert.fail("Expected the tool to complete")
1106 }
1107 const decoded = yield* Schema.decodeUnknownEffect(McpSchema.CallToolResult)(result.message.exit.value)
1108 assert.deepStrictEqual(decoded.structuredContent, { approved: true })
1109 }))
1110
1111 describe("direct service", () => {
1112 it.effect("should return from completion notification when no transport is running", () =>
1113 Effect.gen(function*() {
1114 const server = yield* McpServer.McpServer.make
1115 yield* server.notifyElicitationComplete({ clientId: 0, elicitationId: "not-connected" })
1116 }))
1117
1118 it.effect("inlines identified elicitation schemas", () =>
1119 Effect.gen(function*() {
1120 let observed: Parameters<McpSchema.McpReverseClient["elicit"]>[0] | undefined
1121 const reverseClient: McpSchema.McpReverseClient = {
1122 listRoots: () => Effect.die("Not used"),
1123 createMessage: () => Effect.die("Not used"),
1124 elicit: (request) => {
1125 observed = request
1126 return Effect.succeed({ action: "accept", content: { name: "Ada" } })
1127 }
1128 }
1129 const client = McpSchema.McpServerClient.of({
1130 clientId: 1,
1131 protocolVersion: "2025-11-25",
1132 clientCapabilities: { elicitation: { form: {} } },
1133 clientInfo: { name: "client", version: "1" },
1134 initializePayload: {
1135 protocolVersion: "2025-11-25",
1136 capabilities: { elicitation: { form: {} } },
1137 clientInfo: { name: "client", version: "1" }
1138 },
1139 getClient: Effect.succeed(reverseClient)
1140 })
1141
1142 const result = yield* McpServer.elicit({
1143 message: "Profile",
1144 schema: Schema.Struct({ name: Schema.String }).annotate({ identifier: "Profile" })
1145 }).pipe(Effect.provideService(McpSchema.McpServerClient, client))
1146
1147 assert.deepStrictEqual(result, { name: "Ada" })
1148 assert.isDefined(observed)
1149 if (observed.mode !== "form") return assert.fail("Expected a form elicitation request")
1150 assert.deepStrictEqual(JSON.parse(JSON.stringify(observed.requestedSchema)), {
1151 type: "object",
1152 properties: { name: { type: "string" } },
1153 required: ["name"]
1154 })
1155 }))
1156
1157 describe("registration context", () => {
1158 class RegistrationLabel extends Context.Service<RegistrationLabel, string>()("test/RegistrationLabel") {}
1159
1160 const client = (clientId: number, name: string) =>
1161 McpSchema.McpServerClient.of({
1162 clientId,
1163 protocolVersion: "2025-06-18",
1164 clientCapabilities: {},
1165 clientInfo: { name, version: "1" },
1166 initializePayload: {
1167 protocolVersion: "2025-06-18",
1168 capabilities: {},
1169 clientInfo: { name, version: "1" }
1170 },
1171 requestMetadata: { owner: name },
1172 getClient: Effect.die("No reverse requests are needed")
1173 })
1174
1175 const alice = client(1, "Alice")
1176 const bob = client(2, "Bob")
1177 const currentOwner = Effect.gen(function*() {
1178 const request = yield* McpSchema.McpRequestContext
1179 const label = yield* RegistrationLabel
1180 return `${label}:${request.clientId}:${request.clientInfo?.name}:${request.requestMetadata?.owner}`
1181 })
1182
1183 const toolkit = Toolkit.make(Tool.make("owner", {
1184 success: Schema.String,
1185 dependencies: [McpSchema.McpRequestContext, RegistrationLabel]
1186 }))
1187
1188 // Registration captures application services, but each invocation owns its request identity.
1189 // This contract belongs to the public server helpers rather than wire conformance.
1190 const makeRegisteredServer = Effect.gen(function*() {
1191 const server = yield* McpServer.McpServer.make
1192 yield* server.addTool({
1193 tool: new McpSchema.Tool({ name: "register", inputSchema: { type: "object" } }),
1194 annotations: Context.empty(),
1195 handle: () =>
1196 Effect.gen(function*() {
1197 yield* McpServer.registerResource({ uri: "file:///owner", name: "owner", content: currentOwner })
1198 yield* McpServer.registerResource`file:///owners/${Schema.String}`({
1199 name: "owners",
1200 content: () => currentOwner,
1201 completion: { param0: () => Effect.map(currentOwner, (owner) => [owner]) }
1202 })
1203 yield* McpServer.registerPrompt({
1204 name: "owner",
1205 parameters: { name: Schema.String },
1206 content: () => currentOwner,
1207 completion: { name: () => Effect.map(currentOwner, (owner) => [owner]) }
1208 })
1209 yield* McpServer.registerToolkit(toolkit).pipe(
1210 Effect.provide(toolkit.toLayer({ owner: () => currentOwner }))
1211 )
1212 return new McpSchema.CallToolResult({ content: [] })
1213 }).pipe(
1214 Effect.provideService(McpServer.McpServer, server),
1215 Effect.provideService(RegistrationLabel, "registered")
1216 )
1217 })
1218 yield* server.callTool({ name: "register" }).pipe(Effect.provideService(McpSchema.McpServerClient, alice))
1219 return server
1220 })
1221
1222 const expectedOwner = "registered:2:Bob:Bob"
1223
1224 it.effect("should use the current request when a resource was registered by another client", () =>
1225 Effect.gen(function*() {
1226 const server = yield* makeRegisteredServer
1227 const result = yield* server.findResource("file:///owner").pipe(
1228 Effect.provideService(McpSchema.McpServerClient, bob),
1229 Effect.provideService(RegistrationLabel, "invocation")
1230 )
1231 assert.deepStrictEqual(result.contents, [{ uri: "file:///owner", text: expectedOwner }])
1232 }))
1233
1234 it.effect("should use the current request when a resource template was registered by another client", () =>
1235 Effect.gen(function*() {
1236 const server = yield* makeRegisteredServer
1237 const result = yield* server.findResource("file:///owners/bob").pipe(
1238 Effect.provideService(McpSchema.McpServerClient, bob),
1239 Effect.provideService(RegistrationLabel, "invocation")
1240 )
1241 assert.deepStrictEqual(result.contents, [{ uri: "file:///owners/bob", text: expectedOwner }])
1242 }))
1243
1244 it.effect("should use the current request when a prompt was registered by another client", () =>
1245 Effect.gen(function*() {
1246 const server = yield* makeRegisteredServer
1247 const result = yield* server.getPromptResult({ name: "owner", arguments: { name: "bob" } }).pipe(
1248 Effect.provideService(McpSchema.McpServerClient, bob),
1249 Effect.provideService(RegistrationLabel, "invocation")
1250 )
1251 assert.deepStrictEqual(result.messages, [{ role: "user", content: { type: "text", text: expectedOwner } }])
1252 }))
1253
1254 it.effect("should use the current request when a resource completion was registered by another client", () =>
1255 Effect.gen(function*() {
1256 const server = yield* makeRegisteredServer
1257 const result = yield* server.completion({
1258 ref: { type: "ref/resource", uri: "file:///owners/{param0}" },
1259 argument: { name: "param0", value: "b" }
1260 }).pipe(
1261 Effect.provideService(McpSchema.McpServerClient, bob),
1262 Effect.provideService(RegistrationLabel, "invocation")
1263 )
1264 assert.deepStrictEqual(result.completion.values, [expectedOwner])
1265 }))
1266
1267 it.effect("should use the current request when a prompt completion was registered by another client", () =>
1268 Effect.gen(function*() {
1269 const server = yield* makeRegisteredServer
1270 const result = yield* server.completion({
1271 ref: { type: "ref/prompt", name: "owner" },
1272 argument: { name: "name", value: "b" }
1273 }).pipe(
1274 Effect.provideService(McpSchema.McpServerClient, bob),
1275 Effect.provideService(RegistrationLabel, "invocation")
1276 )
1277 assert.deepStrictEqual(result.completion.values, [expectedOwner])
1278 }))
1279
1280 it.effect("should use the current request when a toolkit was registered by another client", () =>
1281 Effect.gen(function*() {
1282 const server = yield* makeRegisteredServer
1283 const result = yield* server.callTool({ name: "owner" }).pipe(
1284 Effect.provideService(McpSchema.McpServerClient, bob),
1285 Effect.provideService(RegistrationLabel, "invocation")
1286 )
1287 assert.strictEqual(result.isError, false)
1288 assert.strictEqual(result.structuredContent, expectedOwner)
1289 }))
1290 })
1291
1292 it.effect("should complete notification-emitting tools without a running transport", () =>
1293 Effect.gen(function*() {
1294 const server = yield* McpServer.McpServer.make
1295 yield* server.addTool({
1296 tool: new McpSchema.Tool({ name: "Emit", inputSchema: { type: "object" } }),
1297 annotations: Context.empty(),
1298 handle: () =>
1299 server.notifications["notifications/message"]({ level: "error", data: "diagnostic" }).pipe(
1300 Effect.as(new McpSchema.CallToolResult({ content: [] }))
1301 )
1302 })
1303 const result = yield* server.callTool({ name: "Emit" }).pipe(
1304 Effect.provideService(McpSchema.McpServerClient, directClient)
1305 )
1306 assert.deepStrictEqual(result.content, [])
1307 }))
1308
1309 it.effect("should fail when a resource URI is unknown", () =>
1310 Effect.gen(function*() {
1311 const server = yield* McpServer.McpServer.make
1312
1313 const error = yield* server.findResource("file:///unknown").pipe(
1314 Effect.provideService(McpSchema.McpServerClient, directClient),
1315 Effect.flip
1316 )
1317
1318 assertTrue(error instanceof McpSchema.InvalidParams)
1319 assert.strictEqual(error.message, "Resource 'file:///unknown' not found")
1320 }))
1321
1322 it.effect("should resolve an HTTP resource template", () =>
1323 Effect.gen(function*() {
1324 const server = yield* McpServer.McpServer.make
1325 yield* McpServer.registerResource`https://example.test/docs/${Schema.String}`({
1326 name: "document",
1327 content: (_uri, name) => Effect.succeed(name)
1328 }).pipe(Effect.provideService(McpServer.McpServer, server))
1329
1330 const uri = "https://example.test/docs/alice"
1331 const result = yield* server.findResource(uri).pipe(
1332 Effect.provideService(McpSchema.McpServerClient, directClient)
1333 )
1334
1335 assert.deepStrictEqual(result.contents, [{ uri, text: "alice" }])
1336 }))
1337
1338 it.effect("should preserve a registered resource handler's typed failure", () =>
1339 Effect.gen(function*() {
1340 const server = yield* McpServer.McpServer.make
1341 const failure = new McpSchema.InternalError({ message: "resource failed" })
1342 yield* server.addResource({
1343 resource: new McpSchema.Resource({
1344 uri: "file:///failure",
1345 name: "failure"
1346 }),
1347 annotations: Context.empty(),
1348 handle: Effect.fail(failure)
1349 })
1350
1351 const error = yield* server.findResource("file:///failure").pipe(
1352 Effect.provideService(McpSchema.McpServerClient, directClient),
1353 Effect.flip
1354 )
1355
1356 assert.strictEqual(error, failure)
1357 }))
1358
1359 it.effect("should pass decoded values to prompt handlers", () =>
1360 Effect.gen(function*() {
1361 const server = yield* McpServer.McpServer.make
1362 yield* McpServer.registerPrompt({
1363 name: "count",
1364 parameters: { count: Schema.FiniteFromString },
1365 completion: { count: () => Effect.succeed([13]) },
1366 content: ({ count }) => Effect.succeed(count.toFixed(0))
1367 }).pipe(Effect.provideService(McpServer.McpServer, server))
1368
1369 const result = yield* server.getPromptResult({ name: "count", arguments: { count: "12" } }).pipe(
1370 Effect.provideService(McpSchema.McpServerClient, directClient)
1371 )
1372 const completion = yield* server.completion({
1373 ref: { type: "ref/prompt", name: "count" },
1374 argument: { name: "count", value: "1" }
1375 }).pipe(Effect.provideService(McpSchema.McpServerClient, directClient))
1376
1377 assert.deepStrictEqual(result.messages, [{ role: "user", content: { type: "text", text: "12" } }])
1378 assert.deepStrictEqual(completion.completion.values, ["13"])
1379 }))
1380
1381 it.effect("should pass undefined to a low-level tool handler when arguments are omitted", () =>
1382 Effect.gen(function*() {
1383 const server = yield* McpServer.McpServer.make
1384 let received: unknown = "not called"
1385 yield* server.addTool({
1386 tool: new McpSchema.Tool({
1387 name: "arguments-omitted",
1388 inputSchema: {
1389 type: "object",
1390 properties: {}
1391 }
1392 }),
1393 annotations: Context.empty(),
1394 handle: (payload) => {
1395 received = payload
1396 return Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
1397 }
1398 })
1399
1400 yield* server.callTool({ name: "arguments-omitted" }).pipe(
1401 Effect.provideService(McpSchema.McpServerClient, directClient)
1402 )
1403
1404 assert.isUndefined(received)
1405 }))
1406
1407 it.effect("should provide the neutral request service to handlers", () =>
1408 Effect.gen(function*() {
1409 const server = yield* McpServer.McpServer.make
1410 let received: string | undefined
1411 yield* server.addTool({
1412 tool: new McpSchema.Tool({
1413 name: "request-services",
1414 inputSchema: { type: "object" }
1415 }),
1416 annotations: Context.empty(),
1417 handle: () =>
1418 McpSchema.McpRequestContext.useSync((request) => {
1419 received = request.protocolVersion
1420 return new McpSchema.CallToolResult({ content: [] })
1421 })
1422 })
1423
1424 yield* server.callTool({ name: "request-services" }).pipe(
1425 Effect.provideService(McpSchema.McpServerClient, directClient)
1426 )
1427
1428 assert.strictEqual(received, "2025-06-18")
1429 }))
1430 })
1431
1432 it.effect("should reject browser Origins by default while accepting Origin-less clients", () =>
1433 Effect.gen(function*() {
1434 const harness = yield* makeHttpHarness(TestServerLayer)
1435 assert.strictEqual(
1436 (yield* harness.post({
1437 jsonrpc: "2.0",
1438 id: 1,
1439 method: "initialize",
1440 params: initializePayload
1441 })).status,
1442 200
1443 )
1444 assert.strictEqual(
1445 (yield* harness.post({
1446 jsonrpc: "2.0",
1447 id: 2,
1448 method: "initialize",
1449 params: initializePayload
1450 }, { origin: "https://browser.example" })).status,
1451 403
1452 )
1453 }))
1454
1455 it.effect("should replay the selected protocol header when a session is initialized", () =>
1456 Effect.gen(function*() {
1457 const { client, responses } = yield* makeTestClient
1458
1459 yield* client.initialize({
1460 protocolVersion: "2025-03-26",
1461 capabilities: {},
1462 clientInfo: {
1463 name: "TestClient",
1464 version: "1.0.0"
1465 }
1466 })
1467
1468 yield* client.ping({})
1469
1470 strictEqual(responses.length, 2)
1471 strictEqual(responses[0].headers.get("Mcp-Protocol-Version"), "2025-06-18")
1472 strictEqual(responses[1].headers.get("Mcp-Protocol-Version"), "2025-06-18")
1473 }))
1474
1475 it.effect("should return 400 when a non-initialize request omits the MCP session id", () =>
1476 Effect.gen(function*() {
1477 const { httpClient } = yield* makeTestClient
1478
1479 const response = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
1480 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
1481 HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", method: "ping", params: {}, id: 0 }),
1482 httpClient.execute
1483 )
1484
1485 strictEqual(response.status, 400)
1486 }))
1487 it.effect("negotiates an initialize request from a client on an unsupported protocol version", () =>
1488 Effect.gen(function*() {
1489 const { httpClient } = yield* makeTestClientWith(LatestProtocolServerLayer)
1490
1491 const response = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
1492 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
1493 HttpClientRequest.setHeader("Mcp-Protocol-Version", "2025-06-18"),
1494 HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", id: 1, method: "initialize", params: initializePayload }),
1495 httpClient.execute
1496 )
1497
1498 strictEqual(response.status, 200)
1499 strictEqual(response.headers["mcp-protocol-version"], "2025-11-25")
1500 assertTrue(response.headers["mcp-session-id"] !== undefined)
1501 }))
1502
1503 describe("registerToolkit", () => {
1504 it.effect("registers non-strict tools with identified input schemas", () =>
1505 Effect.gen(function*() {
1506 const IdentifiedTool = Tool.make("IdentifiedTool", {
1507 parameters: Schema.Struct({ value: Schema.String }).annotate({ identifier: "Input / ~ %" }),
1508 success: Schema.String
1509 })
1510 const toolkit = Toolkit.make(IdentifiedTool)
1511 const server = yield* McpServer.McpServer.make
1512 yield* McpServer.registerToolkit(toolkit).pipe(
1513 Effect.provideService(McpServer.McpServer, server),
1514 Effect.provide(toolkit.toLayer({ IdentifiedTool: ({ value }) => Effect.succeed(value) }))
1515 )
1516
1517 const { inputSchema } = server.tools[0].tool
1518 assert.strictEqual(inputSchema.type, "object")
1519 assert.deepStrictEqual(inputSchema.properties, { value: { type: "string" } })
1520 assert.strictEqual(inputSchema.additionalProperties, true)
1521
1522 const result = yield* server.callTool({
1523 name: "IdentifiedTool",
1524 arguments: { value: "ok", typo: true }
1525 }).pipe(Effect.provideService(McpSchema.McpServerClient, directClient))
1526
1527 assert.strictEqual(result.structuredContent, "ok")
1528 }))
1529
1530 it.effect("reports all non-strict parameter errors while ignoring unknown keys", () =>
1531 Effect.gen(function*() {
1532 let handlerInvoked = false
1533 const NonStrictTool = Tool.make("NonStrictTool", {
1534 parameters: Schema.Struct({ first: Schema.String, second: Schema.Number }),
1535 success: Schema.String
1536 })
1537 const toolkit = Toolkit.make(NonStrictTool)
1538 const server = yield* McpServer.McpServer.make
1539 yield* McpServer.registerToolkit(toolkit).pipe(
1540 Effect.provideService(McpServer.McpServer, server),
1541 Effect.provide(toolkit.toLayer({
1542 NonStrictTool: () => {
1543 handlerInvoked = true
1544 return Effect.succeed("ok")
1545 }
1546 }))
1547 )
1548
1549 for (const args of [{ first: 1, second: "invalid", extra: true }, { extra: true }]) {
1550 const error = yield* server.callTool({ name: "NonStrictTool", arguments: args }).pipe(
1551 Effect.provideService(McpSchema.McpServerClient, directClient),
1552 Effect.flip
1553 )
1554
1555 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1556 assert.include(error.message, "[\"first\"]")
1557 assert.include(error.message, "[\"second\"]")
1558 assert.notInclude(error.message, "[\"extra\"]")
1559 assert.isFalse(handlerInvoked)
1560 }
1561 }))
1562
1563 it.effect("registers tools with identified output schemas", () =>
1564 Effect.gen(function*() {
1565 const IdentifiedResultTool = Tool.make("IdentifiedResultTool", {
1566 success: Schema.Struct({ value: Schema.String }).annotate({ identifier: "IdentifiedResult" })
1567 })
1568 const toolkit = Toolkit.make(IdentifiedResultTool)
1569 const server = yield* McpServer.McpServer.make
1570 yield* McpServer.registerToolkit(toolkit).pipe(
1571 Effect.provideService(McpServer.McpServer, server),
1572 Effect.provide(toolkit.toLayer({
1573 IdentifiedResultTool: () => Effect.succeed({ value: "ok" })
1574 }))
1575 )
1576
1577 assert.deepStrictEqual(server.tools[0].tool.outputSchema, {
1578 type: "object",
1579 properties: { value: { type: "string" } },
1580 required: ["value"],
1581 additionalProperties: true,
1582 $defs: {
1583 IdentifiedResult: {
1584 type: "object",
1585 properties: { value: { type: "string" } },
1586 required: ["value"],
1587 additionalProperties: true
1588 }
1589 }
1590 })
1591 }))
1592
1593 it.effect("advertises closed strict input schemas with escaped identifiers", () =>
1594 Effect.gen(function*() {
1595 const { client } = yield* makeToolkitTestClient()
1596
1597 const listed = yield* client["tools/list"]({})
1598 const tool = listed.tools.find((tool) => tool.name === "StrictObjectTool")
1599 assert.isDefined(tool)
1600 assert.strictEqual(tool.inputSchema.type, "object")
1601 assert.strictEqual(tool.inputSchema.additionalProperties, false)
1602 assert.deepInclude(tool.inputSchema.properties, {
1603 config: {
1604 type: "object",
1605 properties: { value: { type: "string" } },
1606 required: ["value"],
1607 additionalProperties: false
1608 }
1609 })
1610 }))
1611
1612 it.effect("validates strict return-mode arguments before invoking the handler and decodes them once", () =>
1613 Effect.gen(function*() {
1614 let handlerInvoked = false
1615 const { client } = yield* makeToolkitTestClient(TestToolkit.of({
1616 ...testToolkitHandlers,
1617 StrictObjectTool: ({ config }) => {
1618 handlerInvoked = true
1619 return Effect.succeed(config.value.toFixed(0))
1620 }
1621 }))
1622
1623 for (
1624 const args of [
1625 { config: { value: "1" }, typo: true },
1626 { config: { value: "1", typo: true } },
1627 { config: { value: 1 } }
1628 ]
1629 ) {
1630 const error = yield* client["tools/call"]({
1631 name: "StrictObjectTool",
1632 arguments: args
1633 }).pipe(Effect.flip)
1634
1635 assert.isFalse(handlerInvoked)
1636 assertTrue("code" in error)
1637 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1638 assert.include(error.message, "Invalid parameters for tool 'StrictObjectTool'")
1639 assert.notMatch(error.message, /AiError|ToolParameterValidationError|Toolkit/)
1640 }
1641 const result = yield* client["tools/call"]({
1642 name: "StrictObjectTool",
1643 arguments: { config: { value: "1" } }
1644 })
1645 assert.isTrue(handlerInvoked)
1646 assert.strictEqual(toolResultText(result), "1")
1647 }))
1648
1649 it.effect("reports all strict parameter errors without invoking the handler", () =>
1650 Effect.gen(function*() {
1651 let handlerInvoked = false
1652 const { client } = yield* makeToolkitTestClient(TestToolkit.of({
1653 ...testToolkitHandlers,
1654 StrictObjectTool: () => {
1655 handlerInvoked = true
1656 return Effect.succeed("unexpected")
1657 }
1658 }))
1659
1660 for (const config of [{ value: "1" }, { value: 1 }, {}]) {
1661 const error = yield* client["tools/call"]({
1662 name: "StrictObjectTool",
1663 arguments: { first: true, second: true, config: { ...config, third: true, fourth: true } }
1664 }).pipe(Effect.flip)
1665
1666 assertTrue("code" in error)
1667 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1668 assert.include(error.message, "Invalid parameters for tool 'StrictObjectTool'")
1669 for (const path of ["[\"first\"]", "[\"second\"]", "[\"config\"][\"third\"]", "[\"config\"][\"fourth\"]"]) {
1670 assert.include(error.message, path)
1671 }
1672 if (config.value !== "1") {
1673 assert.include(error.message, "[\"config\"][\"value\"]")
1674 }
1675 assert.isFalse(handlerInvoked)
1676 }
1677 }))
1678
1679 it.effect("dies on strict raw JSON Schema tools before registering any tools", () =>
1680 Effect.gen(function*() {
1681 const ValidTool = Tool.make("ValidTool", { success: Schema.String })
1682 const DynamicTool = Tool.dynamic("DynamicTool", {
1683 parameters: { type: "object" },
1684 success: Schema.String
1685 }).annotate(Tool.Strict, true)
1686 const toolkit = Toolkit.make(ValidTool, DynamicTool)
1687 const server = yield* McpServer.McpServer.make
1688
1689 const exit = yield* McpServer.registerToolkit(toolkit).pipe(
1690 Effect.provideService(McpServer.McpServer, server),
1691 Effect.provide(toolkit.toLayer({
1692 ValidTool: () => Effect.succeed("ok"),
1693 DynamicTool: () => Effect.succeed("ok")
1694 })),
1695 Effect.exit
1696 )
1697
1698 assertTrue(exit._tag === "Failure")
1699 assert.isTrue(Cause.hasDies(exit.cause))
1700 assert.match(
1701 String(Cause.squash(exit.cause)),
1702 /cannot strictly validate the raw JSON Schema for tool 'DynamicTool'/
1703 )
1704 assert.deepStrictEqual(server.tools, [])
1705 }))
1706
1707 it.effect("preserves existing tools when a later toolkit schema is invalid for MCP", () =>
1708 Effect.gen(function*() {
1709 const ValidTool = Tool.make("ValidTool", { success: Schema.String })
1710 const InvalidTool = Tool.make("InvalidTool", {
1711 parameters: Schema.String,
1712 success: Schema.String
1713 }).annotate(Tool.Strict, true)
1714 const toolkit = Toolkit.make(ValidTool, InvalidTool)
1715 const server = yield* McpServer.McpServer.make
1716 yield* server.addTool({
1717 tool: new McpSchema.Tool({ name: "ValidTool", inputSchema: { type: "object" } }),
1718 annotations: Context.empty(),
1719 handle: () =>
1720 Effect.succeed(
1721 new McpSchema.CallToolResult({
1722 content: [{ type: "text", text: "existing" }]
1723 })
1724 )
1725 })
1726
1727 const exit = yield* McpServer.registerToolkit(toolkit).pipe(
1728 Effect.provideService(McpServer.McpServer, server),
1729 Effect.provide(toolkit.toLayer({
1730 ValidTool: () => Effect.succeed("replacement"),
1731 InvalidTool: () => Effect.succeed("invalid")
1732 })),
1733 Effect.exit
1734 )
1735 const existing = yield* server.callTool({ name: "ValidTool" }).pipe(
1736 Effect.provideService(McpSchema.McpServerClient, directClient)
1737 )
1738
1739 assertTrue(exit._tag === "Failure")
1740 assert.deepStrictEqual(server.tools.map(({ tool }) => tool.name), ["ValidTool"])
1741 assert.deepStrictEqual(existing.content, [{ type: "text", text: "existing" }])
1742 }))
1743
1744 describe("dies with a message naming the tool when parameters are not object-rooted", () => {
1745 const Echo = Tool.make("echo", {
1746 parameters: Schema.Struct({ text: Schema.String }),
1747 success: Schema.String
1748 })
1749 const register = Effect.fnUntraced(function*(invalid: Tool.Any) {
1750 const toolkit = Toolkit.make(Echo, invalid)
1751 const server = yield* McpServer.McpServer.make
1752 const exit = yield* McpServer.registerToolkit(toolkit).pipe(
1753 Effect.provideService(McpServer.McpServer, server),
1754 Effect.provide(toolkit.toLayer({
1755 echo: ({ text }: { readonly text: string }) => Effect.succeed(text),
1756 [invalid.name]: () => Effect.succeed("ok")
1757 } as any)),
1758 Effect.exit
1759 )
1760 assertTrue(exit._tag === "Failure")
1761 assert.isTrue(Cause.hasDies(exit.cause))
1762 assert.deepStrictEqual(server.tools, [])
1763 return String(Cause.squash(exit.cause))
1764 })
1765
1766 it.effect("union of structs", () =>
1767 Effect.gen(function*() {
1768 const message = yield* register(Tool.make("act", {
1769 parameters: Schema.Union([
1770 Schema.Struct({ action: Schema.Literal("start"), id: Schema.String }),
1771 Schema.Struct({ action: Schema.Literal("stop"), reason: Schema.String })
1772 ]),
1773 success: Schema.String
1774 }))
1775 assert.match(
1776 message,
1777 /cannot register tool 'act': its parameters must encode to a JSON Schema with an object root/
1778 )
1779 assert.include(message, "Tool.EmptyParams")
1780 }))
1781
1782 it.effect("empty struct", () =>
1783 Effect.gen(function*() {
1784 const message = yield* register(Tool.make("empty", {
1785 parameters: Schema.Struct({}),
1786 success: Schema.String
1787 }))
1788 assert.match(message, /cannot register tool 'empty'/)
1789 assert.include(message, "Tool.EmptyParams")
1790 }))
1791
1792 it.effect("raw JSON Schema", () =>
1793 Effect.gen(function*() {
1794 const message = yield* register(Tool.dynamic("raw", {
1795 parameters: { anyOf: [{ type: "object" }, { type: "string" }] },
1796 success: Schema.String
1797 }))
1798 assert.match(message, /cannot register tool 'raw': its raw JSON Schema must have an object root/)
1799 }))
1800 })
1801
1802 it.effect("lists output schemas only for structured tool results", () =>
1803 Effect.gen(function*() {
1804 const { client } = yield* makeToolkitTestClient()
1805
1806 const result = yield* client["tools/list"]({})
1807 const structuredTool = result.tools.find((tool) => tool.name === "StructuredResultTool")
1808 const scalarTool = result.tools.find((tool) => tool.name === "OptionalStringTool")
1809 const untypedTool = result.tools.find((tool) => tool.name === "UntypedTool")
1810 const annotatedVoidTool = result.tools.find((tool) => tool.name === "AnnotatedVoidTool")
1811
1812 assert.deepStrictEqual(structuredTool?.outputSchema, {
1813 type: "object",
1814 properties: { answer: { type: "string" } },
1815 required: ["answer"],
1816 additionalProperties: true
1817 })
1818 assertTrue(scalarTool !== undefined)
1819 assert.isFalse("outputSchema" in scalarTool)
1820 assertTrue(untypedTool !== undefined)
1821 assert.isFalse("outputSchema" in untypedTool)
1822 assertTrue(annotatedVoidTool !== undefined)
1823 assert.isFalse("outputSchema" in annotatedVoidTool)
1824 }))
1825
1826 it.effect("returns concise parameter-validation errors without invoking the handler", () =>
1827 Effect.gen(function*() {
1828 let handlerInvoked = false
1829 const { client, reported } = yield* makeToolkitTestClient(TestToolkit.of({
1830 ...testToolkitHandlers,
1831 OptionalStringTool: ({ signature }) => {
1832 handlerInvoked = true
1833 return Effect.succeed(signature ?? "omitted")
1834 }
1835 }))
1836
1837 const error = yield* client["tools/call"]({
1838 name: "OptionalStringTool",
1839 arguments: { signature: null }
1840 }).pipe(Effect.flip)
1841
1842 assert.isFalse(handlerInvoked)
1843 assert.isTrue("code" in error)
1844 if ("code" in error) assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
1845 assert.match(error.message, /Invalid parameters for tool 'OptionalStringTool'/)
1846 assert.match(error.message, /Expected string \| undefined/)
1847 assert.match(error.message, /at \["signature"\]/)
1848 assert.lengthOf(reported, 1)
1849 assert.isTrue(Cause.hasFails(reported[0]))
1850 assert.deepInclude(Cause.squash(reported[0]), {
1851 _tag: "ProtocolError",
1852 code: McpSchema.INVALID_PARAMS_ERROR_CODE,
1853 message: error.message
1854 })
1855 }))
1856
1857 it.effect("preserves successful results when optional parameters are omitted", () =>
1858 Effect.gen(function*() {
1859 let handlerInvoked = false
1860 const { client } = yield* makeToolkitTestClient(TestToolkit.of({
1861 ...testToolkitHandlers,
1862 OptionalStringTool: ({ signature }) => {
1863 handlerInvoked = true
1864 return Effect.succeed(signature ?? "omitted")
1865 }
1866 }))
1867
1868 const result = yield* client["tools/call"]({
1869 name: "OptionalStringTool",
1870 arguments: {}
1871 })
1872
1873 assert.isTrue(handlerInvoked)
1874 assert.deepStrictEqual(
1875 result,
1876 new McpSchema.CallToolResult({
1877 isError: false,
1878 content: [{ type: "text", text: "omitted" }]
1879 })
1880 )
1881 }))
1882
1883 it.effect("keeps void tool results successful", () =>
1884 Effect.gen(function*() {
1885 const { client, reported } = yield* makeToolkitTestClient()
1886
1887 const result = yield* client["tools/call"]({
1888 name: "UntypedTool",
1889 arguments: {}
1890 })
1891
1892 assert.deepStrictEqual(
1893 result,
1894 new McpSchema.CallToolResult({
1895 isError: false,
1896 content: []
1897 })
1898 )
1899 assert.deepStrictEqual(reported, [])
1900 }))
1901
1902 it.effect("carries object tool results as structured content", () =>
1903 Effect.gen(function*() {
1904 const { client } = yield* makeToolkitTestClient()
1905
1906 const result = yield* client["tools/call"]({
1907 name: "StructuredResultTool",
1908 arguments: {}
1909 })
1910
1911 assert.deepStrictEqual(result.structuredContent, { answer: "result" })
1912 assert.deepStrictEqual(result.content, [{
1913 type: "text",
1914 text: JSON.stringify({ answer: "result" })
1915 }])
1916 }))
1917
1918 it.effect("omits structured content for null and array tool results", () =>
1919 Effect.gen(function*() {
1920 const { client } = yield* makeToolkitTestClient()
1921
1922 const nullResult = yield* client["tools/call"]({
1923 name: "NullableResultTool",
1924 arguments: {}
1925 })
1926
1927 assert.isUndefined(nullResult.structuredContent)
1928 assert.deepStrictEqual(nullResult.content, [{ type: "text", text: "null" }])
1929
1930 const arrayResult = yield* client["tools/call"]({
1931 name: "ArrayResultTool",
1932 arguments: {}
1933 })
1934
1935 assert.isUndefined(arrayResult.structuredContent)
1936 assert.deepStrictEqual(arrayResult.content, [{
1937 type: "text",
1938 text: JSON.stringify(["first", "second"])
1939 }])
1940 }))
1941
1942 it.effect("returns schema-validated messages for declared handler failures", () =>
1943 Effect.gen(function*() {
1944 const { client, logged, reported } = yield* makeToolkitTestClient()
1945
1946 const result = yield* client["tools/call"]({
1947 name: "PublicFailureTool",
1948 arguments: {}
1949 })
1950
1951 assert.strictEqual(result.isError, true)
1952 const text = toolResultText(result)
1953 assert.strictEqual(text, "Public failure")
1954 assert.isUndefined(result.structuredContent)
1955 assert.deepStrictEqual(reported, [])
1956 assert.deepStrictEqual(logged, [])
1957 }))
1958
1959 it.effect("encodes declared non-Error failures without structured content or internal diagnostics", () =>
1960 Effect.gen(function*() {
1961 const { client, logged, reported } = yield* makeToolkitTestClient()
1962 const result = yield* client["tools/call"]({ name: "StructuredFailureTool" })
1963
1964 assert.isTrue(result.isError)
1965 assert.isUndefined(result.structuredContent)
1966 assert.deepStrictEqual(result.content, [{
1967 type: "text",
1968 text: JSON.stringify({ reason: "busy", retryAfter: "5" })
1969 }])
1970 assert.deepStrictEqual(reported, [])
1971 assert.deepStrictEqual(logged, [])
1972 }))
1973
1974 it.effect("encodes declared Error failures without a message", () =>
1975 Effect.gen(function*() {
1976 class NoteNotFound extends Schema.TaggedError<NoteNotFound>()("NoteNotFound", {
1977 id: Schema.String
1978 }) {}
1979 const toolkit = Toolkit.make(Tool.make("GetNote", {
1980 parameters: Schema.Struct({ id: Schema.String }),
1981 failure: NoteNotFound
1982 }))
1983 const server = yield* McpServer.McpServer.make
1984 yield* McpServer.registerToolkit(toolkit).pipe(
1985 Effect.provideService(McpServer.McpServer, server),
1986 Effect.provide(toolkit.toLayer({ GetNote: ({ id }) => Effect.fail(new NoteNotFound({ id })) }))
1987 )
1988 const result = yield* server.callTool({ name: "GetNote", arguments: { id: "n1" } }).pipe(
1989 Effect.provideService(McpSchema.McpServerClient, directClient)
1990 )
1991
1992 assert.isTrue(result.isError)
1993 assert.isUndefined(result.structuredContent)
1994 assert.strictEqual(toolResultText(result), JSON.stringify({ _tag: "NoteNotFound", id: "n1" }))
1995 }))
1996
1997 it.effect("scrubs and records serialization failures in declared non-Error failures", () =>
1998 Effect.gen(function*() {
1999 const { client, logged, reported } = yield* makeToolkitTestClient(TestToolkit.of({
2000 ...testToolkitHandlers,
2001 StructuredFailureTool: () => Effect.fail({ reason: "busy", retryAfter: 5, detail: 1n })
2002 }))
2003 const result = yield* client["tools/call"]({ name: "StructuredFailureTool" })
2004
2005 assert.isTrue(result.isError)
2006 assert.isUndefined(result.structuredContent)
2007 assert.deepStrictEqual(result.content, [{ type: "text", text: INTERNAL_TOOL_ERROR_MESSAGE }])
2008 assert.lengthOf(reported, 1)
2009 assert.instanceOf(Cause.squash(reported[0]), TypeError)
2010 assert.lengthOf(logged, 1)
2011 assert.strictEqual(Cause.squash(logged[0]), Cause.squash(reported[0]))
2012 }))
2013
2014 for (
2015 const [origin, failureMode] of [
2016 ["handler", "error"],
2017 ["handler", "return"],
2018 ["ToolResultEncodingError", "error"],
2019 ["InvalidToolResultError", "return"]
2020 ] as const
2021 ) {
2022 it.effect(`distinguishes ${origin} failures from decoding failures in ${failureMode} mode`, () =>
2023 Effect.gen(function*() {
2024 let receivedValue: string | undefined
2025 const reported: Array<Cause.Cause<unknown>> = []
2026 const logged: Array<Cause.Cause<unknown>> = []
2027 const failure = AiError.make({
2028 module: "Toolkit",
2029 method: "NestedTool.handle",
2030 reason: new AiError.ToolParameterValidationError({
2031 toolName: "NestedTool",
2032 description: "Nested arguments failed validation"
2033 })
2034 })
2035 const toolkit = Toolkit.make(Tool.make("OriginTool", {
2036 parameters: Schema.Struct({ value: Schema.String }),
2037 success: Schema.Struct({ answer: Schema.String.check(Schema.isMinLength(10)) }),
2038 failure: AiError.AiError,
2039 failureMode
2040 }))
2041 const server = yield* McpServer.McpServer.make
2042 yield* McpServer.registerToolkit(toolkit).pipe(
2043 Effect.provideService(McpServer.McpServer, server),
2044 Effect.provide(toolkit.toLayer({
2045 OriginTool: ({ value }) => {
2046 receivedValue = value
2047 return origin === "handler" ? Effect.fail(failure) : origin === "ToolResultEncodingError"
2048 ? Effect.succeed({ answer: "short" })
2049 : Schema.decodeUnknownEffect(Schema.Struct({ answer: Schema.String }))({ privateDetail: "invalid" })
2050 .pipe(
2051 Effect.mapError((error) => error as never)
2052 )
2053 }
2054 })),
2055 Effect.provide(ErrorReporter.layer([ErrorReporter.make(({ cause }) => {
2056 reported.push(cause)
2057 })])),
2058 Effect.provide(Logger.layer([Logger.make(({ cause, logLevel }) => {
2059 if (logLevel === "Error") logged.push(cause)
2060 })]))
2061 )
2062 const result = yield* server.callTool({ name: "OriginTool", arguments: { value: "valid" } }).pipe(
2063 Effect.provideService(McpSchema.McpServerClient, directClient)
2064 )
2065
2066 assert.strictEqual(receivedValue, "valid")
2067 assert.isTrue(result.isError)
2068 assert.isUndefined(result.structuredContent)
2069 if (origin === "handler") {
2070 assert.strictEqual(
2071 toolResultText(result),
2072 failureMode === "error"
2073 ? failure.message
2074 : JSON.stringify(yield* Schema.encodeEffect(AiError.AiError)(failure))
2075 )
2076 assert.deepStrictEqual(reported, [])
2077 assert.deepStrictEqual(logged, [])
2078 } else {
2079 assert.strictEqual(toolResultText(result), INTERNAL_TOOL_ERROR_MESSAGE)
2080 assert.lengthOf(reported, 1)
2081 const error = Cause.squash(reported[0])
2082 assertTrue(AiError.isAiError(error))
2083 assert.strictEqual(error.reason._tag, origin)
2084 assert.lengthOf(logged, 1)
2085 }
2086 }))
2087 }
2088
2089 it.effect("returns a generic message for non-validation AiError failures", () =>
2090 Effect.gen(function*() {
2091 const { client, logged, reported } = yield* makeToolkitTestClient()
2092
2093 const result = yield* client["tools/call"]({
2094 name: "InternalAiErrorTool",
2095 arguments: {}
2096 })
2097
2098 assert.strictEqual(result.isError, true)
2099 const text = toolResultText(result)
2100 assert.strictEqual(text, INTERNAL_TOOL_ERROR_MESSAGE)
2101 assert.lengthOf(reported, 1)
2102 assert.isTrue(Cause.hasFails(reported[0]))
2103 assert.strictEqual(Cause.squash(reported[0]), internalAiError)
2104 assert.lengthOf(logged, 1)
2105 assert.strictEqual(Cause.squash(logged[0]), internalAiError)
2106 }))
2107
2108 for (const reportErrors of [true, false]) {
2109 it.effect(`logs handler defects with reportErrors=${reportErrors} and scrubs the client message`, () =>
2110 Effect.gen(function*() {
2111 const { client, logged, reported } = yield* makeToolkitTestClient(undefined, { reportErrors })
2112
2113 const result = yield* client["tools/call"]({
2114 name: "DefectTool",
2115 arguments: {}
2116 })
2117
2118 assert.strictEqual(result.isError, true)
2119 const text = toolResultText(result)
2120 assert.strictEqual(text, INTERNAL_TOOL_ERROR_MESSAGE)
2121 assert.isUndefined(result.structuredContent)
2122 assert.lengthOf(reported, reportErrors ? 1 : 0)
2123 if (reportErrors) {
2124 assert.isTrue(Cause.hasDies(reported[0]))
2125 assert.strictEqual(Cause.squash(reported[0]), privateDefect)
2126 }
2127 assert.lengthOf(logged, 1)
2128 assert.isTrue(Cause.hasDies(logged[0]))
2129 assert.strictEqual(Cause.squash(logged[0]), privateDefect)
2130 }))
2131 }
2132
2133 it.effect("reports response serialization defects before returning a generic message", () =>
2134 Effect.gen(function*() {
2135 const { client, reported } = yield* makeToolkitTestClient()
2136
2137 const result = yield* client["tools/call"]({
2138 name: "UnserializableResultTool",
2139 arguments: {}
2140 })
2141
2142 assert.strictEqual(result.isError, true)
2143 assert.strictEqual(toolResultText(result), INTERNAL_TOOL_ERROR_MESSAGE)
2144 assert.lengthOf(reported, 1)
2145 assert.isTrue(Cause.hasDies(reported[0]))
2146 assert.instanceOf(Cause.squash(reported[0]), TypeError)
2147 }))
2148
2149 it.effect("keeps unknown tools as protocol errors", () =>
2150 Effect.gen(function*() {
2151 const { client } = yield* makeToolkitTestClient()
2152
2153 const error = yield* client["tools/call"]({
2154 name: "UnknownTool",
2155 arguments: {}
2156 }).pipe(Effect.flip)
2157
2158 assert.isTrue("code" in error)
2159 if ("code" in error) {
2160 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
2161 assert.strictEqual(error.message, "Tool 'UnknownTool' not found")
2162 }
2163 }))
2164 })
2165
2166 it.effect("rejects unsupported HTTP methods without disturbing an initialized session", () =>
2167 Effect.gen(function*() {
2168 const { client, httpClient } = yield* makeTestClient
2169
2170 yield* client.initialize(initializePayload)
2171
2172 for (const method of ["GET", "PUT", "PATCH", "DELETE", "HEAD"] as const) {
2173 const response = yield* HttpClientRequest.make(method)("http://localhost/mcp").pipe(
2174 httpClient.execute
2175 )
2176 strictEqual(response.status, 405)
2177 strictEqual(response.headers["allow"], "POST")
2178 }
2179
2180 yield* client.ping({})
2181 }))
2182
2183 it.effect("returns an empty 202 for notifications and responses and remains successful for request POSTs", () =>
2184 Effect.gen(function*() {
2185 const { client, httpClient } = yield* makeRouterTestClient(HttpRouter.cors())
2186
2187 yield* client.initialize(initializePayload)
2188
2189 const notificationResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2190 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2191 HttpClientRequest.bodyJsonUnsafe({
2192 jsonrpc: "2.0",
2193 method: "notifications/initialized",
2194 params: {}
2195 }),
2196 httpClient.execute
2197 )
2198 strictEqual(notificationResponse.status, 202)
2199 strictEqual(yield* notificationResponse.text, "")
2200 strictEqual(notificationResponse.headers["content-type"], undefined)
2201 strictEqual(notificationResponse.headers["access-control-allow-origin"], "*")
2202 strictEqual(notificationResponse.headers["mcp-protocol-version"], "2025-06-18")
2203
2204 const responseOnly = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2205 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2206 HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }),
2207 httpClient.execute
2208 )
2209 strictEqual(responseOnly.status, 202)
2210 strictEqual(yield* responseOnly.text, "")
2211
2212 const pingResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2213 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2214 HttpClientRequest.bodyJsonUnsafe(pingBody),
2215 httpClient.execute
2216 )
2217 strictEqual(pingResponse.status, 200)
2218 const pingResponseBody = yield* pingResponse.text
2219 strictEqual(pingResponseBody.length > 0, true)
2220 }))
2221
2222 it.effect("drops server notifications from buffered JSON-RPC responses", () =>
2223 Effect.gen(function*() {
2224 const serverLayer = Layer.effectDiscard(Effect.gen(function*() {
2225 const router = yield* HttpRouter.HttpRouter
2226 const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect()
2227 yield* protocol.run((clientId, message) => {
2228 if (message._tag !== "Request") {
2229 return Effect.void
2230 }
2231 return Effect.gen(function*() {
2232 yield* protocol.send(clientId, {
2233 _tag: "Request",
2234 id: "",
2235 tag: "notifications/message",
2236 payload: { level: "info" },
2237 headers: [],
2238 isNotification: true
2239 })
2240 yield* protocol.send(clientId, {
2241 _tag: "Exit",
2242 requestId: message.id,
2243 exit: { _tag: "Success", value: { ok: true } }
2244 })
2245 yield* protocol.end(clientId)
2246 })
2247 }).pipe(Effect.forkScoped)
2248 yield* router.add("POST", "/mcp", () => httpEffect)
2249 })).pipe(
2250 Layer.provideMerge(HttpRouter.layer),
2251 Layer.provide(RpcSerialization.layerJsonRpc())
2252 )
2253 const harness = yield* makeHttpHarness(serverLayer)
2254
2255 const response = yield* harness.post({
2256 jsonrpc: "2.0",
2257 method: "ping",
2258 params: {},
2259 id: 1
2260 })
2261
2262 assert.strictEqual(response.status, 200)
2263 assert.deepStrictEqual(yield* Effect.promise(() => response.json()), {
2264 jsonrpc: "2.0",
2265 id: 1,
2266 result: { ok: true }
2267 })
2268 }))
2269
2270 it.effect("validates supplied protocol versions on POST", () =>
2271 Effect.gen(function*() {
2272 const { client, httpClient } = yield* makeRouterTestClient(HttpRouter.cors())
2273
2274 yield* client.initialize(initializePayload)
2275
2276 const unsupportedResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2277 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2278 HttpClientRequest.bodyJsonUnsafe(pingBody),
2279 HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"),
2280 httpClient.execute
2281 )
2282 strictEqual(unsupportedResponse.status, 400)
2283 strictEqual(yield* unsupportedResponse.text, "")
2284 strictEqual(unsupportedResponse.headers["access-control-allow-origin"], "*")
2285
2286 const malformedResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2287 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2288 HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"),
2289 HttpClientRequest.bodyText("{"),
2290 HttpClientRequest.setHeader("content-type", "application/json"),
2291 httpClient.execute
2292 )
2293 strictEqual(malformedResponse.status, 400)
2294 strictEqual(yield* malformedResponse.text, "")
2295
2296 const malformedNoVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2297 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2298 HttpClientRequest.bodyText("{"),
2299 HttpClientRequest.setHeader("content-type", "application/json"),
2300 httpClient.execute
2301 )
2302 strictEqual(malformedNoVersionResponse.status, 200)
2303 const malformedNoVersionBody = JSON.parse(yield* malformedNoVersionResponse.text)
2304 strictEqual(malformedNoVersionBody.id, null)
2305 strictEqual(malformedNoVersionBody.error.code, McpSchema.PARSE_ERROR_CODE)
2306
2307 const invalidRequestResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2308 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2309 HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"),
2310 HttpClientRequest.bodyJsonUnsafe({ hello: "world" }),
2311 httpClient.execute
2312 )
2313 strictEqual(invalidRequestResponse.status, 400)
2314 strictEqual(yield* invalidRequestResponse.text, "")
2315
2316 const invalidRequestNoVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2317 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2318 HttpClientRequest.bodyJsonUnsafe({ hello: "world" }),
2319 httpClient.execute
2320 )
2321 strictEqual(invalidRequestNoVersionResponse.status, 200)
2322 const invalidRequestNoVersionBody = JSON.parse(yield* invalidRequestNoVersionResponse.text)
2323 strictEqual(invalidRequestNoVersionBody.id, null)
2324 strictEqual(invalidRequestNoVersionBody.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
2325
2326 const invalidInitializeResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2327 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2328 HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"),
2329 HttpClientRequest.bodyJsonUnsafe({ method: "initialize", id: 7 }),
2330 httpClient.execute
2331 )
2332 strictEqual(invalidInitializeResponse.status, 400)
2333 strictEqual(yield* invalidInitializeResponse.text, "")
2334
2335 const responseOnly = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2336 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2337 HttpClientRequest.bodyJsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }),
2338 HttpClientRequest.setHeader("Mcp-Protocol-Version", "9999-01-01"),
2339 httpClient.execute
2340 )
2341 strictEqual(responseOnly.status, 400)
2342 strictEqual(yield* responseOnly.text, "")
2343
2344 const absentVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2345 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2346 HttpClientRequest.bodyJsonUnsafe(pingBody),
2347 httpClient.execute
2348 )
2349 strictEqual(absentVersionResponse.status, 200)
2350
2351 for (const protocolVersion of ["2025-03-26", "2024-11-05", "2024-10-07"]) {
2352 const response = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2353 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2354 HttpClientRequest.bodyJsonUnsafe(pingBody),
2355 HttpClientRequest.setHeader("Mcp-Protocol-Version", protocolVersion),
2356 httpClient.execute
2357 )
2358 strictEqual(response.status, 400)
2359 }
2360
2361 const declaredVersionResponse = yield* HttpClientRequest.post("http://localhost/mcp").pipe(
2362 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
2363 HttpClientRequest.bodyJsonUnsafe(pingBody),
2364 HttpClientRequest.setHeader("Mcp-Protocol-Version", "2025-06-18"),
2365 httpClient.execute
2366 )
2367 strictEqual(declaredVersionResponse.status, 200)
2368 }))
2369 describe("protocol selection", () => {
2370 it.effect("should select June when an unsupported version is offered", () =>
2371 Effect.gen(function*() {
2372 const { client, responses } = yield* makeTestClient
2373
2374 const result = yield* client.initialize({
2375 protocolVersion: "2024-10-07",
2376 capabilities: {},
2377 clientInfo: {
2378 name: "TestClient",
2379 version: "1.0.0"
2380 }
2381 })
2382
2383 strictEqual(result.protocolVersion, "2025-06-18")
2384 strictEqual(responses[0].headers.get("Mcp-Protocol-Version"), "2025-06-18")
2385 }))
2386 })
2387
2388 describe("list-change notification scheduling", () => {
2389 it.effect("should coalesce notifications when one registration kind changes repeatedly in a scheduling window", () =>
2390 Effect.gen(function*() {
2391 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2026_07_28)
2392 const makeTool = (name: string) => ({
2393 tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }),
2394 annotations: Context.empty(),
2395 handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
2396 })
2397 const makePrompt = (name: string) => ({
2398 prompt: new McpSchema.Prompt({ name }),
2399 annotations: Context.empty(),
2400 completions: {},
2401 handle: () =>
2402 Effect.succeed(
2403 new McpSchema.GetPromptResult({
2404 messages: [{ role: "user", content: { type: "text", text: name } }]
2405 })
2406 )
2407 })
2408
2409 yield* fixture.server.addTool(makeTool("baseline-tool"))
2410 yield* fixture.server.addPrompt(makePrompt("baseline-prompt"))
2411 yield* fixture.flushListChanged
2412 yield* fixture.initialize()
2413 const subscription = yield* fixture.startRequest("subscriptions/listen", {
2414 notifications: { toolsListChanged: true, promptsListChanged: true }
2415 }, "coalesced-list-change")
2416 assert.strictEqual((yield* fixture.takeMessage).method, "notifications/subscriptions/acknowledged")
2417
2418 yield* fixture.server.addTool(makeTool("coalesced-tool-first"))
2419 yield* fixture.server.addTool(makeTool("coalesced-tool-second"))
2420 yield* fixture.server.addPrompt(makePrompt("coalescing-sentinel"))
2421 yield* fixture.flushListChanged
2422
2423 assert.strictEqual((yield* fixture.takeMessage).method, "notifications/tools/list_changed")
2424 assert.strictEqual((yield* fixture.takeMessage).method, "notifications/prompts/list_changed")
2425
2426 yield* fixture.server.addTool(makeTool("next-window-tool"))
2427 yield* fixture.flushListChanged
2428 assert.strictEqual((yield* fixture.takeMessage).method, "notifications/tools/list_changed")
2429
2430 yield* subscription.cancel()
2431 }))
2432 })
2433
2434 describe("resource subscriptions", () => {
2435 // Releasing the upstream listener on overflow is this server's memory-bound policy,
2436 // not an MCP conformance requirement. Transport writes may stay blocked indefinitely.
2437 for (const blockedWrite of ["acknowledgment", "cancellation"] as const) {
2438 it.effect(`should release the overflowing listener when its ${blockedWrite} write remains blocked`, () =>
2439 Effect.gen(function*() {
2440 const core = yield* McpCore.make
2441 const events = yield* PubSub.unbounded<McpProtocolInternal.CanonicalServerNotification>()
2442 const acknowledgmentStarted = yield* Deferred.make<void>()
2443 const releaseWrite = yield* Deferred.make<void>()
2444 const overflowed = yield* Deferred.make<void>()
2445 const subscriptionReleased = yield* Deferred.make<void>()
2446 const sent = yield* Queue.unbounded<"acknowledged" | "cancelled">()
2447 const handlers = McpProtocol2026.makeHandlers(core, undefined, {
2448 subscribeServerNotifications: Effect.acquireRelease(
2449 PubSub.subscribe(events),
2450 () => Deferred.succeed(subscriptionReleased, undefined)
2451 ),
2452 sendNotification: (_protocolVersion, _clientId, notification) =>
2453 notification.tag === McpSchema2026.SubscriptionsAcknowledgedNotification._tag
2454 ? Deferred.succeed(acknowledgmentStarted, undefined).pipe(
2455 Effect.andThen(blockedWrite === "acknowledgment" ? Deferred.await(releaseWrite) : Effect.void),
2456 Effect.andThen(Queue.offer(sent, "acknowledged")),
2457 Effect.asVoid
2458 )
2459 : Effect.never,
2460 markSubscriptionCancelled: () => Deferred.succeed(overflowed, undefined).pipe(Effect.asVoid),
2461 terminateSubscription: () =>
2462 (blockedWrite === "cancellation" ? Deferred.await(releaseWrite) : Effect.void).pipe(
2463 Effect.andThen(Queue.offer(sent, "cancelled")),
2464 Effect.asVoid
2465 ),
2466 supportedVersions: [McpSchema2026.protocolVersion],
2467 serverInfo: { name: "SubscriptionBacklogTest", version: "1.0.0" },
2468 registrationPresence: Effect.succeed({ tools: true, resources: false, prompts: false })
2469 })
2470 yield* handlers["subscriptions/listen"](
2471 {
2472 notifications: { toolsListChanged: true },
2473 _meta: {
2474 "io.modelcontextprotocol/protocolVersion": McpSchema2026.protocolVersion,
2475 "io.modelcontextprotocol/clientCapabilities": {}
2476 }
2477 },
2478 { client: new Rpc.ServerClient(1), requestId: RequestId("blocked-write") }
2479 ).pipe(Effect.scoped, Effect.forkScoped)
2480 yield* Deferred.await(acknowledgmentStarted)
2481
2482 for (let index = 0; index <= 65; index++) {
2483 yield* PubSub.publish(events, {
2484 notification: McpCore.ServerNotification.ToolsChanged({})
2485 })
2486 yield* Effect.yieldNow
2487 }
2488 yield* Deferred.await(overflowed)
2489 assert.isTrue(yield* Deferred.isDone(subscriptionReleased))
2490 yield* Deferred.succeed(releaseWrite, undefined)
2491
2492 assert.strictEqual(yield* Queue.take(sent), "acknowledged")
2493 assert.strictEqual(yield* Queue.take(sent), "cancelled")
2494 }))
2495 }
2496
2497 it.effect("should terminate only the slow subscription when its pending backlog overflows", () =>
2498 Effect.gen(function*() {
2499 const pendingNotificationLimit = 64
2500 const core = yield* McpCore.make
2501 const events = yield* PubSub.unbounded<McpProtocolInternal.CanonicalServerNotification>()
2502 const slowAcknowledged = yield* Deferred.make<void>()
2503 const fastAcknowledged = yield* Deferred.make<void>()
2504 const slowWriteStarted = yield* Deferred.make<void>()
2505 const blockSlowWrite = yield* Deferred.make<void>()
2506 const slowSubscriptionReleased = yield* Deferred.make<void>()
2507 const fastSubscriptionReleased = yield* Deferred.make<void>()
2508 const slowTerminated = yield* Deferred.make<readonly [string, number, RpcMessage.RequestId, string]>()
2509 const subscriptionsStarted = yield* Ref.make(0)
2510 const fastDeliveries = yield* Ref.make(0)
2511 const fastDeliveryCompleted = yield* Queue.unbounded<void>()
2512 const handlers = McpProtocol2026.makeHandlers(core, undefined, {
2513 subscribeServerNotifications: Effect.acquireRelease(
2514 Effect.all([
2515 PubSub.subscribe(events),
2516 Ref.getAndUpdate(subscriptionsStarted, (count) => count + 1)
2517 ]),
2518 ([, subscriptionIndex]) =>
2519 Deferred.succeed(
2520 subscriptionIndex === 0 ? slowSubscriptionReleased : fastSubscriptionReleased,
2521 undefined
2522 )
2523 ).pipe(Effect.map(([subscription]) => subscription)),
2524 sendNotification: (_protocolVersion, clientId, notification) => {
2525 if (notification.tag === McpSchema2026.SubscriptionsAcknowledgedNotification._tag) {
2526 return Deferred.succeed(clientId === 1 ? slowAcknowledged : fastAcknowledged, undefined)
2527 }
2528 if (clientId === 1) {
2529 return Deferred.succeed(slowWriteStarted, undefined).pipe(
2530 Effect.andThen(Deferred.await(blockSlowWrite))
2531 )
2532 }
2533 return Ref.update(fastDeliveries, (count) => count + 1).pipe(
2534 Effect.andThen(Queue.offer(fastDeliveryCompleted, undefined)),
2535 Effect.asVoid
2536 )
2537 },
2538 terminateSubscription: (protocolVersion, clientId, requestId, reason) =>
2539 Deferred.succeed(slowTerminated, [protocolVersion, clientId, requestId, reason]),
2540 supportedVersions: [McpSchema2026.protocolVersion],
2541 serverInfo: { name: "SubscriptionBacklogTest", version: "1.0.0" },
2542 registrationPresence: Effect.succeed({ tools: true, resources: false, prompts: false })
2543 })
2544 const listen = (clientId: number, requestId: string) =>
2545 handlers["subscriptions/listen"](
2546 {
2547 notifications: { toolsListChanged: true },
2548 _meta: {
2549 "io.modelcontextprotocol/protocolVersion": McpSchema2026.protocolVersion,
2550 "io.modelcontextprotocol/clientCapabilities": {}
2551 }
2552 },
2553 { client: new Rpc.ServerClient(clientId), requestId: RequestId(requestId) }
2554 ).pipe(Effect.scoped, Effect.forkScoped)
2555
2556 yield* listen(1, "slow-subscription")
2557 yield* Deferred.await(slowAcknowledged)
2558 yield* listen(2, "responsive-subscription")
2559 yield* Deferred.await(fastAcknowledged)
2560
2561 for (let index = 0; index <= pendingNotificationLimit; index++) {
2562 yield* PubSub.publish(events, {
2563 notification: McpCore.ServerNotification.PromptsChanged({})
2564 })
2565 }
2566 yield* PubSub.publish(events, {
2567 notification: McpCore.ServerNotification.ToolsChanged({})
2568 })
2569 yield* Deferred.await(slowWriteStarted)
2570 yield* Queue.take(fastDeliveryCompleted)
2571
2572 for (let index = 0; index < pendingNotificationLimit; index++) {
2573 yield* PubSub.publish(events, {
2574 notification: McpCore.ServerNotification.ToolsChanged({})
2575 })
2576 yield* Queue.take(fastDeliveryCompleted)
2577 }
2578 assert.isTrue(Option.isNone(yield* Deferred.poll(slowSubscriptionReleased)))
2579 assert.isTrue(Option.isNone(yield* Deferred.poll(slowTerminated)))
2580
2581 yield* PubSub.publish(events, {
2582 notification: McpCore.ServerNotification.ToolsChanged({})
2583 })
2584 yield* Queue.take(fastDeliveryCompleted)
2585
2586 assert.deepStrictEqual(yield* Deferred.await(slowTerminated), [
2587 McpSchema2026.protocolVersion,
2588 1,
2589 RequestId("slow-subscription"),
2590 "Pending notification limit exceeded"
2591 ])
2592 yield* Effect.yieldNow
2593 assert.isTrue(yield* Deferred.isDone(slowSubscriptionReleased))
2594 assert.isTrue(Option.isNone(yield* Deferred.poll(fastSubscriptionReleased)))
2595
2596 yield* PubSub.publish(events, {
2597 notification: McpCore.ServerNotification.ToolsChanged({})
2598 })
2599 yield* Queue.take(fastDeliveryCompleted)
2600 assert.strictEqual(yield* Ref.get(fastDeliveries), pendingNotificationLimit + 3)
2601 assert.isTrue(Option.isNone(yield* Deferred.poll(fastSubscriptionReleased)))
2602 }))
2603
2604 it.effect("should isolate resource subscriptions and clear disconnected sessions", () =>
2605 Effect.gen(function*() {
2606 const clientIds = new Set([1, 2])
2607 const client1Outbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
2608 const client2Outbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
2609 const disconnects = yield* Queue.unbounded<number>()
2610 const writeRequest = yield* Deferred.make<
2611 (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect<void>
2612 >()
2613 const protocol = yield* RpcServer.Protocol.make((write) =>
2614 Deferred.succeed(writeRequest, write).pipe(
2615 Effect.as({
2616 disconnects,
2617 send: (clientId, message) =>
2618 Queue.offer(clientId === 1 ? client1Outbound : client2Outbound, message).pipe(Effect.asVoid),
2619 end: (_clientId) => Effect.void,
2620 clientIds: Effect.succeed(clientIds),
2621 initialMessage: Effect.succeedNone,
2622 supportsAck: false,
2623 supportsTransferables: false,
2624 supportsSpanPropagation: false,
2625 supportsNotifications: true,
2626 codecFor: Schema.toCodecJson as RpcSerialization.CodecFor
2627 })
2628 )
2629 )
2630 const ready = yield* Deferred.make<McpServer.McpServer["Service"]>()
2631 yield* Effect.gen(function*() {
2632 const context = yield* Layer.build(
2633 McpServer.resource({
2634 uri: "file:///target",
2635 name: "Target",
2636 content: Effect.succeed("target")
2637 }).pipe(
2638 Layer.provideMerge(
2639 McpServer.layer({
2640 name: "TestServer",
2641 version: "1.0.0",
2642 protocols: [McpProtocol.v2025_06_18]
2643 }).pipe(Layer.provide(Layer.succeed(RpcServer.Protocol, protocol)))
2644 )
2645 )
2646 )
2647 yield* Deferred.succeed(ready, Context.get(context, McpServer.McpServer))
2648 return yield* Effect.never
2649 }).pipe(Effect.scoped, Effect.forkScoped)
2650 const server = yield* Deferred.await(ready)
2651 const send = yield* Deferred.await(writeRequest)
2652 const nextResponse = Effect.fnUntraced(function*(clientId: number, requestId: number) {
2653 while (true) {
2654 const message = yield* Queue.take(clientId === 1 ? client1Outbound : client2Outbound)
2655 if (message._tag === "Exit" && message.requestId === requestId) {
2656 return message
2657 }
2658 }
2659 })
2660 const nextResourceUpdate = Effect.fnUntraced(function*(clientId: number) {
2661 while (true) {
2662 const message = yield* Queue.take(clientId === 1 ? client1Outbound : client2Outbound)
2663 if (message._tag === "Request" && message.tag === "notifications/resources/updated") {
2664 return yield* Schema.decodeUnknownEffect(
2665 McpSchema.ResourceUpdatedNotification.payloadSchema
2666 )(message.payload)
2667 }
2668 }
2669 })
2670 const request = Effect.fnUntraced(function*(
2671 clientId: number,
2672 id: number,
2673 method: string,
2674 payload: unknown,
2675 isNotification = false
2676 ) {
2677 yield* send(clientId, {
2678 _tag: "Request",
2679 id,
2680 tag: method,
2681 payload,
2682 headers: [],
2683 ...(isNotification ? { isNotification: true as const } : {})
2684 })
2685 if (!isNotification) {
2686 yield* nextResponse(clientId, id)
2687 }
2688 })
2689 const initialize = (clientId: number) =>
2690 request(clientId, clientId, "initialize", initializePayload).pipe(
2691 Effect.andThen(request(clientId, clientId + 10, "notifications/initialized", {}, true))
2692 )
2693
2694 yield* initialize(1)
2695 yield* initialize(2)
2696 yield* request(1, 21, "resources/subscribe", { uri: "file:///target" })
2697 yield* request(2, 22, "resources/subscribe", { uri: "file:///sentinel" })
2698
2699 yield* server.notifications["notifications/resources/updated"]({ uri: "file:///target" })
2700 yield* server.notifications["notifications/resources/updated"]({ uri: "file:///sentinel" })
2701
2702 assert.strictEqual((yield* nextResourceUpdate(1)).uri, "file:///target")
2703 assert.strictEqual((yield* nextResourceUpdate(2)).uri, "file:///sentinel")
2704 assert.isTrue(Option.isNone(yield* Queue.poll(client1Outbound)))
2705 assert.isTrue(Option.isNone(yield* Queue.poll(client2Outbound)))
2706
2707 clientIds.delete(1)
2708 yield* server.notifications["notifications/resources/updated"]({ uri: "file:///sentinel" })
2709 assert.strictEqual((yield* nextResourceUpdate(2)).uri, "file:///sentinel")
2710 clientIds.add(1)
2711 yield* send(1, {
2712 _tag: "Request",
2713 id: 30,
2714 tag: "resources/list",
2715 payload: {},
2716 headers: []
2717 })
2718 const afterSweep = yield* nextResponse(1, 30)
2719 assert.strictEqual(afterSweep.exit._tag, "Failure")
2720
2721 yield* initialize(1)
2722 yield* send(1, { _tag: "Eof" })
2723 yield* send(1, {
2724 _tag: "Request",
2725 id: 31,
2726 tag: "resources/list",
2727 payload: {},
2728 headers: []
2729 })
2730 const afterReconnect = yield* nextResponse(1, 31)
2731 assert.strictEqual(afterReconnect.exit._tag, "Failure")
2732 }))
2733 })
2734
2735 describe("stdio", () => {
2736 it.effect("should deliver stateless request notifications over stdio", () =>
2737 Effect.gen(function*() {
2738 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2026_07_28)
2739 yield* fixture.server.addTool({
2740 tool: new McpSchema.Tool({ name: "Emit", inputSchema: { type: "object" } }),
2741 annotations: Context.empty(),
2742 handle: () =>
2743 Effect.gen(function*() {
2744 yield* fixture.server.notifications["notifications/message"]({ level: "info", data: "filtered" })
2745 yield* fixture.server.notifications["notifications/message"]({ level: "error", data: "delivered" })
2746 yield* fixture.server.notifications["notifications/progress"]({ progressToken: "token", progress: 1 })
2747 return new McpSchema.CallToolResult({ content: [] })
2748 })
2749 })
2750 yield* fixture.initialize()
2751 yield* fixture.takeFrame
2752 yield* fixture.sendRequest("tools/call", {
2753 name: "Emit",
2754 _meta: { "io.modelcontextprotocol/logLevel": "warning", progressToken: "token" }
2755 }, 2)
2756 assert.deepInclude(yield* fixture.takeFrame, {
2757 method: "notifications/message",
2758 params: { level: "error", data: "delivered" }
2759 })
2760 assert.deepInclude(yield* fixture.takeFrame, {
2761 method: "notifications/progress",
2762 params: { progressToken: "token", progress: 1 }
2763 })
2764 assert.deepInclude(yield* fixture.takeFrame, { id: 2 })
2765 }))
2766
2767 it.effect("should accept batches after falling back to a stateful protocol", () =>
2768 Effect.gen(function*() {
2769 const fixture = yield* makeMcpStdioHarness(McpProtocol.v2025_03_26, [
2770 McpProtocol.v2026_07_28,
2771 McpProtocol.v2025_03_26
2772 ])
2773 const initialized = yield* fixture.sendRequest("initialize", {
2774 protocolVersion: "2025-11-25",
2775 capabilities: {},
2776 clientInfo: { name: "fallback-client", version: "1.0.0" }
2777 }, 1)
2778 assert.deepInclude(initialized.result, { protocolVersion: "2025-03-26" })
2779 yield* fixture.sendNotification("notifications/initialized")
2780 yield* fixture.takeFrame
2781 yield* fixture.sendRaw([
2782 { jsonrpc: "2.0", id: 2, method: "ping" },
2783 { jsonrpc: "2.0", id: 3, method: "ping" }
2784 ])
2785 const batch = yield* fixture.takeFrame
2786 assert(Array.isArray(batch))
2787 assert.sameDeepMembers(batch, [
2788 { jsonrpc: "2.0", id: 2, result: {} },
2789 { jsonrpc: "2.0", id: 3, result: {} }
2790 ])
2791 }))
2792
2793 it.effect("should preserve the June wire transcript when requests use stdio", () =>
2794 Effect.gen(function*() {
2795 const stdin = yield* Queue.unbounded<Uint8Array>()
2796 const stdout = yield* Queue.unbounded<string | Uint8Array>()
2797 const encoder = new TextEncoder()
2798 const decoder = new TextDecoder()
2799 const stdioLayer = Stdio.layerTest({
2800 stdin: Stream.fromQueue(stdin),
2801 stdout: () => Sink.forEach((chunk) => Queue.offer(stdout, chunk))
2802 })
2803
2804 const ready = yield* Deferred.make<void>()
2805 yield* Effect.gen(function*() {
2806 yield* Layer.build(
2807 McpServer.layerStdio({
2808 name: "TestServer",
2809 version: "1.0.0",
2810 protocols: [McpProtocol.v2025_06_18]
2811 }).pipe(Layer.provide(stdioLayer))
2812 )
2813 yield* Deferred.succeed(ready, undefined)
2814 return yield* Effect.never
2815 }).pipe(
2816 Effect.scoped,
2817 Effect.forkScoped
2818 )
2819 yield* Deferred.await(ready)
2820
2821 const write = (message: unknown) => Queue.offer(stdin, encoder.encode(`${JSON.stringify(message)}\n`))
2822 const read = Effect.fnUntraced(function*() {
2823 const chunk = yield* Queue.take(stdout)
2824 const frame = typeof chunk === "string" ? chunk : decoder.decode(chunk)
2825 assert.strictEqual(frame.endsWith("\n"), true)
2826 return JSON.parse(frame)
2827 })
2828
2829 yield* write({
2830 jsonrpc: "2.0",
2831 id: 0,
2832 method: "initialize",
2833 params: {
2834 protocolVersion: "2025-06-18",
2835 capabilities: {},
2836 clientInfo: {
2837 name: "TestClient",
2838 version: "1.0.0"
2839 }
2840 }
2841 })
2842
2843 assert.deepStrictEqual(yield* read(), {
2844 jsonrpc: "2.0",
2845 id: 0,
2846 result: {
2847 protocolVersion: "2025-06-18",
2848 capabilities: {
2849 completions: {},
2850 logging: {}
2851 },
2852 serverInfo: {
2853 name: "TestServer",
2854 version: "1.0.0"
2855 }
2856 }
2857 })
2858
2859 yield* write({
2860 jsonrpc: "2.0",
2861 id: 1,
2862 method: "ping",
2863 params: {}
2864 })
2865
2866 assert.deepStrictEqual(yield* read(), {
2867 jsonrpc: "2.0",
2868 id: 1,
2869 result: {}
2870 })
2871 }))
2872
2873 it.effect("should emit correlated cancellation when a STDIO subscription backlog overflows", () =>
2874 Effect.gen(function*() {
2875 const stdin = yield* Queue.unbounded<Uint8Array>()
2876 const stdout = yield* Queue.unbounded<string | Uint8Array>()
2877 const blockedWriteStarted = yield* Deferred.make<void>()
2878 const releaseBlockedWrite = yield* Deferred.make<void>()
2879 const serverReady = yield* Deferred.make<McpServer.McpServer["Service"]>()
2880 const encoder = new TextEncoder()
2881 const decoder = new TextDecoder()
2882 let blocked = false
2883 const stdioLayer = Stdio.layerTest({
2884 stdin: Stream.fromQueue(stdin),
2885 stdout: () =>
2886 Sink.forEach((chunk) => {
2887 const frame = typeof chunk === "string" ? chunk : decoder.decode(chunk)
2888 const message = JSON.parse(frame)
2889 if (!blocked && message.method === "notifications/tools/list_changed") {
2890 blocked = true
2891 return Deferred.succeed(blockedWriteStarted, undefined).pipe(
2892 Effect.andThen(Deferred.await(releaseBlockedWrite))
2893 )
2894 }
2895 return Queue.offer(stdout, chunk)
2896 })
2897 })
2898 yield* Effect.gen(function*() {
2899 const context = yield* Layer.build(
2900 McpServer.layerStdio({
2901 name: "TestServer",
2902 version: "1.0.0",
2903 protocols: [McpProtocol.v2026_07_28]
2904 }).pipe(
2905 Layer.provide(stdioLayer),
2906 Layer.provideMerge(HttpRouter.layer)
2907 )
2908 )
2909 yield* Deferred.succeed(serverReady, Context.get(context, McpServer.McpServer))
2910 return yield* Effect.never
2911 }).pipe(Effect.scoped, Effect.forkScoped)
2912 const server = yield* Deferred.await(serverReady)
2913 yield* server.addTool({
2914 tool: new McpSchema.Tool({ name: "subscription-tool", inputSchema: { type: "object" } }),
2915 annotations: Context.empty(),
2916 handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
2917 })
2918
2919 const metadata = {
2920 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
2921 "io.modelcontextprotocol/clientCapabilities": {},
2922 "io.modelcontextprotocol/clientInfo": { name: "TestClient", version: "1.0.0" }
2923 }
2924 const write = (message: unknown) => Queue.offer(stdin, encoder.encode(`${JSON.stringify(message)}\n`))
2925 const read = Effect.fnUntraced(function*() {
2926 const chunk = yield* Queue.take(stdout)
2927 return JSON.parse(typeof chunk === "string" ? chunk : decoder.decode(chunk))
2928 })
2929
2930 yield* write({
2931 jsonrpc: "2.0",
2932 id: 0,
2933 method: "initialize",
2934 params: {
2935 protocolVersion: "2026-07-28",
2936 capabilities: {},
2937 clientInfo: metadata["io.modelcontextprotocol/clientInfo"]
2938 }
2939 })
2940 yield* read()
2941 yield* write({
2942 jsonrpc: "2.0",
2943 id: "slow-subscription",
2944 method: "subscriptions/listen",
2945 params: {
2946 notifications: { toolsListChanged: true },
2947 _meta: metadata
2948 }
2949 })
2950 assert.strictEqual((yield* read()).method, "notifications/subscriptions/acknowledged")
2951
2952 yield* server.notifications["notifications/tools/list_changed"]({})
2953 yield* Deferred.await(blockedWriteStarted)
2954 // Exceed both the 64-message subscription backlog and the STDIO output buffer.
2955 for (let index = 0; index < 128; index++) {
2956 yield* server.notifications["notifications/tools/list_changed"]({})
2957 yield* Effect.yieldNow
2958 }
2959 yield* Deferred.succeed(releaseBlockedWrite, undefined)
2960
2961 let cancellation = yield* read()
2962 while (cancellation.method !== "notifications/cancelled") {
2963 cancellation = yield* read()
2964 }
2965 assert.strictEqual(cancellation.method, "notifications/cancelled")
2966 assert.strictEqual(cancellation.params.requestId, "slow-subscription")
2967
2968 yield* write({
2969 jsonrpc: "2.0",
2970 id: 1,
2971 method: "tools/list",
2972 params: { _meta: metadata }
2973 })
2974 const tools = yield* read()
2975 assert.strictEqual(tools.id, 1)
2976 assert.isArray(tools.result.tools)
2977 }))
2978 })
2979})