1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as McpServer from "effect/ai/McpServer"
5import * as Tool from "effect/ai/Tool"
6import * as Toolkit from "effect/ai/Toolkit"
7import * as Effect from "effect/Effect"
8import * as Fiber from "effect/Fiber"
9import * as Layer from "effect/Layer"
10import * as Option from "effect/Option"
11import * as Schema from "effect/Schema"
12import { TestClock } from "effect/testing"
13import { initializeHttpSession, makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
14import { readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
15import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
16import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts"
17import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
18
19export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
20 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
21 describe("Base Protocol > General fields", () => {
22 // https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields
23 it.effect("should preserve additional result metadata fields when decoding a result", () =>
24 Effect.gen(function*() {
25 const result = yield* Schema.decodeUnknownEffect(McpSchema.ReadResourceResult)({
26 contents: [],
27 _meta: {
28 "example/conformance": {
29 enabled: true,
30 labels: ["one", "two"]
31 }
32 }
33 })
34
35 assert.deepStrictEqual(result._meta, {
36 "example/conformance": {
37 enabled: true,
38 labels: ["one", "two"]
39 }
40 })
41 }))
42 })
43 })
44
45export const statelessModernSuite = (
46 protocol: McpProtocol.ProtocolAdapter,
47 layer: McpConformanceLayer
48) =>
49 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
50 const requestMetadata = {
51 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
52 "io.modelcontextprotocol/clientCapabilities": {},
53 "io.modelcontextprotocol/clientInfo": { name: "stdio-client", version: "1.0.0" }
54 }
55
56 describe("Base Protocol > Stateless messages", () => {
57 // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests
58 it.effect("should preserve string and numeric identifiers when requests succeed", () =>
59 Effect.gen(function*() {
60 const fixture = yield* makeMcpStdioHarness(protocol)
61 for (const id of ["discover-1", 42] as const) {
62 const message = yield* fixture.sendRequest("server/discover", {}, id)
63 assert.strictEqual(message.id, id)
64 }
65 }))
66
67 // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests
68 it.effect("should reject a request when its JSON-RPC version is invalid", () =>
69 Effect.gen(function*() {
70 const test = yield* McpConformance
71 const discovered = yield* test.initialize()
72 const response = yield* test.send(discovered, {
73 jsonrpc: "1.0",
74 id: 2,
75 method: "server/discover",
76 params: {}
77 })
78 const message = yield* test.decodeError(response)
79
80 assert.strictEqual(message.id, 2)
81 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
82 }))
83
84 // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests
85 it.effect("should reject a request when its identifier is not a string or integer", () =>
86 Effect.gen(function*() {
87 const test = yield* McpConformance
88 const discovered = yield* test.initialize()
89 const response = yield* test.send(discovered, {
90 jsonrpc: "2.0",
91 id: true,
92 method: "server/discover",
93 params: {}
94 })
95 const message = yield* test.decodeError(response)
96
97 assert.strictEqual(message.id, null)
98 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
99 }))
100
101 // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests
102 it.effect("should return method not found when the requested method is unknown", () =>
103 Effect.gen(function*() {
104 const fixture = yield* makeMcpStdioHarness(protocol)
105 const message = yield* fixture.sendRequest("unknown/method", {}, 3)
106
107 assert.strictEqual(message.id, 3)
108 const error = Schema.decodeUnknownSync(McpSchema.McpError)(message.error)
109 assert.strictEqual(error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE)
110 }))
111
112 // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests
113 it.effect("should return invalid params when request parameters do not match the method schema", () =>
114 Effect.gen(function*() {
115 const fixture = yield* makeMcpStdioHarness(protocol)
116 const message = yield* fixture.sendRequest("tools/list", { cursor: 1 }, 7)
117
118 assert.strictEqual(message.id, 7)
119 const error = Schema.decodeUnknownSync(McpSchema.McpError)(message.error)
120 assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
121 }))
122
123 // https://modelcontextprotocol.io/specification/2026-07-28/basic#notifications
124 it.effect("should send no response when an unknown notification is received", () =>
125 Effect.gen(function*() {
126 const fixture = yield* makeMcpStdioHarness(protocol)
127 yield* fixture.sendNotification("unknown/method", {})
128 const response = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild)
129 yield* TestClock.adjust("1 millis")
130
131 assert.isTrue(Option.isNone(yield* Fiber.join(response)))
132 }))
133
134 // https://modelcontextprotocol.io/specification/2026-07-28/basic#notifications
135 it.effect("should send no response when notification parameters are invalid", () =>
136 Effect.gen(function*() {
137 const fixture = yield* makeMcpStdioHarness(protocol)
138 yield* fixture.sendNotification("notifications/cancelled", { requestId: true })
139 const response = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild)
140 yield* TestClock.adjust("1 millis")
141
142 assert.isTrue(Option.isNone(yield* Fiber.join(response)))
143 }))
144
145 // https://modelcontextprotocol.io/specification/2026-07-28/basic#responses
146 it.effect("should send exactly one result response when a request succeeds", () =>
147 Effect.gen(function*() {
148 const fixture = yield* makeMcpStdioHarness(protocol)
149 yield* fixture.sendRaw({
150 jsonrpc: "2.0",
151 id: 4,
152 method: "server/discover",
153 params: { _meta: requestMetadata }
154 })
155 const message = yield* fixture.takeMessage
156 assert.strictEqual(message.id, 4)
157 assert.property(message, "result")
158 assert.notProperty(message, "error")
159
160 const duplicate = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild)
161 yield* TestClock.adjust("1 millis")
162 assert.isTrue(Option.isNone(yield* Fiber.join(duplicate)))
163 }))
164
165 // https://modelcontextprotocol.io/specification/2026-07-28/basic#responses
166 it.effect("should send exactly one error response when a request fails", () =>
167 Effect.gen(function*() {
168 const fixture = yield* makeMcpStdioHarness(protocol)
169 yield* fixture.sendRaw({
170 jsonrpc: "2.0",
171 id: 8,
172 method: "unknown/method",
173 params: { _meta: requestMetadata }
174 })
175 const message = yield* fixture.takeMessage
176 assert.strictEqual(message.id, 8)
177 assert.property(message, "error")
178 assert.notProperty(message, "result")
179
180 const duplicate = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild)
181 yield* TestClock.adjust("1 millis")
182 assert.isTrue(Option.isNone(yield* Fiber.join(duplicate)))
183 }))
184
185 // https://modelcontextprotocol.io/specification/2026-07-28/basic#messages
186 it.effect("should return a parse error when a JSON message is malformed", () =>
187 Effect.gen(function*() {
188 const test = yield* McpConformance
189 const discovered = yield* test.initialize()
190 const response = yield* test.sendText(discovered, "{")
191 const message = yield* test.decodeError(response)
192
193 assert.strictEqual(message.id, null)
194 assert.strictEqual(message.error.code, McpSchema.PARSE_ERROR_CODE)
195 }))
196
197 // https://modelcontextprotocol.io/specification/2026-07-28/basic#messages
198 it.effect("should return invalid request when a JSON-RPC message omits its method", () =>
199 Effect.gen(function*() {
200 const test = yield* McpConformance
201 const discovered = yield* test.initialize()
202 const response = yield* test.send(discovered, { jsonrpc: "2.0", id: 10, params: {} })
203 const message = yield* test.decodeError(response)
204
205 assert.strictEqual(message.id, 10)
206 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
207 }))
208 })
209 })
210
211export const statefulLegacySuite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
212 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
213 describe("Base Protocol", () => {
214 // https://modelcontextprotocol.io/specification/2025-11-25/basic#meta
215 it.effect("should preserve custom request metadata when a legacy tool reads either context API", () =>
216 Effect.gen(function*() {
217 const toolkit = Toolkit.make(Tool.make("Metadata", {
218 parameters: Tool.EmptyParams,
219 success: Schema.String,
220 dependencies: [McpSchema.McpRequestContext, McpSchema.McpServerClient]
221 }))
222 const registration = McpServer.toolkit(toolkit).pipe(Layer.provide(toolkit.toLayer({
223 Metadata: () =>
224 Effect.gen(function*() {
225 const context = yield* McpSchema.McpRequestContext
226 const client = yield* McpSchema.McpServerClient
227 return JSON.stringify({ context: context.requestMetadata, client: client.requestMetadata })
228 })
229 })))
230 const harness = yield* makeHttpHarness(
231 registration.pipe(Layer.provideMerge(makeServerLayer({ name: "RequestMetadata", protocols: [protocol] })))
232 )
233 const headers = yield* initializeHttpSession(harness, protocol)
234 const metadata = { marker: "tool-call", progressToken: "progress-1", custom: { values: [null, true, 42] } }
235 const response = yield* harness.post({
236 jsonrpc: "2.0",
237 id: "call",
238 method: "tools/call",
239 params: { name: "Metadata", arguments: {}, _meta: metadata }
240 }, headers)
241 const message = Schema.decodeUnknownSync(Schema.Struct({
242 result: Schema.Struct({ content: Schema.Array(Schema.Struct({ text: Schema.String })) })
243 }))(yield* readMcpHttpResponse(response))
244 assert.deepStrictEqual(JSON.parse(message.result.content[0].text), { context: metadata, client: metadata })
245 }))
246
247 // Request metadata is defined by https://modelcontextprotocol.io/specification/2025-11-25/basic#meta.
248 // Exposing the current metadata through both context services is an Effect handler contract.
249 it.effect("should expose current metadata when a legacy resource completion reads either context API", () =>
250 Effect.gen(function*() {
251 const registration = McpServer.resource`file:///metadata/${McpSchema.param("name", Schema.String)}`({
252 name: "metadata",
253 content: () => Effect.succeed("content"),
254 completion: {
255 name: () =>
256 Effect.gen(function*() {
257 const context = yield* McpSchema.McpRequestContext
258 const client = yield* McpSchema.McpServerClient
259 return [JSON.stringify({ context: context.requestMetadata, client: client.requestMetadata })]
260 })
261 }
262 })
263 const harness = yield* makeHttpHarness(
264 registration.pipe(
265 Layer.provideMerge(makeServerLayer({ name: "CompletionMetadata", protocols: [protocol] }))
266 )
267 )
268 const headers = yield* initializeHttpSession(harness, protocol)
269 const metadata = { marker: "completion", progressToken: "progress-1" }
270 const response = yield* harness.post({
271 jsonrpc: "2.0",
272 id: "complete",
273 method: "completion/complete",
274 params: {
275 ref: { type: "ref/resource", uri: "file:///metadata/{name}" },
276 argument: { name: "name", value: "" },
277 _meta: metadata
278 }
279 }, headers)
280 const message = Schema.decodeUnknownSync(Schema.Struct({
281 result: Schema.Struct({ completion: Schema.Struct({ values: Schema.Array(Schema.String) }) })
282 }))(yield* readMcpHttpResponse(response))
283
284 assert.deepStrictEqual(JSON.parse(message.result.completion.values[0]), {
285 context: metadata,
286 client: metadata
287 })
288 }))
289
290 // https://modelcontextprotocol.io/specification/2025-06-18/basic
291 describe("Messages", () => {
292 describe("Requests", () => {
293 it.effect("SCHEMA accepts JSON-RPC 2.0 requests with string identifiers", () =>
294 Effect.gen(function*() {
295 const test = yield* McpConformance
296 const initialized = yield* test.initialize()
297 yield* test.notifyInitialized(initialized)
298
299 const response = yield* test.send(initialized, {
300 jsonrpc: "2.0",
301 id: "ping-1",
302 method: "ping"
303 })
304 const message = yield* test.decodeResult(response)
305
306 assert.strictEqual(message.id, "ping-1")
307 }))
308
309 it.effect("SCHEMA accepts JSON-RPC 2.0 requests with numeric identifiers", () =>
310 Effect.gen(function*() {
311 const test = yield* McpConformance
312 const initialized = yield* test.initialize()
313 yield* test.notifyInitialized(initialized)
314
315 const response = yield* test.ping(initialized, { id: 42 })
316 const message = yield* test.decodeResult(response)
317
318 assert.strictEqual(message.id, 42)
319 }))
320
321 it.effect("MUST reject requests with an invalid JSON-RPC version", () =>
322 Effect.gen(function*() {
323 const test = yield* McpConformance
324 const initialized = yield* test.initialize()
325 yield* test.notifyInitialized(initialized)
326
327 const response = yield* test.send(initialized, {
328 jsonrpc: "1.0",
329 id: 2,
330 method: "ping"
331 })
332 const message = yield* test.decodeError(response)
333
334 assert.strictEqual(message.id, 2)
335 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
336 }))
337
338 it.effect("MUST return method not found for unknown request methods", () =>
339 Effect.gen(function*() {
340 const test = yield* McpConformance
341 const initialized = yield* test.initialize()
342 yield* test.notifyInitialized(initialized)
343
344 const response = yield* test.send(initialized, {
345 jsonrpc: "2.0",
346 id: 3,
347 method: "unknown/method"
348 })
349 const message = yield* test.decodeError(response)
350
351 assert.strictEqual(message.id, 3)
352 assert.strictEqual(message.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE)
353 }))
354 it.effect("MUST return invalid params for request payloads that do not match the method schema", () =>
355 Effect.gen(function*() {
356 const test = yield* McpConformance
357 const initialized = yield* test.initialize()
358 yield* test.notifyInitialized(initialized)
359
360 const response = yield* test.send(initialized, {
361 jsonrpc: "2.0",
362 id: 7,
363 method: "ping",
364 params: "invalid"
365 })
366 const message = yield* test.decodeError(response)
367
368 assert.strictEqual(message.id, 7)
369 assert.strictEqual(message.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
370 }))
371
372 it.effect("MUST not reply to unknown notifications", () =>
373 Effect.gen(function*() {
374 const test = yield* McpConformance
375 const initialized = yield* test.initialize()
376 yield* test.notifyInitialized(initialized)
377
378 const response = yield* test.send(initialized, {
379 jsonrpc: "2.0",
380 method: "unknown/method"
381 })
382
383 assert.strictEqual(response.status, 202)
384 assert.strictEqual(yield* Effect.promise(() => response.text()), "")
385 }))
386
387 it.effect("MUST not reply to notifications with invalid params", () =>
388 Effect.gen(function*() {
389 const test = yield* McpConformance
390 const initialized = yield* test.initialize()
391 yield* test.notifyInitialized(initialized)
392
393 const response = yield* test.send(initialized, {
394 jsonrpc: "2.0",
395 method: "ping",
396 params: "invalid"
397 })
398
399 assert.strictEqual(response.status, 202)
400 assert.strictEqual(yield* Effect.promise(() => response.text()), "")
401 }))
402
403 it.effect("MUST reject requests with invalid identifiers", () =>
404 Effect.gen(function*() {
405 const test = yield* McpConformance
406 const initialized = yield* test.initialize()
407 yield* test.notifyInitialized(initialized)
408
409 const response = yield* test.send(initialized, {
410 jsonrpc: "2.0",
411 id: true,
412 method: "ping"
413 })
414 const message = yield* test.decodeError(response)
415
416 assert.strictEqual(message.id, null)
417 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
418 }))
419 })
420
421 describe("Responses", () => {
422 it.effect("MUST return exactly one result response for a successful request", () =>
423 Effect.gen(function*() {
424 const fixture = yield* makeMcpStdioHarness(protocol)
425 yield* fixture.initialize()
426 yield* fixture.sendRaw({ jsonrpc: "2.0", id: 4, method: "ping" })
427 const message = yield* fixture.takeMessage
428 assert.strictEqual(message.id, 4)
429 assert.deepStrictEqual(message.result, {})
430
431 const duplicate = yield* fixture.takeMessage.pipe(
432 Effect.timeoutOption("1 millis"),
433 Effect.forkChild
434 )
435 yield* TestClock.adjust("1 millis")
436
437 assert.isTrue(Option.isNone(yield* Fiber.join(duplicate)))
438 }))
439
440 it.effect("MUST return exactly one error response for a failed request", () =>
441 Effect.gen(function*() {
442 const fixture = yield* makeMcpStdioHarness(protocol)
443 yield* fixture.initialize()
444 yield* fixture.sendRaw({
445 jsonrpc: "2.0",
446 id: 8,
447 method: "unknown/method"
448 })
449 const message = yield* fixture.takeMessage
450 assert.strictEqual(message.id, 8)
451 assert.property(message, "error")
452 assert.notProperty(message, "result")
453
454 const duplicate = yield* fixture.takeMessage.pipe(
455 Effect.timeoutOption("1 millis"),
456 Effect.forkChild
457 )
458 yield* TestClock.adjust("1 millis")
459
460 assert.isTrue(Option.isNone(yield* Fiber.join(duplicate)))
461 }))
462 it.effect("SCHEMA preserves the request identifier in result responses", () =>
463 Effect.gen(function*() {
464 const test = yield* McpConformance
465 const initialized = yield* test.initialize()
466 yield* test.notifyInitialized(initialized)
467
468 const message = yield* test.ping(initialized, { id: 5 }).pipe(
469 Effect.flatMap(test.decodeResult)
470 )
471
472 assert.strictEqual(message.id, 5)
473 }))
474
475 it.effect("SCHEMA preserves the request identifier in error responses", () =>
476 Effect.gen(function*() {
477 const test = yield* McpConformance
478 const initialized = yield* test.initialize()
479 yield* test.notifyInitialized(initialized)
480
481 const message = yield* test.send(initialized, {
482 jsonrpc: "2.0",
483 id: "unknown-1",
484 method: "unknown/method"
485 }).pipe(Effect.flatMap(test.decodeError))
486
487 assert.strictEqual(message.id, "unknown-1")
488 }))
489 it.effect("MUST not include both result and error in a response", () =>
490 Effect.gen(function*() {
491 const test = yield* McpConformance
492 const initialized = yield* test.initialize()
493 yield* test.notifyInitialized(initialized)
494
495 const response = yield* test.send(initialized, {
496 jsonrpc: "2.0",
497 id: 9,
498 method: "unknown/method"
499 })
500 const raw = yield* Effect.promise<unknown>(() => response.json()).pipe(
501 Effect.map(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown)))
502 )
503
504 assert.property(raw, "error")
505 assert.notProperty(raw, "result")
506 }))
507 })
508
509 describe("Notifications", () => {
510 it.effect("MUST accept notifications without an identifier and send no response", () =>
511 Effect.gen(function*() {
512 const test = yield* McpConformance
513 const initialized = yield* test.initialize()
514
515 const response = yield* test.send(initialized, test.initializedNotification)
516
517 assert.strictEqual(response.status, 202)
518 assert.strictEqual(yield* Effect.promise(() => response.text()), "")
519 }))
520 })
521
522 it.effect("MUST return a parse error for malformed JSON", () =>
523 Effect.gen(function*() {
524 const test = yield* McpConformance
525 const initialized = yield* test.initialize()
526 yield* test.notifyInitialized(initialized)
527
528 const response = yield* test.sendText(initialized, "{")
529 const message = yield* test.decodeError(response)
530
531 assert.strictEqual(message.id, null)
532 assert.strictEqual(message.error.code, McpSchema.PARSE_ERROR_CODE)
533 }))
534
535 it.effect("MUST return an invalid request error for malformed JSON-RPC messages", () =>
536 Effect.gen(function*() {
537 const test = yield* McpConformance
538 const initialized = yield* test.initialize()
539 yield* test.notifyInitialized(initialized)
540
541 const response = yield* test.send(initialized, {
542 jsonrpc: "2.0",
543 id: 10
544 })
545 const message = yield* test.decodeError(response)
546
547 assert.strictEqual(message.id, 10)
548 assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE)
549 }))
550 })
551 })
552 })