1import { assert, describe, it } from "@effect/vitest"
2import type * as McpProtocol from "effect/ai/McpProtocol"
3import * as McpSchema from "effect/ai/McpSchema"
4import * as Context from "effect/Context"
5import * as Effect from "effect/Effect"
6import * as Schema from "effect/Schema"
7import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts"
8import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts"
9
10const decodePrompts = Schema.decodeUnknownEffect(McpSchema.ListPromptsResult)
11const decodeGetPrompt = Schema.decodeUnknownEffect(McpSchema.GetPromptResult)
12
13const getPrompt = (name: string) =>
14 Effect.gen(function*() {
15 const message = yield* getPromptWire(name)
16 return yield* decodeGetPrompt(message.result)
17 })
18
19const getPromptWire = (name: string) =>
20 Effect.gen(function*() {
21 const test = yield* McpConformance
22 const initialized = yield* test.initialize({ server: "features" })
23 yield* test.notifyInitialized(initialized)
24 const response = yield* test.send(initialized, {
25 jsonrpc: "2.0",
26 id: 2,
27 method: "prompts/get",
28 params: { name }
29 })
30 return yield* test.decodeResult(response)
31 })
32
33export const suite = (
34 protocol: McpProtocol.ProtocolAdapter,
35 layer: McpConformanceLayer,
36 options?: { readonly additionalPromptNames?: ReadonlyArray<string> }
37) =>
38 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
39 describe("Prompts", () => {
40 // Shared capability contract; each dated entrypoint owns its normative specification revision.
41 describe("Capabilities", () => {
42 it.effect("MUST advertise prompts when prompts are registered", () =>
43 Effect.gen(function*() {
44 const test = yield* McpConformance
45 const initialized = yield* test.initialize({ server: "features" })
46
47 assert.property(initialized.message.result.capabilities, "prompts")
48 }))
49
50 it.effect("MUST NOT advertise prompts when prompts are not supported", () =>
51 Effect.gen(function*() {
52 const test = yield* McpConformance
53 const initialized = yield* test.initialize()
54
55 assert.notProperty(initialized.message.result.capabilities, "prompts")
56 }))
57 })
58
59 describe("Listing Prompts", () => {
60 it.effect(
61 "MUST list every prompt visible to the initialized client",
62 () =>
63 Effect.gen(function*() {
64 const test = yield* McpConformance
65 const initialized = yield* test.initialize({ server: "features" })
66 yield* test.notifyInitialized(initialized)
67 const response = yield* test.send(initialized, {
68 jsonrpc: "2.0",
69 id: 2,
70 method: "prompts/list",
71 params: {}
72 })
73 const result = yield* test.decodeResult(response).pipe(
74 Effect.flatMap((message) => decodePrompts(message.result))
75 )
76
77 const expected = [
78 "AudioPrompt",
79 "ContextCompletionPrompt",
80 "EmbeddedResourcePrompt",
81 "ImagePrompt",
82 "NoArgumentPrompt",
83 "TestPrompt",
84 ...(options?.additionalPromptNames ?? [])
85 ].sort()
86 assert.deepStrictEqual(result.prompts.map((prompt) => prompt.name).sort(), expected)
87 })
88 )
89
90 it.effect(
91 "SCHEMA preserves prompt names, titles, descriptions, and arguments",
92 () =>
93 Effect.gen(function*() {
94 const test = yield* McpConformance
95 const initialized = yield* test.initialize({ server: "features" })
96 yield* test.notifyInitialized(initialized)
97 const response = yield* test.send(initialized, {
98 jsonrpc: "2.0",
99 id: 2,
100 method: "prompts/list",
101 params: {}
102 })
103 const result = yield* test.decodeResult(response).pipe(
104 Effect.flatMap((message) => decodePrompts(message.result))
105 )
106
107 const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt")
108 assert.isDefined(prompt)
109 assert.strictEqual(
110 prompt.title,
111 ["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion) ? undefined : "Test prompt"
112 )
113 assert.strictEqual(prompt.description, "A test prompt")
114 assert.deepStrictEqual(prompt.arguments?.map((argument) => argument.name), [
115 "required",
116 "optional"
117 ])
118 })
119 )
120
121 it.effect(
122 "MUST mark required and optional prompt arguments correctly",
123 () =>
124 Effect.gen(function*() {
125 const test = yield* McpConformance
126 const initialized = yield* test.initialize({ server: "features" })
127 yield* test.notifyInitialized(initialized)
128 const response = yield* test.send(initialized, {
129 jsonrpc: "2.0",
130 id: 2,
131 method: "prompts/list",
132 params: {}
133 })
134 const result = yield* test.decodeResult(response).pipe(
135 Effect.flatMap((message) => decodePrompts(message.result))
136 )
137
138 const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt")
139 assert.isDefined(prompt)
140 assert.deepStrictEqual(prompt.arguments, [
141 { name: "required", required: true },
142 { name: "optional", required: false }
143 ])
144 })
145 )
146 })
147
148 describe("Getting Prompts", () => {
149 it.effect("MUST get a registered prompt without arguments", () =>
150 Effect.gen(function*() {
151 const test = yield* McpConformance
152 const initialized = yield* test.initialize({ server: "features" })
153 yield* test.notifyInitialized(initialized)
154 const response = yield* test.send(initialized, {
155 jsonrpc: "2.0",
156 id: 2,
157 method: "prompts/get",
158 params: { name: "NoArgumentPrompt" }
159 })
160 const result = yield* test.decodeResult(response).pipe(
161 Effect.flatMap((message) => decodeGetPrompt(message.result))
162 )
163
164 assert.deepStrictEqual(result.messages, [{
165 role: "user",
166 content: { type: "text", text: "no arguments" }
167 }])
168 }))
169 it.effect("MUST get a registered prompt with valid arguments", () =>
170 Effect.gen(function*() {
171 const test = yield* McpConformance
172 const initialized = yield* test.initialize({ server: "features" })
173 yield* test.notifyInitialized(initialized)
174 const response = yield* test.send(initialized, {
175 jsonrpc: "2.0",
176 id: 2,
177 method: "prompts/get",
178 params: {
179 name: "TestPrompt",
180 arguments: {
181 required: "required",
182 optional: "optional"
183 }
184 }
185 })
186 const result = yield* test.decodeResult(response).pipe(
187 Effect.flatMap((message) => decodeGetPrompt(message.result))
188 )
189
190 assert.deepStrictEqual(result.messages, [{
191 role: "user",
192 content: { type: "text", text: "required:optional" }
193 }])
194 }))
195
196 it.effect("SHOULD reject an unknown prompt name with Invalid Params", () =>
197 Effect.gen(function*() {
198 const test = yield* McpConformance
199 const initialized = yield* test.initialize({ server: "features" })
200 yield* test.notifyInitialized(initialized)
201 const response = yield* test.send(initialized, {
202 jsonrpc: "2.0",
203 id: 2,
204 method: "prompts/get",
205 params: {
206 name: "UnknownPrompt",
207 arguments: {}
208 }
209 })
210 const error = yield* test.decodeError(response)
211
212 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
213 }))
214
215 it.effect("SHOULD reject missing required prompt arguments with Invalid Params", () =>
216 Effect.gen(function*() {
217 const test = yield* McpConformance
218 const initialized = yield* test.initialize({ server: "features" })
219 yield* test.notifyInitialized(initialized)
220 const response = yield* test.send(initialized, {
221 jsonrpc: "2.0",
222 id: 2,
223 method: "prompts/get",
224 params: {
225 name: "TestPrompt",
226 arguments: {}
227 }
228 })
229 const error = yield* test.decodeError(response)
230
231 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
232 }))
233
234 it.effect("SHOULD reject prompt arguments with invalid values", () =>
235 Effect.gen(function*() {
236 const test = yield* McpConformance
237 const initialized = yield* test.initialize({ server: "features" })
238 yield* test.notifyInitialized(initialized)
239 const response = yield* test.send(initialized, {
240 jsonrpc: "2.0",
241 id: 2,
242 method: "prompts/get",
243 params: {
244 name: "TestPrompt",
245 arguments: {
246 required: 123
247 }
248 }
249 })
250 const error = yield* test.decodeError(response)
251
252 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
253 }))
254 it.effect("MUST not invoke the prompt handler when argument validation fails", () =>
255 Effect.gen(function*() {
256 const test = yield* McpConformance
257 yield* test.resetObservations
258 const initialized = yield* test.initialize({ server: "features" })
259 yield* test.notifyInitialized(initialized)
260 const response = yield* test.send(initialized, {
261 jsonrpc: "2.0",
262 id: 2,
263 method: "prompts/get",
264 params: {
265 name: "TestPrompt",
266 arguments: { required: 123 }
267 }
268 })
269 const error = yield* test.decodeError(response)
270
271 assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE)
272 assert.strictEqual((yield* test.observations).promptInvocations, 0)
273 }))
274 it.effect("SCHEMA preserves the prompt description and message order", () =>
275 Effect.gen(function*() {
276 const test = yield* McpConformance
277 const initialized = yield* test.initialize({ server: "features" })
278 yield* test.notifyInitialized(initialized)
279 const response = yield* test.send(initialized, {
280 jsonrpc: "2.0",
281 id: 2,
282 method: "prompts/get",
283 params: {
284 name: "TestPrompt",
285 arguments: { required: "test" }
286 }
287 })
288 const result = yield* test.decodeResult(response).pipe(
289 Effect.flatMap((message) => decodeGetPrompt(message.result))
290 )
291
292 assert.strictEqual(result.description, "A test prompt")
293 assert.deepStrictEqual(result.messages.map((message) => message.role), ["user"])
294 }))
295
296 it.effect("MUST return text message content", () =>
297 Effect.gen(function*() {
298 const test = yield* McpConformance
299 const initialized = yield* test.initialize({ server: "features" })
300 yield* test.notifyInitialized(initialized)
301 const response = yield* test.send(initialized, {
302 jsonrpc: "2.0",
303 id: 2,
304 method: "prompts/get",
305 params: {
306 name: "TestPrompt",
307 arguments: { required: "text" }
308 }
309 })
310 const result = yield* test.decodeResult(response).pipe(
311 Effect.flatMap((message) => decodeGetPrompt(message.result))
312 )
313
314 assert.deepStrictEqual(result.messages[0]?.content, {
315 type: "text",
316 text: "text:omitted"
317 })
318 }))
319 it.effect("MUST return image message content", () =>
320 Effect.gen(function*() {
321 const result = yield* getPromptWire("ImagePrompt")
322 assert.deepStrictEqual(result.result.messages, [{
323 role: "user",
324 content: {
325 type: "image",
326 data: "AQID",
327 mimeType: "image/png"
328 }
329 }])
330 }))
331 it.effect("MUST return embedded resource message content", () =>
332 Effect.gen(function*() {
333 const result = yield* getPrompt("EmbeddedResourcePrompt")
334 assert.deepStrictEqual(result.messages, [{
335 role: "user",
336 content: {
337 type: "resource",
338 resource: {
339 uri: "file:///embedded",
340 mimeType: "text/plain",
341 text: "embedded"
342 }
343 }
344 }])
345 }))
346
347 it.effect.skipIf(protocol.protocolVersion === "2024-11-05")(
348 "should return base64 audio content when an audio prompt is requested",
349 () =>
350 Effect.gen(function*() {
351 const result = yield* getPromptWire("AudioPrompt")
352 assert.deepStrictEqual(result.result.messages, [{
353 role: "user",
354 content: {
355 type: "audio",
356 data: "BAUG",
357 mimeType: "audio/wav"
358 }
359 }])
360 })
361 )
362 })
363 })
364 })
365
366export const statefulLegacySuite = (
367 protocol: McpProtocol.ProtocolAdapter<McpProtocol.StatefulProtocolVersion>,
368 layer: McpConformanceLayer
369) =>
370 it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => {
371 // https://modelcontextprotocol.io/specification/2025-11-25/server/prompts
372 describe("Prompts > Legacy notifications", () => {
373 it.effect("should advertise prompt list-change notifications when supported", () =>
374 Effect.gen(function*() {
375 const test = yield* McpConformance
376 const initialized = yield* test.initialize({ server: "features" })
377
378 assert.strictEqual(initialized.message.result.capabilities.prompts?.listChanged, true)
379 }))
380
381 it.effect("should send a prompt list-change notification when the advertised list changes", () =>
382 Effect.gen(function*() {
383 const fixture = yield* makeMcpStdioHarness(protocol)
384 const makePrompt = (name: string) => ({
385 prompt: new McpSchema.Prompt({ name }),
386 annotations: Context.empty(),
387 completions: {},
388 handle: () =>
389 Effect.succeed(
390 new McpSchema.GetPromptResult({
391 messages: [{ role: "user", content: { type: "text", text: name } }]
392 })
393 )
394 })
395 yield* fixture.server.addPrompt(makePrompt("baseline-list-changed-prompt"))
396 yield* fixture.initialize()
397 yield* fixture.server.addPrompt(makePrompt("dynamic-list-changed-prompt"))
398 yield* fixture.flushListChanged
399
400 const notification = yield* fixture.awaitOutboundMethod("notifications/prompts/list_changed")
401 assert.strictEqual(notification.jsonrpc, "2.0")
402 assert.strictEqual(notification.method, "notifications/prompts/list_changed")
403 assert.notProperty(notification, "id")
404
405 const response = yield* fixture.sendRequest("prompts/list", {})
406 const result = yield* decodePrompts(response.result)
407 assert.isTrue(result.prompts.some((prompt) => prompt.name === "dynamic-list-changed-prompt"))
408 }))
409 })
410 })