@opengeni/api-router 0.21.11 → 0.22.2

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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createAppComposition,
3
3
  startSlackInteractionPump
4
- } from "./chunk-J4JS2F7L.js";
4
+ } from "./chunk-HWXJW5C7.js";
5
5
 
6
6
  // src/index.ts
7
7
  import {
@@ -13,7 +13,7 @@ export declare const SLACK_TASK_INSTRUCTIONS: string;
13
13
  * selection at session creation while keeping Slack mutations out of the model
14
14
  * surface; interaction delivery remains owned by the durable delivery pump.
15
15
  */
16
- export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "github_token" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
16
+ export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
17
17
  export type NormalizedSlackInteraction = {
18
18
  providerEventId: string;
19
19
  providerMessageId: string;
@@ -0,0 +1,3 @@
1
+ import { type ApiRouteDeps } from "@opengeni/core";
2
+ import type { Hono } from "hono";
3
+ export declare function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void;
@@ -0,0 +1,10 @@
1
+ import type { TranscriptionSegmenter } from "@opengeni/core";
2
+ export declare class TranscriptionSegmenterError extends Error {
3
+ readonly code: "unavailable" | "invalid_audio" | "cancelled" | "too_large" | "unknown";
4
+ readonly retryable: boolean;
5
+ readonly name = "TranscriptionSegmenterError";
6
+ constructor(message: string, code: "unavailable" | "invalid_audio" | "cancelled" | "too_large" | "unknown", retryable: boolean);
7
+ }
8
+ export declare function createFfmpegTranscriptionSegmenter(input: {
9
+ ffmpegPath: string;
10
+ }): TranscriptionSegmenter;
@@ -7,4 +7,9 @@ export declare function createTranscriptionService(input: {
7
7
  fetch?: typeof fetch;
8
8
  codexFetch?: typeof fetch;
9
9
  probeCodex?: (context?: TranscriptionAvailabilityContext) => boolean | Promise<boolean>;
10
+ /** Test seam for exercising timeout and late-completion behavior quickly. */
11
+ providerRequestTimeoutMilliseconds?: number;
12
+ /** Test seam for evaluating persisted absolute deadlines after delayed setup. */
13
+ now?: () => Date;
10
14
  }): TranscriptionService;
15
+ export declare function remainingTranscriptionProviderRequestMilliseconds(providerDeadlineAt: Date, now: Date): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.21.11",
3
+ "version": "0.22.2",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,18 +43,18 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
- "@opengeni/codex": "^0.2.10",
47
- "@opengeni/config": "^0.10.12",
48
- "@opengeni/contracts": "^0.38.2",
49
- "@opengeni/core": "^0.20.13",
50
- "@opengeni/db": "^0.27.9",
51
- "@opengeni/documents": "^0.5.5",
52
- "@opengeni/events": "^0.3.75",
53
- "@opengeni/github": "^0.4.28",
46
+ "@opengeni/codex": "^0.2.11",
47
+ "@opengeni/config": "^0.11.0",
48
+ "@opengeni/contracts": "^0.39.0",
49
+ "@opengeni/core": "^0.21.2",
50
+ "@opengeni/db": "^0.28.1",
51
+ "@opengeni/documents": "^0.5.10",
52
+ "@opengeni/events": "^0.3.80",
53
+ "@opengeni/github": "^0.4.31",
54
54
  "@opengeni/network": "^0.2.0",
55
- "@opengeni/observability": "^0.4.15",
56
- "@opengeni/runtime": "^0.18.12",
57
- "@opengeni/storage": "^0.2.65",
55
+ "@opengeni/observability": "^0.5.0",
56
+ "@opengeni/runtime": "^0.18.17",
57
+ "@opengeni/storage": "^0.2.68",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  OPENGENI_CORRELATION_HEADER,
14
14
  resolveWorkspaceMemoryEnabled,
15
15
  VOICE_INPUT_ACCEPTED_MIME_TYPES,
16
+ TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
16
17
  type AccessGrant,
17
18
  type ErrorCode,
18
19
  } from "@opengeni/contracts";
@@ -77,6 +78,7 @@ import { registerInsightsRoutes } from "./routes/insights";
77
78
  import { registerTranscriptionRoutes } from "./routes/transcriptions";
78
79
  import { projectClientModel } from "./model-catalog";
79
80
  import { createTranscriptionService } from "./transcription/service";
81
+ import { createFfmpegTranscriptionSegmenter } from "./transcription/segmenter";
80
82
  import { registerSlackInteractionRoutes } from "./integrations/slack-interactions";
81
83
 
82
84
  export type {
@@ -103,8 +105,15 @@ export { replaySessionEvents, sseSessionStream, sseWorkspaceControlStream } from
103
105
  export const API_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
104
106
 
105
107
  /** Effective Hono bodyLimit — API JSON ceiling or voice multipart + multipart overhead. */
106
- export function apiRequestBodyLimitBytes(settings: { voiceInputMaxSizeBytes: number }): number {
107
- return Math.max(API_MAX_REQUEST_BODY_BYTES, settings.voiceInputMaxSizeBytes + 64 * 1024);
108
+ export function apiRequestBodyLimitBytes(settings: {
109
+ voiceInputMaxSizeBytes: number;
110
+ voiceInputResumableMaxChunkSizeBytes?: number;
111
+ }): number {
112
+ return Math.max(
113
+ API_MAX_REQUEST_BODY_BYTES,
114
+ settings.voiceInputMaxSizeBytes + 64 * 1024,
115
+ (settings.voiceInputResumableMaxChunkSizeBytes ?? 0) + 64 * 1024,
116
+ );
108
117
  }
109
118
  const API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
110
119
 
@@ -204,6 +213,12 @@ export function createAppComposition(deps: AppDependencies): {
204
213
  ...(deps.codexFetch ? { codexFetch: deps.codexFetch } : {}),
205
214
  })
206
215
  : deps.transcription;
216
+ const transcriptionSegmenter =
217
+ deps.transcriptionSegmenter === undefined
218
+ ? createFfmpegTranscriptionSegmenter({
219
+ ffmpegPath: deps.settings.voiceInputFfmpegPath,
220
+ })
221
+ : deps.transcriptionSegmenter;
207
222
  const routeDeps: ApiRouteDeps = {
208
223
  ...deps,
209
224
  observability,
@@ -214,6 +229,7 @@ export function createAppComposition(deps: AppDependencies): {
214
229
  documentIndexer,
215
230
  getDocumentServices,
216
231
  transcription,
232
+ transcriptionSegmenter,
217
233
  ...(sandboxClient ? { sandboxClient } : {}),
218
234
  resumeBoxById,
219
235
  };
@@ -440,6 +456,24 @@ export function createAppComposition(deps: AppDependencies): {
440
456
  maxDurationSeconds: deps.settings.voiceInputMaxDurationSeconds,
441
457
  maxSizeBytes: deps.settings.voiceInputMaxSizeBytes,
442
458
  acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
459
+ ...(deps.settings.voiceInputResumableEnabled &&
460
+ objectStorage &&
461
+ transcription &&
462
+ transcriptionSegmenter &&
463
+ (await transcription.available()) &&
464
+ (await transcriptionSegmenter.available())
465
+ ? {
466
+ resumable: {
467
+ maxDurationSeconds: deps.settings.voiceInputResumableMaxDurationSeconds,
468
+ maxSizeBytes: deps.settings.voiceInputResumableMaxSizeBytes,
469
+ maxChunkSizeBytes: deps.settings.voiceInputResumableMaxChunkSizeBytes,
470
+ providerSegmentSeconds: Math.min(
471
+ TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
472
+ deps.settings.voiceInputMaxDurationSeconds,
473
+ ),
474
+ },
475
+ }
476
+ : {}),
443
477
  },
444
478
  productAccessMode: deps.settings.productAccessMode,
445
479
  auth: clientAuthConfig(deps.settings),
package/src/mcp/server.ts CHANGED
@@ -43,7 +43,6 @@ import {
43
43
  getPreferenceRegistryFullContent,
44
44
  getVariableSet,
45
45
  getVariableSetByName,
46
- areGitHubRepositoriesAllowedForWorkspace,
47
46
  listScheduledTaskRuns,
48
47
  listScheduledTasks,
49
48
  listSessionEventPage,
@@ -84,7 +83,6 @@ import {
84
83
  import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
85
84
  import {
86
85
  createSignedState,
87
- createGitHubAppInstallationToken,
88
86
  GitHubAppConfigurationError,
89
87
  githubAppMissingSettings,
90
88
  } from "@opengeni/github";
@@ -233,7 +231,6 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
233
231
  variable_set_set_variable: { allOf: ["variable-sets:manage"] },
234
232
  environment_set_variable: { allOf: ["variable-sets:manage"] },
235
233
  github_connect_link: { allOf: ["github:use"] },
236
- github_token: { sessionRequired: true, allOf: ["github:use"] },
237
234
  github_repositories_list: { allOf: ["github:use"] },
238
235
  social_connections_list: { allOf: ["connections:read"] },
239
236
  social_posts_recent: { allOf: ["connections:read"] },
@@ -443,26 +440,21 @@ export function buildOpenGeniMcpServer(
443
440
  registerSlackBotTools(server, deps, grant, sessionId, json);
444
441
  }
445
442
 
446
- // Orchestration, variableSet, and GitHub status/token tools are permission-gated
443
+ // Orchestration, variableSet, and GitHub status tools are permission-gated
447
444
  // at registration: a grant without the permission does not see the tool.
448
445
  // Sandboxed workers reach this server with the first-party delegated
449
446
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
450
447
  // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
451
448
  // so agents can spawn/read sessions, manage variable set variables, inspect
452
- // GitHub connection availability, and refresh scoped tokens for already-bound
453
- // repositories out of the box. A user DEMOTES a specific
449
+ // GitHub connection availability, and use already-bound repository resources
450
+ // out of the box. GitHub installation credentials are refreshed host-side and
451
+ // never returned through a model-visible MCP tool. A user DEMOTES a specific
454
452
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
455
453
  // the creator's own grant); operators still cap what any session can be given.
456
454
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
457
455
  registerVariableSetTools(server, deps, grant, can, json);
458
456
  if (can("github:use")) {
459
457
  registerGitHubConnectTool(server, deps, grant, options, json);
460
- // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
461
- // worker-signed sessionId claim so it mints for THIS session's repos), gated on
462
- // the same github:use capability as github_connect_link.
463
- if (sessionId !== null) {
464
- registerGitHubTokenTool(server, deps, grant, sessionId, json);
465
- }
466
458
  }
467
459
 
468
460
  if (!toolspaceMode || can("github:use")) {
@@ -1936,7 +1928,7 @@ function registerFleetTools(
1936
1928
  "sandboxes_list",
1937
1929
  {
1938
1930
  description:
1939
- "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
1931
+ "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. An idle session-home sandbox may report offline/cold/draining and still wake or restore on the next ordinary sandbox operation; never infer that shell/files are unavailable from list liveness alone—only a typed operation/attach failure proves that. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
1940
1932
  inputSchema: {},
1941
1933
  },
1942
1934
  async () => json(await listFleet(services, await fleetContext())),
@@ -2960,73 +2952,6 @@ function registerGitHubConnectTool(
2960
2952
  );
2961
2953
  }
2962
2954
 
2963
- // TOKEN-BROKER (B1): mint a FRESH short-lived GitHub App installation token for the
2964
- // session's repository resources. The agent calls this to refresh git auth before
2965
- // the current token expires. The MCP server CANNOT write the box, so the tool RETURNS
2966
- // the token as JSON; the agent writes it to the token file (via exec) to refresh
2967
- // GIT_ASKPASS. Same github:use capability gate as github_connect_link.
2968
- function registerGitHubTokenTool(
2969
- server: McpServer,
2970
- deps: ApiRouteDeps,
2971
- grant: AccessGrant,
2972
- sessionId: string,
2973
- json: JsonResult,
2974
- ): void {
2975
- server.registerTool(
2976
- "github_token",
2977
- {
2978
- description:
2979
- "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
2980
- inputSchema: {},
2981
- },
2982
- async () => {
2983
- const session = await requireSession(deps.db, grant.workspaceId, sessionId);
2984
- // Resolve the run-scoped installation + repository ids from THIS session's
2985
- // repository resources (same shape sandboxEnvironmentForRun mints against). Only
2986
- // private GitHub-App repos carry the installation/repository ids.
2987
- const selected = (session.resources ?? []).flatMap((resource) => {
2988
- if (resource.kind !== "repository") {
2989
- return [];
2990
- }
2991
- const installationId = resource.githubInstallationId;
2992
- const repositoryId = resource.githubRepositoryId;
2993
- return typeof installationId === "number" &&
2994
- installationId > 0 &&
2995
- typeof repositoryId === "number" &&
2996
- repositoryId > 0
2997
- ? [{ installationId, repositoryId }]
2998
- : [];
2999
- });
3000
- if (selected.length === 0) {
3001
- throw new Error("this session has no GitHub App repository resources to mint a token for");
3002
- }
3003
- const installationId = selected[0]!.installationId;
3004
- if (selected.some((item) => item.installationId !== installationId)) {
3005
- throw new Error("GitHub App repository resources must belong to one installation");
3006
- }
3007
- const repositoryIds = selected.map((item) => item.repositoryId);
3008
- if (
3009
- !(await areGitHubRepositoriesAllowedForWorkspace(
3010
- deps.db,
3011
- grant.workspaceId,
3012
- installationId,
3013
- repositoryIds,
3014
- ))
3015
- ) {
3016
- throw new Error("this workspace no longer authorizes the session's GitHub repositories");
3017
- }
3018
- const token = await createGitHubAppInstallationToken(deps.settings, {
3019
- installationId,
3020
- repositoryIds,
3021
- });
3022
- return json({
3023
- token,
3024
- tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
3025
- });
3026
- },
3027
- );
3028
- }
3029
-
3030
2955
  // Defense-in-depth for invariant "agents cannot self-attach": the worker's
3031
2956
  // first-party delegated token never carries variable-sets:use, so sandboxed
3032
2957
  // agents calling these MCP tools cannot attach a variable set.
@@ -45,7 +45,10 @@ import {
45
45
  boundedParallelMap,
46
46
  cancelMcpResponseBody,
47
47
  guardedMcpFetch,
48
+ mcpJsonRpcErrorPayloadForRequest,
49
+ mcpRequestReplayInfo,
48
50
  mcpSerializedSizeBytes,
51
+ type McpRequestReplayInfo,
49
52
  } from "@opengeni/runtime/mcp-network";
50
53
  import { Buffer } from "node:buffer";
51
54
  import { createHash } from "node:crypto";
@@ -85,13 +88,21 @@ const TOOLSPACE_AUTH_NEEDED_ERROR = {
85
88
  code: 40_101,
86
89
  message: "Authentication required - a connection link was posted to the session.",
87
90
  } as const;
91
+ const TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR = {
92
+ code: 40_102,
93
+ message:
94
+ "Tool outcome uncertain: the provider returned 401 after receiving the request. OpenGeni did not replay this call. Do not retry automatically; verify provider state before any new attempt.",
95
+ } as const;
88
96
  const TOOLSPACE_NO_ACTIVE_TURN_MESSAGE =
89
97
  "no active turn - toolspace calls require an in-flight turn";
90
98
  // First-party OpenGeni MCP proxies (files/docs) route back through the same
91
99
  // /mcp mount. They are excluded from the toolspace surface by construction so a
92
100
  // toolspace principal can never re-enter /mcp as a first-party caller, even if
93
101
  // a future grant carried files:read / documents:search (see docs invariants).
94
- const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs"]);
102
+ // Codex Apps is also excluded: its dynamic designated-owner authorization and
103
+ // wire sanitizer live on the model MCP path and must never degrade into static
104
+ // Toolspace headers.
105
+ const FIRST_PARTY_PROXY_IDS = new Set(["files", "docs", "codex_apps"]);
95
106
  // In-process cache of the per-session upstream tool listing. Keyed on the set of
96
107
  // proxyable server ids + their credential versions, so a credential rotation
97
108
  // busts the entry; a short TTL bounds staleness for everything else. This is
@@ -754,6 +765,9 @@ async function callRemoteTool(
754
765
  if (isToolspaceAuthNeededError(error)) {
755
766
  return mcpError(TOOLSPACE_AUTH_NEEDED_ERROR.message);
756
767
  }
768
+ if (isToolspaceOutcomeUncertainError(error)) {
769
+ return mcpError(TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message);
770
+ }
757
771
  // The raw upstream error can carry provider-specific detail; log it
758
772
  // server-side and return only a generic result to the sandbox so no header
759
773
  // or credential material can ride the message back out.
@@ -840,9 +854,9 @@ function selectedMcpServerIds(tools: ToolRef[], sessionServerIds: string[]): Set
840
854
  }
841
855
 
842
856
  // Whether a selected server id may enter the toolspace proxy at all. The
843
- // first-party OpenGeni tool server and the files/docs proxies are excluded by
844
- // construction: they route back through /mcp, so admitting them would let a
845
- // toolspace principal re-enter as a first-party caller (recursion guard).
857
+ // The first-party OpenGeni tool server and files/docs proxies are excluded by
858
+ // construction because they route back through /mcp (recursion guard). Codex
859
+ // Apps is excluded because Toolspace does not own its dynamic authorization.
846
860
  export function toolspaceCanProxyServerId(serverId: string): boolean {
847
861
  return serverId !== "opengeni" && !FIRST_PARTY_PROXY_IDS.has(serverId);
848
862
  }
@@ -891,12 +905,6 @@ function toolspaceRequestOptions(config: McpServerConfig): {
891
905
  return config.timeoutMs ? { timeout: config.timeoutMs, maxTotalTimeout: config.timeoutMs } : {};
892
906
  }
893
907
 
894
- type McpRequestInfo = {
895
- method?: string;
896
- id?: string | number | null;
897
- toolName?: string;
898
- };
899
-
900
908
  function mcpRequestDestinationUrl(input: string | URL | Request): string {
901
909
  return new URL(input instanceof Request ? input.url : input.toString()).toString();
902
910
  }
@@ -950,7 +958,7 @@ export function connectionBrokerFetch(
950
958
  ownerHasWorkspaceMembership: delegatedOwnerHasMembership,
951
959
  });
952
960
  return async (requestInput, init) => {
953
- const request = await mcpRequestInfo(requestInput, init);
961
+ const request = await mcpRequestReplayInfo(requestInput, init);
954
962
  const destinationUrl = mcpRequestDestinationUrl(requestInput);
955
963
  const resolverSubjectId =
956
964
  connectionRef.subjectScope !== "subject" && hostCredentialPort
@@ -978,10 +986,22 @@ export function connectionBrokerFetch(
978
986
  );
979
987
  if (response.status === 401) {
980
988
  await cancelMcpResponseBody(response);
981
- const refreshed = await resolve(true);
989
+ let refreshed: ResolveConnectionCredentialResult;
990
+ try {
991
+ refreshed = await resolve(true);
992
+ } catch {
993
+ refreshed = authNeededFromStatus(input.config, first, "refresh_failed");
994
+ }
982
995
  if (refreshed.status === "auth_needed") {
996
+ if (!request.replaySafeAfter401) {
997
+ await publishToolspaceAuthNeeded(input, request, refreshed);
998
+ return toolspaceMcpOutcomeUncertainResponse(request);
999
+ }
983
1000
  return await authNeededFetchResponse(input, request, refreshed);
984
1001
  }
1002
+ if (!request.replaySafeAfter401) {
1003
+ return toolspaceMcpOutcomeUncertainResponse(request);
1004
+ }
985
1005
  const retry = await baseFetch(
986
1006
  fetchInputForAttempt(requestInput),
987
1007
  withConnectionHeaders(requestInput, init, refreshed.headers),
@@ -1044,9 +1064,27 @@ async function authNeededFetchResponse(
1044
1064
  sessionId: string;
1045
1065
  turn: SessionTurn;
1046
1066
  },
1047
- request: McpRequestInfo,
1067
+ request: McpRequestReplayInfo,
1048
1068
  auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
1049
1069
  ): Promise<Response> {
1070
+ await publishToolspaceAuthNeeded(input, request, auth);
1071
+ if (request.method === "tools/call") {
1072
+ return toolspaceMcpErrorResponse(request.id, TOOLSPACE_AUTH_NEEDED_ERROR);
1073
+ }
1074
+ return new Response("Authentication required for MCP server connection", { status: 401 });
1075
+ }
1076
+
1077
+ async function publishToolspaceAuthNeeded(
1078
+ input: {
1079
+ deps: ApiRouteDeps;
1080
+ grant: AccessGrant;
1081
+ config: McpServerConfig;
1082
+ sessionId: string;
1083
+ turn: SessionTurn;
1084
+ },
1085
+ request: McpRequestReplayInfo,
1086
+ auth: Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>,
1087
+ ): Promise<void> {
1050
1088
  await appendAndPublishEvents(
1051
1089
  input.deps.db,
1052
1090
  input.deps.bus,
@@ -1072,64 +1110,35 @@ async function authNeededFetchResponse(
1072
1110
  },
1073
1111
  ],
1074
1112
  ).catch(() => undefined);
1075
- if (request.method === "tools/call") {
1076
- return new Response(
1077
- JSON.stringify({
1078
- jsonrpc: "2.0",
1079
- id: request.id ?? null,
1080
- error: {
1081
- code: TOOLSPACE_AUTH_NEEDED_ERROR.code,
1082
- message: TOOLSPACE_AUTH_NEEDED_ERROR.message,
1083
- },
1084
- }),
1085
- {
1086
- status: 200,
1087
- headers: { "content-type": "application/json" },
1088
- },
1089
- );
1090
- }
1091
- return new Response("Authentication required for MCP server connection", { status: 401 });
1092
1113
  }
1093
1114
 
1094
- async function mcpRequestInfo(
1095
- input: string | URL | Request,
1096
- init?: RequestInit,
1097
- ): Promise<McpRequestInfo> {
1098
- const body =
1099
- typeof init?.body === "string"
1100
- ? init.body
1101
- : input instanceof Request && (init?.method ?? input.method).toUpperCase() === "POST"
1102
- ? await input
1103
- .clone()
1104
- .text()
1105
- .catch(() => "")
1106
- : "";
1107
- if (!body) {
1108
- return {};
1109
- }
1110
- try {
1111
- const parsed = JSON.parse(body) as {
1112
- id?: unknown;
1113
- method?: unknown;
1114
- params?: { name?: unknown };
1115
- };
1116
- const method = typeof parsed.method === "string" ? parsed.method : undefined;
1117
- const id =
1118
- typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null
1119
- ? parsed.id
1120
- : undefined;
1121
- const toolName =
1122
- method === "tools/call" && typeof parsed.params?.name === "string"
1123
- ? parsed.params.name
1124
- : undefined;
1125
- return {
1126
- ...(method ? { method } : {}),
1127
- ...(id !== undefined ? { id } : {}),
1128
- ...(toolName ? { toolName } : {}),
1129
- };
1130
- } catch {
1131
- return {};
1132
- }
1115
+ function toolspaceMcpErrorResponse(
1116
+ id: string | number | null | undefined,
1117
+ error: { code: number; message: string },
1118
+ ): Response {
1119
+ return new Response(
1120
+ JSON.stringify({
1121
+ jsonrpc: "2.0",
1122
+ id: id ?? null,
1123
+ error,
1124
+ }),
1125
+ {
1126
+ status: 200,
1127
+ headers: { "content-type": "application/json" },
1128
+ },
1129
+ );
1130
+ }
1131
+
1132
+ function toolspaceMcpOutcomeUncertainResponse(request: McpRequestReplayInfo): Response {
1133
+ return new Response(
1134
+ JSON.stringify(
1135
+ mcpJsonRpcErrorPayloadForRequest(request, TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR),
1136
+ ),
1137
+ {
1138
+ status: 200,
1139
+ headers: { "content-type": "application/json" },
1140
+ },
1141
+ );
1133
1142
  }
1134
1143
 
1135
1144
  function withConnectionHeaders(
@@ -1162,3 +1171,16 @@ function isToolspaceAuthNeededError(error: unknown): boolean {
1162
1171
  `MCP error ${TOOLSPACE_AUTH_NEEDED_ERROR.code}: ${TOOLSPACE_AUTH_NEEDED_ERROR.message}`)
1163
1172
  );
1164
1173
  }
1174
+
1175
+ function isToolspaceOutcomeUncertainError(error: unknown): boolean {
1176
+ if (!(error instanceof Error)) {
1177
+ return false;
1178
+ }
1179
+ const code = (error as { code?: unknown }).code;
1180
+ return (
1181
+ code === TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.code &&
1182
+ (error.message === TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message ||
1183
+ error.message ===
1184
+ `MCP error ${TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.code}: ${TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message}`)
1185
+ );
1186
+ }