@opengeni/api-router 2.6.4 → 2.10.0-canary.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.
- package/dist/access-grant-rls.d.ts +3 -0
- package/dist/app.d.ts +2 -1
- package/dist/app.js +3 -1
- package/dist/auth/organization-user-setup.d.ts +23 -0
- package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
- package/dist/chunk-XXH7DW34.js.map +1 -0
- package/dist/codemode.d.ts +6 -0
- package/dist/github-access.d.ts +25 -2
- package/dist/http/request-source.d.ts +14 -0
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/integrations/oauth-client.d.ts +2 -11
- package/dist/integrations/personal-github-repositories.d.ts +41 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/server.d.ts +158 -1
- package/dist/mcp/session-view.d.ts +56 -2
- package/dist/mcp/session-wait.d.ts +1 -1
- package/dist/mcp-oauth.d.ts +19 -0
- package/dist/model-catalog.d.ts +5 -31
- package/dist/routes/codex.d.ts +27 -2
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/routes/workspace-artifacts.d.ts +2 -1
- package/dist/work-discovery-observability.d.ts +1 -1
- package/dist/workspace-artifact-content.d.ts +28 -0
- package/dist/workspace-artifact-provenance.d.ts +7 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/dist/workspace-tool-gateway-observability.d.ts +17 -0
- package/dist/workspace-tool-gateway.d.ts +118 -0
- package/package.json +19 -18
- package/src/access-grant-rls.ts +40 -0
- package/src/app.ts +296 -53
- package/src/auth/organization-user-setup.ts +95 -5
- package/src/codemode.ts +33 -4
- package/src/github-access.ts +117 -1
- package/src/http/auth.ts +11 -0
- package/src/http/request-source.ts +37 -0
- package/src/http/sse.ts +15 -7
- package/src/index.ts +18 -2
- package/src/integrations/oauth-client.ts +168 -203
- package/src/integrations/personal-github-repositories.ts +238 -9
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/integrations/slack-interactions.ts +77 -37
- package/src/mcp/company-brain-governed-writes.ts +2 -2
- package/src/mcp/company-profile-agent-admin.ts +1 -1
- package/src/mcp/remember.ts +1 -1
- package/src/mcp/server.ts +504 -133
- package/src/mcp/session-view.ts +20 -1
- package/src/mcp/session-wait.ts +3 -0
- package/src/mcp-oauth.ts +539 -0
- package/src/model-catalog.ts +45 -337
- package/src/routes/automations.ts +178 -19
- package/src/routes/codex.ts +242 -73
- package/src/routes/connections.ts +271 -16
- package/src/routes/documents.ts +67 -19
- package/src/routes/files.ts +54 -6
- package/src/routes/github.ts +116 -15
- package/src/routes/organization-memberships.ts +59 -16
- package/src/routes/organization-model-providers.ts +231 -0
- package/src/routes/packs.ts +1 -0
- package/src/routes/personal-github.ts +39 -0
- package/src/routes/pr-review.ts +72 -4
- package/src/routes/scheduled-tasks.ts +40 -13
- package/src/routes/sessions.ts +153 -65
- package/src/routes/supergrok.ts +3 -2
- package/src/routes/workspace-artifacts.ts +176 -67
- package/src/routes/workspaces.ts +405 -62
- package/src/sandbox/channel-a.ts +17 -4
- package/src/work-discovery-observability.ts +3 -3
- package/src/workspace-artifact-content.ts +163 -0
- package/src/workspace-artifact-provenance.ts +104 -0
- package/src/workspace-deletion.ts +64 -0
- package/src/workspace-tool-gateway-observability.ts +100 -0
- package/src/workspace-tool-gateway.ts +691 -0
- package/dist/chunk-XTLI3CBH.js.map +0 -1
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
type CallToolResult,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { CODEX_CLIENT_VERSION } from "@opengeni/codex";
|
|
12
|
+
import { IntegrationInvocationError } from "@opengeni/capabilities";
|
|
13
|
+
import type { Settings } from "@opengeni/config";
|
|
14
|
+
import {
|
|
15
|
+
FIRST_PARTY_MCP_TOOL_NAMES,
|
|
16
|
+
ToolGatewayCallRequest,
|
|
17
|
+
ToolGatewayCallResponse,
|
|
18
|
+
ToolGatewayApprovalRequest,
|
|
19
|
+
ToolGatewayApprovalResponse,
|
|
20
|
+
ToolGatewayDeclarationsResponse,
|
|
21
|
+
type AccessGrant,
|
|
22
|
+
type ToolGatewayCatalog,
|
|
23
|
+
type ToolGatewayIdentity,
|
|
24
|
+
type ToolRef,
|
|
25
|
+
} from "@opengeni/contracts";
|
|
26
|
+
import {
|
|
27
|
+
buildApiIntegrationMcpServers,
|
|
28
|
+
hasPermission,
|
|
29
|
+
requireResolvedAccessGrantAuthorization,
|
|
30
|
+
resolveCodexAppsCredentialIdForRun,
|
|
31
|
+
resolveWorkspaceCatalogSettings,
|
|
32
|
+
settingsWithEnabledCapabilityMcpServers,
|
|
33
|
+
type AccessGrantAuthorization,
|
|
34
|
+
type ApiRouteDeps,
|
|
35
|
+
} from "@opengeni/core";
|
|
36
|
+
import {
|
|
37
|
+
buildCodexTokenResolver,
|
|
38
|
+
buildConnectionTokenResolver,
|
|
39
|
+
withCodexAppsRequestAuthorization,
|
|
40
|
+
consumeToolGatewayApproval,
|
|
41
|
+
getWorkspaceArtifact,
|
|
42
|
+
issueToolGatewayApproval,
|
|
43
|
+
ToolGatewayApprovalOperationStartedError,
|
|
44
|
+
ToolGatewayApprovalRateLimitError,
|
|
45
|
+
WorkspaceArtifactNotFoundError,
|
|
46
|
+
type ApiIntegrationRuntime,
|
|
47
|
+
} from "@opengeni/db";
|
|
48
|
+
import {
|
|
49
|
+
type LocalMcpServerRegistration,
|
|
50
|
+
type PreparedWorkspaceToolGatewayTools,
|
|
51
|
+
type ResolveConnectionCredentialInput,
|
|
52
|
+
type ResolveConnectionCredentialResult,
|
|
53
|
+
prepareWorkspaceToolGatewayTools,
|
|
54
|
+
} from "@opengeni/runtime/workspace-tool-gateway";
|
|
55
|
+
import {
|
|
56
|
+
ToolGatewayApprovalRequiredError,
|
|
57
|
+
ToolGatewayCatalogStaleError,
|
|
58
|
+
ToolGatewayInputValidationError,
|
|
59
|
+
ToolGatewayToolNotFoundError,
|
|
60
|
+
digestCanonicalJson,
|
|
61
|
+
generateToolGatewayDeclarations,
|
|
62
|
+
type PreparedToolGatewayCall,
|
|
63
|
+
type ToolGatewayDefinition,
|
|
64
|
+
} from "@opengeni/tool-gateway";
|
|
65
|
+
import { HTTPException } from "hono/http-exception";
|
|
66
|
+
|
|
67
|
+
import { ApiHttpError } from "./http/api-error";
|
|
68
|
+
import { buildDocumentsMcpServer } from "./mcp/documents";
|
|
69
|
+
import { buildFilesMcpServer } from "./mcp/files";
|
|
70
|
+
import { buildOpenGeniMcpServer } from "./mcp/server";
|
|
71
|
+
import { startWorkspaceToolGatewayObservation } from "./workspace-tool-gateway-observability";
|
|
72
|
+
import type { Observability } from "@opengeni/observability";
|
|
73
|
+
|
|
74
|
+
export type PreparedWorkspaceToolGateway = Pick<
|
|
75
|
+
PreparedWorkspaceToolGatewayTools,
|
|
76
|
+
"toolGateway" | "toolGatewayCatalog" | "close"
|
|
77
|
+
> & {
|
|
78
|
+
toolGateway: NonNullable<PreparedWorkspaceToolGatewayTools["toolGateway"]>;
|
|
79
|
+
toolGatewayCatalog: NonNullable<PreparedWorkspaceToolGatewayTools["toolGatewayCatalog"]>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type WorkspaceSiteToolContext = {
|
|
83
|
+
siteArtifactId: string;
|
|
84
|
+
siteVersionId: string;
|
|
85
|
+
identity: { serverId: string; toolName: string };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type AuthorizeWorkspaceSiteTool = (
|
|
89
|
+
db: ApiRouteDeps["db"],
|
|
90
|
+
grant: AccessGrant,
|
|
91
|
+
context: WorkspaceSiteToolContext,
|
|
92
|
+
) => Promise<void>;
|
|
93
|
+
|
|
94
|
+
export function grantUsesAttemptScopedMcp(grant: AccessGrant): boolean {
|
|
95
|
+
return (
|
|
96
|
+
grant.principalKind === "agent_attempt" ||
|
|
97
|
+
grant.metadata?.delegated === true ||
|
|
98
|
+
typeof grant.metadata?.sessionId === "string"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function requireWorkspaceToolGatewayGrant(grant: AccessGrant): void {
|
|
103
|
+
if (
|
|
104
|
+
grantUsesAttemptScopedMcp(grant) ||
|
|
105
|
+
(grant.principalKind !== undefined && grant.principalKind !== "human_session")
|
|
106
|
+
) {
|
|
107
|
+
throw new HTTPException(403, { message: "current-human tool access required" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function requireWorkspaceToolGatewayAuthorization(
|
|
112
|
+
authorization: AccessGrantAuthorization,
|
|
113
|
+
): AccessGrant {
|
|
114
|
+
const grant = requireResolvedAccessGrantAuthorization(
|
|
115
|
+
authorization,
|
|
116
|
+
authorization.grant.workspaceId,
|
|
117
|
+
);
|
|
118
|
+
requireWorkspaceToolGatewayGrant(grant);
|
|
119
|
+
if (!authorization.canonicalManagedHumanSession && !authorization.canonicalLocalHumanSession) {
|
|
120
|
+
throw new HTTPException(403, { message: "current-human tool access required" });
|
|
121
|
+
}
|
|
122
|
+
return grant;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function prepareWorkspaceToolGateway(
|
|
126
|
+
routeDeps: ApiRouteDeps,
|
|
127
|
+
authorization: AccessGrantAuthorization,
|
|
128
|
+
): Promise<PreparedWorkspaceToolGateway> {
|
|
129
|
+
const grant = requireWorkspaceToolGatewayAuthorization(authorization);
|
|
130
|
+
return await prepareWorkspaceToolGatewayForGrant(routeDeps, grant);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function prepareMcpOAuthWorkspaceToolGateway(
|
|
134
|
+
routeDeps: ApiRouteDeps,
|
|
135
|
+
grant: AccessGrant,
|
|
136
|
+
allowedIdentities: readonly { serverId: string; toolName: string }[],
|
|
137
|
+
): Promise<PreparedWorkspaceToolGateway> {
|
|
138
|
+
if (grant.metadata?.mcpOAuth !== true || grant.principalKind !== "human_session") {
|
|
139
|
+
throw new HTTPException(403, { message: "MCP OAuth authority is invalid" });
|
|
140
|
+
}
|
|
141
|
+
return await prepareWorkspaceToolGatewayForGrant(routeDeps, grant, allowedIdentities);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function prepareWorkspaceToolGatewayForGrant(
|
|
145
|
+
routeDeps: ApiRouteDeps,
|
|
146
|
+
grant: AccessGrant,
|
|
147
|
+
allowedIdentities?: readonly { serverId: string; toolName: string }[],
|
|
148
|
+
): Promise<PreparedWorkspaceToolGateway> {
|
|
149
|
+
const catalogSourceSettings = routeDeps.catalogSourceSettings ?? routeDeps.settings;
|
|
150
|
+
const resolvedCatalog = await resolveWorkspaceCatalogSettings(
|
|
151
|
+
routeDeps.db,
|
|
152
|
+
catalogSourceSettings,
|
|
153
|
+
{
|
|
154
|
+
accountId: grant.accountId,
|
|
155
|
+
workspaceId: grant.workspaceId,
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
let integrations: readonly ApiIntegrationRuntime[] = [];
|
|
159
|
+
const settings = await settingsWithEnabledCapabilityMcpServers(
|
|
160
|
+
routeDeps.db,
|
|
161
|
+
grant.workspaceId,
|
|
162
|
+
resolvedCatalog.settings,
|
|
163
|
+
{
|
|
164
|
+
subjectId: grant.subjectId,
|
|
165
|
+
onResolvedApiIntegrations: (resolved) => {
|
|
166
|
+
integrations = resolved;
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
const gatewaySettings = workspaceToolGatewaySettingsForGrant(settings, grant, allowedIdentities);
|
|
171
|
+
const gatewayServerIds = new Set(gatewaySettings.mcpServers.map((server) => server.id));
|
|
172
|
+
const deps = { ...routeDeps, catalogSourceSettings, settings: gatewaySettings };
|
|
173
|
+
const resolveConnection = buildConnectionTokenResolver(routeDeps.db, gatewaySettings);
|
|
174
|
+
const resolveCredential = async (
|
|
175
|
+
input: ResolveConnectionCredentialInput,
|
|
176
|
+
): Promise<ResolveConnectionCredentialResult> =>
|
|
177
|
+
await resolveConnection({
|
|
178
|
+
...input,
|
|
179
|
+
...(input.connectionRef.subjectScope === "subject" ? { subjectId: grant.subjectId } : {}),
|
|
180
|
+
});
|
|
181
|
+
const firstPartyServers = await Promise.all([
|
|
182
|
+
...(gatewayServerIds.has("opengeni")
|
|
183
|
+
? [inMemoryMcpRegistration("opengeni", buildOpenGeniMcpServer(deps, grant))]
|
|
184
|
+
: []),
|
|
185
|
+
...(gatewayServerIds.has("files")
|
|
186
|
+
? [inMemoryMcpRegistration("files", buildFilesMcpServer(deps, grant))]
|
|
187
|
+
: []),
|
|
188
|
+
...(gatewayServerIds.has("docs")
|
|
189
|
+
? [
|
|
190
|
+
inMemoryMcpRegistration(
|
|
191
|
+
"docs",
|
|
192
|
+
buildDocumentsMcpServer(
|
|
193
|
+
routeDeps.db,
|
|
194
|
+
grant.accountId,
|
|
195
|
+
grant.workspaceId,
|
|
196
|
+
routeDeps.getDocumentServices(),
|
|
197
|
+
{ initiatingSubjectId: grant.subjectId },
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
]
|
|
201
|
+
: []),
|
|
202
|
+
]);
|
|
203
|
+
const apiIntegrationServers = buildApiIntegrationMcpServers({
|
|
204
|
+
settings: gatewaySettings,
|
|
205
|
+
integrations: integrations.filter((integration) => gatewayServerIds.has(integration.serverId)),
|
|
206
|
+
authority: {
|
|
207
|
+
accountId: grant.accountId,
|
|
208
|
+
workspaceId: grant.workspaceId,
|
|
209
|
+
initiatingSubjectId: grant.subjectId,
|
|
210
|
+
},
|
|
211
|
+
resolveCredential: async (input) =>
|
|
212
|
+
await resolveConnection({
|
|
213
|
+
...input,
|
|
214
|
+
...(input.connectionRef.subjectScope === "subject" ? { subjectId: grant.subjectId } : {}),
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
const codexAppsCredentialId = gatewayServerIds.has("codex_apps")
|
|
218
|
+
? await resolveCodexAppsCredentialIdForRun(routeDeps.db, grant.workspaceId)
|
|
219
|
+
: null;
|
|
220
|
+
const codexAppsAuth = codexAppsCredentialId
|
|
221
|
+
? (() => {
|
|
222
|
+
const resolver = buildCodexTokenResolver(
|
|
223
|
+
routeDeps.db,
|
|
224
|
+
settings,
|
|
225
|
+
grant.workspaceId,
|
|
226
|
+
codexAppsCredentialId,
|
|
227
|
+
);
|
|
228
|
+
return {
|
|
229
|
+
clientVersion: CODEX_CLIENT_VERSION,
|
|
230
|
+
withAuthorization: async <T>(
|
|
231
|
+
use: (token: { accessToken: string; chatgptAccountId: string | null }) => Promise<T>,
|
|
232
|
+
): Promise<T> =>
|
|
233
|
+
await resolver.getToken().then(
|
|
234
|
+
async (token) =>
|
|
235
|
+
await withCodexAppsRequestAuthorization(
|
|
236
|
+
routeDeps.db,
|
|
237
|
+
{
|
|
238
|
+
workspaceId: grant.workspaceId,
|
|
239
|
+
credentialId: codexAppsCredentialId,
|
|
240
|
+
},
|
|
241
|
+
async () => await use(token),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
})()
|
|
246
|
+
: undefined;
|
|
247
|
+
const localMcpServers = [...firstPartyServers, ...apiIntegrationServers];
|
|
248
|
+
const prepared = await prepareWorkspaceToolGatewayTools(
|
|
249
|
+
gatewaySettings,
|
|
250
|
+
allGatewayToolRefs(gatewaySettings),
|
|
251
|
+
{
|
|
252
|
+
accountId: grant.accountId,
|
|
253
|
+
workspaceId: grant.workspaceId,
|
|
254
|
+
subjectId: grant.subjectId,
|
|
255
|
+
credentialSubjectId: grant.subjectId,
|
|
256
|
+
resolveCredential,
|
|
257
|
+
localMcpServers,
|
|
258
|
+
...(codexAppsAuth ? { codexAppsAuth } : {}),
|
|
259
|
+
workspaceToolGateway: {
|
|
260
|
+
requireApproval: (entry, _caller, context) =>
|
|
261
|
+
entry.approval === "human" && context.transportMeta?.approvalConfirmed !== true,
|
|
262
|
+
filterDefinition: workspaceToolGatewayDefinitionFilter(gatewaySettings, allowedIdentities),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
if (!prepared.toolGateway || !prepared.toolGatewayCatalog) {
|
|
267
|
+
await prepared.close().catch(() => undefined);
|
|
268
|
+
throw new Error("workspace tool gateway preparation did not produce a gateway");
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
toolGateway: prepared.toolGateway,
|
|
272
|
+
toolGatewayCatalog: prepared.toolGatewayCatalog,
|
|
273
|
+
close: prepared.close,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function workspaceToolGatewayDefinitionFilter(
|
|
278
|
+
settings: Pick<Settings, "allowedFirstPartyMcpTools">,
|
|
279
|
+
allowedIdentities?: readonly ToolGatewayIdentity[],
|
|
280
|
+
): (definition: ToolGatewayDefinition) => boolean {
|
|
281
|
+
const allowedFirstPartyTools: ReadonlySet<string> = new Set(
|
|
282
|
+
settings.allowedFirstPartyMcpTools ?? FIRST_PARTY_MCP_TOOL_NAMES,
|
|
283
|
+
);
|
|
284
|
+
const frozenIdentityKeys = allowedIdentities
|
|
285
|
+
? new Set(allowedIdentities.map(workspaceToolGatewayIdentityKey))
|
|
286
|
+
: null;
|
|
287
|
+
return (definition) => {
|
|
288
|
+
if (
|
|
289
|
+
definition.identity.serverId === "opengeni" &&
|
|
290
|
+
!allowedFirstPartyTools.has(definition.identity.toolName)
|
|
291
|
+
) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
definition.approval === "human" &&
|
|
296
|
+
definition.requiresProviderPreflight === true &&
|
|
297
|
+
!definition.preflightCall
|
|
298
|
+
) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
return frozenIdentityKeys?.has(workspaceToolGatewayIdentityKey(definition.identity)) ?? true;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function workspaceToolGatewayIdentityKey(identity: ToolGatewayIdentity): string {
|
|
306
|
+
return JSON.stringify([identity.serverId, identity.toolName]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function workspaceToolGatewaySettingsForGrant(
|
|
310
|
+
settings: Settings,
|
|
311
|
+
grant: AccessGrant,
|
|
312
|
+
allowedIdentities?: readonly { serverId: string; toolName: string }[],
|
|
313
|
+
): Settings {
|
|
314
|
+
const allowedServerIds = allowedIdentities
|
|
315
|
+
? new Set(allowedIdentities.map((identity) => identity.serverId))
|
|
316
|
+
: null;
|
|
317
|
+
return {
|
|
318
|
+
...settings,
|
|
319
|
+
mcpServers: settings.mcpServers.filter((server) => {
|
|
320
|
+
if (allowedServerIds && !allowedServerIds.has(server.id)) return false;
|
|
321
|
+
if (server.id === "docs" && !hasPermission(grant.permissions, "documents:search")) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (server.id === "files" && !hasPermission(grant.permissions, "files:read")) return false;
|
|
325
|
+
return true;
|
|
326
|
+
}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function buildWorkspaceToolGatewayMcpServer(
|
|
331
|
+
prepared: PreparedWorkspaceToolGateway,
|
|
332
|
+
grant: AccessGrant,
|
|
333
|
+
observability?: Observability,
|
|
334
|
+
): Server {
|
|
335
|
+
const callableEntries = prepared.toolGatewayCatalog.entries.filter(
|
|
336
|
+
(entry) => entry.approval !== "human",
|
|
337
|
+
);
|
|
338
|
+
const server = new Server(
|
|
339
|
+
{ name: "opengeni-tool-gateway", version: "1.0.0" },
|
|
340
|
+
{ capabilities: { tools: {} } },
|
|
341
|
+
);
|
|
342
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
343
|
+
tools: callableEntries.map((entry) => ({
|
|
344
|
+
name: entry.modelName,
|
|
345
|
+
...(entry.title ? { title: entry.title } : {}),
|
|
346
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
347
|
+
inputSchema: entry.inputSchema,
|
|
348
|
+
...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}),
|
|
349
|
+
...(entry.annotations ? { annotations: entry.annotations } : {}),
|
|
350
|
+
...(entry.icons ? { icons: entry.icons } : {}),
|
|
351
|
+
_meta: {
|
|
352
|
+
"opengeni/identity": entry.identity,
|
|
353
|
+
"opengeni/path": entry.codemodePath,
|
|
354
|
+
"opengeni/source": entry.source,
|
|
355
|
+
"opengeni/approval": entry.approval,
|
|
356
|
+
"opengeni/catalogDigest": prepared.toolGatewayCatalog.digest,
|
|
357
|
+
},
|
|
358
|
+
})),
|
|
359
|
+
}));
|
|
360
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
361
|
+
const entry = callableEntries.find((candidate) => candidate.modelName === request.params.name);
|
|
362
|
+
if (!entry) throw new ToolGatewayToolNotFoundError();
|
|
363
|
+
const observation = startWorkspaceToolGatewayObservation(observability, {
|
|
364
|
+
adapter: "mcp",
|
|
365
|
+
operation: "call",
|
|
366
|
+
source: entry.source,
|
|
367
|
+
});
|
|
368
|
+
try {
|
|
369
|
+
const result = (await prepared.toolGateway.call(
|
|
370
|
+
{
|
|
371
|
+
operationId: crypto.randomUUID(),
|
|
372
|
+
catalogDigest: prepared.toolGatewayCatalog.digest,
|
|
373
|
+
identity: entry.identity,
|
|
374
|
+
arguments: request.params.arguments ?? {},
|
|
375
|
+
caller: { kind: "mcp", subjectId: grant.subjectId },
|
|
376
|
+
},
|
|
377
|
+
{ signal: extra.signal },
|
|
378
|
+
)) as CallToolResult;
|
|
379
|
+
observation.end(result.isError ? "tool_error" : "ok");
|
|
380
|
+
return result;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
observation.end(workspaceToolGatewayOutcome(error));
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return server;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function callWorkspaceToolGateway(
|
|
390
|
+
prepared: PreparedWorkspaceToolGateway,
|
|
391
|
+
grant: AccessGrant,
|
|
392
|
+
input: unknown,
|
|
393
|
+
db?: ApiRouteDeps["db"],
|
|
394
|
+
consumeApproval: typeof consumeToolGatewayApproval = consumeToolGatewayApproval,
|
|
395
|
+
observability?: Observability,
|
|
396
|
+
authorizeSiteTool: AuthorizeWorkspaceSiteTool = requireWorkspaceSiteToolAuthorization,
|
|
397
|
+
) {
|
|
398
|
+
const request = ToolGatewayCallRequest.parse(input);
|
|
399
|
+
const operationId = request.operationId ?? crypto.randomUUID();
|
|
400
|
+
const entry = prepared.toolGatewayCatalog.entries.find(
|
|
401
|
+
(candidate) =>
|
|
402
|
+
candidate.identity.serverId === request.identity.serverId &&
|
|
403
|
+
candidate.identity.toolName === request.identity.toolName,
|
|
404
|
+
);
|
|
405
|
+
const observation = startWorkspaceToolGatewayObservation(observability, {
|
|
406
|
+
adapter: "http",
|
|
407
|
+
operation: "call",
|
|
408
|
+
source: entry?.source ?? "aggregate",
|
|
409
|
+
});
|
|
410
|
+
try {
|
|
411
|
+
const siteContext =
|
|
412
|
+
request.siteArtifactId && request.siteVersionId
|
|
413
|
+
? {
|
|
414
|
+
siteArtifactId: request.siteArtifactId,
|
|
415
|
+
siteVersionId: request.siteVersionId,
|
|
416
|
+
identity: request.identity,
|
|
417
|
+
}
|
|
418
|
+
: null;
|
|
419
|
+
if (siteContext) {
|
|
420
|
+
if (!db) throw new HTTPException(503, { message: "site_tool_authorization_unavailable" });
|
|
421
|
+
await authorizeSiteTool(db, grant, siteContext);
|
|
422
|
+
}
|
|
423
|
+
// Approval is adapter-owned. Prepare provisionally so provider preflight can
|
|
424
|
+
// run, then require the ordinary single-use capability before execution.
|
|
425
|
+
const transportMeta: Record<string, unknown> = { approvalConfirmed: true };
|
|
426
|
+
const preparedCall = await prepared.toolGateway.prepareCall(
|
|
427
|
+
{
|
|
428
|
+
operationId,
|
|
429
|
+
catalogDigest: request.catalogDigest,
|
|
430
|
+
identity: request.identity,
|
|
431
|
+
arguments: request.arguments,
|
|
432
|
+
caller: { kind: "http", subjectId: grant.subjectId },
|
|
433
|
+
},
|
|
434
|
+
{ transportMeta },
|
|
435
|
+
);
|
|
436
|
+
let approvalConfirmed = false;
|
|
437
|
+
const approvalRequired = preparedCall.entry.approval === "human";
|
|
438
|
+
if (approvalRequired && request.approvalToken && db) {
|
|
439
|
+
approvalConfirmed = await consumeApproval(db, {
|
|
440
|
+
tokenHash: hashOpaqueValue(request.approvalToken),
|
|
441
|
+
accountId: grant.accountId,
|
|
442
|
+
workspaceId: grant.workspaceId,
|
|
443
|
+
subjectId: grant.subjectId,
|
|
444
|
+
operationId,
|
|
445
|
+
catalogDigest: request.catalogDigest,
|
|
446
|
+
identity: request.identity,
|
|
447
|
+
argumentsDigest: digestCanonicalJson(request.arguments),
|
|
448
|
+
approvalAuthorityDigest: preparedCall.approvalAuthorityDigest,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (approvalRequired && !approvalConfirmed) {
|
|
452
|
+
throw new HTTPException(409, { message: "tool_gateway_approval_required" });
|
|
453
|
+
}
|
|
454
|
+
transportMeta.approvalConfirmed = approvalConfirmed;
|
|
455
|
+
const result = await preparedCall.execute();
|
|
456
|
+
observation.end(result.isError ? "tool_error" : "ok");
|
|
457
|
+
return ToolGatewayCallResponse.parse({
|
|
458
|
+
operationId,
|
|
459
|
+
catalogDigest: prepared.toolGatewayCatalog.digest,
|
|
460
|
+
result,
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
observation.end(workspaceToolGatewayOutcome(error));
|
|
464
|
+
throwWorkspaceToolGatewayHttpError(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function approveWorkspaceToolGatewayCall(
|
|
469
|
+
prepared: PreparedWorkspaceToolGateway,
|
|
470
|
+
grant: AccessGrant,
|
|
471
|
+
db: ApiRouteDeps["db"],
|
|
472
|
+
input: unknown,
|
|
473
|
+
issueApproval: typeof issueToolGatewayApproval = issueToolGatewayApproval,
|
|
474
|
+
observability?: Observability,
|
|
475
|
+
) {
|
|
476
|
+
const request = ToolGatewayApprovalRequest.parse(input);
|
|
477
|
+
let preparedCall: PreparedToolGatewayCall;
|
|
478
|
+
try {
|
|
479
|
+
preparedCall = await prepared.toolGateway.prepareCall(
|
|
480
|
+
{
|
|
481
|
+
operationId: request.operationId,
|
|
482
|
+
catalogDigest: request.catalogDigest,
|
|
483
|
+
identity: request.identity,
|
|
484
|
+
arguments: request.arguments,
|
|
485
|
+
caller: { kind: "http", subjectId: grant.subjectId },
|
|
486
|
+
},
|
|
487
|
+
{ transportMeta: { approvalConfirmed: true } },
|
|
488
|
+
);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
throwWorkspaceToolGatewayHttpError(error);
|
|
491
|
+
}
|
|
492
|
+
if (preparedCall.entry.approval !== "human") {
|
|
493
|
+
throw new HTTPException(422, { message: "tool_does_not_require_human_approval" });
|
|
494
|
+
}
|
|
495
|
+
const observation = startWorkspaceToolGatewayObservation(observability, {
|
|
496
|
+
adapter: "http",
|
|
497
|
+
operation: "approval",
|
|
498
|
+
source: preparedCall.entry.source,
|
|
499
|
+
});
|
|
500
|
+
const approvalToken = `ogta_${randomBytes(32).toString("base64url")}`;
|
|
501
|
+
const expiresAt = new Date(Date.now() + 5 * 60_000);
|
|
502
|
+
try {
|
|
503
|
+
await issueApproval(db, {
|
|
504
|
+
tokenHash: hashOpaqueValue(approvalToken),
|
|
505
|
+
accountId: grant.accountId,
|
|
506
|
+
workspaceId: grant.workspaceId,
|
|
507
|
+
subjectId: grant.subjectId,
|
|
508
|
+
operationId: request.operationId,
|
|
509
|
+
catalogDigest: request.catalogDigest,
|
|
510
|
+
identity: request.identity,
|
|
511
|
+
argumentsDigest: digestCanonicalJson(request.arguments),
|
|
512
|
+
approvalAuthorityDigest: preparedCall.approvalAuthorityDigest,
|
|
513
|
+
expiresAt,
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error instanceof ToolGatewayApprovalRateLimitError) {
|
|
517
|
+
observation.end("rate_limited");
|
|
518
|
+
throw new HTTPException(429, { message: "tool_approval_rate_limited", cause: error });
|
|
519
|
+
}
|
|
520
|
+
if (error instanceof ToolGatewayApprovalOperationStartedError) {
|
|
521
|
+
observation.end("failed");
|
|
522
|
+
throw new ApiHttpError(409, {
|
|
523
|
+
code: "conflict",
|
|
524
|
+
message:
|
|
525
|
+
"This tool operation may already have started and cannot be approved again. Reconcile its outcome before creating a new operation.",
|
|
526
|
+
retryable: false,
|
|
527
|
+
outcomeUnknown: true,
|
|
528
|
+
details: {
|
|
529
|
+
code: "tool_gateway_operation_already_started",
|
|
530
|
+
operationId: request.operationId,
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
observation.end("failed");
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
observation.end("ok");
|
|
538
|
+
return ToolGatewayApprovalResponse.parse({
|
|
539
|
+
operationId: request.operationId,
|
|
540
|
+
catalogDigest: request.catalogDigest,
|
|
541
|
+
identity: preparedCall.entry.identity,
|
|
542
|
+
approvalToken,
|
|
543
|
+
expiresAt: expiresAt.toISOString(),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function catalogStaleHttpError(): ApiHttpError {
|
|
548
|
+
return new ApiHttpError(409, {
|
|
549
|
+
code: "conflict",
|
|
550
|
+
message: "The workspace tool catalog changed; retry with the current catalog.",
|
|
551
|
+
retryable: true,
|
|
552
|
+
details: { code: "catalog_stale" },
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function throwWorkspaceToolGatewayHttpError(error: unknown): never {
|
|
557
|
+
if (error instanceof ToolGatewayCatalogStaleError) {
|
|
558
|
+
throw catalogStaleHttpError();
|
|
559
|
+
}
|
|
560
|
+
if (error instanceof ToolGatewayToolNotFoundError) {
|
|
561
|
+
throw new HTTPException(404, { message: error.code, cause: error });
|
|
562
|
+
}
|
|
563
|
+
if (error instanceof ToolGatewayInputValidationError) {
|
|
564
|
+
throw new HTTPException(422, { message: error.code, cause: error });
|
|
565
|
+
}
|
|
566
|
+
if (error instanceof ToolGatewayApprovalRequiredError) {
|
|
567
|
+
throw new HTTPException(409, { message: error.code, cause: error });
|
|
568
|
+
}
|
|
569
|
+
if (error instanceof IntegrationInvocationError && error.outcome === "unknown") {
|
|
570
|
+
throw new ApiHttpError(502, {
|
|
571
|
+
code: "upstream_unavailable",
|
|
572
|
+
message:
|
|
573
|
+
"The integration call ended without a confirmed provider result. Inspect the external system before retrying.",
|
|
574
|
+
retryable: false,
|
|
575
|
+
outcomeUnknown: true,
|
|
576
|
+
details: { code: "tool_outcome_unknown", providerCode: error.code },
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function requireWorkspaceSiteToolAuthorization(
|
|
583
|
+
db: ApiRouteDeps["db"],
|
|
584
|
+
grant: AccessGrant,
|
|
585
|
+
context: WorkspaceSiteToolContext,
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
try {
|
|
588
|
+
const detail = await getWorkspaceArtifact(db, grant.workspaceId, context.siteArtifactId);
|
|
589
|
+
const currentVersion = detail.artifact.currentVersion;
|
|
590
|
+
const requested = currentVersion?.requestedTools.some(
|
|
591
|
+
(identity) =>
|
|
592
|
+
identity.serverId === context.identity.serverId &&
|
|
593
|
+
identity.toolName === context.identity.toolName,
|
|
594
|
+
);
|
|
595
|
+
if (
|
|
596
|
+
detail.artifact.status !== "active" ||
|
|
597
|
+
currentVersion?.id !== context.siteVersionId ||
|
|
598
|
+
!requested
|
|
599
|
+
) {
|
|
600
|
+
throw new HTTPException(403, { message: "site_tool_not_authorized" });
|
|
601
|
+
}
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error instanceof WorkspaceArtifactNotFoundError) {
|
|
604
|
+
throw new HTTPException(403, { message: "site_tool_not_authorized" });
|
|
605
|
+
}
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function hashOpaqueValue(value: string): string {
|
|
611
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function workspaceToolGatewayOutcome(error: unknown) {
|
|
615
|
+
if (error instanceof ToolGatewayApprovalRequiredError) return "approval_required" as const;
|
|
616
|
+
if (error instanceof ToolGatewayCatalogStaleError) return "catalog_stale" as const;
|
|
617
|
+
if (error instanceof ToolGatewayInputValidationError) return "invalid_input" as const;
|
|
618
|
+
if (error instanceof ToolGatewayToolNotFoundError) return "not_found" as const;
|
|
619
|
+
return "failed" as const;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function allGatewayToolRefs(settings: Settings): ToolRef[] {
|
|
623
|
+
return settings.mcpServers.map((server) => ({
|
|
624
|
+
kind: "mcp" as const,
|
|
625
|
+
id: server.id,
|
|
626
|
+
...(server.connectionRef ? { optional: true } : {}),
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function inMemoryMcpRegistration(
|
|
631
|
+
id: string,
|
|
632
|
+
server: McpServer,
|
|
633
|
+
): Promise<LocalMcpServerRegistration> {
|
|
634
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
635
|
+
const client = new Client({ name: `opengeni-tool-gateway-${id}`, version: "1.0.0" });
|
|
636
|
+
let connected = false;
|
|
637
|
+
let closed = false;
|
|
638
|
+
type RuntimeMcpServer = LocalMcpServerRegistration["server"];
|
|
639
|
+
type RuntimeMcpTools = Awaited<ReturnType<RuntimeMcpServer["listTools"]>>;
|
|
640
|
+
type RuntimeMcpCallResult = Awaited<ReturnType<NonNullable<RuntimeMcpServer["callToolResult"]>>>;
|
|
641
|
+
return {
|
|
642
|
+
id,
|
|
643
|
+
server: {
|
|
644
|
+
name: `opengeni-tool-gateway-local:${id}`,
|
|
645
|
+
cacheToolsList: false,
|
|
646
|
+
connect: async () => {
|
|
647
|
+
if (closed) throw new Error(`Local MCP server ${id} is closed`);
|
|
648
|
+
if (connected) return;
|
|
649
|
+
await server.connect(serverTransport);
|
|
650
|
+
await client.connect(clientTransport);
|
|
651
|
+
connected = true;
|
|
652
|
+
},
|
|
653
|
+
close: async () => {
|
|
654
|
+
if (closed) return;
|
|
655
|
+
closed = true;
|
|
656
|
+
await Promise.allSettled([client.close(), server.close()]);
|
|
657
|
+
},
|
|
658
|
+
listTools: async () => (await client.listTools()).tools as unknown as RuntimeMcpTools,
|
|
659
|
+
callTool: async (toolName, args, _meta, options) =>
|
|
660
|
+
(
|
|
661
|
+
(await client.callTool(
|
|
662
|
+
{ name: toolName, arguments: args ?? {} },
|
|
663
|
+
undefined,
|
|
664
|
+
options?.signal ? { signal: options.signal } : undefined,
|
|
665
|
+
)) as CallToolResult
|
|
666
|
+
).content,
|
|
667
|
+
callToolResult: async (toolName, args, _meta, options) =>
|
|
668
|
+
(await client.callTool(
|
|
669
|
+
{ name: toolName, arguments: args ?? {} },
|
|
670
|
+
undefined,
|
|
671
|
+
options?.signal ? { signal: options.signal } : undefined,
|
|
672
|
+
)) as unknown as RuntimeMcpCallResult,
|
|
673
|
+
invalidateToolsCache: async () => undefined,
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function toolGatewayCatalogResponse(catalog: ToolGatewayCatalog): ToolGatewayCatalog {
|
|
679
|
+
return catalog;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function workspaceToolGatewayDeclarations(
|
|
683
|
+
prepared: PreparedWorkspaceToolGateway,
|
|
684
|
+
): ToolGatewayDeclarationsResponse {
|
|
685
|
+
const moduleSpecifier = "@opengeni/sdk";
|
|
686
|
+
return ToolGatewayDeclarationsResponse.parse({
|
|
687
|
+
catalogDigest: prepared.toolGatewayCatalog.digest,
|
|
688
|
+
moduleSpecifier,
|
|
689
|
+
source: generateToolGatewayDeclarations(prepared.toolGatewayCatalog, { moduleSpecifier }),
|
|
690
|
+
});
|
|
691
|
+
}
|