@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.
Files changed (75) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/app.d.ts +2 -1
  3. package/dist/app.js +3 -1
  4. package/dist/auth/organization-user-setup.d.ts +23 -0
  5. package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
  6. package/dist/chunk-XXH7DW34.js.map +1 -0
  7. package/dist/codemode.d.ts +6 -0
  8. package/dist/github-access.d.ts +25 -2
  9. package/dist/http/request-source.d.ts +14 -0
  10. package/dist/index.js +19 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/oauth-client.d.ts +2 -11
  13. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  14. package/dist/integrations/slack-interactions.d.ts +1 -1
  15. package/dist/mcp/server.d.ts +158 -1
  16. package/dist/mcp/session-view.d.ts +56 -2
  17. package/dist/mcp/session-wait.d.ts +1 -1
  18. package/dist/mcp-oauth.d.ts +19 -0
  19. package/dist/model-catalog.d.ts +5 -31
  20. package/dist/routes/codex.d.ts +27 -2
  21. package/dist/routes/github.d.ts +2 -0
  22. package/dist/routes/organization-model-providers.d.ts +3 -0
  23. package/dist/routes/workspace-artifacts.d.ts +2 -1
  24. package/dist/work-discovery-observability.d.ts +1 -1
  25. package/dist/workspace-artifact-content.d.ts +28 -0
  26. package/dist/workspace-artifact-provenance.d.ts +7 -0
  27. package/dist/workspace-deletion.d.ts +6 -0
  28. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  29. package/dist/workspace-tool-gateway.d.ts +118 -0
  30. package/package.json +19 -18
  31. package/src/access-grant-rls.ts +40 -0
  32. package/src/app.ts +296 -53
  33. package/src/auth/organization-user-setup.ts +95 -5
  34. package/src/codemode.ts +33 -4
  35. package/src/github-access.ts +117 -1
  36. package/src/http/auth.ts +11 -0
  37. package/src/http/request-source.ts +37 -0
  38. package/src/http/sse.ts +15 -7
  39. package/src/index.ts +18 -2
  40. package/src/integrations/oauth-client.ts +168 -203
  41. package/src/integrations/personal-github-repositories.ts +238 -9
  42. package/src/integrations/slack-app-home.ts +2 -2
  43. package/src/integrations/slack-interactions.ts +77 -37
  44. package/src/mcp/company-brain-governed-writes.ts +2 -2
  45. package/src/mcp/company-profile-agent-admin.ts +1 -1
  46. package/src/mcp/remember.ts +1 -1
  47. package/src/mcp/server.ts +504 -133
  48. package/src/mcp/session-view.ts +20 -1
  49. package/src/mcp/session-wait.ts +3 -0
  50. package/src/mcp-oauth.ts +539 -0
  51. package/src/model-catalog.ts +45 -337
  52. package/src/routes/automations.ts +178 -19
  53. package/src/routes/codex.ts +242 -73
  54. package/src/routes/connections.ts +271 -16
  55. package/src/routes/documents.ts +67 -19
  56. package/src/routes/files.ts +54 -6
  57. package/src/routes/github.ts +116 -15
  58. package/src/routes/organization-memberships.ts +59 -16
  59. package/src/routes/organization-model-providers.ts +231 -0
  60. package/src/routes/packs.ts +1 -0
  61. package/src/routes/personal-github.ts +39 -0
  62. package/src/routes/pr-review.ts +72 -4
  63. package/src/routes/scheduled-tasks.ts +40 -13
  64. package/src/routes/sessions.ts +153 -65
  65. package/src/routes/supergrok.ts +3 -2
  66. package/src/routes/workspace-artifacts.ts +176 -67
  67. package/src/routes/workspaces.ts +405 -62
  68. package/src/sandbox/channel-a.ts +17 -4
  69. package/src/work-discovery-observability.ts +3 -3
  70. package/src/workspace-artifact-content.ts +163 -0
  71. package/src/workspace-artifact-provenance.ts +104 -0
  72. package/src/workspace-deletion.ts +64 -0
  73. package/src/workspace-tool-gateway-observability.ts +100 -0
  74. package/src/workspace-tool-gateway.ts +691 -0
  75. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -1,4 +1,10 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import {
3
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
4
+ WORKSPACE_OPENROUTER_CONNECTION_ROLE,
5
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
6
+ VERCEL_AI_GATEWAY_CONNECTION_ROLE,
7
+ } from "@opengeni/config";
2
8
  import {
3
9
  ATLASSIAN_PROVIDER_DOMAIN,
4
10
  AtlassianConnectionMetadata,
@@ -21,11 +27,16 @@ import {
21
27
  IntegrationClientMetadata,
22
28
  ListSlackInstallationBindingsResponse,
23
29
  ListConnectionsResponse,
30
+ OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
31
+ OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
24
32
  OpenGeniSlackBotInstallRequest,
25
33
  OpenGeniSlackBotInstallStart,
26
34
  OAuthStartRequest,
27
35
  OAuthStartResponse,
28
36
  UpdateConnectionRequest,
37
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
38
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
39
+ stableJson,
29
40
  } from "@opengeni/contracts";
30
41
  import {
31
42
  bindConnectorDocumentDestination,
@@ -69,11 +80,17 @@ import {
69
80
  persistSlackBotInstallationWithSuccessAudit,
70
81
  recordSlackBotInstallCallbackFailure,
71
82
  revokeConnection,
83
+ revokeWorkspaceOpenRouterConnections,
84
+ revokeWorkspaceVercelAiGatewayConnections,
72
85
  revokeConnectionWithSlackBotSuccessAudit,
86
+ rotateWorkspaceVercelAiGatewayConnection,
87
+ rotateWorkspaceOpenRouterConnection,
73
88
  SlackBotLifecycleSuccessAuditError,
74
89
  SlackInstallationBindingConflictError,
75
90
  updateConnection,
76
91
  updateSlackBotDocumentDestination,
92
+ upsertWorkspaceVercelAiGatewayConnection,
93
+ upsertWorkspaceOpenRouterConnection,
77
94
  type SlackBotInstallCallbackFailureReason,
78
95
  type SlackBotInstallCallbackFailureStage,
79
96
  } from "@opengeni/db";
@@ -193,18 +210,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
193
210
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
194
211
  assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
195
212
  assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
196
- const connection = await createConnection(db, {
197
- accountId: grant.accountId,
198
- workspaceId,
213
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
214
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
199
215
  subjectId,
200
216
  providerDomain,
201
217
  kind: payload.kind,
202
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
203
- grantedScopes: payload.grantedScopes,
204
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
205
218
  metadata: payload.metadata,
206
- createdBySubjectId: grant.subjectId,
207
219
  });
220
+ const connection = workspaceProviderKind
221
+ ? await (async () => {
222
+ const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
223
+ if (!payload.operationId) {
224
+ throw new HTTPException(400, {
225
+ message: `connecting ${provider.label} requires an operationId`,
226
+ });
227
+ }
228
+ const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
229
+ const metadata = workspaceProviderCredentialMetadata(
230
+ workspaceProviderKind,
231
+ payload.metadata,
232
+ );
233
+ const input = {
234
+ accountId: grant.accountId,
235
+ workspaceId,
236
+ operationId: payload.operationId,
237
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
238
+ action: "create",
239
+ providerDomain,
240
+ kind: payload.kind,
241
+ subjectId,
242
+ credential: payload.credential,
243
+ grantedScopes: payload.grantedScopes,
244
+ expiresAt: expiresAt?.toISOString() ?? null,
245
+ metadata,
246
+ }),
247
+ credentialEncrypted,
248
+ grantedScopes: payload.grantedScopes,
249
+ expiresAt,
250
+ metadata,
251
+ updatedBySubjectId: grant.subjectId,
252
+ };
253
+ const created =
254
+ workspaceProviderKind === "vercel_gateway"
255
+ ? await upsertWorkspaceVercelAiGatewayConnection(db, input)
256
+ : await upsertWorkspaceOpenRouterConnection(db, input);
257
+ if (!created || created.status === "revoked") {
258
+ throw new HTTPException(409, {
259
+ message: `${provider.label} is already connected; reload before replacing its key`,
260
+ });
261
+ }
262
+ return created;
263
+ })()
264
+ : await createConnection(db, {
265
+ accountId: grant.accountId,
266
+ workspaceId,
267
+ subjectId,
268
+ providerDomain,
269
+ kind: payload.kind,
270
+ credentialEncrypted,
271
+ grantedScopes: payload.grantedScopes,
272
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
273
+ metadata: payload.metadata,
274
+ createdBySubjectId: grant.subjectId,
275
+ });
208
276
  return c.json(ConnectionResponse.parse({ connection }), 201);
