@opengeni/api-router 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5
+ import { environmentsEncryptionKeyBytes, type McpServerConfig } from "@opengeni/config";
6
+ import { prefixedMcpToolName, type AccessGrant, type ToolRef } from "@opengeni/contracts";
7
+ import { hasPermission, settingsWithEnabledCapabilityMcpServers, type ApiRouteDeps } from "@opengeni/core";
8
+ import {
9
+ buildConnectionTokenResolver,
10
+ listSessionMcpServerMetadata,
11
+ listSessionMcpServersForRun,
12
+ requireSession,
13
+ reserveToolspaceCallForTurn,
14
+ type ResolveConnectionCredentialResult,
15
+ } from "@opengeni/db";
16
+ import { appendAndPublishEvents } from "@opengeni/events";
17
+
18
+ export type ToolspaceCallResult = CallToolResult;
19
+
20
+ export type ToolspaceRegisteredTool = {
21
+ name: string;
22
+ description?: string;
23
+ inputSchema?: Record<string, unknown>;
24
+ call: (args: Record<string, unknown>) => Promise<ToolspaceCallResult>;
25
+ };
26
+
27
+ export type ToolspaceMcpSurface = {
28
+ sessionId: string;
29
+ subjectId: string;
30
+ tools: ToolspaceRegisteredTool[];
31
+ close: () => Promise<void>;
32
+ };
33
+
34
+ type ConnectedToolspaceServer = {
35
+ config: McpServerConfig;
36
+ client: Client;
37
+ close: () => Promise<void>;
38
+ };
39
+
40
+ type McpTool = {
41
+ name: string;
42
+ description?: string;
43
+ inputSchema?: Record<string, unknown>;
44
+ };
45
+
46
+ const APPROVAL_REQUIRED_MESSAGE = "requires approval - invoke via the agent";
47
+ const TOOLSPACE_AUTH_NEEDED_ERROR_CODE = -32001;
48
+ const TOOLSPACE_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
49
+ const TOOLSPACE_NO_ACTIVE_TURN_MESSAGE = "no active turn - toolspace calls require an in-flight turn";
50
+ // First-party OpenGeni MCP proxies (files/docs) route back through the same
51
+ // /mcp mount. They are excluded from the toolspace surface by construction so a
52
+ // toolspace principal can never re-enter /mcp as a first-party caller, even if
53
+ // a future grant carried files:read / documents:search (see docs invariants).
54
+ const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs"]);
55
+ // In-process cache of the per-session upstream tool listing. Keyed on the set of
56
+ // proxyable server ids + their credential versions, so a credential rotation
57
+ // busts the entry; a short TTL bounds staleness for everything else. This is
58
+ // what keeps list-type /mcp requests (initialize, tools/list) from fanning out
59
+ // to every upstream on every call.
60
+ const TOOLSPACE_TOOL_LIST_TTL_MS = 30_000;
61
+ const TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES = 2_000;
62
+ const toolListCache = new Map<string, { expiresAt: number; entries: ToolListingEntry[] }>();
63
+
64
+ type ToolListingEntry = {
65
+ serverId: string;
66
+ tool: McpTool;
67
+ requireApproval: McpServerConfig["requireApproval"];
68
+ };
69
+
70
+ export function isToolspaceGrant(settings: ApiRouteDeps["settings"], grant: AccessGrant): boolean {
71
+ return settings.toolspaceEnabled
72
+ && hasPermission(grant.permissions, "toolspace:call")
73
+ && typeof grant.metadata?.sessionId === "string";
74
+ }
75
+
76
+ export async function prepareToolspaceMcpSurface(input: {
77
+ deps: ApiRouteDeps;
78
+ grant: AccessGrant;
79
+ }): Promise<ToolspaceMcpSurface | null> {
80
+ const { deps, grant } = input;
81
+ if (!isToolspaceGrant(deps.settings, grant)) {
82
+ return null;
83
+ }
84
+ const sessionId = grant.metadata!.sessionId as string;
85
+ const session = await requireSession(deps.db, grant.workspaceId, sessionId);
86
+ const selectedIds = selectedMcpServerIds(session.tools, session.mcpServers.map((server) => server.id));
87
+ // Proxyable ids: everything selected except the first-party OpenGeni tool
88
+ // server and the first-party MCP proxies, both of which would re-enter /mcp.
89
+ const proxyableIds = [...selectedIds].filter((id) => toolspaceCanProxyServerId(id));
90
+ if (proxyableIds.length === 0) {
91
+ return emptyToolspaceSurface(sessionId, grant.subjectId);
92
+ }
93
+
94
+ // The registry (decrypted session servers + capability/pack expansion) is a
95
+ // handful of DB reads with no upstream dials. Build it at most once per
96
+ // request, and only when we actually need it (a cache-miss listing or a real
97
+ // tools/call), so a cache-hit request does no registry work.
98
+ let registryPromise: Promise<Map<string, McpServerConfig>> | null = null;
99
+ const getRegistry = () => (registryPromise ??= buildToolspaceRegistry(deps, grant.workspaceId, sessionId));
100
+
101
+ const listing = await resolveToolListing({
102
+ deps,
103
+ grant,
104
+ sessionId,
105
+ proxyableIds,
106
+ activeTurnId: session.activeTurnId ?? null,
107
+ getRegistry,
108
+ });
109
+ const tools = listing.map((entry) => toolspaceToolFor({ deps, grant, sessionId, entry, getRegistry }));
110
+
111
+ return {
112
+ sessionId,
113
+ subjectId: grant.subjectId,
114
+ tools,
115
+ // Connections are opened lazily and closed inline (per listing pass, per
116
+ // call), so there is nothing persistent to tear down here.
117
+ close: async () => {},
118
+ };
119
+ }
120
+
121
+ function emptyToolspaceSurface(sessionId: string, subjectId: string): ToolspaceMcpSurface {
122
+ return { sessionId, subjectId, tools: [], close: async () => {} };
123
+ }
124
+
125
+ async function buildToolspaceRegistry(
126
+ deps: ApiRouteDeps,
127
+ workspaceId: string,
128
+ sessionId: string,
129
+ ): Promise<Map<string, McpServerConfig>> {
130
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(deps.db, workspaceId, deps.settings);
131
+ const withSessionServers = await settingsWithSessionMcpServersForToolspace(deps, workspaceId, sessionId, runtimeSettings);
132
+ return new Map(withSessionServers.mcpServers.map((server) => [server.id, server]));
133
+ }
134
+
135
+ // Resolve the toolspace tool listing for a request. Serves from the in-process
136
+ // cache when warm; otherwise dials the proxyable upstreams ONCE to (re)list, but
137
+ // only while a turn is active — a request with no active turn never dials an
138
+ // upstream (fix: unbudgeted fan-out). tools/call still funnels through here to
139
+ // register its tool, but with the cache warm that costs no upstream dials.
140
+ async function resolveToolListing(input: {
141
+ deps: ApiRouteDeps;
142
+ grant: AccessGrant;
143
+ sessionId: string;
144
+ proxyableIds: string[];
145
+ activeTurnId: string | null;
146
+ getRegistry: () => Promise<Map<string, McpServerConfig>>;
147
+ }): Promise<ToolListingEntry[]> {
148
+ const { deps, grant, sessionId, proxyableIds, activeTurnId, getRegistry } = input;
149
+ const cacheKey = await toolListCacheKey(deps, grant.workspaceId, sessionId, proxyableIds);
150
+ const cached = readToolListCache(cacheKey);
151
+ if (cached) {
152
+ return cached;
153
+ }
154
+ if (!activeTurnId) {
155
+ return [];
156
+ }
157
+ const registry = await getRegistry();
158
+ const entries: ToolListingEntry[] = [];
159
+ for (const serverId of proxyableIds) {
160
+ const config = registry.get(serverId);
161
+ if (!config || !toolspaceCanProxyServer(config)) {
162
+ continue;
163
+ }
164
+ const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(() => null);
165
+ if (!connection) {
166
+ continue;
167
+ }
168
+ try {
169
+ const listed = await connection.client.listTools(undefined, toolspaceRequestOptions(config)).catch(() => ({ tools: [] }));
170
+ for (const tool of listed.tools as McpTool[]) {
171
+ if (!tool?.name || !allowedByConfig(config, tool.name)) {
172
+ continue;
173
+ }
174
+ entries.push({ serverId, tool, requireApproval: config.requireApproval });
175
+ }
176
+ } finally {
177
+ await connection.close();
178
+ }
179
+ }
180
+ writeToolListCache(cacheKey, entries);
181
+ return entries;
182
+ }
183
+
184
+ async function toolListCacheKey(
185
+ deps: ApiRouteDeps,
186
+ workspaceId: string,
187
+ sessionId: string,
188
+ proxyableIds: string[],
189
+ ): Promise<string> {
190
+ const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
191
+ const versions = new Map(metadata.map((server) => [server.id, server.credentialVersion]));
192
+ const signature = proxyableIds
193
+ .slice()
194
+ .sort()
195
+ .map((id) => `${id}@${versions.get(id) ?? 0}`)
196
+ .join(",");
197
+ return `${workspaceId}:${sessionId}:${signature}`;
198
+ }
199
+
200
+ function readToolListCache(key: string): ToolListingEntry[] | null {
201
+ const hit = toolListCache.get(key);
202
+ if (!hit) {
203
+ return null;
204
+ }
205
+ if (hit.expiresAt <= Date.now()) {
206
+ toolListCache.delete(key);
207
+ return null;
208
+ }
209
+ return hit.entries;
210
+ }
211
+
212
+ function writeToolListCache(key: string, entries: ToolListingEntry[]): void {
213
+ if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
214
+ const now = Date.now();
215
+ for (const [existingKey, value] of toolListCache) {
216
+ if (value.expiresAt <= now) {
217
+ toolListCache.delete(existingKey);
218
+ }
219
+ }
220
+ if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
221
+ toolListCache.clear();
222
+ }
223
+ }
224
+ toolListCache.set(key, { expiresAt: Date.now() + TOOLSPACE_TOOL_LIST_TTL_MS, entries });
225
+ }
226
+
227
+ async function settingsWithSessionMcpServersForToolspace(
228
+ deps: ApiRouteDeps,
229
+ workspaceId: string,
230
+ sessionId: string,
231
+ settings: ApiRouteDeps["settings"],
232
+ ): Promise<ApiRouteDeps["settings"]> {
233
+ const encryptionKey = environmentsEncryptionKeyBytes(settings);
234
+ if (!encryptionKey) {
235
+ const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
236
+ if (metadata.length === 0) {
237
+ return settings;
238
+ }
239
+ throw new Error("session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY");
240
+ }
241
+ const servers = await listSessionMcpServersForRun(deps.db, workspaceId, sessionId, encryptionKey);
242
+ if (servers.length === 0) {
243
+ return settings;
244
+ }
245
+ const sessionIds = new Set(servers.map((server) => server.id));
246
+ return {
247
+ ...settings,
248
+ mcpServers: [
249
+ ...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
250
+ ...servers.map((server) => ({
251
+ id: server.id,
252
+ ...(server.name ? { name: server.name } : {}),
253
+ url: server.url,
254
+ ...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
255
+ ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
256
+ cacheToolsList: server.cacheToolsList ?? false,
257
+ ...(server.requireApproval !== undefined ? { requireApproval: server.requireApproval } : {}),
258
+ headers: server.headers,
259
+ })),
260
+ ],
261
+ };
262
+ }
263
+
264
+ async function connectToolspaceServer(input: {
265
+ deps: ApiRouteDeps;
266
+ grant: AccessGrant;
267
+ config: McpServerConfig;
268
+ sessionId: string;
269
+ }): Promise<ConnectedToolspaceServer> {
270
+ const baseFetch: FetchLike = input.config.connectionRef
271
+ ? connectionBrokerFetch(globalThis.fetch, input)
272
+ : globalThis.fetch;
273
+ const client = new Client({ name: `opengeni-toolspace-${input.config.id}`, version: "1.0.0" }, { capabilities: {} });
274
+ const transport = new StreamableHTTPClientTransport(new URL(input.config.url), {
275
+ ...(baseFetch !== globalThis.fetch ? { fetch: baseFetch } : {}),
276
+ requestInit: {
277
+ headers: toolspaceServerHeaders(input.config),
278
+ },
279
+ });
280
+ await client.connect(transport as unknown as Transport, toolspaceRequestOptions(input.config));
281
+ return {
282
+ config: input.config,
283
+ client,
284
+ close: async () => {
285
+ await client.close().catch(() => undefined);
286
+ },
287
+ };
288
+ }
289
+
290
+ function toolspaceToolFor(input: {
291
+ deps: ApiRouteDeps;
292
+ grant: AccessGrant;
293
+ sessionId: string;
294
+ entry: ToolListingEntry;
295
+ getRegistry: () => Promise<Map<string, McpServerConfig>>;
296
+ }): ToolspaceRegisteredTool {
297
+ const { deps, grant, sessionId, entry, getRegistry } = input;
298
+ const { serverId, tool } = entry;
299
+ const name = prefixedMcpToolName(serverId, tool.name);
300
+ const approvalRequired = mcpToolRequiresApproval(entry.requireApproval, tool.name);
301
+ const description = approvalRequired
302
+ ? `${tool.description ?? tool.name} (unavailable: ${APPROVAL_REQUIRED_MESSAGE})`
303
+ : tool.description;
304
+ return {
305
+ name,
306
+ ...(description ? { description } : {}),
307
+ ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
308
+ call: async (args) => {
309
+ if (approvalRequired) {
310
+ return mcpError(APPROVAL_REQUIRED_MESSAGE);
311
+ }
312
+ const reservation = await reserveActiveTurnCall(deps, grant.workspaceId, sessionId);
313
+ if (reservation.status === "no_active_turn") {
314
+ return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
315
+ }
316
+ if (reservation.status === "budget_exhausted") {
317
+ return mcpError(`toolspace call budget exhausted (${deps.settings.toolspaceMaxCallsPerTurn}/turn)`);
318
+ }
319
+ const turnId = reservation.turnId;
320
+ // Dial only the ONE server this tool belongs to, from the freshly-built
321
+ // registry, and re-check policy against that live config (the listing may
322
+ // have been served from a slightly stale cache entry).
323
+ const registry = await getRegistry();
324
+ const config = registry.get(serverId);
325
+ if (!config || !toolspaceCanProxyServer(config) || !allowedByConfig(config, tool.name)) {
326
+ return mcpError(`upstream tool failed: ${name}`);
327
+ }
328
+ if (mcpToolRequiresApproval(config.requireApproval, tool.name)) {
329
+ return mcpError(APPROVAL_REQUIRED_MESSAGE);
330
+ }
331
+ const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(() => null);
332
+ if (!connection) {
333
+ return mcpError(`upstream tool failed: ${name}`);
334
+ }
335
+ try {
336
+ const callId = crypto.randomUUID();
337
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
338
+ type: "agent.toolCall.created",
339
+ turnId,
340
+ producerId: grant.subjectId,
341
+ payload: {
342
+ id: callId,
343
+ name,
344
+ arguments: args,
345
+ origin: "toolspace",
346
+ subjectId: grant.subjectId,
347
+ raw: {
348
+ type: "toolspace_call",
349
+ serverId,
350
+ toolName: tool.name,
351
+ },
352
+ },
353
+ }]);
354
+ const output = await callRemoteTool(deps, connection, tool.name, args);
355
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
356
+ type: "agent.toolCall.output",
357
+ turnId,
358
+ producerId: grant.subjectId,
359
+ payload: {
360
+ id: callId,
361
+ output,
362
+ origin: "toolspace",
363
+ subjectId: grant.subjectId,
364
+ },
365
+ }]);
366
+ return output;
367
+ } finally {
368
+ await connection.close();
369
+ }
370
+ },
371
+ };
372
+ }
373
+
374
+ async function callRemoteTool(
375
+ deps: ApiRouteDeps,
376
+ server: ConnectedToolspaceServer,
377
+ toolName: string,
378
+ args: Record<string, unknown>,
379
+ ): Promise<ToolspaceCallResult> {
380
+ try {
381
+ return await server.client.callTool({
382
+ name: toolName,
383
+ arguments: args,
384
+ }, undefined, toolspaceRequestOptions(server.config)) as ToolspaceCallResult;
385
+ } catch (error) {
386
+ if (isToolspaceAuthNeededError(error)) {
387
+ return mcpError(TOOLSPACE_AUTH_NEEDED_MESSAGE);
388
+ }
389
+ // The raw upstream error can carry provider-specific detail; log it
390
+ // server-side and return only a generic result to the sandbox so no header
391
+ // or credential material can ride the message back out.
392
+ deps.observability?.warn("toolspace upstream tool call failed", {
393
+ serverId: server.config.id,
394
+ toolName,
395
+ error: error instanceof Error ? error.message : String(error),
396
+ });
397
+ return mcpError(`upstream tool failed: ${prefixedMcpToolName(server.config.id, toolName)}`);
398
+ }
399
+ }
400
+
401
+ type ToolspaceReservation =
402
+ | { status: "ok"; turnId: string }
403
+ | { status: "no_active_turn" }
404
+ | { status: "budget_exhausted" };
405
+
406
+ async function reserveActiveTurnCall(deps: ApiRouteDeps, workspaceId: string, sessionId: string): Promise<ToolspaceReservation> {
407
+ const session = await requireSession(deps.db, workspaceId, sessionId);
408
+ if (!session.activeTurnId) {
409
+ return { status: "no_active_turn" };
410
+ }
411
+ const reservation = await reserveToolspaceCallForTurn(
412
+ deps.db,
413
+ workspaceId,
414
+ sessionId,
415
+ session.activeTurnId,
416
+ deps.settings.toolspaceMaxCallsPerTurn,
417
+ );
418
+ return reservation.reserved
419
+ ? { status: "ok", turnId: session.activeTurnId }
420
+ : { status: "budget_exhausted" };
421
+ }
422
+
423
+ function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set<string> {
424
+ const out = new Set<string>(sessionServerIds);
425
+ for (const tool of tools) {
426
+ if (tool.kind === "mcp") {
427
+ out.add(tool.id);
428
+ }
429
+ }
430
+ return out;
431
+ }
432
+
433
+ // Whether a selected server id may enter the toolspace proxy at all. The
434
+ // first-party OpenGeni tool server and the files/docs proxies are excluded by
435
+ // construction: they route back through /mcp, so admitting them would let a
436
+ // toolspace principal re-enter as a first-party caller (recursion guard).
437
+ export function toolspaceCanProxyServerId(serverId: string): boolean {
438
+ return serverId !== "opengeni" && !FIRST_PARTY_PROXY_IDS.has(serverId);
439
+ }
440
+
441
+ function toolspaceCanProxyServer(config: McpServerConfig): boolean {
442
+ return toolspaceCanProxyServerId(config.id);
443
+ }
444
+
445
+ // Only third-party / session / pack MCP servers reach this path (first-party
446
+ // proxies are excluded above), so headers are just the server's own configured
447
+ // or broker-injected headers. The caller's `ogd_` bearer is deliberately never
448
+ // forwarded upstream.
449
+ function toolspaceServerHeaders(config: McpServerConfig): Record<string, string> {
450
+ const headers: Record<string, string> = {};
451
+ for (const [name, value] of Object.entries(config.headers ?? {})) {
452
+ headers[name] = value;
453
+ }
454
+ return headers;
455
+ }
456
+
457
+ function allowedByConfig(config: McpServerConfig, toolName: string): boolean {
458
+ return !config.allowedTools || config.allowedTools.includes(toolName);
459
+ }
460
+
461
+ function mcpToolRequiresApproval(policy: McpServerConfig["requireApproval"], unprefixedName: string): boolean {
462
+ if (policy === true) {
463
+ return true;
464
+ }
465
+ return Array.isArray(policy) && policy.includes(unprefixedName);
466
+ }
467
+
468
+ function mcpError(message: string): ToolspaceCallResult {
469
+ return {
470
+ isError: true,
471
+ content: [{ type: "text", text: message }],
472
+ };
473
+ }
474
+
475
+ function toolspaceRequestOptions(config: McpServerConfig): { timeout?: number; maxTotalTimeout?: number } {
476
+ return config.timeoutMs ? { timeout: config.timeoutMs, maxTotalTimeout: config.timeoutMs } : {};
477
+ }
478
+
479
+ type McpRequestInfo = {
480
+ method?: string;
481
+ id?: string | number | null;
482
+ toolName?: string;
483
+ };
484
+
485
+ function connectionBrokerFetch(
486
+ baseFetch: FetchLike,
487
+ input: {
488
+ deps: ApiRouteDeps;
489
+ grant: AccessGrant;
490
+ config: McpServerConfig;
491
+ sessionId: string;
492
+ },
493
+ ): FetchLike {
494
+ const connectionRef = input.config.connectionRef;
495
+ if (!connectionRef) {
496
+ return baseFetch;
497
+ }
498
+ const resolveCredential = buildConnectionTokenResolver(input.deps.db, input.deps.settings);
499
+ return async (requestInput, init) => {
500
+ const request = await mcpRequestInfo(requestInput, init);
501
+ const first = await resolveCredential({
502
+ workspaceId: input.grant.workspaceId,
503
+ serverId: input.config.id,
504
+ connectionRef,
505
+ forceRefresh: false,
506
+ ...(request.toolName ? { toolId: request.toolName } : {}),
507
+ subjectId: input.grant.subjectId,
508
+ });
509
+ if (first.status === "auth_needed") {
510
+ return await authNeededFetchResponse(input, request, first);
511
+ }
512
+ const response = await baseFetch(fetchInputForAttempt(requestInput), withConnectionHeaders(requestInput, init, first.headers));
513
+ if (response.status === 401) {
514
+ const refreshed = await resolveCredential({
515
+ workspaceId: input.grant.workspaceId,
516
+ serverId: input.config.id,
517
+ connectionRef,
518
+ forceRefresh: true,
519
+ ...(request.toolName ? { toolId: request.toolName } : {}),
520
+ subjectId: input.grant.subjectId,
521
+ });
522
+ if (refreshed.status === "auth_needed") {
523
+ return await authNeededFetchResponse(input, request, refreshed);
524
+ }
525
+ return await baseFetch(fetchInputForAttempt(requestInput), withConnectionHeaders(requestInput, init, refreshed.headers));
526
+ }
527
+ if (response.status === 403) {
528
+ return await authNeededFetchResponse(input, request, authNeededFromStatus(input.config, first, "insufficient_scope"));
529
+ }
530
+ return response;
531
+ };
532
+ }
533
+
534
+ function authNeededFromStatus(
535
+ config: McpServerConfig,
536
+ first: Extract<ResolveConnectionCredentialResult, { status: "ok" }>,
537
+ reason: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>["reason"],
538
+ ): Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }> {
539
+ const connectionRef = config.connectionRef!;
540
+ return {
541
+ status: "auth_needed",
542
+ reason,
543
+ providerDomain: connectionRef.providerDomain,
544
+ connectionId: first.connectionId,
545
+ ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
546
+ ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
547
+ };
548
+ }
549
+
550
+ async function authNeededFetchResponse(
551
+ input: {
552
+ deps: ApiRouteDeps;
553
+ grant: AccessGrant;
554
+ config: McpServerConfig;
555
+ sessionId: string;
556
+ },
557
+ request: McpRequestInfo,
558
+ auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
559
+ ): Promise<Response> {
560
+ await appendAndPublishEvents(input.deps.db, input.deps.bus, input.grant.workspaceId, input.sessionId, [{
561
+ type: "tool.auth_needed",
562
+ producerId: input.grant.subjectId,
563
+ payload: {
564
+ serverId: input.config.id,
565
+ toolName: request.toolName ?? null,
566
+ providerDomain: auth.providerDomain,
567
+ reason: auth.reason,
568
+ ...(auth.connectionId ? { connectionId: auth.connectionId } : {}),
569
+ ...(auth.scopes ? { scopes: auth.scopes } : {}),
570
+ ...(auth.resource ? { resource: auth.resource } : {}),
571
+ ...(auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {}),
572
+ subjectId: input.grant.subjectId,
573
+ },
574
+ }]).catch(() => undefined);
575
+ if (request.method === "tools/call") {
576
+ return new Response(JSON.stringify({
577
+ jsonrpc: "2.0",
578
+ id: request.id ?? null,
579
+ error: {
580
+ code: TOOLSPACE_AUTH_NEEDED_ERROR_CODE,
581
+ message: TOOLSPACE_AUTH_NEEDED_MESSAGE,
582
+ },
583
+ }), {
584
+ status: 200,
585
+ headers: { "content-type": "application/json" },
586
+ });
587
+ }
588
+ return new Response("Authentication required for MCP server connection", { status: 401 });
589
+ }
590
+
591
+ async function mcpRequestInfo(_input: string | URL, init?: RequestInit): Promise<McpRequestInfo> {
592
+ const body = typeof init?.body === "string" ? init.body : "";
593
+ if (!body) {
594
+ return {};
595
+ }
596
+ try {
597
+ const parsed = JSON.parse(body) as { id?: unknown; method?: unknown; params?: { name?: unknown } };
598
+ const method = typeof parsed.method === "string" ? parsed.method : undefined;
599
+ const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : undefined;
600
+ const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : undefined;
601
+ return {
602
+ ...(method ? { method } : {}),
603
+ ...(id !== undefined ? { id } : {}),
604
+ ...(toolName ? { toolName } : {}),
605
+ };
606
+ } catch {
607
+ return {};
608
+ }
609
+ }
610
+
611
+ function withConnectionHeaders(_input: string | URL, init: RequestInit | undefined, authHeaders: Record<string, string>): RequestInit {
612
+ const headers = new Headers(init?.headers);
613
+ for (const [name, value] of Object.entries(authHeaders)) {
614
+ headers.set(name, value);
615
+ }
616
+ return { ...init, headers };
617
+ }
618
+
619
+ function fetchInputForAttempt(input: string | URL): string | URL {
620
+ return input;
621
+ }
622
+
623
+ function isToolspaceAuthNeededError(error: unknown): boolean {
624
+ return error instanceof Error
625
+ && (((error as { code?: unknown }).code === TOOLSPACE_AUTH_NEEDED_ERROR_CODE)
626
+ || error.message.includes(TOOLSPACE_AUTH_NEEDED_MESSAGE));
627
+ }