Unified · Split
| @@ -10,7 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | // 4. Retrying once on connection failure (invalidate + reconnect). |
| 11 | 11 | // --------------------------------------------------------------------------- |
| 12 | 12 | |
| 13 | −import { Cause, Effect, Exit, Schema, ScopedCache } from "effect"; | |
| 13 | +import { Cause, Effect, Exit, Option, Predicate, Schema, ScopedCache } from "effect"; | |
| 14 | 14 | |
| 15 | 15 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 16 | 16 | |
| @@ -29,15 +29,13 @@ import type { McpStoredSourceData } from "./types"; · 12 unmodified lines | ||
| 29 | 29 | // Helpers |
| 30 | 30 | // --------------------------------------------------------------------------- |
| 31 | 31 | |
| 32 | −const asRecord = (value: unknown): Record<string, unknown> => | |
| 33 | − typeof value === "object" && value !== null && !Array.isArray(value) | |
| 34 | − ? (value as Record<string, unknown>) | |
| 35 | − : {}; | |
| 32 | +const ArgsRecord = Schema.Record(Schema.String, Schema.Unknown); | |
| 33 | +const decodeArgsRecord = Schema.decodeUnknownOption(ArgsRecord); | |
| 36 | 34 | |
| 37 | −const connectionCacheKey = ( | |
| 38 | − sd: McpStoredSourceData, | |
| 39 | − invokerScope: string, | |
| 40 | −): string => | |
| 35 | +const argsRecord = (value: unknown): Record<string, unknown> => | |
| 36 | + Option.getOrElse(decodeArgsRecord(value), () => ({})); | |
| 37 | + | |
| 38 | +const connectionCacheKey = (sd: McpStoredSourceData, invokerScope: string): string => | |
| 41 | 39 | sd.transport === "stdio" |
| 42 | 40 | ? `stdio:${sd.command}` |
| 43 | 41 | : // Remote sources may resolve per-user secrets (OAuth tokens, header |
| @@ -83,42 +81,34 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 39 unmodified lines | ||
| 83 | 81 | requestedSchema: params.requestedSchema, |
| 84 | 82 | }); |
| 85 | 83 | |
| 86 | −const installElicitationHandler = ( | |
| 87 | − client: McpConnection["client"], | |
| 88 | − elicit: Elicit, | |
| 89 | −): void => { | |
| 90 | − client.setRequestHandler( | |
| 91 | − ElicitRequestSchema, | |
| 92 | − async (request: { params: unknown }) => { | |
| 93 | − const params = decodeElicitParams(request.params); | |
| 94 | − const req = toElicitationRequest(params); | |
| 95 | − // Use runPromiseExit so we can inspect typed failures — `elicit` | |
| 96 | − // fails with `ElicitationDeclinedError` on decline/cancel, which | |
| 97 | − // we translate into the equivalent MCP elicit response instead of | |
| 98 | − // surfacing as a JSON-RPC error. | |
| 99 | − const exit = await Effect.runPromiseExit(elicit(req)); | |
| 100 | − if (Exit.isSuccess(exit)) { | |
| 101 | − const response = exit.value; | |
| 102 | − return { | |
| 103 | − action: response.action, | |
| 104 | − ...(response.action === "accept" && response.content | |
| 105 | − ? { content: response.content } | |
| 106 | − : {}), | |
| 107 | − }; | |
| 108 | − } | |
| 109 | − const failure = exit.cause.reasons.find(Cause.isFailReason); | |
| 110 | − if (failure) { | |
| 111 | − const err = failure.error as { | |
| 112 | − readonly _tag?: string; | |
| 113 | − readonly action?: "decline" | "cancel"; | |
| 114 | − }; | |
| 115 | − if (err._tag === "ElicitationDeclinedError") { | |
| 116 | − return { action: err.action ?? "decline" }; | |
| 117 | − } | |
| 84 | +const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { | |
| 85 | + client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => { | |
| 86 | + const params = decodeElicitParams(request.params); | |
| 87 | + const req = toElicitationRequest(params); | |
| 88 | + // Use runPromiseExit so we can inspect typed failures — `elicit` | |
| 89 | + // fails with `ElicitationDeclinedError` on decline/cancel, which | |
| 90 | + // we translate into the equivalent MCP elicit response instead of | |
| 91 | + // surfacing as a JSON-RPC error. | |
| 92 | + const exit = await Effect.runPromiseExit(elicit(req)); | |
| 93 | + if (Exit.isSuccess(exit)) { | |
| 94 | + const response = exit.value; | |
| 95 | + return { | |
| 96 | + action: response.action, | |
| 97 | + ...(response.action === "accept" && response.content ? { content: response.content } : {}), | |
| 98 | + }; | |
| 99 | + } | |
| 100 | + const failure = exit.cause.reasons.find(Cause.isFailReason); | |
| 101 | + if (failure) { | |
| 102 | + const err = failure.error; | |
| 103 | + if (Predicate.isTagged(err, "ElicitationDeclinedError")) { | |
| 104 | + const action = | |
| 105 | + Predicate.hasProperty(err, "action") && err.action === "cancel" ? "cancel" : "decline"; | |
| 106 | + return { action }; | |
| 118 | 107 | } |
| 119 | − throw Cause.squash(exit.cause); | |
| 120 | − }, | |
| 121 | − ); | |
| 108 | + } | |
| 109 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK async request handlers signal unexpected failures by rejecting | |
| 110 | + throw Cause.squash(exit.cause); | |
| 111 | + }); | |
| 122 | 112 | }; |
| 123 | 113 | |
| 124 | 114 | // --------------------------------------------------------------------------- |
| @@ -135,12 +125,10 @@ const useConnection = ( · 10 unmodified lines | ||
| 135 | 125 | installElicitationHandler(connection.client, elicit); |
| 136 | 126 | return yield* Effect.tryPromise({ |
| 137 | 127 | try: () => connection.client.callTool({ name: toolName, arguments: args }), |
| 138 | − catch: (cause) => | |
| 128 | + catch: () => | |
| 139 | 129 | new McpInvocationError({ |
| 140 | 130 | toolName, |
| 141 | − message: `MCP tool call failed for ${toolName}: ${ | |
| 142 | − cause instanceof Error ? cause.message : String(cause) | |
| 143 | − }`, | |
| 131 | + message: `MCP tool call failed for ${toolName}`, | |
| 144 | 132 | }), |
| 145 | 133 | }).pipe( |
| 146 | 134 | Effect.withSpan("plugin.mcp.client.call_tool", { |
| @@ -163,15 +151,8 @@ export interface InvokeMcpToolInput { · 16 unmodified lines | ||
| 163 | 151 | * collapse multiple users onto one shared connection. */ |
| 164 | 152 | readonly invokerScope: string; |
| 165 | 153 | readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>; |
| 166 | − readonly connectionCache: ScopedCache.ScopedCache< | |
| 167 | − string, | |
| 168 | − McpConnection, | |
| 169 | − McpConnectionError | |
| 170 | − >; | |
| 171 | − readonly pendingConnectors: Map< | |
| 172 | − string, | |
| 173 | − Effect.Effect<McpConnection, McpConnectionError> | |
| 174 | − >; | |
| 154 | + readonly connectionCache: ScopedCache.ScopedCache<string, McpConnection, McpConnectionError>; | |
| 155 | + readonly pendingConnectors: Map<string, Effect.Effect<McpConnection, McpConnectionError>>; | |
| 175 | 156 | readonly elicit: Elicit; |
| 176 | 157 | } |
| 177 | 158 | |
| @@ -179,12 +160,10 @@ export const invokeMcpTool = ( · 1 unmodified line | ||
| 179 | 160 | input: InvokeMcpToolInput, |
| 180 | 161 | ): Effect.Effect<unknown, McpConnectionError | McpInvocationError> => { |
| 181 | 162 | const transport: string = |
| 182 | − input.sourceData.transport === "stdio" | |
| 183 | − ? "stdio" | |
| 184 | − : (input.sourceData.remoteTransport ?? "auto"); | |
| 163 | + input.sourceData.transport === "stdio" ? "stdio" : (input.sourceData.remoteTransport ?? "auto"); | |
| 185 | 164 | return Effect.gen(function* () { |
| 186 | 165 | const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope); |
| 187 | − const args = asRecord(input.args); | |
| 166 | + const args = argsRecord(input.args); | |
| 188 | 167 | |
| 189 | 168 | // Register the connector for the cache lookup (side-channel pattern |
| 190 | 169 | // — the ScopedCache lookup closure reads from `pendingConnectors`). |
| @@ -209,12 +188,7 @@ export const invokeMcpTool = ( · 18 unmodified lines | ||
| 209 | 188 | }), |
| 210 | 189 | ); |
| 211 | 190 | |
| 212 | − return yield* useConnection( | |
| 213 | − firstConnection, | |
| 214 | − input.toolName, | |
| 215 | − args, | |
| 216 | − input.elicit, | |
| 217 | − ).pipe( | |
| 191 | + return yield* useConnection(firstConnection, input.toolName, args, input.elicit).pipe( | |
| 218 | 192 | // On failure, invalidate the cache and retry once with a fresh |
| 219 | 193 | // connection. Matches the old invoker's retry-once semantics. |
| 220 | 194 | Effect.catch(() => |
| @@ -222,12 +196,7 @@ export const invokeMcpTool = ( · 1 unmodified line | ||
| 222 | 196 | yield* ScopedCache.invalidate(input.connectionCache, cacheKey); |
| 223 | 197 | input.pendingConnectors.set(cacheKey, connector); |
| 224 | 198 | const fresh = yield* ScopedCache.get(input.connectionCache, cacheKey); |
| 225 | − return yield* useConnection( | |
| 226 | − fresh, | |
| 227 | − input.toolName, | |
| 228 | − args, | |
| 229 | − input.elicit, | |
| 230 | − ); | |
| 199 | + return yield* useConnection(fresh, input.toolName, args, input.elicit); | |
| 231 | 200 | }).pipe( |
| 232 | 201 | Effect.withSpan("plugin.mcp.invoke.retry", { |
| 233 | 202 | attributes: { |