| @@ -2,7 +2,7 @@ · 1 unmodified line |
| 2 | 2 | // MCP tool invoker — bridges the binding store + MCP client into SDK invoker |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | | −import { Effect, Schema } from "effect"; |
| 5 | +import { Effect, Schema, type ScopedCache } from "effect"; |
| 6 | 6 | |
| 7 | 7 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 8 | 8 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| @@ -48,6 +48,10 @@ type Secrets = { · 39 unmodified lines |
| 48 | 48 | ) => Effect.Effect<string, unknown>; |
| 49 | 49 | }; |
| 50 | 50 | |
| 51 | +// --------------------------------------------------------------------------- |
| 52 | +// OAuth provider factory |
| 53 | +// --------------------------------------------------------------------------- |
| 54 | + |
| 51 | 55 | const makeOAuthProvider = ( |
| 52 | 56 | accessToken: string, |
| 53 | 57 | tokenType: string, |
| @@ -57,8 +61,8 @@ const makeOAuthProvider = ( · 3 unmodified lines |
| 57 | 61 | get clientMetadata() { |
| 58 | 62 | return { |
| 59 | 63 | redirect_uris: ["http://localhost/oauth/callback"], |
| 60 | | − grant_types: ["authorization_code", "refresh_token"], |
| 61 | | − response_types: ["code"], |
| 64 | + grant_types: ["authorization_code", "refresh_token"] as string[], |
| 65 | + response_types: ["code"] as string[], |
| 62 | 66 | token_endpoint_auth_method: "none" as const, |
| 63 | 67 | client_name: "Executor", |
| 64 | 68 | }; |
| @@ -81,24 +85,23 @@ const makeOAuthProvider = ( · 16 unmodified lines |
| 81 | 85 | }); |
| 82 | 86 | |
| 83 | 87 | // --------------------------------------------------------------------------- |
| 84 | | −// Elicitation bridge — MCP elicit request ↔ SDK ElicitationHandler |
| 88 | +// Elicitation bridge |
| 85 | 89 | // --------------------------------------------------------------------------- |
| 86 | 90 | |
| 87 | | −const McpFormElicitParams = Schema.Struct({ |
| 88 | | − mode: Schema.optional(Schema.Literal("form")), |
| 89 | | − message: Schema.String, |
| 90 | | − requestedSchema: Schema.Record({ key: Schema.String, value: Schema.Unknown }), |
| 91 | | −}); |
| 92 | | − |
| 93 | | −const McpUrlElicitParams = Schema.Struct({ |
| 94 | | − mode: Schema.Literal("url"), |
| 95 | | − message: Schema.String, |
| 96 | | − url: Schema.String, |
| 97 | | − elicitationId: Schema.optional(Schema.String), |
| 98 | | − id: Schema.optional(Schema.String), |
| 99 | | −}); |
| 100 | | − |
| 101 | | −const McpElicitParams = Schema.Union(McpUrlElicitParams, McpFormElicitParams); |
| 91 | +const McpElicitParams = Schema.Union( |
| 92 | + Schema.Struct({ |
| 93 | + mode: Schema.Literal("url"), |
| 94 | + message: Schema.String, |
| 95 | + url: Schema.String, |
| 96 | + elicitationId: Schema.optional(Schema.String), |
| 97 | + id: Schema.optional(Schema.String), |
| 98 | + }), |
| 99 | + Schema.Struct({ |
| 100 | + mode: Schema.optional(Schema.Literal("form")), |
| 101 | + message: Schema.String, |
| 102 | + requestedSchema: Schema.Record({ key: Schema.String, value: Schema.Unknown }), |
| 103 | + }), |
| 104 | +); |
| 102 | 105 | type McpElicitParams = typeof McpElicitParams.Type; |
| 103 | 106 | |
| 104 | 107 | const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); |
| @@ -124,18 +127,18 @@ const installElicitationHandler = ( · 19 unmodified lines |
| 124 | 127 | client.setRequestHandler( |
| 125 | 128 | ElicitRequestSchema, |
| 126 | 129 | async (request: { params: unknown }) => { |
| 127 | | − const params = decodeElicitParams(request.params); |
| 128 | | − const response = await Effect.runPromise( |
| 129 | | − handler({ toolId, args, request: toElicitationRequest(params) }), |
| 130 | | − ); |
| 131 | | − |
| 132 | | − return { |
| 133 | | − action: response.action, |
| 134 | | − ...(response.action === "accept" && response.content |
| 135 | | − ? { content: response.content } |
| 136 | | − : {}), |
| 137 | | − }; |
| 138 | | − }); |
| 130 | + const params = decodeElicitParams(request.params); |
| 131 | + const response = await Effect.runPromise( |
| 132 | + handler({ toolId, args, request: toElicitationRequest(params) }), |
| 133 | + ); |
| 134 | + return { |
| 135 | + action: response.action, |
| 136 | + ...(response.action === "accept" && response.content |
| 137 | + ? { content: response.content } |
| 138 | + : {}), |
| 139 | + }; |
| 140 | + }, |
| 141 | + ); |
| 139 | 142 | }; |
| 140 | 143 | |
| 141 | 144 | // --------------------------------------------------------------------------- |
| @@ -221,24 +224,14 @@ const resolveConnectorInput = ( · 79 unmodified lines |
| 221 | 224 | }); |
| 222 | 225 | }; |
| 223 | 226 | |
| 224 | | −/** Execute a single MCP tool call */ |
| 225 | | −const callMcpTool = ( |
| 226 | | − connection: McpConnection, |
| 227 | | − toolName: string, |
| 228 | | − args: Record<string, unknown>, |
| 229 | | −): Effect.Effect<unknown, ToolInvocationError> => |
| 230 | | − Effect.tryPromise({ |
| 231 | | − try: () => |
| 232 | | − connection.client.callTool({ name: toolName, arguments: args }), |
| 233 | | − catch: (cause) => |
| 234 | | − new ToolInvocationError({ |
| 235 | | − toolId: "" as ToolId, |
| 236 | | − message: `MCP tool call failed for ${toolName}: ${ |
| 237 | | − cause instanceof Error ? cause.message : String(cause) |
| 238 | | − }`, |
| 239 | | − cause, |
| 240 | | − }), |
| 241 | | − }); |
| 227 | +// --------------------------------------------------------------------------- |
| 228 | +// Connection cache key |
| 229 | +// --------------------------------------------------------------------------- |
| 230 | + |
| 231 | +const connectionCacheKey = (sourceData: McpStoredSourceData): string => |
| 232 | + sourceData.transport === "stdio" |
| 233 | + ? `stdio:${sourceData.command}` |
| 234 | + : `remote:${sourceData.endpoint}`; |
| 242 | 235 | |
| 243 | 236 | // --------------------------------------------------------------------------- |
| 244 | 237 | // Resolve elicitation handler from options |
| @@ -251,6 +244,34 @@ const resolveElicitationHandler = ( · 6 unmodified lines |
| 251 | 244 | ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) |
| 252 | 245 | : options.onElicitation; |
| 253 | 246 | |
| 247 | +// --------------------------------------------------------------------------- |
| 248 | +// Use pattern — wrap MCP Client as an Effect service |
| 249 | +// --------------------------------------------------------------------------- |
| 250 | + |
| 251 | +const useMcpConnection = ( |
| 252 | + connection: McpConnection, |
| 253 | + toolId: ToolId, |
| 254 | + toolName: string, |
| 255 | + args: Record<string, unknown>, |
| 256 | + handler: ElicitationHandler, |
| 257 | +): Effect.Effect<unknown, ToolInvocationError> => |
| 258 | + Effect.gen(function* () { |
| 259 | + installElicitationHandler(connection.client, toolId, args, handler); |
| 260 | + |
| 261 | + return yield* Effect.tryPromise({ |
| 262 | + try: () => |
| 263 | + connection.client.callTool({ name: toolName, arguments: args }), |
| 264 | + catch: (cause) => |
| 265 | + new ToolInvocationError({ |
| 266 | + toolId, |
| 267 | + message: `MCP tool call failed for ${toolName}: ${ |
| 268 | + cause instanceof Error ? cause.message : String(cause) |
| 269 | + }`, |
| 270 | + cause, |
| 271 | + }), |
| 272 | + }); |
| 273 | + }).pipe(Effect.withSpan(`mcp.callTool.${toolName}`)); |
| 274 | + |
| 254 | 275 | // --------------------------------------------------------------------------- |
| 255 | 276 | // Public API |
| 256 | 277 | // --------------------------------------------------------------------------- |
| @@ -259,93 +280,140 @@ export const makeMcpInvoker = (opts: { · 2 unmodified lines |
| 259 | 280 | readonly bindingStore: McpBindingStore; |
| 260 | 281 | readonly secrets: Secrets; |
| 261 | 282 | readonly scopeId: ScopeId; |
| 262 | | −}): ToolInvoker => ({ |
| 263 | | − resolveAnnotations: () => |
| 264 | | − Effect.succeed( |
| 265 | | − new ToolAnnotations({ |
| 266 | | − requiresApproval: false, |
| 267 | | − }), |
| 268 | | − ), |
| 269 | | − |
| 270 | | − invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 271 | | − Effect.gen(function* () { |
| 272 | | − const entry = yield* opts.bindingStore.get(toolId); |
| 273 | | − if (!entry) { |
| 274 | | − return yield* new ToolInvocationError({ |
| 275 | | − toolId, |
| 276 | | − message: `No MCP binding found for tool "${toolId}"`, |
| 277 | | − cause: undefined, |
| 278 | | − }); |
| 279 | | − } |
| 283 | + readonly connectionCache: ScopedCache.ScopedCache<string, McpConnection, McpConnectionError>; |
| 284 | + /** Shared map between cache lookup and invoker — set connector before cache.get */ |
| 285 | + readonly pendingConnectors: Map<string, Effect.Effect<McpConnection, McpConnectionError>>; |
| 286 | +}): ToolInvoker & { readonly closeConnections: () => Effect.Effect<void> } => { |
| 287 | + const { connectionCache, pendingConnectors } = opts; |
| 288 | + |
| 289 | + return { |
| 290 | + resolveAnnotations: () => |
| 291 | + Effect.succeed( |
| 292 | + new ToolAnnotations({ requiresApproval: false }), |
| 293 | + ), |
| 294 | + |
| 295 | + invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 296 | + Effect.gen(function* () { |
| 297 | + const entry = yield* opts.bindingStore.get(toolId); |
| 298 | + if (!entry) { |
| 299 | + return yield* new ToolInvocationError({ |
| 300 | + toolId, |
| 301 | + message: `No MCP binding found for tool "${toolId}"`, |
| 302 | + cause: undefined, |
| 303 | + }); |
| 304 | + } |
| 280 | 305 | |
| 281 | | − const { binding, sourceData } = entry; |
| 306 | + const { binding, sourceData } = entry; |
| 307 | + const cacheKey = connectionCacheKey(sourceData); |
| 282 | 308 | |
| 283 | | − const connector = Effect.gen(function* () { |
| 284 | | − const ci = yield* resolveConnectorInput(sourceData, opts.secrets, opts.scopeId); |
| 285 | | − return yield* createMcpConnector(ci); |
| 286 | | − }).pipe( |
| 287 | | − Effect.mapError((err) => |
| 288 | | − new McpConnectionError({ |
| 289 | | − transport: "auto", |
| 290 | | − message: err instanceof Error ? err.message : String(err), |
| 291 | | − }), |
| 292 | | − ), |
| 293 | | − ); |
| 309 | + // Build the connector and register it for the cache lookup |
| 310 | + const connector = resolveConnectorInput( |
| 311 | + sourceData, |
| 312 | + opts.secrets, |
| 313 | + opts.scopeId, |
| 314 | + ).pipe( |
| 315 | + Effect.flatMap((ci) => createMcpConnector(ci)), |
| 316 | + Effect.mapError( |
| 317 | + (err) => |
| 318 | + new McpConnectionError({ |
| 319 | + transport: "auto", |
| 320 | + message: err instanceof Error ? err.message : String(err), |
| 321 | + }), |
| 322 | + ), |
| 323 | + ); |
| 324 | + pendingConnectors.set(cacheKey, connector); |
| 325 | + |
| 326 | + const connection = yield* connectionCache.get(cacheKey).pipe( |
| 327 | + Effect.mapError( |
| 328 | + (err) => |
| 329 | + new ToolInvocationError({ |
| 330 | + toolId, |
| 331 | + message: `Failed connecting to MCP server: ${ |
| 332 | + err instanceof Error ? err.message : String(err) |
| 333 | + }`, |
| 334 | + cause: err, |
| 335 | + }), |
| 336 | + ), |
| 337 | + ); |
| 294 | 338 | |
| 295 | | − const connection = yield* connector.pipe( |
| 296 | | − Effect.mapError( |
| 297 | | − (err) => |
| 339 | + const elicitationHandler = resolveElicitationHandler(options); |
| 340 | + |
| 341 | + return yield* useMcpConnection( |
| 342 | + connection, |
| 343 | + toolId, |
| 344 | + binding.toolName, |
| 345 | + asRecord(args), |
| 346 | + elicitationHandler, |
| 347 | + ).pipe( |
| 348 | + // On failure, invalidate the cached connection and retry once |
| 349 | + Effect.catchAll((err) => |
| 350 | + Effect.gen(function* () { |
| 351 | + yield* connectionCache.invalidate(cacheKey); |
| 352 | + pendingConnectors.set(cacheKey, connector); |
| 353 | + |
| 354 | + const freshConnection = yield* connectionCache |
| 355 | + .get(cacheKey) |
| 356 | + .pipe( |
| 357 | + Effect.mapError( |
| 358 | + (retryErr) => |
| 359 | + new ToolInvocationError({ |
| 360 | + toolId, |
| 361 | + message: `Failed reconnecting: ${ |
| 362 | + retryErr instanceof Error |
| 363 | + ? retryErr.message |
| 364 | + : String(retryErr) |
| 365 | + }`, |
| 366 | + cause: retryErr, |
| 367 | + }), |
| 368 | + ), |
| 369 | + ); |
| 370 | + |
| 371 | + return yield* useMcpConnection( |
| 372 | + freshConnection, |
| 373 | + toolId, |
| 374 | + binding.toolName, |
| 375 | + asRecord(args), |
| 376 | + elicitationHandler, |
| 377 | + ); |
| 378 | + }), |
| 379 | + ), |
| 380 | + ); |
| 381 | + }).pipe( |
| 382 | + Effect.scoped, |
| 383 | + Effect.map((callResult) => { |
| 384 | + const resultRecord = asRecord(callResult); |
| 385 | + const isError = resultRecord.isError === true; |
| 386 | + return new ToolInvocationResult({ |
| 387 | + data: isError ? null : (callResult ?? null), |
| 388 | + error: isError ? callResult : null, |
| 389 | + }); |
| 390 | + }), |
| 391 | + Effect.catchAll((err) => { |
| 392 | + if ( |
| 393 | + typeof err === "object" && |
| 394 | + err !== null && |
| 395 | + "_tag" in err && |
| 396 | + (err as { _tag: string })._tag === "ToolInvocationError" |
| 397 | + ) { |
| 398 | + return Effect.fail(err as ToolInvocationError); |
| 399 | + } |
| 400 | + return Effect.fail( |
| 298 | 401 | new ToolInvocationError({ |
| 299 | 402 | toolId, |
| 300 | | − message: `Failed connecting to MCP server: ${err.message}`, |
| 403 | + message: `MCP invocation failed: ${ |
| 404 | + err instanceof Error ? err.message : String(err) |
| 405 | + }`, |
| 301 | 406 | cause: err, |
| 302 | 407 | }), |
| 303 | | − ), |
| 304 | | − ); |
| 305 | | − |
| 306 | | − // Install elicitation handler before calling the tool |
| 307 | | − const elicitationHandler = resolveElicitationHandler(options); |
| 308 | | − installElicitationHandler( |
| 309 | | − connection.client, |
| 310 | | − toolId, |
| 311 | | − args, |
| 312 | | − elicitationHandler, |
| 313 | | − ); |
| 314 | | − |
| 315 | | − const result = yield* callMcpTool( |
| 316 | | − connection, |
| 317 | | − binding.toolName, |
| 318 | | − asRecord(args), |
| 319 | | − ); |
| 320 | | − |
| 321 | | − yield* Effect.promise(() => connection.close().catch(() => {})); |
| 322 | | − |
| 323 | | − const resultRecord = asRecord(result); |
| 324 | | − const isError = resultRecord.isError === true; |
| 408 | + ); |
| 409 | + }), |
| 410 | + ), |
| 325 | 411 | |
| 326 | | − return new ToolInvocationResult({ |
| 327 | | − data: isError ? null : (result ?? null), |
| 328 | | − error: isError ? result : null, |
| 329 | | − }); |
| 330 | | − }).pipe( |
| 331 | | − Effect.catchAll((err) => { |
| 332 | | − if ( |
| 333 | | − typeof err === "object" && |
| 334 | | − err !== null && |
| 335 | | − "_tag" in err && |
| 336 | | − (err as { _tag: string })._tag === "ToolInvocationError" |
| 337 | | − ) { |
| 338 | | − return Effect.fail(err as ToolInvocationError); |
| 339 | | − } |
| 340 | | − return Effect.fail( |
| 341 | | − new ToolInvocationError({ |
| 342 | | − toolId, |
| 343 | | − message: `MCP invocation failed: ${ |
| 344 | | − err instanceof Error ? err.message : String(err) |
| 345 | | − }`, |
| 346 | | − cause: err, |
| 347 | | − }), |
| 348 | | − ); |
| 349 | | − }), |
| 350 | | − ), |
| 351 | | −}); |
| 412 | + closeConnections: () => |
| 413 | + Effect.sync(() => { |
| 414 | + pendingConnectors.clear(); |
| 415 | + }).pipe( |
| 416 | + Effect.flatMap(() => connectionCache.invalidateAll), |
| 417 | + ), |
| 418 | + }; |
| 419 | +}; |