@opengeni/api-router 0.15.6 → 0.16.1
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.js +1 -1
- package/dist/{chunk-MN7BW3UV.js → chunk-ZD5YBUNA.js} +180 -58
- package/dist/chunk-ZD5YBUNA.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/mcp/toolspace.d.ts +2 -1
- package/package.json +11 -11
- package/src/integrations/oauth-client.ts +51 -6
- package/src/integrations/slack-interactions.ts +49 -8
- package/src/mcp/server.ts +7 -3
- package/src/mcp/toolspace.ts +68 -30
- package/src/routes/connections.ts +17 -1
- package/src/routes/scheduled-tasks.ts +7 -3
- package/dist/chunk-MN7BW3UV.js.map +0 -1
package/dist/index.js
CHANGED
package/dist/mcp/toolspace.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.16.1",
|
|
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.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.
|
|
50
|
-
"@opengeni/db": "^0.
|
|
51
|
-
"@opengeni/documents": "^0.2.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.4.
|
|
47
|
+
"@opengeni/config": "^0.9.3",
|
|
48
|
+
"@opengeni/contracts": "^0.30.0",
|
|
49
|
+
"@opengeni/core": "^0.17.1",
|
|
50
|
+
"@opengeni/db": "^0.21.0",
|
|
51
|
+
"@opengeni/documents": "^0.2.66",
|
|
52
|
+
"@opengeni/events": "^0.3.57",
|
|
53
|
+
"@opengeni/github": "^0.4.15",
|
|
54
54
|
"@opengeni/network": "^0.1.1",
|
|
55
|
-
"@opengeni/observability": "^0.4.
|
|
56
|
-
"@opengeni/runtime": "^0.16.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
55
|
+
"@opengeni/observability": "^0.4.2",
|
|
56
|
+
"@opengeni/runtime": "^0.16.2",
|
|
57
|
+
"@opengeni/storage": "^0.2.52",
|
|
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:
|
|
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:
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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 ===
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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
|
|
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
|
|
893
|
+
previous,
|
|
890
894
|
task,
|
|
891
895
|
});
|
|
892
896
|
return json(task);
|
package/src/mcp/toolspace.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|
|
@@ -14,6 +14,7 @@ import { requireAccessGrant } from "@opengeni/core";
|
|
|
14
14
|
import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
|
|
15
15
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
16
16
|
import {
|
|
17
|
+
captureScheduledTaskRestoreState,
|
|
17
18
|
createValidatedScheduledTask,
|
|
18
19
|
manualScheduledTaskTriggerUsageKey,
|
|
19
20
|
manualScheduledTaskTriggerWorkflowId,
|
|
@@ -69,6 +70,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
69
70
|
const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
70
71
|
const taskId = c.req.param("taskId");
|
|
71
72
|
const existing = await requireScheduledTaskForApi(db, workspaceId, taskId);
|
|
73
|
+
const previous = await captureScheduledTaskRestoreState(db, existing);
|
|
72
74
|
const rawPayload = await c.req.json();
|
|
73
75
|
const payload = UpdateScheduledTaskRequest.parse(rawPayload);
|
|
74
76
|
const update = await validatedScheduledTaskUpdate({
|
|
@@ -81,7 +83,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
81
83
|
toolsProvided: scheduledTaskToolsProvided(rawPayload),
|
|
82
84
|
});
|
|
83
85
|
const task = await updateScheduledTask(db, workspaceId, taskId, update);
|
|
84
|
-
await syncUpdatedScheduledTask({ db, workflowClient, previous
|
|
86
|
+
await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
|
|
85
87
|
return c.json(task);
|
|
86
88
|
});
|
|
87
89
|
|
|
@@ -89,8 +91,9 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
89
91
|
const workspaceId = c.req.param("workspaceId");
|
|
90
92
|
await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
91
93
|
const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
94
|
+
const previous = await captureScheduledTaskRestoreState(db, existing);
|
|
92
95
|
const task = await updateScheduledTask(db, workspaceId, existing.id, { status: "paused" });
|
|
93
|
-
await syncUpdatedScheduledTask({ db, workflowClient, previous
|
|
96
|
+
await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
|
|
94
97
|
return c.json(task);
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -98,8 +101,9 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
98
101
|
const workspaceId = c.req.param("workspaceId");
|
|
99
102
|
await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
100
103
|
const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
104
|
+
const previous = await captureScheduledTaskRestoreState(db, existing);
|
|
101
105
|
const task = await updateScheduledTask(db, workspaceId, existing.id, { status: "active" });
|
|
102
|
-
await syncUpdatedScheduledTask({ db, workflowClient, previous
|
|
106
|
+
await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
|
|
103
107
|
return c.json(task);
|
|
104
108
|
});
|
|
105
109
|
|