@opengeni/api-router 0.15.6 → 0.16.4

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-MN7BW3UV.js";
4
+ } from "./chunk-TFHWQL2W.js";
5
5
 
6
6
  // src/index.ts
7
7
  import {
@@ -1,6 +1,6 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { type McpServerConfig } from "@opengeni/config";
3
- import { type AccessGrant, type SessionTurn } from "@opengeni/contracts";
3
+ import { type AccessGrant, type McpPersonalConnectionDelegation, type SessionTurn } from "@opengeni/contracts";
4
4
  import { type ApiRouteDeps } from "@opengeni/core";
5
5
  import { type FetchLike } from "@opengeni/network";
6
6
  export type ToolspaceCallResult = CallToolResult;
@@ -57,5 +57,6 @@ export declare function connectionBrokerFetch(baseFetch: FetchLike, input: {
57
57
  sessionId: string;
58
58
  rootSessionId: string;
59
59
  turn: SessionTurn;
60
+ personalConnectionDelegations?: McpPersonalConnectionDelegation[];
60
61
  }): FetchLike;
61
62
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.15.6",
3
+ "version": "0.16.4",
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": {
@@ -44,17 +44,17 @@
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
46
  "@opengeni/codex": "^0.2.9",
47
- "@opengeni/config": "^0.9.1",
48
- "@opengeni/contracts": "^0.28.1",
49
- "@opengeni/core": "^0.16.3",
50
- "@opengeni/db": "^0.19.0",
51
- "@opengeni/documents": "^0.2.64",
52
- "@opengeni/events": "^0.3.55",
53
- "@opengeni/github": "^0.4.13",
47
+ "@opengeni/config": "^0.10.0",
48
+ "@opengeni/contracts": "^0.31.0",
49
+ "@opengeni/core": "^0.18.0",
50
+ "@opengeni/db": "^0.22.1",
51
+ "@opengeni/documents": "^0.2.69",
52
+ "@opengeni/events": "^0.3.59",
53
+ "@opengeni/github": "^0.4.16",
54
54
  "@opengeni/network": "^0.1.1",
55
- "@opengeni/observability": "^0.4.0",
56
- "@opengeni/runtime": "^0.16.0",
57
- "@opengeni/storage": "^0.2.50",
55
+ "@opengeni/observability": "^0.4.3",
56
+ "@opengeni/runtime": "^0.17.0",
57
+ "@opengeni/storage": "^0.2.53",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
@@ -6,6 +6,7 @@ import {
6
6
  OPENGENI_PERSONAL_SLACK_MCP_URL,
7
7
  OAuthStartResponse,
8
8
  selectCanonicalPersonalSlackConnection,
9
+ type ConnectionOwnership,
9
10
  type OAuthStartRequest,
10
11
  } from "@opengeni/contracts";
11
12
  import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
@@ -100,6 +101,7 @@ type OAuthStatePayload = {
100
101
  accountId: string;
101
102
  workspaceId: string;
102
103
  subjectId: string;
104
+ ownership: ConnectionOwnership;
103
105
  providerDomain: string;
104
106
  mcpUrl: string;
105
107
  resource: string;
@@ -154,6 +156,15 @@ export async function startMcpOAuth(
154
156
  : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
155
157
  const personalSlack = officialSlackResource || providerDomain === "slack.com";
156
158
  assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
159
+ if (personalSlack && context.payload.ownership === "workspace") {
160
+ throw new HTTPException(422, {
161
+ message:
162
+ "Slack's hosted MCP connection is personal; use the OpenGeni Slack bot installation for workspace access",
163
+ });
164
+ }
165
+ const requestedOwnership: ConnectionOwnership = personalSlack
166
+ ? "personal"
167
+ : (context.payload.ownership ?? "workspace");
157
168
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
158
169
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
159
170
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -165,10 +176,15 @@ export async function startMcpOAuth(
165
176
  mcpUrl,
166
177
  personalSlack,
167
178
  connectionId: context.payload.connectionId,
179
+ requestedOwnership: context.payload.ownership,
180
+ newConnectionOwnership: requestedOwnership,
168
181
  });
169
182
  if (context.payload.connectionId && !existing) {
170
183
  throw new HTTPException(404, { message: "connection not found" });
171
184
  }
185
+ const ownership = existing
186
+ ? ownershipForConnection(existing.subjectId, context.subjectId)
187
+ : requestedOwnership;
172
188
 
173
189
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
174
190
  if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
@@ -195,6 +211,7 @@ export async function startMcpOAuth(
195
211
  accountId: context.accountId,
196
212
  workspaceId: context.workspaceId,
197
213
  subjectId: context.subjectId,
214
+ ownership,
198
215
  providerDomain,
199
216
  mcpUrl,
200
217
  resource,
@@ -274,6 +291,7 @@ export async function completeMcpOAuthCallback(
274
291
  };
275
292
  }
276
293
 
294
+ const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
277
295
  try {
278
296
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
279
297
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -313,7 +331,7 @@ export async function completeMcpOAuthCallback(
313
331
  connectionId: state.connectionId,
314
332
  visibleToSubjectId: state.subjectId,
315
333
  expectedVersion: state.connectionVersion,
316
- subjectId: state.subjectId,
334
+ subjectId: ownerSubjectId,
317
335
  providerDomain: state.providerDomain,
318
336
  kind: "oauth2",
319
337
  status: "active",
@@ -326,7 +344,7 @@ export async function completeMcpOAuthCallback(
326
344
  : createConnection(db, {
327
345
  accountId: state.accountId,
328
346
  workspaceId: state.workspaceId,
329
- subjectId: state.subjectId,
347
+ subjectId: ownerSubjectId,
330
348
  providerDomain: state.providerDomain,
331
349
  kind: "oauth2",
332
350
  credentialEncrypted,
@@ -349,6 +367,7 @@ export async function completeMcpOAuthCallback(
349
367
  redirectTo: callbackReturnPath(state.returnPath, "success", {
350
368
  connectionId: connection.id,
351
369
  providerDomain: connection.providerDomain,
370
+ ownership: state.ownership,
352
371
  ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
353
372
  }),
354
373
  };
@@ -853,6 +872,8 @@ async function existingOAuthConnectionForStart(
853
872
  mcpUrl: string;
854
873
  personalSlack: boolean;
855
874
  connectionId?: string | undefined;
875
+ requestedOwnership?: ConnectionOwnership | undefined;
876
+ newConnectionOwnership: ConnectionOwnership;
856
877
  },
857
878
  ) {
858
879
  if (input.connectionId) {
@@ -862,17 +883,25 @@ async function existingOAuthConnectionForStart(
862
883
  input.connectionId,
863
884
  input.subjectId,
864
885
  );
865
- return connection?.subjectId === input.subjectId &&
866
- connection.kind === "oauth2" &&
867
- connection.providerDomain === input.providerDomain &&
886
+ if (!connection || connection.kind !== "oauth2") {
887
+ return null;
888
+ }
889
+ const ownership = ownershipForConnection(connection.subjectId, input.subjectId);
890
+ if (input.requestedOwnership && input.requestedOwnership !== ownership) {
891
+ throw new HTTPException(409, {
892
+ message: "connection ownership cannot be changed during OAuth reconnect",
893
+ });
894
+ }
895
+ return connection.providerDomain === input.providerDomain &&
868
896
  (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
869
897
  ? connection
870
898
  : null;
871
899
  }
872
900
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
901
+ const ownerSubjectId = input.newConnectionOwnership === "personal" ? input.subjectId : null;
873
902
  const matching = visible.filter(
874
903
  (connection) =>
875
- connection.subjectId === input.subjectId &&
904
+ connection.subjectId === ownerSubjectId &&
876
905
  connection.kind === "oauth2" &&
877
906
  connection.providerDomain === input.providerDomain &&
878
907
  (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl),
@@ -883,6 +912,15 @@ async function existingOAuthConnectionForStart(
883
912
  return matching.find((connection) => connection.status === "active") ?? null;
884
913
  }
885
914
 
915
+ function ownershipForConnection(
916
+ subjectId: string | null,
917
+ authenticatingSubjectId: string,
918
+ ): ConnectionOwnership {
919
+ if (subjectId === null) return "workspace";
920
+ if (subjectId === authenticatingSubjectId) return "personal";
921
+ throw new HTTPException(404, { message: "connection not found" });
922
+ }
923
+
886
924
  function buildAuthorizationUrl(input: {
887
925
  endpoint: string;
888
926
  settings: Settings;
@@ -926,6 +964,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
926
964
  accountId: requiredString(payload.accountId, "state.accountId"),
927
965
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
928
966
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
967
+ // OAuth states minted before ownership was explicit were always personal.
968
+ // Preserve that meaning for in-flight reconnects during a rolling deploy.
969
+ ownership: connectionOwnership(payload.ownership) ?? "personal",
929
970
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
930
971
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
931
972
  resource,
@@ -972,6 +1013,10 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
972
1013
  };
973
1014
  }
974
1015
 
1016
+ function connectionOwnership(value: unknown): ConnectionOwnership | undefined {
1017
+ return value === "workspace" || value === "personal" ? value : undefined;
1018
+ }
1019
+
975
1020
  async function clientForState(
976
1021
  db: Database,
977
1022
  settings: Settings,
@@ -18,6 +18,7 @@ import {
18
18
  deleteSlackBotUserLink,
19
19
  enqueueSlackInteractionInbox,
20
20
  getOrCreateSlackInteraction,
21
+ getLatestSessionModelForSubject,
21
22
  getSlackBotUserLink,
22
23
  getSlackInteractionByRoute,
23
24
  getWorkspaceGrant,
@@ -344,6 +345,14 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
344
345
  });
345
346
  } catch (error) {
346
347
  const code = safeErrorCode(error);
348
+ console.error("[slack-interactions] inbox processing failed", {
349
+ workspaceId: entry.workspaceId,
350
+ connectionId: entry.connectionId,
351
+ providerEventId: entry.providerEventId,
352
+ triggerKind: entry.triggerKind,
353
+ attemptCount: entry.attemptCount,
354
+ errorCode: code,
355
+ });
347
356
  if (
348
357
  entry.attemptCount >= 5 ||
349
358
  permanentSlackInteractionError(error) ||
@@ -492,14 +501,35 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
492
501
  await continueSlackSession(deps, grant, interaction, entry);
493
502
  return;
494
503
  }
495
- const session = await createSessionForRequest(deps, grant, entry.workspaceId, {
496
- requestedSessionId: interaction.sessionReservationId,
497
- initialMessage: entry.text,
498
- turnInstructions: SLACK_TASK_INSTRUCTIONS,
499
- firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
500
- idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
501
- clientEventId: `slack:${entry.providerEventId}`,
502
- });
504
+ const preferredModel = await getLatestSessionModelForSubject(
505
+ deps.db,
506
+ entry.workspaceId,
507
+ grant.subjectId,
508
+ );
509
+ let session: Awaited<ReturnType<typeof createSessionForRequest>>;
510
+ try {
511
+ session = await createSessionForRequest(deps, grant, entry.workspaceId, {
512
+ requestedSessionId: interaction.sessionReservationId,
513
+ initialMessage: entry.text,
514
+ turnInstructions: SLACK_TASK_INSTRUCTIONS,
515
+ firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
516
+ ...(preferredModel ? { model: preferredModel } : {}),
517
+ idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
518
+ clientEventId: `slack:${entry.providerEventId}`,
519
+ });
520
+ } catch (error) {
521
+ if (error instanceof HTTPException) {
522
+ await client.postMessage({
523
+ operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
524
+ channelId: entry.slackChannelId,
525
+ ...(entry.triggerKind === "slash_command"
526
+ ? {}
527
+ : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
528
+ text: slackAdmissionFailureText(error),
529
+ });
530
+ }
531
+ throw error;
532
+ }
503
533
  const bound = await bindSlackInteractionSession(deps.db, {
504
534
  ...interaction,
505
535
  owningSubjectId: grant.subjectId,
@@ -982,6 +1012,7 @@ function safePayloadText(payload: unknown, field: string) {
982
1012
 
983
1013
  function safeErrorCode(error: unknown) {
984
1014
  if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
1015
+ if (error instanceof HTTPException) return `http_${error.status}`;
985
1016
  const raw = error instanceof Error ? error.name : "slack_interaction_error";
986
1017
  return (
987
1018
  raw
@@ -991,6 +1022,16 @@ function safeErrorCode(error: unknown) {
991
1022
  );
992
1023
  }
993
1024
 
1025
+ function slackAdmissionFailureText(error: HTTPException) {
1026
+ if (error.status === 402) {
1027
+ return "OpenGeni could not start this task because the selected model has no available billing source. Open OpenGeni, select a connected subscription model, and try again.";
1028
+ }
1029
+ if (error.status === 429) {
1030
+ return "OpenGeni could not start this task because this workspace has reached a usage limit. Try again later or review the workspace limits in OpenGeni.";
1031
+ }
1032
+ return "OpenGeni could not start this task because the workspace rejected the session settings. Open OpenGeni, select an available model, and try again.";
1033
+ }
1034
+
994
1035
  class SlackInteractionPermanentError extends Error {}
995
1036
 
996
1037
  function permanentSlackInteractionError(error: unknown) {
package/src/mcp/server.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  requireVariableSetEncryption,
131
131
  } from "@opengeni/core";
132
132
  import {
133
+ captureScheduledTaskRestoreState,
133
134
  createValidatedScheduledTask,
134
135
  manualScheduledTaskTriggerUsageKey,
135
136
  manualScheduledTaskTriggerWorkflowId,
@@ -829,6 +830,7 @@ export function buildOpenGeniMcpServer(
829
830
  },
830
831
  async ({ id, ...raw }) => {
831
832
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
833
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
832
834
  const payload = UpdateScheduledTaskRequest.parse(raw);
833
835
  requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
834
836
  const update = await validatedScheduledTaskUpdate({
@@ -844,7 +846,7 @@ export function buildOpenGeniMcpServer(
844
846
  await syncUpdatedScheduledTask({
845
847
  db: deps.db,
846
848
  workflowClient: deps.workflowClient,
847
- previous: existing,
849
+ previous,
848
850
  task,
849
851
  });
850
852
  return json(task);
@@ -859,13 +861,14 @@ export function buildOpenGeniMcpServer(
859
861
  },
860
862
  async ({ id }) => {
861
863
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
864
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
862
865
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
863
866
  status: "paused",
864
867
  });
865
868
  await syncUpdatedScheduledTask({
866
869
  db: deps.db,
867
870
  workflowClient: deps.workflowClient,
868
- previous: existing,
871
+ previous,
869
872
  task,
870
873
  });
871
874
  return json(task);
@@ -880,13 +883,14 @@ export function buildOpenGeniMcpServer(
880
883
  },
881
884
  async ({ id }) => {
882
885
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
886
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
883
887
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
884
888
  status: "active",
885
889
  });
886
890
  await syncUpdatedScheduledTask({
887
891
  db: deps.db,
888
892
  workflowClient: deps.workflowClient,
889
- previous: existing,
893
+ previous,
890
894
  task,
891
895
  });
892
896
  return json(task);
@@ -6,11 +6,13 @@ import { environmentsEncryptionKeyBytes, type McpServerConfig } from "@opengeni/
6
6
  import {
7
7
  prefixedMcpToolName,
8
8
  type AccessGrant,
9
+ type McpPersonalConnectionDelegation,
9
10
  type SessionTurn,
10
11
  type ToolRef,
11
12
  } from "@opengeni/contracts";
12
13
  import {
13
14
  hasPermission,
15
+ withFrozenPersonalConnectionDelegations,
14
16
  settingsWithEnabledCapabilityMcpServers,
15
17
  type ApiRouteDeps,
16
18
  } from "@opengeni/core";
@@ -20,6 +22,7 @@ import {
20
22
  clearPendingSessionToolspaceCall,
21
23
  getActiveSessionTurnForExecution,
22
24
  getSessionRootId,
25
+ getWorkspaceGrant,
23
26
  listSessionMcpServerMetadata,
24
27
  listSessionMcpServersForRun,
25
28
  registerPendingSessionToolCall,
@@ -229,6 +232,7 @@ export async function prepareToolspaceMcpSurface(input: {
229
232
  executionGeneration: activeTurn.executionGeneration,
230
233
  };
231
234
  const session = await requireSession(deps.db, grant.workspaceId, sessionId);
235
+ const personalConnectionDelegations = activeTurn.personalConnectionDelegations;
232
236
  let rootSessionId = sessionId;
233
237
  if (deps.connectionCredentials?.mcpCredentials) {
234
238
  const resolvedRootSessionId = await getSessionRootId(deps.db, grant.workspaceId, sessionId);
@@ -270,6 +274,7 @@ export async function prepareToolspaceMcpSurface(input: {
270
274
  rootSessionId,
271
275
  proxyableIds,
272
276
  activeTurn,
277
+ personalConnectionDelegations,
273
278
  getRegistry: () => getRegistry(attemptAuthority.attemptId),
274
279
  });
275
280
  const tools = listing.map((entry) =>
@@ -279,6 +284,7 @@ export async function prepareToolspaceMcpSurface(input: {
279
284
  authority: attemptAuthority,
280
285
  rootSessionId,
281
286
  entry,
287
+ personalConnectionDelegations,
282
288
  getRegistry,
283
289
  }),
284
290
  );
@@ -330,9 +336,19 @@ async function resolveToolListing(input: {
330
336
  rootSessionId: string;
331
337
  proxyableIds: string[];
332
338
  activeTurn: SessionTurn;
339
+ personalConnectionDelegations: McpPersonalConnectionDelegation[];
333
340
  getRegistry: () => Promise<Map<string, McpServerConfig>>;
334
341
  }): Promise<ToolListingEntry[]> {
335
- const { deps, grant, sessionId, rootSessionId, proxyableIds, activeTurn, getRegistry } = input;
342
+ const {
343
+ deps,
344
+ grant,
345
+ sessionId,
346
+ rootSessionId,
347
+ proxyableIds,
348
+ activeTurn,
349
+ personalConnectionDelegations,
350
+ getRegistry,
351
+ } = input;
336
352
  // Host credentials can be initiator-specific. A prior turn's tool list must
337
353
  // never be reused under a different frozen authority in the same session.
338
354
  const cacheKey = await toolListCacheKey(
@@ -370,6 +386,7 @@ async function resolveToolListing(input: {
370
386
  sessionId,
371
387
  rootSessionId,
372
388
  turn: activeTurn,
389
+ personalConnectionDelegations,
373
390
  }).catch((error) => {
374
391
  deps.observability?.warn("toolspace upstream connection failed", {
375
392
  serverId,
@@ -508,6 +525,7 @@ async function connectToolspaceServer(input: {
508
525
  sessionId: string;
509
526
  rootSessionId: string;
510
527
  turn: SessionTurn;
528
+ personalConnectionDelegations: McpPersonalConnectionDelegation[];
511
529
  }): Promise<ConnectedToolspaceServer> {
512
530
  // npm Undici's dispatcher transport is not reliable under Bun. The worker's
513
531
  // model-visible MCP path already uses Bun's native fetch while retaining the
@@ -555,9 +573,18 @@ function toolspaceToolFor(input: {
555
573
  authority: ToolspaceAttemptAuthority;
556
574
  rootSessionId: string;
557
575
  entry: ToolListingEntry;
576
+ personalConnectionDelegations: McpPersonalConnectionDelegation[];
558
577
  getRegistry: (attemptId: string) => Promise<Map<string, McpServerConfig>>;
559
578
  }): ToolspaceRegisteredTool {
560
- const { deps, grant, authority, rootSessionId, entry, getRegistry } = input;
579
+ const {
580
+ deps,
581
+ grant,
582
+ authority,
583
+ rootSessionId,
584
+ entry,
585
+ personalConnectionDelegations,
586
+ getRegistry,
587
+ } = input;
561
588
  const { sessionId } = authority;
562
589
  const { serverId, tool } = entry;
563
590
  const name = prefixedMcpToolName(serverId, tool.name);
@@ -598,6 +625,7 @@ function toolspaceToolFor(input: {
598
625
  sessionId,
599
626
  rootSessionId,
600
627
  turn: reservation.turn,
628
+ personalConnectionDelegations,
601
629
  }).catch(() => null);
602
630
  if (!connection) {
603
631
  return mcpError(`upstream tool failed: ${name}`);
@@ -882,22 +910,15 @@ export function connectionBrokerFetch(
882
910
  sessionId: string;
883
911
  rootSessionId: string;
884
912
  turn: SessionTurn;
913
+ personalConnectionDelegations?: McpPersonalConnectionDelegation[];
885
914
  },
886
915
  ): FetchLike {
887
916
  const connectionRef = input.config.connectionRef;
888
917
  if (!connectionRef) {
889
918
  return baseFetch;
890
919
  }
891
- const credentialSubjectId =
892
- input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : undefined;
893
- if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
894
- throw new Error(
895
- `subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`,
896
- );
897
- }
898
920
  const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
899
- const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
900
- const resolveCredential = hostCredentialPort
921
+ const rawResolveCredential = hostCredentialPort
901
922
  ? buildHostConnectionTokenResolver(hostCredentialPort, {
902
923
  accountId: input.grant.accountId,
903
924
  workspaceId: input.grant.workspaceId,
@@ -911,18 +932,43 @@ export function connectionBrokerFetch(
911
932
  surface: "toolspace",
912
933
  })
913
934
  : buildConnectionTokenResolver(input.deps.db, input.deps.settings);
935
+ const personalDelegations = input.personalConnectionDelegations ?? [];
936
+ const delegatedMembershipChecks = new Map<string, Promise<boolean>>();
937
+ const delegatedOwnerHasMembership = async (subjectId: string): Promise<boolean> => {
938
+ const existing = delegatedMembershipChecks.get(subjectId);
939
+ if (existing) return await existing;
940
+ const check = getWorkspaceGrant(input.deps.db, subjectId, input.grant.workspaceId).then(
941
+ Boolean,
942
+ );
943
+ delegatedMembershipChecks.set(subjectId, check);
944
+ return await check;
945
+ };
946
+ const resolveCredential = withFrozenPersonalConnectionDelegations({
947
+ resolveCredential: rawResolveCredential,
948
+ settings: { mcpServers: [input.config] },
949
+ personalConnectionDelegations: personalDelegations,
950
+ ownerHasWorkspaceMembership: delegatedOwnerHasMembership,
951
+ });
914
952
  return async (requestInput, init) => {
915
953
  const request = await mcpRequestInfo(requestInput, init);
916
954
  const destinationUrl = mcpRequestDestinationUrl(requestInput);
917
- const first = await resolveCredential({
918
- workspaceId: input.grant.workspaceId,
919
- serverId: input.config.id,
920
- connectionRef,
921
- destinationUrl,
922
- forceRefresh: false,
923
- ...(request.toolName ? { toolName: request.toolName } : {}),
924
- ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
925
- });
955
+ const resolverSubjectId =
956
+ connectionRef.subjectScope !== "subject" && hostCredentialPort
957
+ ? input.grant.subjectId
958
+ : undefined;
959
+ const resolve = async (forceRefresh: boolean) => {
960
+ const result = await resolveCredential({
961
+ workspaceId: input.grant.workspaceId,
962
+ serverId: input.config.id,
963
+ connectionRef,
964
+ destinationUrl,
965
+ forceRefresh,
966
+ ...(request.toolName ? { toolName: request.toolName } : {}),
967
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
968
+ });
969
+ return result;
970
+ };
971
+ const first = await resolve(false);
926
972
  if (first.status === "auth_needed") {
927
973
  return await authNeededFetchResponse(input, request, first);
928
974
  }
@@ -932,15 +978,7 @@ export function connectionBrokerFetch(
932
978
  );
933
979
  if (response.status === 401) {
934
980
  await cancelMcpResponseBody(response);
935
- const refreshed = await resolveCredential({
936
- workspaceId: input.grant.workspaceId,
937
- serverId: input.config.id,
938
- connectionRef,
939
- destinationUrl,
940
- forceRefresh: true,
941
- ...(request.toolName ? { toolName: request.toolName } : {}),
942
- ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
943
- });
981
+ const refreshed = await resolve(true);
944
982
  if (refreshed.status === "auth_needed") {
945
983
  return await authNeededFetchResponse(input, request, refreshed);
946
984
  }
@@ -989,7 +1027,7 @@ function authNeededFromStatus(
989
1027
  reason,
990
1028
  providerDomain: connectionRef.providerDomain,
991
1029
  ...(connectionRef.provider ? { provider: connectionRef.provider } : {}),
992
- connectionId: first.connectionId,
1030
+ ...(connectionRef.subjectScope === "subject" ? {} : { connectionId: first.connectionId }),
993
1031
  ...(connectionRef.scopes ? { scopes: connectionRef.scopes } : {}),
994
1032
  ...(connectionRef.resource ? { resource: connectionRef.resource } : {}),
995
1033
  ...(connectionRef.selectedResources
@@ -105,7 +105,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
105
105
  const payload = CreateConnectionRequest.parse(await c.req.json());
106
106
  assertNotReservedSlackBotMetadata(payload.metadata);
107
107
  const key = requireEnvironmentEncryption(settings);
108
- const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
108
+ const subjectId = createConnectionSubjectId(payload, grant.subjectId);
109
109
  const providerDomain = canonicalProviderDomain(payload.providerDomain);
110
110
  assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
111
111
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
@@ -836,6 +836,22 @@ function writableSubjectId(
836
836
  return requested;
837
837
  }
838
838
 
839
+ function createConnectionSubjectId(
840
+ payload: Pick<CreateConnectionRequest, "ownership" | "subjectId">,
841
+ grantSubjectId: string,
842
+ ): string | null {
843
+ if (payload.ownership === undefined) {
844
+ return writableSubjectId(payload.subjectId, grantSubjectId);
845
+ }
846
+ const subjectId = payload.ownership === "personal" ? grantSubjectId : null;
847
+ if (payload.subjectId !== undefined && payload.subjectId !== subjectId) {
848
+ throw new HTTPException(422, {
849
+ message: "ownership and subjectId describe different connection owners",
850
+ });
851
+ }
852
+ return subjectId;
853
+ }
854
+
839
855
  function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unknown>): string {
840
856
  return encryptEnvironmentValue(key, JSON.stringify(credential));
841
857
  }
@@ -30,7 +30,7 @@ import {
30
30
  ensureDefaultBase,
31
31
  getDocument,
32
32
  getDocumentBase,
33
- listDocumentBases,
33
+ listDocumentBasesEnsuringDefault,
34
34
  listDocuments,
35
35
  moveDocumentToBase,
36
36
  queueDocumentForReindex,
@@ -62,9 +62,14 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
62
62
 
63
63
  app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
64
64
  const workspaceId = c.req.param("workspaceId");
65
- await requireAccessGrant(c, deps, workspaceId, "documents:search");
65
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
66
66
  return c.json(
67
- (await listDocumentBases(db, workspaceId)).map((base) => DocumentBase.parse(base)),
67
+ (
68
+ await listDocumentBasesEnsuringDefault(db, {
69
+ accountId: grant.accountId,
70
+ workspaceId,
71
+ })
72
+ ).map((base) => DocumentBase.parse(base)),
68
73
  );
69
74
  });
70
75