1import type * as McpProtocol from "effect/ai/McpProtocol"
2import * as McpSchema from "effect/ai/McpSchema"
3import * as Context from "effect/Context"
4import * as Effect from "effect/Effect"
5import * as Layer from "effect/Layer"
6import * as Ref from "effect/Ref"
7import * as Schema from "effect/Schema"
8import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts"
9import { readMcpHttpResponse } from "../TestUtils/McpHttpResponse.ts"
10import { makeServerLayer } from "../TestUtils/McpServerLayer.ts"
11import { makeFeaturesServerLayer, type Observations } from "./McpConformanceFixtures.ts"
12import { makeMcpTestPeer, type McpTestPeerOptions } from "./McpTestPeer.ts"
13
14const SERVER_NAME = "McpConformance"
15const SERVER_VERSION = "1.0.0"
16
17const InitializeResponse = Schema.Struct({
18 jsonrpc: Schema.Literal("2.0"),
19 id: Schema.Number,
20 result: McpSchema.InitializeResult
21})
22
23const ErrorResponse = Schema.Struct({
24 jsonrpc: Schema.Literal("2.0"),
25 id: Schema.NullOr(Schema.Union([Schema.String, Schema.Number])),
26 error: McpSchema.McpError
27})
28
29const ResultResponse = Schema.Struct({
30 jsonrpc: Schema.Literal("2.0"),
31 id: Schema.Union([Schema.String, Schema.Number]),
32 result: Schema.Record(Schema.String, Schema.Unknown)
33})
34
35const BatchResponse = Schema.Array(Schema.Struct({
36 id: Schema.Number,
37 result: Schema.Struct({})
38}))
39
40const StatelessDiscoverResponse = Schema.Struct({
41 jsonrpc: Schema.Literal("2.0"),
42 id: Schema.Number,
43 result: Schema.Struct({
44 capabilities: McpSchema.ServerCapabilities,
45 instructions: Schema.optional(Schema.String)
46 })
47})
48
49const decodeInitializeResponse = Schema.decodeUnknownEffect(InitializeResponse)
50const decodeErrorResponse = Schema.decodeUnknownEffect(ErrorResponse)
51const decodeResultResponse = Schema.decodeUnknownEffect(ResultResponse)
52const decodeBatchResponse = Schema.decodeUnknownEffect(BatchResponse)
53
54const statelessBody = (
55 protocol: McpProtocol.ProtocolAdapter,
56 body: unknown,
57 clientCapabilities?: Record<string, unknown>
58): unknown => {
59 if (
60 protocol.runtime._tag !== "Stateless" ||
61 typeof body !== "object" ||
62 body === null ||
63 !("method" in body)
64 ) {
65 return body
66 }
67 const message = body as { readonly params?: unknown }
68 const params = typeof message.params === "object" && message.params !== null
69 ? message.params as Record<string, unknown>
70 : {}
71 const metadata = typeof params._meta === "object" && params._meta !== null && !Array.isArray(params._meta)
72 ? params._meta as Record<string, unknown>
73 : {}
74 return {
75 ...body,
76 params: {
77 ...params,
78 _meta: {
79 ...metadata,
80 "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
81 "io.modelcontextprotocol/clientCapabilities": clientCapabilities ?? {
82 elicitation: { form: {} },
83 roots: {},
84 sampling: {}
85 },
86 "io.modelcontextprotocol/clientInfo": { name: "McpConformanceClient", version: "1.0.0" }
87 }
88 }
89 }
90}
91
92const statelessHeaders = (protocol: McpProtocol.ProtocolAdapter, body: unknown): HeadersInit => {
93 if (
94 protocol.runtime._tag !== "Stateless" ||
95 typeof body !== "object" ||
96 body === null ||
97 !("method" in body) ||
98 typeof body.method !== "string"
99 ) {
100 return {}
101 }
102 const params = "params" in body && typeof body.params === "object" && body.params !== null
103 ? body.params as Record<string, unknown>
104 : {}
105 const name = body.method === "resources/read"
106 ? params.uri
107 : body.method === "tools/call" || body.method === "prompts/get"
108 ? params.name
109 : undefined
110 return {
111 "MCP-Protocol-Version": protocol.protocolVersion,
112 "Mcp-Method": body.method,
113 ...(typeof name === "string" ? { "Mcp-Name": name } : {})
114 }
115}
116
117export interface InitializedSession {
118 readonly response: Response
119 readonly message: typeof InitializeResponse.Type
120 readonly sessionId: string | null
121 readonly server: "default" | "features"
122}
123
124export interface InitializeOptions {
125 readonly id?: number | undefined
126 readonly protocolVersion?: string | undefined
127 readonly server?: "default" | "features" | undefined
128}
129
130export interface SendOptions {
131 readonly includeProtocolVersion?: boolean | undefined
132 readonly protocolVersion?: string | undefined
133 readonly clientCapabilities?: Record<string, unknown> | undefined
134 readonly headers?: HeadersInit | undefined
135}
136
137export interface McpConformanceShape {
138 readonly protocol: McpProtocol.ProtocolAdapter
139 readonly serverInfo: {
140 readonly name: string
141 readonly version: string
142 }
143 readonly initializeRequest: (options?: Omit<InitializeOptions, "server">) => {
144 readonly jsonrpc: "2.0"
145 readonly id: number
146 readonly method: "initialize"
147 readonly params: {
148 readonly protocolVersion: string
149 readonly capabilities: {}
150 readonly clientInfo: {
151 readonly name: string
152 readonly version: string
153 }
154 }
155 }
156 readonly initializedNotification: {
157 readonly jsonrpc: "2.0"
158 readonly method: "notifications/initialized"
159 }
160 readonly pingRequest: (id?: number) => {
161 readonly jsonrpc: "2.0"
162 readonly id: number
163 readonly method: "ping"
164 readonly params: {}
165 }
166 readonly post: (body: unknown, headers?: HeadersInit) => Effect.Effect<Response>
167 readonly request: (request: Request) => Effect.Effect<Response>
168 readonly initialize: (
169 options?: InitializeOptions
170 ) => Effect.Effect<InitializedSession, Schema.SchemaError>
171 readonly send: (
172 session: InitializedSession,
173 body: unknown,
174 options?: SendOptions
175 ) => Effect.Effect<Response>
176 readonly sendText: (
177 session: InitializedSession,
178 body: string,
179 options?: SendOptions
180 ) => Effect.Effect<Response>
181 readonly notifyInitialized: (
182 session: InitializedSession,
183 options?: SendOptions
184 ) => Effect.Effect<Response>
185 readonly ping: (
186 session: InitializedSession,
187 options?: SendOptions & { readonly id?: number | undefined }
188 ) => Effect.Effect<Response>
189 readonly makePeer: (
190 options?: McpTestPeerOptions
191 ) => ReturnType<typeof makeMcpTestPeer>
192 readonly observations: Effect.Effect<Observations>
193 readonly requestCount: Effect.Effect<number>
194 readonly resetObservations: Effect.Effect<void>
195 readonly decodeError: (response: Response) => Effect.Effect<typeof ErrorResponse.Type, Schema.SchemaError>
196 readonly decodeResult: (response: Response) => Effect.Effect<typeof ResultResponse.Type, Schema.SchemaError>
197 readonly decodeBatchResponseIds: (
198 response: Response
199 ) => Effect.Effect<ReadonlyArray<number>, Schema.SchemaError>
200}
201
202export class McpConformance extends Context.Service<McpConformance, McpConformanceShape>()(
203 "effect/test/unstable/ai/McpConformance"
204) {}
205
206export type McpConformanceLayer = Layer.Layer<McpConformance, unknown>
207
208export const layer = (protocol: McpProtocol.ProtocolAdapter) =>
209 Layer.effect(
210 McpConformance,
211 Effect.gen(function*() {
212 const defaultHarness = yield* makeHttpHarness(makeServerLayer({
213 name: SERVER_NAME,
214 protocols: [protocol]
215 }))
216 const observations = yield* Ref.make<Observations>({
217 toolInvocations: 0,
218 promptInvocations: 0,
219 resourceTemplateInvocations: 0
220 })
221 const requestCount = yield* Ref.make(0)
222 const featuresHarness = yield* makeHttpHarness(makeFeaturesServerLayer(protocol, observations))
223 const post = (
224 harness: typeof defaultHarness,
225 body: unknown,
226 headers?: HeadersInit
227 ) => Ref.update(requestCount, (count) => count + 1).pipe(Effect.andThen(harness.post(body, headers)))
228 const postText = (
229 harness: typeof defaultHarness,
230 body: string,
231 headers?: HeadersInit
232 ) => Ref.update(requestCount, (count) => count + 1).pipe(Effect.andThen(harness.postText(body, headers)))
233
234 const initializeRequest: McpConformanceShape["initializeRequest"] = (options) => ({
235 jsonrpc: "2.0",
236 id: options?.id ?? 1,
237 method: "initialize",
238 params: {
239 protocolVersion: options?.protocolVersion ?? protocol.protocolVersion,
240 capabilities: {},
241 clientInfo: {
242 name: "McpConformanceClient",
243 version: "1.0.0"
244 }
245 }
246 })
247
248 const initializedNotification = {
249 jsonrpc: "2.0",
250 method: "notifications/initialized"
251 } as const
252
253 const pingRequest: McpConformanceShape["pingRequest"] = (id = 2) => ({
254 jsonrpc: "2.0",
255 id,
256 method: "ping",
257 params: {}
258 })
259
260 const initialize: McpConformanceShape["initialize"] = Effect.fnUntraced(function*(options) {
261 const server = options?.server ?? "default"
262 const harness = server === "features" ? featuresHarness : defaultHarness
263 if (protocol.runtime._tag === "Stateless") {
264 const request = statelessBody(protocol, {
265 jsonrpc: "2.0",
266 id: options?.id ?? 1,
267 method: "server/discover",
268 params: {}
269 })
270 const response = yield* post(harness, request, statelessHeaders(protocol, request))
271 const decoded = yield* Effect.promise<unknown>(() => response.json()).pipe(
272 Effect.flatMap(Schema.decodeUnknownEffect(StatelessDiscoverResponse))
273 )
274 return {
275 response,
276 message: {
277 jsonrpc: "2.0",
278 id: options?.id ?? 1,
279 result: {
280 protocolVersion: protocol.protocolVersion,
281 capabilities: decoded.result.capabilities,
282 instructions: decoded.result.instructions,
283 serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
284 }
285 },
286 sessionId: null,
287 server
288 }
289 }
290 const response = yield* post(harness, initializeRequest(options))
291 const body = yield* readMcpHttpResponse(response)
292 return {
293 response,
294 message: yield* decodeInitializeResponse(body),
295 sessionId: response.headers.get("Mcp-Session-Id"),
296 server
297 }
298 })
299
300 const sessionHeaders = (session: InitializedSession, options?: SendOptions): HeadersInit => ({
301 ...(session.sessionId === null ? {} : { "Mcp-Session-Id": session.sessionId }),
302 ...(options?.includeProtocolVersion ?? true
303 ? {
304 "Mcp-Protocol-Version": options?.protocolVersion ?? session.message.result.protocolVersion
305 }
306 : {}),
307 ...options?.headers
308 })
309
310 const harnessFor = (session: InitializedSession) =>
311 session.server === "features" ? featuresHarness : defaultHarness
312
313 const sendText: McpConformanceShape["sendText"] = (session, body, options) =>
314 postText(harnessFor(session), body, sessionHeaders(session, options))
315
316 const send: McpConformanceShape["send"] = (session, body, options) => {
317 if (protocol.runtime._tag !== "Stateless") {
318 return post(harnessFor(session), body, sessionHeaders(session, options))
319 }
320 const request = statelessBody(protocol, body, options?.clientCapabilities)
321 return post(harnessFor(session), request, {
322 ...statelessHeaders(protocol, request),
323 ...options?.headers
324 })
325 }
326
327 const notifyInitialized: McpConformanceShape["notifyInitialized"] = (session, options) =>
328 protocol.runtime._tag === "Stateless"
329 ? Effect.succeed(new Response(null, { status: 202 }))
330 : send(session, initializedNotification, options)
331
332 const ping: McpConformanceShape["ping"] = (session, options) => send(session, pingRequest(options?.id), options)
333
334 return McpConformance.of({
335 protocol,
336 serverInfo: {
337 name: SERVER_NAME,
338 version: SERVER_VERSION
339 },
340 initializeRequest,
341 initializedNotification,
342 pingRequest,
343 post: (body, headers) => post(defaultHarness, body, headers),
344 request: (request) => Effect.promise(() => defaultHarness.handler(request)),
345 initialize,
346 send,
347 sendText,
348 notifyInitialized,
349 ping,
350 makePeer: (options) => makeMcpTestPeer(protocol, options),
351 observations: Ref.get(observations),
352 requestCount: Ref.get(requestCount),
353 resetObservations: Ref.set(observations, {
354 toolInvocations: 0,
355 promptInvocations: 0,
356 resourceTemplateInvocations: 0
357 }),
358 decodeError: (response) =>
359 readMcpHttpResponse(response).pipe(
360 Effect.flatMap(decodeErrorResponse)
361 ),
362 decodeResult: (response) =>
363 readMcpHttpResponse(response).pipe(
364 Effect.flatMap(decodeResultResponse)
365 ),
366 decodeBatchResponseIds: (response) =>
367 readMcpHttpResponse(response).pipe(
368 Effect.flatMap(decodeBatchResponse),
369 Effect.map((responses) => responses.map((message) => message.id))
370 )
371 })
372 })
373 )