209
277
  });
210
278
 
@@ -794,6 +862,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
794
862
  payload.providerDomain ?? existing.providerDomain,
795
863
  );
796
864
  const kind = payload.kind ?? existing.kind;
865
+ const subjectId =
866
+ payload.subjectId === undefined
867
+ ? existing.subjectId
868
+ : writableSubjectId(payload.subjectId, grant.subjectId);
869
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
870
+ subjectId,
871
+ providerDomain,
872
+ kind,
873
+ metadata: payload.metadata ?? existing.metadata,
874
+ });
875
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
876
+ throw new HTTPException(422, {
877
+ message:
878
+ targetWorkspaceProviderKind === "vercel_gateway"
879
+ ? "use the Vercel AI Gateway connect flow to create this connection"
880
+ : "use the OpenRouter connect flow to create this connection",
881
+ });
882
+ }
797
883
  assertNotDirectPersonalSlackOAuth(providerDomain, kind);
798
884
  assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
799
885
  assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
@@ -819,6 +905,86 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
819
905
  });
820
906
  }
821
907
  }
908
+ const existingWorkspaceProviderKind = existing
909
+ ? workspaceProviderApiKeyConnectionKind(existing)
910
+ : null;
911
+ if (existing && existingWorkspaceProviderKind) {
912
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
913
+ const providerDomain = canonicalProviderDomain(
914
+ payload.providerDomain ?? existing.providerDomain,
915
+ );
916
+ const subjectId =
917
+ payload.subjectId === undefined
918
+ ? existing.subjectId
919
+ : writableSubjectId(payload.subjectId, grant.subjectId);
920
+ const kind = payload.kind ?? existing.kind;
921
+ if (
922
+ subjectId !== null ||
923
+ providerDomain !== provider.providerDomain ||
924
+ kind !== "api_key" ||
925
+ (payload.metadata?.credentialRole !== undefined &&
926
+ payload.metadata.credentialRole !== provider.credentialRole)
927
+ ) {
928
+ throw new HTTPException(422, {
929
+ message: `${provider.label} connection identity cannot be changed`,
930
+ });
931
+ }
932
+ if (payload.credential === undefined) {
933
+ throw new HTTPException(400, {
934
+ message: `updating a ${provider.label} connection requires a new credential`,
935
+ });
936
+ }
937
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
938
+ throw new HTTPException(400, {
939
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
940
+ });
941
+ }
942
+ const key = requireEnvironmentEncryption(settings);
943
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
944
+ const expiresAt =
945
+ payload.expiresAt !== undefined
946
+ ? payload.expiresAt
947
+ ? new Date(payload.expiresAt)
948
+ : null
949
+ : existing.expiresAt
950
+ ? new Date(existing.expiresAt)
951
+ : null;
952
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
953
+ ...existing.metadata,
954
+ ...(payload.metadata ?? {}),
955
+ });
956
+ const input = {
957
+ accountId: grant.accountId,
958
+ workspaceId,
959
+ connectionId: existing.id,
960
+ expectedVersion: payload.expectedVersion,
961
+ operationId: payload.operationId,
962
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
963
+ action: "rotate",
964
+ connectionId: existing.id,
965
+ expectedVersion: payload.expectedVersion,
966
+ credential: payload.credential,
967
+ grantedScopes,
968
+ expiresAt: expiresAt?.toISOString() ?? null,
969
+ metadata,
970
+ }),
971
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
972
+ grantedScopes,
973
+ expiresAt,
974
+ metadata,
975
+ updatedBySubjectId: grant.subjectId,
976
+ };
977
+ const connection =
978
+ existingWorkspaceProviderKind === "vercel_gateway"
979
+ ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
980
+ : await rotateWorkspaceOpenRouterConnection(db, input);
981
+ if (!connection) {
982
+ throw new HTTPException(409, {
983
+ message: `${provider.label} connection changed; reload before replacing its key`,
984
+ });
985
+ }
986
+ return c.json(ConnectionResponse.parse({ connection }));
987
+ }
822
988
  const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
