| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import type * as McpProtocol from "effect/ai/McpProtocol" |
| 3 | import * as McpSchema from "effect/ai/McpSchema" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as Schema from "effect/Schema" |
| 6 | import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" |
| 7 | |
| 8 | const decodeCompletion = Schema.decodeUnknownEffect(McpSchema.CompleteResult) |
| 9 | |
| 10 | const complete = ( |
| 11 | ref: { readonly type: "ref/prompt"; readonly name: string } | { |
| 12 | readonly type: "ref/resource" |
| 13 | readonly uri: string |
| 14 | }, |
| 15 | argument: { readonly name: string; readonly value: string }, |
| 16 | context?: { readonly arguments?: Readonly<Record<string, string>> | undefined } |
| 17 | ) => |
| 18 | Effect.gen(function*() { |
| 19 | const response = yield* completeRaw(ref, argument, context) |
| 20 | const test = yield* McpConformance |
| 21 | return yield* test.decodeResult(response).pipe( |
| 22 | Effect.flatMap((message) => decodeCompletion(message.result)) |
| 23 | ) |
| 24 | }) |
| 25 | |
| 26 | const completeRaw = ( |
| 27 | ref: { readonly type: "ref/prompt"; readonly name: string } | { |
| 28 | readonly type: "ref/resource" |
| 29 | readonly uri: string |
| 30 | }, |
| 31 | argument: { readonly name: string; readonly value: string }, |
| 32 | context?: { readonly arguments?: Readonly<Record<string, string>> | undefined } |
| 33 | ) => |
| 34 | Effect.gen(function*() { |
| 35 | const test = yield* McpConformance |
| 36 | const initialized = yield* test.initialize({ server: "features" }) |
| 37 | yield* test.notifyInitialized(initialized) |
| 38 | const response = yield* test.send(initialized, { |
| 39 | jsonrpc: "2.0", |
| 40 | id: 2, |
| 41 | method: "completion/complete", |
| 42 | params: { ref, argument, ...(context === undefined ? {} : { context }) } |
| 43 | }) |
| 44 | return response |
| 45 | }) |
| 46 | |
| 47 | export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => |
| 48 | it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { |
| 49 | describe("Completion", () => { |
| 50 | // https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/completion |
| 51 | describe("Capabilities", () => { |
| 52 | it.effect("MUST advertise completions when argument completion is supported", () => |
| 53 | Effect.gen(function*() { |
| 54 | const test = yield* McpConformance |
| 55 | const initialized = yield* test.initialize({ server: "features" }) |
| 56 | |
| 57 | assert.property(initialized.message.result.capabilities, "completions") |
| 58 | })) |
| 59 | }) |
| 60 | |
| 61 | describe("Requesting Completions", () => { |
| 62 | it.effect("MUST complete a prompt argument", () => |
| 63 | Effect.gen(function*() { |
| 64 | const result = yield* complete( |
| 65 | { type: "ref/prompt", name: "TestPrompt" }, |
| 66 | { name: "required", value: "f" } |
| 67 | ) |
| 68 | |
| 69 | assert.deepStrictEqual(result.completion.values, ["first", "second"]) |
| 70 | })) |
| 71 | |
| 72 | it.effect("MUST complete a resource template argument", () => |
| 73 | Effect.gen(function*() { |
| 74 | const result = yield* complete( |
| 75 | { type: "ref/resource", uri: "file:///template/{path}" }, |
| 76 | { name: "path", value: "a" } |
| 77 | ) |
| 78 | |
| 79 | assert.deepStrictEqual(result.completion.values, ["alpha", "beta"]) |
| 80 | })) |
| 81 | it.effect("SHOULD reject an unknown prompt reference with Invalid Params", () => |
| 82 | Effect.gen(function*() { |
| 83 | const test = yield* McpConformance |
| 84 | const initialized = yield* test.initialize({ server: "features" }) |
| 85 | yield* test.notifyInitialized(initialized) |
| 86 | const response = yield* test.send(initialized, { |
| 87 | jsonrpc: "2.0", |
| 88 | id: 2, |
| 89 | method: "completion/complete", |
| 90 | params: { |
| 91 | ref: { type: "ref/prompt", name: "UnknownPrompt" }, |
| 92 | argument: { name: "value", value: "" } |
| 93 | } |
| 94 | }) |
| 95 | const error = yield* test.decodeError(response) |
| 96 | |
| 97 | assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) |
| 98 | })) |
| 99 | |
| 100 | it.effect("MUST reject an unknown argument name", () => |
| 101 | Effect.gen(function*() { |
| 102 | const test = yield* McpConformance |
| 103 | const initialized = yield* test.initialize({ server: "features" }) |
| 104 | yield* test.notifyInitialized(initialized) |
| 105 | const response = yield* test.send(initialized, { |
| 106 | jsonrpc: "2.0", |
| 107 | id: 2, |
| 108 | method: "completion/complete", |
| 109 | params: { |
| 110 | ref: { type: "ref/prompt", name: "TestPrompt" }, |
| 111 | argument: { name: "unknown", value: "" } |
| 112 | } |
| 113 | }) |
| 114 | const error = yield* test.decodeError(response) |
| 115 | |
| 116 | assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) |
| 117 | })) |
| 118 | |
| 119 | it.effect("MUST return completion values in order", () => |
| 120 | Effect.gen(function*() { |
| 121 | const result = yield* complete( |
| 122 | { type: "ref/resource", uri: "file:///template/{path}" }, |
| 123 | { name: "path", value: "" } |
| 124 | ) |
| 125 | |
| 126 | assert.deepStrictEqual(result.completion.values, ["beta", "alpha"]) |
| 127 | })) |
| 128 | |
| 129 | it.effect("SCHEMA returns the total and additional-results indicator", () => |
| 130 | Effect.gen(function*() { |
| 131 | const result = yield* complete( |
| 132 | { type: "ref/resource", uri: "file:///template/{path}" }, |
| 133 | { name: "path", value: "" } |
| 134 | ) |
| 135 | |
| 136 | assert.strictEqual(result.completion.total, 2) |
| 137 | assert.strictEqual(result.completion.hasMore, false) |
| 138 | })) |
| 139 | |
| 140 | it.effect("MUST return at most one hundred completion values", () => |
| 141 | Effect.gen(function*() { |
| 142 | const result = yield* complete( |
| 143 | { type: "ref/prompt", name: "TestPrompt" }, |
| 144 | { name: "required", value: "limit" } |
| 145 | ) |
| 146 | |
| 147 | assert.strictEqual(result.completion.values.length, 100) |
| 148 | assert.strictEqual(result.completion.total, 101) |
| 149 | assert.strictEqual(result.completion.hasMore, true) |
| 150 | })) |
| 151 | |
| 152 | // https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion#completioncontext |
| 153 | it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( |
| 154 | "should pass previously resolved arguments when completion context is supplied", |
| 155 | () => |
| 156 | Effect.gen(function*() { |
| 157 | const result = yield* complete( |
| 158 | { type: "ref/prompt", name: "ContextCompletionPrompt" }, |
| 159 | { name: "value", value: "c" }, |
| 160 | { arguments: { locale: "en" } } |
| 161 | ) |
| 162 | |
| 163 | assert.deepStrictEqual(result.completion.values, ["context received"]) |
| 164 | }) |
| 165 | ) |
| 166 | }) |
| 167 | }) |
| 168 | }) |