1import * as Data from "../../Data.ts"
2import * as Effect from "../../Effect.ts"
3import * as Base64 from "../../encoding/Base64.ts"
4import type * as Headers from "../../http/Headers.ts"
5import type * as LogLevel from "../../LogLevel.ts"
6import * as Match from "../../Match.ts"
7import * as Predicate from "../../Predicate.ts"
8import type * as PubSub from "../../PubSub.ts"
9import * as Result from "../../Result.ts"
10import type * as Rpc from "../../rpc/Rpc.ts"
11import * as RpcClient from "../../rpc/RpcClient.ts"
12import type { RpcClientError } from "../../rpc/RpcClientError.ts"
13import type * as RpcGroup from "../../rpc/RpcGroup.ts"
14import type * as RpcMessage from "../../rpc/RpcMessage.ts"
15import * as Schema from "../../Schema.ts"
16import type * as Scope from "../../Scope.ts"
17import type * as PublicMcpProtocol from "../McpProtocol.ts"
18import * as PublicMcpSchema from "../McpSchema.ts"
19import * as McpCore from "./mcpCore.ts"
20
21const LEGACY_RESOURCE_NOT_FOUND_ERROR_CODE = -32002
22const BASE64_SENTINEL_PREFIX = "=?base64?"
23const BASE64_SENTINEL_SUFFIX = "?="
24// A leading U+FEFF belongs to the header value and must survive comparison with the JSON body.
25const routingHeaderDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true })
26
27/**
28 * @internal
29 */
30export const mcpLogLevels: Readonly<
31 Record<
32 PublicMcpSchema.LoggingLevel,
33 { readonly effect: LogLevel.LogLevel; readonly order: number }
34 >
35> = {
36 debug: { effect: "Debug", order: 0 },
37 info: { effect: "Info", order: 1 },
38 notice: { effect: "Info", order: 2 },
39 warning: { effect: "Warn", order: 3 },
40 error: { effect: "Error", order: 4 },
41 critical: { effect: "Fatal", order: 5 },
42 alert: { effect: "Fatal", order: 6 },
43 emergency: { effect: "Fatal", order: 7 }
44}
45
46/**
47 * @internal
48 */
49export const decodeRoutingHeader = (value: string): string | undefined => {
50 const startsWithSentinel = value.startsWith(BASE64_SENTINEL_PREFIX)
51 const endsWithSentinel = value.endsWith(BASE64_SENTINEL_SUFFIX)
52 if (!startsWithSentinel || !endsWithSentinel) {
53 return /^[\t\x20-\x7e]*$/.test(value) ? value : undefined
54 }
55 const decoded = Base64.decode(
56 value.slice(BASE64_SENTINEL_PREFIX.length, -BASE64_SENTINEL_SUFFIX.length)
57 )
58 if (Result.isFailure(decoded)) return undefined
59 try {
60 return routingHeaderDecoder.decode(decoded.success)
61 } catch {
62 return undefined
63 }
64}
65
66/**
67 * @internal
68 */
69export const profileFromClient = (
70 request: PublicMcpSchema.McpServerClient["Service"]
71): McpCore.NegotiatedProtocolProfile => ({
72 protocolVersion: request.protocolVersion,
73 clientCapabilities: request.clientCapabilities,
74 clientInfo: request.clientInfo,
75 requestMetadata: request.initializePayload._meta
76})
77
78/**
79 * @internal
80 */
81export const invocationFromClient = (
82 request: PublicMcpSchema.McpServerClient["Service"]
83): McpCore.McpInvocation => ({
84 clientId: request.clientId,
85 protocol: profileFromClient(request),
86 requestContext: PublicMcpSchema.McpRequestContext.of({
87 clientId: request.clientId,
88 protocolVersion: request.protocolVersion,
89 clientCapabilities: request.clientCapabilities,
90 clientInfo: request.clientInfo,
91 requestMetadata: request.requestMetadata
92 }),
93 serverClient: request
94})
95
96/**
97 * @internal
98 */
99export const invocationFromRequestContext = (
100 request: PublicMcpSchema.McpRequestContext["Service"]
101): McpCore.McpInvocation => ({
102 clientId: request.clientId,
103 protocol: {
104 protocolVersion: request.protocolVersion,
105 clientCapabilities: request.clientCapabilities,
106 clientInfo: request.clientInfo,
107 requestMetadata: request.requestMetadata
108 },
109 requestContext: request
110})
111
112/**
113 * @internal
114 */
115export const requireCompleteOperation = <A>(
116 protocolVersion: PublicMcpProtocol.ProtocolVersion,
117 outcome: McpCore.OperationOutcome<A>
118): Effect.Effect<A, McpCore.UnsupportedByProtocol> =>
119 outcome._tag === "Complete"
120 ? Effect.succeed(outcome.value)
121 : Effect.fail(new McpCore.UnsupportedByProtocol({ protocolVersion, feature: "Client input" }))
122
123/**
124 * Unquotes an exact text mirror when the protocol drops string `structuredContent`.
125 *
126 * @internal
127 */
128export const unwrapStringStructuredContent = (
129 result: PublicMcpSchema.CallToolResult
130): PublicMcpSchema.CallToolResult["content"] => {
131 const { content, structuredContent } = result
132 if (typeof structuredContent !== "string" || content.length !== 1) return content
133 const [block] = content
134 return block.type === "text" && block.text === JSON.stringify(structuredContent)
135 ? [{ ...block, text: structuredContent }]
136 : content
137}
138
139const isSamplingToolContent = (content: unknown): boolean =>
140 Predicate.isReadonlyObject(content) && (content.type === "tool_use" || content.type === "tool_result")
141
142/**
143 * @internal
144 */
145export const samplingRequestRequiresTools = (request: unknown): boolean => {
146 if (!Predicate.isReadonlyObject(request)) return false
147 if (request.tools !== undefined || request.toolChoice !== undefined) return true
148 if (!Array.isArray(request.messages)) return false
149 return request.messages.some((message) => {
150 if (!Predicate.isReadonlyObject(message)) return false
151 const content = message.content
152 return Array.isArray(content) ? content.some(isSamplingToolContent) : isSamplingToolContent(content)
153 })
154}
155
156// NOTE: Keep the two codec assertions below as the single documented
157// existential-schema boundary. Rpc.AnyWithProps intentionally erases each
158// request's payload type, while the runtime schema still performs decoding and
159// encoding; do not spread this erasure into MCP domain records.
160
161/**
162 * @internal
163 */
164export interface LifecycleRuntime {
165 readonly initialize: (
166 protocolVersion: PublicMcpProtocol.ProtocolVersion,
167 profile: McpCore.NegotiatedProtocolProfile,
168 clientId: number
169 ) => Effect.Effect<McpCore.CanonicalInitializeResult>
170 readonly setLogLevel: (
171 level: PublicMcpSchema.LoggingLevel,
172 clientId: number,
173 headers: Headers.Headers
174 ) => Effect.Effect<void>
175 readonly subscribe: (uri: string, clientId: number, headers: Headers.Headers) => Effect.Effect<void, ProtocolError>
176 readonly unsubscribe: (uri: string, clientId: number, headers: Headers.Headers) => Effect.Effect<void, ProtocolError>
177 readonly clientNotification: (
178 notification: McpCore.ClientNotification,
179 clientId: number,
180 headers: Headers.Headers
181 ) => Effect.Effect<void>
182}
183
184/**
185 * @internal
186 */
187export class ProtocolError extends Data.TaggedError("ProtocolError")<{
188 readonly code: number
189 readonly message: string
190 readonly data?: unknown
191}> {
192 static fromTool(
193 error: McpCore.ToolError | McpCore.UnsupportedByProtocol
194 ): ProtocolError {
195 const message = Match.value(error).pipe(
196 Match.tag("ToolNotFound", (error) => `Tool '${error.name}' not found`),
197 Match.tag(
198 "UnsupportedByProtocol",
199 (error) => `${error.feature} is not supported by MCP ${error.protocolVersion}`
200 ),
201 Match.tags({
202 InvalidToolInput: (error) => error.message,
203 InvalidToolContinuation: (error) => error.message,
204 ToolExecutionError: (error) => error.message
205 }),
206 Match.exhaustive
207 )
208 return new ProtocolError({
209 code: error._tag === "ToolExecutionError"
210 ? PublicMcpSchema.INTERNAL_ERROR_CODE
211 : PublicMcpSchema.INVALID_PARAMS_ERROR_CODE,
212 message
213 })
214 }
215
216 static fromFeature(error: unknown): ProtocolError {
217 if (Predicate.hasProperty(error, "_tag")) {
218 if (error._tag === "PromptNotFound" && Predicate.hasProperty(error, "name") && Predicate.isString(error.name)) {
219 return new ProtocolError({
220 code: PublicMcpSchema.INVALID_PARAMS_ERROR_CODE,
221 message: `Prompt '${error.name}' not found`
222 })
223 }
224 if (error._tag === "ResourceNotFound" && Predicate.hasProperty(error, "uri") && Predicate.isString(error.uri)) {
225 return new ProtocolError({
226 code: LEGACY_RESOURCE_NOT_FOUND_ERROR_CODE,
227 message: `Resource '${error.uri}' not found`
228 })
229 }
230 }
231 const decoded = decodeProtocolErrorFields(error)
232 return Result.isSuccess(decoded)
233 ? new ProtocolError(decoded.success)
234 : new ProtocolError({
235 code: PublicMcpSchema.INTERNAL_ERROR_CODE,
236 message: "MCP feature handler failed"
237 })
238 }
239}
240
241const ProtocolErrorFields = Schema.Struct({
242 code: Schema.Finite,
243 message: Schema.String,
244 data: Schema.optionalKey(Schema.Unknown)
245})
246
247const decodeProtocolErrorFields = Schema.decodeUnknownResult(ProtocolErrorFields)
248
249const isReverseOperationUnsupported = (cause: unknown): cause is PublicMcpSchema.McpReverseOperationUnsupported =>
250 Predicate.isTagged(cause, "McpReverseOperationUnsupported")
251
252/**
253 * @internal
254 */
255export const reverseError = (
256 operation: PublicMcpSchema.McpReverseOperationError["operation"]
257) =>
258(cause: unknown) =>
259 isReverseOperationUnsupported(cause)
260 ? cause
261 : new PublicMcpSchema.McpReverseOperationError({ operation, cause })
262
263/**
264 * @internal
265 */
266export const transcode = <
267 From extends Schema.Constraint,
268 To extends Schema.Constraint
269>(
270 from: From,
271 to: To,
272 input: Schema.Schema.Type<From>
273) =>
274 Schema.encodeEffect(from)(input).pipe(
275 Effect.flatMap(Schema.decodeUnknownEffect(to))
276 )
277
278/**
279 * @internal
280 */
281export const transcodeStrict = <
282 From extends Schema.Constraint,
283 To extends Schema.Constraint
284>(
285 from: From,
286 to: To,
287 input: Schema.Schema.Type<From>
288) =>
289 Schema.encodeEffect(from)(input).pipe(
290 Effect.flatMap(Schema.decodeUnknownEffect(to, { onExcessProperty: "error" }))
291 )
292
293/**
294 * @internal
295 */
296export const makeNotificationProjector = Effect.fn(function*(
297 options: {
298 readonly supportsProgressMessage: boolean
299 },
300 notification: McpCore.ServerNotification
301) {
302 return McpCore.ServerNotification.$match(notification, {
303 Cancelled: (notification): PublicMcpProtocol.ProjectedNotification => ({
304 tag: PublicMcpSchema.CancelledNotification._tag,
305 payload: PublicMcpSchema.CancelledNotification.payloadSchema.make({
306 _meta: notification.metadata,
307 requestId: notification.requestId,
308 reason: notification.reason
309 })
310 }),
311 Progress: (notification): PublicMcpProtocol.ProjectedNotification => ({
312 tag: PublicMcpSchema.ProgressNotification._tag,
313 payload: PublicMcpSchema.ProgressNotification.payloadSchema.make({
314 _meta: notification.metadata,
315 progressToken: notification.progressToken,
316 progress: notification.progress,
317 total: notification.total,
318 message: options.supportsProgressMessage ? notification.message : undefined
319 })
320 }),
321 LoggingMessage: (notification): PublicMcpProtocol.ProjectedNotification => ({
322 tag: PublicMcpSchema.LoggingMessageNotification._tag,
323 payload: PublicMcpSchema.LoggingMessageNotification.payloadSchema.make({
324 _meta: notification.metadata,
325 level: notification.level,
326 logger: notification.logger,
327 data: notification.data
328 })
329 }),
330 ResourceUpdated: (notification): PublicMcpProtocol.ProjectedNotification => ({
331 tag: PublicMcpSchema.ResourceUpdatedNotification._tag,
332 payload: PublicMcpSchema.ResourceUpdatedNotification.payloadSchema.make({
333 _meta: notification.metadata,
334 uri: notification.uri
335 })
336 }),
337 ResourcesChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({
338 tag: PublicMcpSchema.ResourceListChangedNotification._tag,
339 payload: PublicMcpSchema.ResourceListChangedNotification.payloadSchema.make({
340 _meta: notification.metadata
341 })
342 }),
343 ToolsChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({
344 tag: PublicMcpSchema.ToolListChangedNotification._tag,
345 payload: PublicMcpSchema.ToolListChangedNotification.payloadSchema.make({
346 _meta: notification.metadata
347 })
348 }),
349 PromptsChanged: (notification): PublicMcpProtocol.ProjectedNotification => ({
350 tag: PublicMcpSchema.PromptListChangedNotification._tag,
351 payload: PublicMcpSchema.PromptListChangedNotification.payloadSchema.make({
352 _meta: notification.metadata
353 })
354 }),
355 ElicitationComplete: () => undefined
356 })
357})
358
359/**
360 * @internal
361 */
362export interface HandlerInstallationTarget {
363 readonly context: HandlerInstallationContext
364 readonly install: <
365 Rpcs extends Rpc.Any,
366 Handlers extends RpcGroup.HandlersFrom<Rpcs>
367 >(
368 protocol: {
369 readonly protocolVersion: string
370 },
371 rpcs: RpcGroup.RpcGroup<Rpcs>,
372 handlers: Handlers
373 ) => Effect.Effect<void, never, RpcGroup.HandlersServices<Rpcs, Handlers>>
374}
375
376/**
377 * @internal
378 */
379export interface HandlerInstallationContext {
380 readonly subscribeServerNotifications: Effect.Effect<
381 PubSub.Subscription<CanonicalServerNotification>,
382 never,
383 Scope.Scope
384 >
385 readonly sendNotification?: (
386 protocolVersion: string,
387 clientId: number,
388 notification: PublicMcpProtocol.ProjectedNotification
389 ) => Effect.Effect<void>
390 readonly terminateSubscription?: (
391 protocolVersion: string,
392 clientId: number,
393 requestId: RpcMessage.RequestId,
394 reason: string
395 ) => Effect.Effect<void>
396 readonly markSubscriptionCancelled?: (
397 clientId: number,
398 requestId: RpcMessage.RequestId
399 ) => Effect.Effect<void>
400 readonly supportedVersions: ReadonlyArray<string>
401 readonly serverInfo: {
402 readonly name: string
403 readonly version: string
404 readonly instructions?: string | undefined
405 readonly description?: string | undefined
406 readonly websiteUrl?: string | undefined
407 readonly icons?: ReadonlyArray<PublicMcpSchema.Icon> | undefined
408 readonly extensions?: PublicMcpSchema.ServerCapabilities["extensions"] | undefined
409 }
410 readonly registrationPresence: Effect.Effect<{
411 readonly tools: boolean
412 readonly resources: boolean
413 readonly prompts: boolean
414 }>
415}
416
417/**
418 * @internal
419 */
420export interface CanonicalServerNotification {
421 readonly notification: SubscriptionServerNotification
422 readonly targetClientId?: number | undefined
423}
424
425/**
426 * @internal
427 */
428export type SubscriptionServerNotification = Extract<
429 McpCore.ServerNotification,
430 { readonly _tag: "ToolsChanged" | "PromptsChanged" | "ResourcesChanged" | "ResourceUpdated" }
431>
432
433/**
434 * @internal
435 */
436export const isSubscriptionServerNotification = (
437 notification: McpCore.ServerNotification
438): notification is SubscriptionServerNotification => {
439 switch (notification._tag) {
440 case "ToolsChanged":
441 case "PromptsChanged":
442 case "ResourcesChanged":
443 case "ResourceUpdated":
444 return true
445 default:
446 return false
447 }
448}
449
450/**
451 * @internal
452 */
453export interface ProtocolAdapter<
454 out Version extends string = string,
455 out Runtime extends PublicMcpProtocol.RuntimeDescriptor = PublicMcpProtocol.RuntimeDescriptor,
456 ClientRpcs extends Rpc.Any = Rpc.Any,
457 ClientNotificationRpcs extends ClientRpcs = ClientRpcs,
458 ServerRequestRpcs extends Rpc.Any = Rpc.Any,
459 ServerNotificationRpcs extends Rpc.Any = Rpc.Any,
460 HandlerRpcs extends Rpc.Any = never,
461 HandlerRequirements = never
462> {
463 // Each adapter owns its dated RPC vocabulary, transport policy, handler
464 // projection, and wire behavior.
465 readonly protocolVersion: Version
466 readonly runtime: Runtime
467 readonly clientRpcs: RpcGroup.RpcGroup<ClientRpcs>
468 readonly clientNotificationRpcs: RpcGroup.RpcGroup<ClientNotificationRpcs>
469 readonly serverRequestRpcs: RpcGroup.RpcGroup<ServerRequestRpcs>
470 readonly serverNotificationRpcs: RpcGroup.RpcGroup<ServerNotificationRpcs>
471 readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PublicMcpProtocol.PayloadCodecs
472 readonly handlerRpcs?: RpcGroup.RpcGroup<HandlerRpcs> | undefined
473 readonly installHandlers: (
474 core: McpCore.McpCore,
475 lifecycle: LifecycleRuntime | undefined,
476 target: HandlerInstallationTarget
477 ) => Effect.Effect<void, never, HandlerRequirements>
478 readonly makeReverseClient: (
479 profile: McpCore.NegotiatedProtocolProfile
480 ) => Effect.Effect<
481 PublicMcpSchema.McpReverseClient,
482 never,
483 RpcClient.Protocol | Scope.Scope
484 >
485 readonly projectNotification: (
486 notification: McpCore.ServerNotification
487 ) => Effect.Effect<PublicMcpProtocol.ProjectedNotification | undefined, McpCore.UnsupportedByProtocol>
488 readonly normalizeCancellation: (
489 payload: unknown
490 ) => Effect.Effect<McpCore.Cancellation, unknown>
491}
492
493type HandlerLifecycle<Runtime extends PublicMcpProtocol.RuntimeDescriptor> = Runtime extends
494 PublicMcpProtocol.StatefulRuntimeDescriptor ? LifecycleRuntime : undefined
495
496/**
497 * @internal
498 */
499export const make = <
500 const Version extends string,
501 const Runtime extends PublicMcpProtocol.RuntimeDescriptor,
502 ClientRpcs extends Rpc.Any,
503 ClientNotificationRpcs extends ClientRpcs,
504 ServerRequestRpcs extends Rpc.Any,
505 ServerNotificationRpcs extends Rpc.Any,
506 HandlerRpcs extends Rpc.Any = never,
507 Handlers extends RpcGroup.HandlersFrom<HandlerRpcs> = RpcGroup.HandlersFrom<HandlerRpcs>
508>(options: {
509 readonly protocolVersion: Version
510 readonly runtime: Runtime
511 readonly clientRpcs: RpcGroup.RpcGroup<ClientRpcs>
512 readonly clientNotificationRpcs: RpcGroup.RpcGroup<ClientNotificationRpcs>
513 readonly serverRequestRpcs: RpcGroup.RpcGroup<ServerRequestRpcs>
514 readonly serverNotificationRpcs: RpcGroup.RpcGroup<ServerNotificationRpcs>
515 readonly handlerRpcs?: RpcGroup.RpcGroup<HandlerRpcs> | undefined
516 readonly makeHandlers?:
517 | ((
518 core: McpCore.McpCore,
519 lifecycle: HandlerLifecycle<Runtime>,
520 context: HandlerInstallationContext
521 ) => Handlers)
522 | undefined
523 readonly toReverseClient: (
524 profile: McpCore.NegotiatedProtocolProfile,
525 client: RpcClient.RpcClient<ServerRequestRpcs, RpcClientError>
526 ) => PublicMcpSchema.McpReverseClient
527 readonly projectNotification: (
528 notification: McpCore.ServerNotification
529 ) => Effect.Effect<PublicMcpProtocol.ProjectedNotification | undefined, McpCore.UnsupportedByProtocol>
530 readonly normalizeCancellation: (
531 payload: unknown
532 ) => Effect.Effect<McpCore.Cancellation, unknown>
533}): ProtocolAdapter<
534 Version,
535 Runtime,
536 ClientRpcs,
537 ClientNotificationRpcs,
538 ServerRequestRpcs,
539 ServerNotificationRpcs,
540 HandlerRpcs,
541 RpcGroup.HandlersServices<HandlerRpcs, Handlers>
542> => {
543 const payloadCodecsCache = new WeakMap<Rpc.AnyWithProps, PublicMcpProtocol.PayloadCodecs>()
544 const payloadCodecs = (rpc: Rpc.AnyWithProps): PublicMcpProtocol.PayloadCodecs => {
545 let codecs = payloadCodecsCache.get(rpc)
546 if (codecs === undefined) {
547 const schema = Schema.toCodecJson(rpc.payloadSchema)
548 codecs = {
549 decode: Schema.decodeUnknownEffect(schema) as PublicMcpProtocol.PayloadCodecs["decode"],
550 encode: Schema.encodeUnknownEffect(schema) as PublicMcpProtocol.PayloadCodecs["encode"]
551 }
552 payloadCodecsCache.set(rpc, codecs)
553 }
554 return codecs
555 }
556
557 const installHandlers = (
558 core: McpCore.McpCore,
559 lifecycle: LifecycleRuntime | undefined,
560 target: HandlerInstallationTarget
561 ): Effect.Effect<void, never, RpcGroup.HandlersServices<HandlerRpcs, Handlers>> =>
562 options.handlerRpcs === undefined || options.makeHandlers === undefined
563 ? Effect.void
564 : lifecycle === undefined && options.runtime._tag === "Stateful"
565 ? Effect.die("MCP sessionful handler installation requires a lifecycle runtime")
566 : target.install(
567 options,
568 options.handlerRpcs,
569 options.makeHandlers(
570 core,
571 // TypeScript cannot narrow HandlerLifecycle<Runtime> from the generic runtime tag; the preceding guard
572 // enforces the stateful lifecycle invariant.
573 (options.runtime._tag === "Stateful" ? lifecycle : undefined) as HandlerLifecycle<Runtime>,
574 target.context
575 )
576 )
577
578 const makeReverseClient = (
579 profile: McpCore.NegotiatedProtocolProfile
580 ): Effect.Effect<
581 PublicMcpSchema.McpReverseClient,
582 never,
583 RpcClient.Protocol | Scope.Scope
584 > =>
585 RpcClient.make(options.serverRequestRpcs, {
586 spanPrefix: "McpServer/Client"
587 }).pipe(
588 Effect.map((client) => options.toReverseClient(profile, client))
589 )
590
591 return {
592 ...options,
593 payloadCodecs,
594 installHandlers,
595 makeReverseClient
596 }
597}