@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/app.d.ts +1 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-J4JS2F7L.js → chunk-HWXJW5C7.js} +1210 -173
- package/dist/chunk-HWXJW5C7.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/routes/transcription-recordings.d.ts +3 -0
- package/dist/transcription/segmenter.d.ts +10 -0
- package/dist/transcription/service.d.ts +5 -0
- package/package.json +12 -12
- package/src/app.ts +36 -2
- package/src/mcp/server.ts +5 -80
- package/src/mcp/toolspace.ts +91 -69
- package/src/routes/codex.ts +138 -8
- package/src/routes/transcription-recordings.ts +722 -0
- package/src/routes/transcriptions.ts +2 -0
- package/src/transcription/providers/azure-openai.ts +4 -3
- package/src/transcription/providers/codex-subscription.ts +4 -1
- package/src/transcription/providers/openai.ts +7 -2
- package/src/transcription/segmenter.ts +260 -0
- package/src/transcription/service.ts +111 -10
- package/dist/chunk-J4JS2F7L.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -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" | "
|
|
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,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.
|
|
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.
|
|
47
|
-
"@opengeni/config": "^0.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.
|
|
50
|
-
"@opengeni/db": "^0.
|
|
51
|
-
"@opengeni/documents": "^0.5.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.4.
|
|
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.
|
|
56
|
-
"@opengeni/runtime": "^0.18.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
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: {
|
|
107
|
-
|
|
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
|
|
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
|
|
453
|
-
//
|
|
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.
|
package/src/mcp/toolspace.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
844
|
-
// construction
|
|
845
|
-
//
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
):
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
:
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
+
}
|