823
989
  const subjectId =
824
990
  payload.subjectId === undefined
@@ -879,6 +1045,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
879
1045
  existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
880
1046
  existing.kind === "oauth2" &&
881
1047
  AtlassianConnectionMetadata.safeParse(existing.metadata).success;
1048
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
882
1049
  const disconnectPayload = await c.req.json().catch(() => null);
883
1050
  const googleDriveDisconnect = isGoogleDrive
884
1051
  ? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
@@ -909,7 +1076,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
909
1076
  "invalid personal GitHub disconnect request",
910
1077
  });
911
1078
  }
912
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian && !isPersonalGitHub) {
1079
+ if (
1080
+ existing.status === "revoked" &&
1081
+ !isGoogleDrive &&
1082
+ !isAtlassian &&
1083
+ !isPersonalGitHub &&
1084
+ !workspaceProviderKind
1085
+ ) {
913
1086
  return c.json(ConnectionResponse.parse({ connection: existing }));
914
1087
  }
915
1088
  const connection = isGoogleDrive
@@ -944,13 +1117,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
944
1117
  credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
945
1118
  slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
946
1119
  })
947
- : await revokeConnection(
948
- db,
949
- workspaceId,
950
- connectionId,
951
- grant.subjectId,
952
- existing.version,
953
- );
1120
+ : workspaceProviderKind
1121
+ ? workspaceProviderKind === "vercel_gateway"
1122
+ ? await revokeWorkspaceVercelAiGatewayConnections(db, {
1123
+ accountId: grant.accountId,
1124
+ workspaceId,
1125
+ connectionId,
1126
+ expectedVersion: existing.version,
1127
+ updatedBySubjectId: grant.subjectId,
1128
+ })
1129
+ : await revokeWorkspaceOpenRouterConnections(db, {
1130
+ accountId: grant.accountId,
1131
+ workspaceId,
1132
+ connectionId,
1133
+ expectedVersion: existing.version,
1134
+ updatedBySubjectId: grant.subjectId,
1135
+ })
1136
+ : await revokeConnection(
1137
+ db,
1138
+ workspaceId,
1139
+ connectionId,
1140
+ grant.subjectId,
1141
+ existing.version,
1142
+ );
954
1143
  if (!connection) {
955
1144
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
956
1145
  }
