| 187 | * Always closes the per-call client in `finally`. Never returns transport config. |
| 188 | */ |
| 189 | export function createMcpAppCallHandler(opts: McpAppCallHandlerOptions) { |
| 190 | const registry = buildRegistry(opts.clients) |
| 191 | |
| 192 | // Resolve a serverId against the static `clients` registry. When serverId is |
| 193 | // undefined and exactly one descriptor is registered, default to that sole |
| 194 | // descriptor; with zero or multiple, undefined stays unresolvable. |
| 195 | const resolveFromRegistry = ( |
| 196 | serverId: string | undefined, |
| 197 | ): McpServerDescriptor | null => { |
| 198 | if (serverId !== undefined) { |
| 199 | return registry.byServerId[serverId] ?? null |
| 200 | } |
| 201 | if (registry.total !== 1) return null |
| 202 | return registry.fallback ?? Object.values(registry.byServerId)[0] ?? null |
| 203 | } |
| 204 | |
| 205 | return async ( |
| 206 | req: McpAppCallRequest, |
| 207 | ): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> => { |
| 208 | // Resolve server descriptor. The store WINS when it has an entry; otherwise |
| 209 | // we fall back to the static `clients` registry (the base). A store miss |
| 210 | // (null) must not reject when the registry can serve the request. |
| 211 | const descriptor = |
| 212 | (opts.store ? await opts.store.get(req.threadId, req.serverId) : null) ?? |
| 213 | resolveFromRegistry(req.serverId) |
| 214 | |
| 215 | if (!descriptor) { |
| 216 | // serverId omitted but resolution was ambiguous (zero or multiple |
| 217 | // servers configured) → clearer message than "Unknown serverId: undefined". |
| 218 | const error = |
| 219 | req.serverId === undefined |
| 220 | ? 'No serverId provided and zero or multiple servers configured; specify serverId' |
| 221 | : `Unknown serverId: ${req.serverId}` |
| 222 | return { ok: false, error } |
| 223 | } |
| 224 | |
| 225 | if (descriptor.transport === undefined) { |
| 226 | // Client was built from a raw Transport instance (no reconnectable |
| 227 | // descriptor), so there is nothing to reconnect per-call. |
| 228 | return { |
| 229 | ok: false, |
| 230 | error: 'MCP client has no reconnectable transport descriptor', |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | const client = await createMCPClient({ |
| 235 | transport: descriptor.transport, |
| 236 | prefix: descriptor.prefix, |
| 237 | }) |
| 238 | |
| 239 | try { |
| 240 | // The widget sends the server-native (UNPREFIXED) tool name |
| 241 | // (`UIResourcePart.toolName` is the native name), so we match it directly |
| 242 | // against the native names the server exposes — carried on |
| 243 | // `metadata.mcp.serverToolName` (falling back to `name` for unprefixed |
| 244 | // clients) — and forward `req.toolName` unchanged to `client.callTool`. |
| 245 | const exposedNative = new Set( |
| 246 | (await client.tools()).map((t) => serverToolNameOf(t)), |