1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as McpServer from "effect/ai/McpServer"
5import * as Context from "effect/Context"
6import * as Effect from "effect/Effect"
7import * as Layer from "effect/Layer"
8import * as Schema from "effect/Schema"
9import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
10import { readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
11import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
12import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
13import { MrtrPromptName, mrtrRequestState, MrtrStateOnlyToolName, MrtrToolName } from "./McpConformanceFixtures.ts"
14
15const decodeInputRequired = Schema.decodeUnknownEffect(Schema.Struct({
16 resultType: Schema.Literal("input_required"),
17 inputRequests: Schema.Record(Schema.String, Schema.Unknown),
18 requestState: Schema.String
19}))
20
21const decodeComplete = Schema.decodeUnknownEffect(Schema.Struct({
22 resultType: Schema.Literal("complete"),
23 content: Schema.Array(Schema.Struct({
24 type: Schema.Literal("text"),
25 text: Schema.String
26 }))
27}))
28
29const inputResponses = {
30 approval: { action: "accept", content: { approved: true } },
31 sample: {
32 role: "assistant",
33 content: { type: "text", text: "Suggested title", _meta: {} },
34 model: "fixture-model",
35 _meta: {}
36 },
37 roots: { roots: [{ uri: "file:///workspace" }] }
38} as const
39
40export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) =>
41 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
42 // SEP-2322: https://modelcontextprotocol.io/seps/2322-MRTR
43 // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
44 describe("Multi round-trip requests", () => {
45 // Follow the sampling SHOULD NOT recommendation by rejecting context requests without client support.
46 // https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#capabilities
47 it.effect("should require context capability when sampling requests include server context", () =>
48 Effect.gen(function*() {
49 for (const includeContext of ["thisServer", "allServers"] as const) {
50 const sample = {
51 method: "sampling/createMessage",
52 params: {
53 messages: [{ role: "user", content: { type: "text", text: "Summarize context" } }],
54 includeContext,
55 maxTokens: 20
56 }
57 } as const
58 const registration = Layer.effectDiscard(McpServer.McpServer.use((server) =>
59 server.addTool({
60 tool: new McpSchema.Tool({ name: "Context", inputSchema: { type: "object" } }),
61 annotations: Context.empty(),
62 handle: () => Effect.succeed(new McpSchema.InputRequired({ inputRequests: { sample } }))
63 })
64 ))
65 const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge(
66 makeServerLayer({ name: "SamplingContext", protocols: [protocol] })
67 )))
68 for (const supported of [false, true]) {
69 const response = yield* harness.post({
70 jsonrpc: "2.0",
71 id: supported ? "supported" : "unsupported",
72 method: "tools/call",
73 params: {
74 name: "Context",
75 arguments: {},
76 _meta: {
77 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
78 "io.modelcontextprotocol/clientCapabilities": { sampling: supported ? { context: {} } : {} }
79 }
80 }
81 }, {
82 "Mcp-Protocol-Version": protocol.protocolVersion,
83 "Mcp-Method": "tools/call",
84 "Mcp-Name": "Context"
85 })
86 const message = yield* readMcpHttpResponse(response)
87 if (supported) {
88 assert.strictEqual(response.status, 200)
89 const result = Schema.decodeUnknownSync(Schema.Struct({
90 result: Schema.Struct({ resultType: Schema.String, inputRequests: Schema.JsonObject })
91 }))(message).result
92 assert.strictEqual(result.resultType, "input_required")
93 assert.deepStrictEqual(result.inputRequests, { sample })
94 } else {
95 assert.strictEqual(response.status, 400)
96 const error = Schema.decodeUnknownSync(Schema.Struct({
97 error: Schema.Struct({ code: Schema.Number, data: Schema.JsonObject })
98 }))(message).error
99 assert.strictEqual(error.code, -32021)
100 assert.deepStrictEqual(error.data, { requiredCapabilities: { sampling: { context: {} } } })
101 }
102 }
103 }
104 }))
105
106 // Conformance: input-required-result-non-tool-request
107 it.effect("should return input_required and then complete when prompts/get is retried with client input", () =>
108 Effect.gen(function*() {
109 const test = yield* McpConformance
110 const initialized = yield* test.initialize({ server: "features" })
111 const firstResponse = yield* test.send(initialized, {
112 jsonrpc: "2.0",
113 id: 30,
114 method: "prompts/get",
115 params: { name: MrtrPromptName, arguments: {} }
116 })
117 const first = yield* test.decodeResult(firstResponse).pipe(
118 Effect.flatMap((message) =>
119 Schema.decodeUnknownEffect(Schema.Struct({
120 resultType: Schema.Literal("input_required"),
121 inputRequests: Schema.Record(Schema.String, Schema.Unknown)
122 }))(message.result)
123 )
124 )
125 assert.deepStrictEqual(first.inputRequests.userContext, {
126 method: "elicitation/create",
127 params: {
128 message: "What context should the prompt use?",
129 requestedSchema: {
130 type: "object",
131 properties: { context: { type: "string" } },
132 required: ["context"]
133 }
134 }
135 })
136
137 const completedResponse = yield* test.send(initialized, {
138 jsonrpc: "2.0",
139 id: 31,
140 method: "prompts/get",
141 params: {
142 name: MrtrPromptName,
143 arguments: {},
144 inputResponses: {
145 userContext: { action: "accept", content: { context: "test context" } }
146 }
147 }
148 })
149 const completed = yield* test.decodeResult(completedResponse).pipe(
150 Effect.flatMap((message) =>
151 Schema.decodeUnknownEffect(Schema.Struct({
152 resultType: Schema.Literal("complete"),
153 messages: Schema.Array(Schema.Struct({
154 role: Schema.Literal("user"),
155 content: Schema.Struct({
156 type: Schema.Literal("text"),
157 text: Schema.String
158 })
159 }))
160 }))(message.result)
161 )
162 )
163 assert.deepStrictEqual(completed.messages, [{
164 role: "user",
165 content: { type: "text", text: "Prompt completed" }
166 }])
167 }))
168
169 // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
170 it.effect("should reject prompt input requests when the client omits their required capabilities", () =>
171 Effect.gen(function*() {
172 const test = yield* McpConformance
173 const initialized = yield* test.initialize({ server: "features" })
174 const response = yield* test.send(initialized, {
175 jsonrpc: "2.0",
176 id: 32,
177 method: "prompts/get",
178 params: { name: MrtrPromptName, arguments: {} }
179 }, { clientCapabilities: {} })
180 const error = yield* test.decodeError(response)
181
182 assert.strictEqual(response.status, 400)
183 assert.strictEqual(error.error.code, -32021)
184 assert.deepStrictEqual(error.error.data, {
185 requiredCapabilities: { elicitation: { form: {} } }
186 })
187 }))
188
189 // Elicitation choices must survive the input-required result sent to the client.
190 // https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#requested-schema
191 it.effect("should return supported keyed input requests and resume when matching responses are supplied", () =>
192 Effect.gen(function*() {
193 const test = yield* McpConformance
194 const initialized = yield* test.initialize({ server: "features" })
195 const firstResponse = yield* test.send(initialized, {
196 jsonrpc: "2.0",
197 id: 1,
198 method: "tools/call",
199 params: { name: MrtrToolName, arguments: {} }
200 })
201 const first = yield* test.decodeResult(firstResponse).pipe(
202 Effect.flatMap((message) => decodeInputRequired(message.result))
203 )
204
205 assert.strictEqual(first.requestState, mrtrRequestState)
206 assert.deepStrictEqual(first.inputRequests, {
207 approval: {
208 method: "elicitation/create",
209 params: {
210 message: "Approve the operation",
211 requestedSchema: {
212 type: "object",
213 properties: {
214 approved: { type: "boolean" },
215 color: { type: "string", enum: ["red", "blue"] },
216 titled: {
217 type: "string",
218 oneOf: [{ const: "red", title: "Red" }, { const: "blue", title: "Blue" }]
219 },
220 legacy: { type: "string", enum: ["red", "blue"], enumNames: ["Red", "Blue"] }
221 },
222 required: ["approved"]
223 }
224 }
225 },
226 sample: {
227 method: "sampling/createMessage",
228 params: {
229 messages: [{ role: "user", content: { type: "text", text: "Suggest a title" } }],
230 maxTokens: 20
231 }
232 },
233 roots: {
234 method: "roots/list"
235 }
236 })
237
238 const completedResponse = yield* test.send(initialized, {
239 jsonrpc: "2.0",
240 id: 3,
241 method: "tools/call",
242 params: {
243 name: MrtrToolName,
244 arguments: {},
245 inputResponses,
246 requestState: first.requestState
247 }
248 })
249 const completed = yield* test.decodeResult(completedResponse).pipe(
250 Effect.flatMap((message) => decodeComplete(message.result))
251 )
252
253 const content = completed.content[0]
254 assert.isDefined(content)
255 assert.deepStrictEqual(JSON.parse(content.text), {
256 approval: { approved: true },
257 sample: {
258 role: "assistant",
259 content: { type: "text", text: "Suggested title", _meta: {} },
260 model: "fixture-model",
261 _meta: {}
262 },
263 roots: { roots: [{ uri: "file:///workspace" }] }
264 })
265 }))
266
267 it.effect("should return a retry state without requesting client input", () =>
268 Effect.gen(function*() {
269 const test = yield* McpConformance
270 const initialized = yield* test.initialize({ server: "features" })
271 const response = yield* test.send(initialized, {
272 jsonrpc: "2.0",
273 id: 2,
274 method: "tools/call",
275 params: { name: MrtrStateOnlyToolName, arguments: {} }
276 })
277 const result = yield* test.decodeResult(response).pipe(
278 Effect.flatMap((message) =>
279 Schema.decodeUnknownEffect(Schema.Struct({
280 resultType: Schema.Literal("input_required"),
281 inputRequests: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
282 requestState: Schema.String
283 }))(message.result)
284 )
285 )
286
287 assert.strictEqual(result.requestState, mrtrRequestState)
288 assert.isUndefined(result.inputRequests)
289 }))
290
291 it.effect("should remain input-required when continuation keys or request state do not match", () =>
292 Effect.gen(function*() {
293 const test = yield* McpConformance
294 const initialized = yield* test.initialize({ server: "features" })
295 const cases = [
296 {
297 inputResponses: { other: { action: "accept", content: { approved: true } } },
298 requestState: mrtrRequestState
299 },
300 {
301 inputResponses,
302 requestState: `${mrtrRequestState}:mismatch`
303 }
304 ] as const
305
306 for (const [index, continuation] of cases.entries()) {
307 const response = yield* test.send(initialized, {
308 jsonrpc: "2.0",
309 id: index + 10,
310 method: "tools/call",
311 params: {
312 name: MrtrToolName,
313 arguments: {},
314 ...continuation
315 }
316 })
317 const result = yield* test.decodeResult(response).pipe(
318 Effect.flatMap((message) => decodeInputRequired(message.result))
319 )
320 assert.strictEqual(result.requestState, mrtrRequestState)
321 }
322 }))
323
324 // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-validation-and-security
325 it.effect("should reject a continuation when its input responses or request state are malformed", () =>
326 Effect.gen(function*() {
327 const test = yield* McpConformance
328 const initialized = yield* test.initialize({ server: "features" })
329 const cases = [
330 { inputResponses: { approval: "accepted" }, requestState: mrtrRequestState },
331 { inputResponses, requestState: 42 }
332 ] as const
333
334 for (const [index, continuation] of cases.entries()) {
335 const response = yield* test.send(initialized, {
336 jsonrpc: "2.0",
337 id: index + 20,
338 method: "tools/call",
339 params: {
340 name: MrtrToolName,
341 arguments: {},
342 ...continuation
343 }
344 })
345 const error = yield* test.decodeError(response)
346 assert.strictEqual(error.error.code, -32602)
347 }
348 }))
349 })
350 })