| @@ -35,16 +35,54 @@ const decodeArgsRecord = Schema.decodeUnknownOption(ArgsRecord); · 34 unmodified lines |
| 35 | const argsRecord = (value: unknown): Record<string, unknown> => | 35 | const argsRecord = (value: unknown): Record<string, unknown> => |
| 36 | Option.getOrElse(decodeArgsRecord(value), () => ({})); | 36 | Option.getOrElse(decodeArgsRecord(value), () => ({})); |
| 37 | | 37 | |
| 38 | const connectionCacheKey = (sd: McpStoredSourceData, invokerScope: string): string => | 38 | const stableJson = (value: unknown): string => { |
| 39 | sd.transport === "stdio" | 39 | if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; |
| 40 | ? `stdio:${sd.command}` | 40 | if (value && typeof value === "object") { |
| 41 | : // Remote sources may resolve per-user secrets (OAuth tokens, header | 41 | return `{${Object.entries(value as Record<string, unknown>) |
| 42 | // auth) via scope shadowing, so two users invoking the same source | 42 | .sort(([a], [b]) => a.localeCompare(b)) |
| 43 | // get different Authorization headers. The connection caches that | 43 | .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) |
| 44 | // header in transport state, so the cache key must include the | 44 | .join(",")}}`; |
| 45 | // invoking scope — otherwise user B re-uses user A's connection | 45 | } |
| 46 | // (and user A's tokens). | 46 | return JSON.stringify(value); |
| 47 | `remote:${invokerScope}:${sd.endpoint}`; | 47 | }; |
| | 48 | |
| | 49 | const fingerprint = (value: unknown): string => { |
| | 50 | const input = stableJson(value); |
| | 51 | let hash = 0x811c9dc5; |
| | 52 | for (let i = 0; i < input.length; i++) { |
| | 53 | hash ^= input.charCodeAt(i); |
| | 54 | hash = Math.imul(hash, 0x01000193); |
| | 55 | } |
| | 56 | return (hash >>> 0).toString(16).padStart(8, "0"); |
| | 57 | }; |
| | 58 | |
| | 59 | const connectionCacheKey = (input: { |
| | 60 | readonly sourceData: McpStoredSourceData; |
| | 61 | readonly invokerScope: string; |
| | 62 | readonly sourceId: string; |
| | 63 | readonly sourceScope: string; |
| | 64 | }): string => { |
| | 65 | const sd = input.sourceData; |
| | 66 | return sd.transport === "stdio" |
| | 67 | ? `stdio:${fingerprint({ |
| | 68 | sourceId: input.sourceId, |
| | 69 | sourceScope: input.sourceScope, |
| | 70 | command: sd.command, |
| | 71 | args: sd.args ?? [], |
| | 72 | env: sd.env ?? {}, |
| | 73 | cwd: sd.cwd ?? null, |
| | 74 | })}` |
| | 75 | : `remote:${fingerprint({ |
| | 76 | sourceId: input.sourceId, |
| | 77 | sourceScope: input.sourceScope, |
| | 78 | invokerScope: input.invokerScope, |
| | 79 | endpoint: sd.endpoint, |
| | 80 | remoteTransport: sd.remoteTransport ?? "auto", |
| | 81 | headers: sd.headers ?? {}, |
| | 82 | queryParams: sd.queryParams ?? {}, |
| | 83 | auth: sd.auth, |
| | 84 | })}`; |
| | 85 | }; |
| 48 | | 86 | |
| 49 | // --------------------------------------------------------------------------- | 87 | // --------------------------------------------------------------------------- |
| 50 | // Elicitation bridge — decode incoming MCP ElicitRequest, route through | 88 | // Elicitation bridge — decode incoming MCP ElicitRequest, route through |
| @@ -146,6 +184,8 @@ export interface InvokeMcpToolInput { · 95 unmodified lines |
| 146 | readonly toolName: string; | 184 | readonly toolName: string; |
| 147 | readonly args: unknown; | 185 | readonly args: unknown; |
| 148 | readonly sourceData: McpStoredSourceData; | 186 | readonly sourceData: McpStoredSourceData; |
| | 187 | readonly sourceId: string; |
| | 188 | readonly sourceScope: string; |
| 149 | /** Innermost executor scope id at invoke time. Mixed into the | 189 | /** Innermost executor scope id at invoke time. Mixed into the |
| 150 | * connection cache key so per-user OAuth/secret resolution doesn't | 190 | * connection cache key so per-user OAuth/secret resolution doesn't |
| 151 | * collapse multiple users onto one shared connection. */ | 191 | * collapse multiple users onto one shared connection. */ |
| @@ -162,7 +202,7 @@ export const invokeMcpTool = ( · 10 unmodified lines |
| 162 | const transport: string = | 202 | const transport: string = |
| 163 | input.sourceData.transport === "stdio" ? "stdio" : (input.sourceData.remoteTransport ?? "auto"); | 203 | input.sourceData.transport === "stdio" ? "stdio" : (input.sourceData.remoteTransport ?? "auto"); |
| 164 | return Effect.gen(function* () { | 204 | return Effect.gen(function* () { |
| 165 | const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope); | 205 | const cacheKey = connectionCacheKey(input); |
| 166 | const args = argsRecord(input.args); | 206 | const args = argsRecord(input.args); |
| 167 | | 207 | |
| 168 | // Register the connector for the cache lookup (side-channel pattern | 208 | // Register the connector for the cache lookup (side-channel pattern |