@@ -1367,6 +1556,51 @@ function assertNotReservedPersonalGitHubMetadata(
1367
1556
  }
1368
1557
  }
1369
1558
 
1559
+ type WorkspaceProviderApiKeyConnectionKind = "vercel_gateway" | "openrouter";
1560
+
1561
+ function workspaceProviderApiKeyConnectionSpec(
1562
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1563
+ ): {
1564
+ providerDomain: string;
1565
+ credentialRole: string;
1566
+ label: string;
1567
+ } {
1568
+ return providerKind === "vercel_gateway"
1569
+ ? {
1570
+ providerDomain: VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
1571
+ credentialRole: VERCEL_AI_GATEWAY_CONNECTION_ROLE,
1572
+ label: "Vercel AI Gateway",
1573
+ }
1574
+ : {
1575
+ providerDomain: WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
1576
+ credentialRole: WORKSPACE_OPENROUTER_CONNECTION_ROLE,
1577
+ label: "OpenRouter",
1578
+ };
1579
+ }
1580
+
1581
+ function workspaceProviderApiKeyConnectionKind(input: {
1582
+ subjectId?: string | null;
1583
+ providerDomain: string;
1584
+ kind: string;
1585
+ metadata?: Record<string, unknown>;
1586
+ }): WorkspaceProviderApiKeyConnectionKind | null {
1587
+ if (input.subjectId != null || input.kind !== "api_key") return null;
1588
+ const providerDomain = input.providerDomain.toLowerCase();
1589
+ if (
1590
+ providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
1591
+ input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
1592
+ ) {
1593
+ return "vercel_gateway";
1594
+ }
1595
+ if (
1596
+ providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
1597
+ input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
1598
+ ) {
1599
+ return "openrouter";
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1370
1604
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1371
1605
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1372
1606
  throw new HTTPException(422, {
@@ -1426,6 +1660,27 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
1426
1660
  return encryptEnvironmentValue(key, JSON.stringify(credential));
1427
1661
  }
1428
1662
 
1663
+ function workspaceProviderCredentialMetadata(
1664
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1665
+ metadata: Record<string, unknown> | undefined,
1666
+ ): Record<string, unknown> {
1667
+ const {
1668
+ [OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
1669
+ [OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
1670
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
1671
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
1672
+ ...effectiveMetadata
1673
+ } = metadata ?? {};
1674
+ return {
1675
+ ...effectiveMetadata,
1676
+ credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
1677
+ };
1678
+ }
1679
+
1680
+ function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
1681
+ return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
1682
+ }
1683
+
1429
1684
  function slackBotCredentialBundle(token: string): Record<string, unknown> {
1430
1685
  const headerName = ["author", "ization"].join("");
1431
1686
  const scheme = ["Bear", "er"].join("");
@@ -70,6 +70,12 @@ import {
70
70
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
71
71
  import type { ApiRouteDeps } from "@opengeni/core";
72
72
  import { buildDocumentsMcpServer } from "../mcp/documents";
73
+ import { mcpOAuthAuthenticateHeader, resolveMcpOAuthRouteAccess } from "../mcp-oauth";
74
+ import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
75
+ import {
76
+ buildWorkspaceToolGatewayMcpServer,
77
+ prepareMcpOAuthWorkspaceToolGateway,
78
+ } from "../workspace-tool-gateway";
73
79
  import { sanitizeFilename } from "./files";
74
80
 
75
81
  export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
@@ -949,25 +955,67 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
949
955
 
950
956
  app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
951
957
  const workspaceId = c.req.param("workspaceId");
952
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
953
- const sessionId =
954
- typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : undefined;
955
- const attemptId =
956
- typeof grant.metadata?.attemptId === "string" ? grant.metadata.attemptId : undefined;
957
- const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
958
- const server = buildDocumentsMcpServer(
959
- db,
960
- grant.accountId,
961
- workspaceId,
962
- getDocumentServices(),
963
- {
964
- createdBySessionId: sessionId,
965
- attemptId,
966
- initiatingSubjectId: grant.subjectId,
967
- },
968
- );
969
- await server.connect(transport);
970
- return await transport.handleRequest(c.req.raw);
958
+ let oauthAccess: Awaited<ReturnType<typeof resolveMcpOAuthRouteAccess>>;
959
+ try {
960
+ oauthAccess = await resolveMcpOAuthRouteAccess(deps, c.req.raw, workspaceId);
961
+ } catch (error) {
962
+ if (error instanceof HTTPException && error.status === 401) {
963
+ c.header("www-authenticate", mcpOAuthAuthenticateHeader(deps, new URL(c.req.url).pathname));
964
+ }
965
+ throw error;
966
+ }
967
+ if (oauthAccess) {
968
+ return await withAccessGrantSessionRlsContext(deps, oauthAccess.grant, async () => {
969
+ const prepared = await prepareMcpOAuthWorkspaceToolGateway(
970
+ deps,
971
+ oauthAccess.grant,
972
+ oauthAccess.allowedToolIdentities,
973
+ );
974
+ const transport = new WebStandardStreamableHTTPServerTransport({
975
+ enableJsonResponse: true,
976
+ });
977
+ const server = buildWorkspaceToolGatewayMcpServer(
978
+ prepared,
979
+ oauthAccess.grant,
980
+ deps.observability,
981
+ );
982
+ try {
983
+ await server.connect(transport);
984
+ return await transport.handleRequest(c.req.raw);
985
+ } finally {
986
+ await Promise.allSettled([server.close(), prepared.close()]);
987
+ }
988
+ });
989
+ }
990
+ let grant: Awaited<ReturnType<typeof requireAccessGrant>>;
991
+ try {
992
+ grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
993
+ } catch (error) {
994
+ if (deps.settings.mcpOauthEnabled && error instanceof HTTPException && error.status === 401) {
995
+ c.header("www-authenticate", mcpOAuthAuthenticateHeader(deps, new URL(c.req.url).pathname));
996
+ }
997
+ throw error;
998
+ }
999
+ return await withAccessGrantSessionRlsContext(deps, grant, async () => {
1000
+ const sessionId =
1001
+ typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : undefined;
1002
+ const attemptId =
1003
+ typeof grant.metadata?.attemptId === "string" ? grant.metadata.attemptId : undefined;
1004
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
1005
+ const server = buildDocumentsMcpServer(
1006
+ db,
1007
+ grant.accountId,
1008
+ workspaceId,
1009
+ getDocumentServices(),
1010
+ {
1011
+ createdBySessionId: sessionId,
1012
+ attemptId,
1013
+ initiatingSubjectId: grant.subjectId,
1014
+ },
1015
+ );
1016
+ await server.connect(transport);
1017
+ return await transport.handleRequest(c.req.raw);
1018
+ });
971
1019
  });
972
1020
  }
973
1021
 
@@ -46,19 +46,67 @@ import type { ApiRouteDeps } from "@opengeni/core";
46
46
  import { retryWhileMissing } from "@opengeni/storage";
47
47
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
48
48
  import { buildFilesMcpServer } from "../mcp/files";
49
+ import { mcpOAuthAuthenticateHeader, resolveMcpOAuthRouteAccess } from "../mcp-oauth";
50
+ import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
51
+ import {
52
+ buildWorkspaceToolGatewayMcpServer,
53
+ prepareMcpOAuthWorkspaceToolGateway,
54
+ } from "../workspace-tool-gateway";
49
55
 
50
56
  export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
51
57
  const { db, objectStorage } = deps;
52
58
 
53
59
  app.all("/v1/workspaces/:workspaceId/mcp/files", async (c) => {
54
60
  const workspaceId = c.req.param("workspaceId");
55
- const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
56
- const transport = new WebStandardStreamableHTTPServerTransport({
57
- enableJsonResponse: true,
61
+ let oauthAccess: Awaited<ReturnType<typeof resolveMcpOAuthRouteAccess>>;
62
+ try {
63
+ oauthAccess = await resolveMcpOAuthRouteAccess(deps, c.req.raw, workspaceId);
64
+ } catch (error) {
65
+ if (error instanceof HTTPException && error.status === 401) {
66
+ c.header("www-authenticate", mcpOAuthAuthenticateHeader(deps, new URL(c.req.url).pathname));
67
+ }
68
+ throw error;
69
+ }
70
+ if (oauthAccess) {
71
+ return await withAccessGrantSessionRlsContext(deps, oauthAccess.grant, async () => {
72
+ const prepared = await prepareMcpOAuthWorkspaceToolGateway(
73
+ deps,
74
+ oauthAccess.grant,
75
+ oauthAccess.allowedToolIdentities,
76
+ );
77
+ const transport = new WebStandardStreamableHTTPServerTransport({
78
+ enableJsonResponse: true,
79
+ });
80
+ const server = buildWorkspaceToolGatewayMcpServer(
81
+ prepared,
82
+ oauthAccess.grant,
83
+ deps.observability,
84
+ );
85
+ try {
86
+ await server.connect(transport);
87
+ return await transport.handleRequest(c.req.raw);
88
+ } finally {
89
+ await Promise.allSettled([server.close(), prepared.close()]);
90
+ }
91
+ });
92
+ }
93
+ let grant: Awaited<ReturnType<typeof requireAccessGrant>>;
94
+ try {
95
+ grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
96
+ } catch (error) {
97
+ if (deps.settings.mcpOauthEnabled && error instanceof HTTPException && error.status === 401) {
98
+ c.header("www-authenticate", mcpOAuthAuthenticateHeader(deps, new URL(c.req.url).pathname));
99
+ }
100
+ throw error;
101
+ }
102
+ return await withAccessGrantSessionRlsContext(deps, grant, async () => {
103
+ const transport = new WebStandardStreamableHTTPServerTransport({
104
+ enableJsonResponse: true,
105
+ });
106
+ const server = buildFilesMcpServer(deps, grant);
107
+ await server.connect(transport);
108
+ return await transport.handleRequest(c.req.raw);
58
109
  });
59
- const server = buildFilesMcpServer(deps, grant);
60
- await server.connect(transport);
61
- return await transport.handleRequest(c.req.raw);
62
110
  });
63
111
 
64
112
  app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {