| @@ -2,7 +2,7 @@ · 1 unmodified line |
| 2 | // MCP tool invoker — bridges the binding store + MCP client into SDK invoker | 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 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 7 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 8 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; | 8 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| @@ -48,6 +48,10 @@ type Secrets = { · 39 unmodified lines |
| 48 | ) => Effect.Effect<string, unknown>; | 48 | ) => Effect.Effect<string, unknown>; |
| 49 | }; | 49 | }; |
| 50 | | 50 | |
| | 51 | // --------------------------------------------------------------------------- |
| | 52 | // OAuth provider factory |
| | 53 | // --------------------------------------------------------------------------- |
| | 54 | |
| 51 | const makeOAuthProvider = ( | 55 | const makeOAuthProvider = ( |
| 52 | accessToken: string, | 56 | accessToken: string, |
| 53 | tokenType: string, | 57 | tokenType: string, |
| @@ -57,8 +61,8 @@ const makeOAuthProvider = ( · 3 unmodified lines |
| 57 | get clientMetadata() { | 61 | get clientMetadata() { |
| 58 | return { | 62 | return { |
| 59 | redirect_uris: ["http://localhost/oauth/callback"], | 63 | redirect_uris: ["http://localhost/oauth/callback"], |
| 60 | grant_types: ["authorization_code", "refresh_token"], | 64 | grant_types: ["authorization_code", "refresh_token"] as string[], |
| 61 | response_types: ["code"], | 65 | response_types: ["code"] as string[], |
| 62 | token_endpoint_auth_method: "none" as const, | 66 | token_endpoint_auth_method: "none" as const, |
| 63 | client_name: "Executor", | 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({ | 91 | const McpElicitParams = Schema.Union( |
| 88 | mode: Schema.optional(Schema.Literal("form")), | 92 | Schema.Struct({ |
| 89 | message: Schema.String, | 93 | mode: Schema.Literal("url"), |
| 90 | requestedSchema: Schema.Record({ key: Schema.String, value: Schema.Unknown }), | 94 | message: Schema.String, |
| 91 | }); | 95 | url: Schema.String, |
| 92 | | 96 | elicitationId: Schema.optional(Schema.String), |
| 93 | const McpUrlElicitParams = Schema.Struct({ | 97 | id: Schema.optional(Schema.String), |
| 94 | mode: Schema.Literal("url"), | 98 | }), |
| 95 | message: Schema.String, | 99 | Schema.Struct({ |
| 96 | url: Schema.String, | 100 | mode: Schema.optional(Schema.Literal("form")), |
| 97 | elicitationId: Schema.optional(Schema.String), | 101 | message: Schema.String, |
| 98 | id: Schema.optional(Schema.String), | 102 | requestedSchema: Schema.Record({ key: Schema.String, value: Schema.Unknown }), |
| 99 | }); | 103 | }), |
| 100 | | 104 | ); |
| 101 | const McpElicitParams = Schema.Union(McpUrlElicitParams, McpFormElicitParams); | | |
| 102 | type McpElicitParams = typeof McpElicitParams.Type; | 105 | type McpElicitParams = typeof McpElicitParams.Type; |
| 103 | | 106 | |
| 104 | const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); | 107 | const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); |
| @@ -124,18 +127,18 @@ const installElicitationHandler = ( · 19 unmodified lines |
| 124 | client.setRequestHandler( | 127 | client.setRequestHandler( |
| 125 | ElicitRequestSchema, | 128 | ElicitRequestSchema, |
| 126 | async (request: { params: unknown }) => { | 129 | async (request: { params: unknown }) => { |
| 127 | const params = decodeElicitParams(request.params); | 130 | const params = decodeElicitParams(request.params); |
| 128 | const response = await Effect.runPromise( | 131 | const response = await Effect.runPromise( |
| 129 | handler({ toolId, args, request: toElicitationRequest(params) }), | 132 | handler({ toolId, args, request: toElicitationRequest(params) }), |
| 130 | ); | 133 | ); |
| 131 | | 134 | return { |
| 132 | return { | 135 | action: response.action, |
| 133 | action: response.action, | 136 | ...(response.action === "accept" && response.content |
| 134 | ...(response.action === "accept" && response.content | 137 | ? { content: response.content } |
| 135 | ? { content: response.content } | 138 | : {}), |
| 136 | : {}), | 139 | }; |
| 137 | }; | 140 | }, |
| 138 | }); | 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 */ | 227 | // --------------------------------------------------------------------------- |
| 225 | const callMcpTool = ( | 228 | // Connection cache key |
| 226 | connection: McpConnection, | 229 | // --------------------------------------------------------------------------- |
| 227 | toolName: string, | 230 | |
| 228 | args: Record<string, unknown>, | 231 | const connectionCacheKey = (sourceData: McpStoredSourceData): string => |
| 229 | ): Effect.Effect<unknown, ToolInvocationError> => | 232 | sourceData.transport === "stdio" |
| 230 | Effect.tryPromise({ | 233 | ? `stdio:${sourceData.command}` |
| 231 | try: () => | 234 | : `remote:${sourceData.endpoint}`; |
| 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 | }); | | |
| 242 | | 235 | |
| 243 | // --------------------------------------------------------------------------- | 236 | // --------------------------------------------------------------------------- |
| 244 | // Resolve elicitation handler from options | 237 | // Resolve elicitation handler from options |
| @@ -251,6 +244,34 @@ const resolveElicitationHandler = ( · 6 unmodified lines |
| 251 | ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) | 244 | ? () => Effect.succeed(new ElicitationResponse({ action: "accept" })) |
| 252 | : options.onElicitation; | 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 | // Public API | 276 | // Public API |
| 256 | // --------------------------------------------------------------------------- | 277 | // --------------------------------------------------------------------------- |
| @@ -259,93 +280,140 @@ export const makeMcpInvoker = (opts: { · 2 unmodified lines |
| 259 | readonly bindingStore: McpBindingStore; | 280 | readonly bindingStore: McpBindingStore; |
| 260 | readonly secrets: Secrets; | 281 | readonly secrets: Secrets; |
| 261 | readonly scopeId: ScopeId; | 282 | readonly scopeId: ScopeId; |
| 262 | }): ToolInvoker => ({ | 283 | readonly connectionCache: ScopedCache.ScopedCache<string, McpConnection, McpConnectionError>; |
| 263 | resolveAnnotations: () => | 284 | /** Shared map between cache lookup and invoker — set connector before cache.get */ |
| 264 | Effect.succeed( | 285 | readonly pendingConnectors: Map<string, Effect.Effect<McpConnection, McpConnectionError>>; |
| 265 | new ToolAnnotations({ | 286 | }): ToolInvoker & { readonly closeConnections: () => Effect.Effect<void> } => { |
| 266 | requiresApproval: false, | 287 | const { connectionCache, pendingConnectors } = opts; |
| 267 | }), | 288 | |
| 268 | ), | 289 | return { |
| 269 | | 290 | resolveAnnotations: () => |
| 270 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => | 291 | Effect.succeed( |
| 271 | Effect.gen(function* () { | 292 | new ToolAnnotations({ requiresApproval: false }), |
| 272 | const entry = yield* opts.bindingStore.get(toolId); | 293 | ), |
| 273 | if (!entry) { | 294 | |
| 274 | return yield* new ToolInvocationError({ | 295 | invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) => |
| 275 | toolId, | 296 | Effect.gen(function* () { |
| 276 | message: `No MCP binding found for tool "${toolId}"`, | 297 | const entry = yield* opts.bindingStore.get(toolId); |
| 277 | cause: undefined, | 298 | if (!entry) { |
| 278 | }); | 299 | return yield* new ToolInvocationError({ |
| 279 | } | 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* () { | 309 | // Build the connector and register it for the cache lookup |
| 284 | const ci = yield* resolveConnectorInput(sourceData, opts.secrets, opts.scopeId); | 310 | const connector = resolveConnectorInput( |
| 285 | return yield* createMcpConnector(ci); | 311 | sourceData, |
| 286 | }).pipe( | 312 | opts.secrets, |
| 287 | Effect.mapError((err) => | 313 | opts.scopeId, |
| 288 | new McpConnectionError({ | 314 | ).pipe( |
| 289 | transport: "auto", | 315 | Effect.flatMap((ci) => createMcpConnector(ci)), |
| 290 | message: err instanceof Error ? err.message : String(err), | 316 | Effect.mapError( |
| 291 | }), | 317 | (err) => |
| 292 | ), | 318 | new McpConnectionError({ |
| 293 | ); | 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( | 339 | const elicitationHandler = resolveElicitationHandler(options); |
| 296 | Effect.mapError( | 340 | |
| 297 | (err) => | 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 | new ToolInvocationError({ | 401 | new ToolInvocationError({ |
| 299 | toolId, | 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 | cause: err, | 406 | cause: err, |
| 302 | }), | 407 | }), |
| 303 | ), | 408 | ); |
| 304 | ); | 409 | }), |
| 305 | | 410 | ), |
| 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; | | |
| 325 | | 411 | |
| 326 | return new ToolInvocationResult({ | 412 | closeConnections: () => |
| 327 | data: isError ? null : (result ?? null), | 413 | Effect.sync(() => { |
| 328 | error: isError ? result : null, | 414 | pendingConnectors.clear(); |
| 329 | }); | 415 | }).pipe( |
| 330 | }).pipe( | 416 | Effect.flatMap(() => connectionCache.invalidateAll), |
| 331 | Effect.catchAll((err) => { | 417 | ), |
| 332 | if ( | 418 | }; |
| 333 | typeof err === "object" && | 419 | }; |
| 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 | }); | | |