@opengeni/api-router 0.12.1 → 0.12.5
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-S2N4252E.js → chunk-3BHOMOSD.js} +631 -169
- package/dist/chunk-3BHOMOSD.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +13 -5
- package/src/http/auth.ts +2 -1
- package/src/integrations/oauth-client.ts +94 -8
- package/src/integrations/slack-bot.ts +102 -9
- package/src/mcp/server.ts +20 -0
- package/src/mcp/toolspace.ts +57 -7
- package/src/routes/connections.ts +448 -103
- package/dist/chunk-S2N4252E.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
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.7",
|
|
47
|
-
"@opengeni/config": "^0.7.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.12.
|
|
50
|
-
"@opengeni/db": "^0.13.
|
|
51
|
-
"@opengeni/documents": "^0.2.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.3.
|
|
47
|
+
"@opengeni/config": "^0.7.10",
|
|
48
|
+
"@opengeni/contracts": "^0.21.0",
|
|
49
|
+
"@opengeni/core": "^0.12.5",
|
|
50
|
+
"@opengeni/db": "^0.13.4",
|
|
51
|
+
"@opengeni/documents": "^0.2.41",
|
|
52
|
+
"@opengeni/events": "^0.3.32",
|
|
53
|
+
"@opengeni/github": "^0.3.22",
|
|
54
54
|
"@opengeni/network": "^0.1.1",
|
|
55
55
|
"@opengeni/observability": "^0.3.0",
|
|
56
|
-
"@opengeni/runtime": "^0.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
56
|
+
"@opengeni/runtime": "^0.14.0",
|
|
57
|
+
"@opengeni/storage": "^0.2.34",
|
|
58
58
|
"@temporalio/client": "^1.17.0",
|
|
59
59
|
"better-auth": "^1.6.14",
|
|
60
60
|
"hono": "^4.12.18",
|
package/src/app.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
configuredAllowedModels,
|
|
4
4
|
configuredAllowedReasoningEfforts,
|
|
5
5
|
configuredModels,
|
|
6
|
+
withCodexCatalogProvider,
|
|
6
7
|
} from "@opengeni/config";
|
|
7
8
|
import {
|
|
8
9
|
ClientConfig,
|
|
@@ -322,18 +323,21 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
322
323
|
|
|
323
324
|
app.get("/v1/config/client", (c) => {
|
|
324
325
|
c.header("cache-control", "no-store");
|
|
326
|
+
const catalogSettings = deps.settings.codexSubscriptionEnabled
|
|
327
|
+
? withCodexCatalogProvider(deps.settings)
|
|
328
|
+
: deps.settings;
|
|
325
329
|
return c.json(
|
|
326
330
|
ClientConfig.parse({
|
|
327
331
|
deploymentRevision: deps.settings.deploymentRevision,
|
|
328
332
|
apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
|
|
329
333
|
...(deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {}),
|
|
330
|
-
defaultModel: canonicalizeConfiguredModelId(
|
|
331
|
-
allowedModels: configuredAllowedModels(
|
|
334
|
+
defaultModel: canonicalizeConfiguredModelId(catalogSettings, catalogSettings.openaiModel),
|
|
335
|
+
allowedModels: configuredAllowedModels(catalogSettings),
|
|
332
336
|
// Provider-grouped model list for the picker. configuredModels() carries the
|
|
333
337
|
// union of the built-in allow-list and every registry provider's models, in
|
|
334
338
|
// selection order (default model first); project each to the client-safe
|
|
335
339
|
// ClientModel shape (ConfiguredModel.providerId → ClientModel.provider).
|
|
336
|
-
models: configuredModels(
|
|
340
|
+
models: configuredModels(catalogSettings).map(projectClientModel),
|
|
337
341
|
defaultReasoningEffort: deps.settings.openaiReasoningEffort,
|
|
338
342
|
allowedReasoningEfforts: configuredAllowedReasoningEfforts(deps.settings),
|
|
339
343
|
mcpServers: deps.settings.mcpServers.map((server) => ({
|
|
@@ -957,8 +961,8 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
957
961
|
label: "/v1/workspaces/:workspaceId/connections/oauth/start",
|
|
958
962
|
},
|
|
959
963
|
{
|
|
960
|
-
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
|
|
961
|
-
label: "/v1/workspaces/:workspaceId/connections/slack-bot",
|
|
964
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot\/install$/,
|
|
965
|
+
label: "/v1/workspaces/:workspaceId/connections/slack-bot/install",
|
|
962
966
|
},
|
|
963
967
|
{
|
|
964
968
|
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
|
|
@@ -973,6 +977,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
973
977
|
pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/,
|
|
974
978
|
label: "/v1/integrations/oauth/client-metadata.json",
|
|
975
979
|
},
|
|
980
|
+
{
|
|
981
|
+
pattern: /^\/v1\/integrations\/slack\/callback$/,
|
|
982
|
+
label: "/v1/integrations/slack/callback",
|
|
983
|
+
},
|
|
976
984
|
{
|
|
977
985
|
pattern: /^\/v1\/enrollments\/device\/start$/,
|
|
978
986
|
label: "/v1/enrollments/device/start",
|
package/src/http/auth.ts
CHANGED
|
@@ -49,7 +49,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
49
49
|
}
|
|
50
50
|
if (
|
|
51
51
|
path === "/v1/integrations/oauth/callback" ||
|
|
52
|
-
path === "/v1/integrations/oauth/client-metadata.json"
|
|
52
|
+
path === "/v1/integrations/oauth/client-metadata.json" ||
|
|
53
|
+
path === "/v1/integrations/slack/callback"
|
|
53
54
|
) {
|
|
54
55
|
return true;
|
|
55
56
|
}
|
|
@@ -3,7 +3,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
3
3
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
4
|
import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
|
|
5
5
|
import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
|
|
6
|
-
import { requireEnvironmentEncryption } from "@opengeni/core";
|
|
6
|
+
import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
|
|
7
7
|
import type { Observability } from "@opengeni/observability";
|
|
8
8
|
import {
|
|
9
9
|
consumeIntegrationOAuthStateNonce,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
decryptEnvironmentValue,
|
|
12
12
|
encryptEnvironmentValue,
|
|
13
13
|
getConnectionMetadata,
|
|
14
|
+
getWorkspaceGrant,
|
|
14
15
|
listConnectionsMetadata,
|
|
15
16
|
loadIntegrationOAuthClient,
|
|
16
17
|
normalizeBearerScheme,
|
|
@@ -33,6 +34,8 @@ import { HTTPException } from "hono/http-exception";
|
|
|
33
34
|
import { canonicalProviderDomain } from "./provider-domain";
|
|
34
35
|
|
|
35
36
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
37
|
+
export const OFFICIAL_SLACK_MCP_URL = "https://mcp.slack.com/mcp";
|
|
38
|
+
const SLACK_OAUTH_ORIGIN = "https://slack.com";
|
|
36
39
|
export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
|
|
37
40
|
|
|
38
41
|
type OAuthClientDeps = {
|
|
@@ -139,9 +142,12 @@ export async function startMcpOAuth(
|
|
|
139
142
|
): Promise<OAuthStartResponse> {
|
|
140
143
|
const { db, settings } = deps;
|
|
141
144
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
|
|
146
|
+
const providerDomain = officialSlackResource
|
|
147
|
+
? "slack.com"
|
|
148
|
+
: canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
149
|
+
const personalSlack = officialSlackResource || providerDomain === "slack.com";
|
|
150
|
+
assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
|
|
145
151
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
146
152
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
147
153
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -157,6 +163,9 @@ export async function startMcpOAuth(
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
166
|
+
if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
|
|
167
|
+
assertSlackAuthorizationServer(discovery.as);
|
|
168
|
+
}
|
|
160
169
|
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
161
170
|
const verifier = randomPkceVerifier();
|
|
162
171
|
const authorizeScopes = chooseAuthorizeScopes(
|
|
@@ -230,6 +239,7 @@ export async function completeMcpOAuthCallback(
|
|
|
230
239
|
}
|
|
231
240
|
try {
|
|
232
241
|
state = readOAuthState(input.state, settings);
|
|
242
|
+
await requireOAuthCallbackGrant(db, state);
|
|
233
243
|
if (!input.code) {
|
|
234
244
|
return {
|
|
235
245
|
redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
|
|
@@ -287,6 +297,7 @@ export async function completeMcpOAuthCallback(
|
|
|
287
297
|
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
288
298
|
};
|
|
289
299
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
300
|
+
await requireOAuthCallbackGrant(db, state);
|
|
290
301
|
const connection = await runCallbackStage("persist", "persist_failed", () =>
|
|
291
302
|
state.connectionId
|
|
292
303
|
? updateConnection(db, {
|
|
@@ -294,6 +305,7 @@ export async function completeMcpOAuthCallback(
|
|
|
294
305
|
connectionId: state.connectionId,
|
|
295
306
|
visibleToSubjectId: state.subjectId,
|
|
296
307
|
expectedVersion: state.connectionVersion,
|
|
308
|
+
subjectId: state.subjectId,
|
|
297
309
|
providerDomain: state.providerDomain,
|
|
298
310
|
kind: "oauth2",
|
|
299
311
|
status: "active",
|
|
@@ -306,7 +318,7 @@ export async function completeMcpOAuthCallback(
|
|
|
306
318
|
: createConnection(db, {
|
|
307
319
|
accountId: state.accountId,
|
|
308
320
|
workspaceId: state.workspaceId,
|
|
309
|
-
subjectId:
|
|
321
|
+
subjectId: state.subjectId,
|
|
310
322
|
providerDomain: state.providerDomain,
|
|
311
323
|
kind: "oauth2",
|
|
312
324
|
credentialEncrypted,
|
|
@@ -356,6 +368,56 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
|
|
|
356
368
|
return secret;
|
|
357
369
|
}
|
|
358
370
|
|
|
371
|
+
async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
|
|
372
|
+
const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
|
|
373
|
+
if (
|
|
374
|
+
!grant ||
|
|
375
|
+
grant.accountId !== state.accountId ||
|
|
376
|
+
!hasPermission(grant.permissions, "connections:write")
|
|
377
|
+
) {
|
|
378
|
+
throw new HTTPException(403, {
|
|
379
|
+
message: "OAuth subject no longer has permission to write this workspace connection",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertPersonalSlackOAuthStart(
|
|
385
|
+
settings: Settings,
|
|
386
|
+
payload: OAuthStartRequest,
|
|
387
|
+
mcpUrl: string,
|
|
388
|
+
personalSlack: boolean,
|
|
389
|
+
): void {
|
|
390
|
+
if (!personalSlack) return;
|
|
391
|
+
if (payload.oauthClient) {
|
|
392
|
+
throw new HTTPException(422, {
|
|
393
|
+
message: "Slack OAuth client credentials are deployment-managed",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
|
|
397
|
+
throw new HTTPException(422, { message: "Slack provider identity does not match slack.com" });
|
|
398
|
+
}
|
|
399
|
+
if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
|
|
400
|
+
throw new HTTPException(422, {
|
|
401
|
+
message: `personal Slack OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
|
|
405
|
+
throw new HTTPException(503, {
|
|
406
|
+
message:
|
|
407
|
+
"personal Slack OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
|
|
413
|
+
const urls = [as.issuer, as.authorizationServer, as.authorizationEndpoint, as.tokenEndpoint];
|
|
414
|
+
if (urls.some((value) => new URL(value).origin !== SLACK_OAUTH_ORIGIN)) {
|
|
415
|
+
throw new HTTPException(422, {
|
|
416
|
+
message: "Slack MCP authorization metadata did not remain bound to slack.com",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
359
421
|
async function discoverMcpOAuth(
|
|
360
422
|
resource: string,
|
|
361
423
|
settings: Settings,
|
|
@@ -656,6 +718,18 @@ function operatorClientEntryFor(
|
|
|
656
718
|
settings: Settings,
|
|
657
719
|
candidates: string[],
|
|
658
720
|
): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
|
|
721
|
+
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
722
|
+
if (
|
|
723
|
+
normalizedCandidates.has(SLACK_OAUTH_ORIGIN) &&
|
|
724
|
+
settings.slackClientId?.trim() &&
|
|
725
|
+
settings.slackClientSecret?.trim()
|
|
726
|
+
) {
|
|
727
|
+
return {
|
|
728
|
+
clientId: settings.slackClientId.trim(),
|
|
729
|
+
clientSecret: settings.slackClientSecret.trim(),
|
|
730
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
731
|
+
};
|
|
732
|
+
}
|
|
659
733
|
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
660
734
|
const exactKeys = uniqueStrings(
|
|
661
735
|
candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
|
|
@@ -666,7 +740,6 @@ function operatorClientEntryFor(
|
|
|
666
740
|
return entry;
|
|
667
741
|
}
|
|
668
742
|
}
|
|
669
|
-
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
670
743
|
for (const [key, entry] of Object.entries(configured)) {
|
|
671
744
|
if (normalizedCandidates.has(normalizedIssuerKey(key))) {
|
|
672
745
|
return entry;
|
|
@@ -743,13 +816,23 @@ async function existingOAuthConnectionForStart(
|
|
|
743
816
|
},
|
|
744
817
|
) {
|
|
745
818
|
if (input.connectionId) {
|
|
746
|
-
|
|
819
|
+
const connection = await getConnectionMetadata(
|
|
820
|
+
db,
|
|
821
|
+
input.workspaceId,
|
|
822
|
+
input.connectionId,
|
|
823
|
+
input.subjectId,
|
|
824
|
+
);
|
|
825
|
+
return connection?.subjectId === input.subjectId &&
|
|
826
|
+
connection.kind === "oauth2" &&
|
|
827
|
+
connection.providerDomain === input.providerDomain
|
|
828
|
+
? connection
|
|
829
|
+
: null;
|
|
747
830
|
}
|
|
748
831
|
const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
|
|
749
832
|
return (
|
|
750
833
|
visible.find(
|
|
751
834
|
(connection) =>
|
|
752
|
-
connection.subjectId ===
|
|
835
|
+
connection.subjectId === input.subjectId &&
|
|
753
836
|
connection.kind === "oauth2" &&
|
|
754
837
|
connection.status === "active" &&
|
|
755
838
|
connection.providerDomain === input.providerDomain,
|
|
@@ -836,6 +919,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
836
919
|
};
|
|
837
920
|
const connectionId = stringValue(payload.connectionId);
|
|
838
921
|
const connectionVersion = numberValue(payload.connectionVersion);
|
|
922
|
+
if (Boolean(connectionId) !== Boolean(connectionVersion)) {
|
|
923
|
+
throw new HTTPException(400, { message: "invalid OAuth reconnect state" });
|
|
924
|
+
}
|
|
839
925
|
return {
|
|
840
926
|
...parsed,
|
|
841
927
|
...(connectionId ? { connectionId } : {}),
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getSession,
|
|
23
23
|
recordAuditEvent,
|
|
24
24
|
releaseSlackBotPostOperationClaim,
|
|
25
|
+
setConnectionStatus,
|
|
25
26
|
type Database,
|
|
26
27
|
} from "@opengeni/db";
|
|
27
28
|
import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
|
|
@@ -43,6 +44,55 @@ export type VerifiedOpenGeniSlackBot = {
|
|
|
43
44
|
metadata: OpenGeniSlackBotConnectionMetadata;
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
48
|
+
input: {
|
|
49
|
+
code: string;
|
|
50
|
+
clientId: string;
|
|
51
|
+
clientSecret: string;
|
|
52
|
+
redirectUri: string;
|
|
53
|
+
},
|
|
54
|
+
fetchImpl: FetchLike = fetch,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const body = new URLSearchParams({
|
|
57
|
+
code: input.code,
|
|
58
|
+
client_id: input.clientId,
|
|
59
|
+
client_secret: input.clientSecret,
|
|
60
|
+
redirect_uri: input.redirectUri,
|
|
61
|
+
});
|
|
62
|
+
let response: Response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetchImpl(`${SLACK_API_BASE}oauth.v2.access`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
accept: "application/json",
|
|
68
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
69
|
+
},
|
|
70
|
+
body: body.toString(),
|
|
71
|
+
redirect: "error",
|
|
72
|
+
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
throw new HTTPException(502, { message: "Slack installation token exchange failed" });
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new HTTPException(502, { message: "Slack installation token exchange failed" });
|
|
79
|
+
}
|
|
80
|
+
const payload = await readResponseJsonBounded<unknown>(
|
|
81
|
+
response,
|
|
82
|
+
SLACK_RESPONSE_MAX_BYTES,
|
|
83
|
+
"Slack OAuth response",
|
|
84
|
+
);
|
|
85
|
+
const record = slackRecord(payload);
|
|
86
|
+
if (!record || record.ok !== true) {
|
|
87
|
+
throw new SlackBotProviderError(slackString(record?.error) || "oauth_exchange_failed");
|
|
88
|
+
}
|
|
89
|
+
const accessToken = slackString(record.access_token);
|
|
90
|
+
if (!accessToken?.startsWith("xoxb-")) {
|
|
91
|
+
throw new HTTPException(502, { message: "Slack installation did not return a bot token" });
|
|
92
|
+
}
|
|
93
|
+
return accessToken;
|
|
94
|
+
}
|
|
95
|
+
|
|
46
96
|
export type SlackBotReceipt = {
|
|
47
97
|
credentialRole: typeof OPENGENI_SLACK_BOT_CREDENTIAL_ROLE;
|
|
48
98
|
credentialLabel: typeof OPENGENI_SLACK_BOT_CREDENTIAL_LABEL;
|
|
@@ -70,6 +120,30 @@ export class SlackBotProviderError extends Error {
|
|
|
70
120
|
}
|
|
71
121
|
}
|
|
72
122
|
|
|
123
|
+
const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
|
|
124
|
+
"account_inactive",
|
|
125
|
+
"invalid_auth",
|
|
126
|
+
"not_authed",
|
|
127
|
+
"token_expired",
|
|
128
|
+
"token_revoked",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
function slackCredentialRejected(error: unknown): error is SlackBotProviderError {
|
|
132
|
+
return error instanceof SlackBotProviderError && SLACK_CREDENTIAL_REJECTION_CODES.has(error.code);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch";
|
|
136
|
+
|
|
137
|
+
export class SlackBotCredentialVerificationError extends HTTPException {
|
|
138
|
+
constructor(
|
|
139
|
+
readonly failureReason: SlackBotCredentialVerificationFailureReason,
|
|
140
|
+
message: string,
|
|
141
|
+
) {
|
|
142
|
+
super(422, { message });
|
|
143
|
+
this.name = "SlackBotCredentialVerificationError";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
73
147
|
/**
|
|
74
148
|
* Validates a write-only xoxb credential before it can enter encrypted storage.
|
|
75
149
|
* Slack does not expose the app's display_information name to this scope set;
|
|
@@ -95,14 +169,18 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
95
169
|
});
|
|
96
170
|
const user = slackRecord(userResponse.payload.user);
|
|
97
171
|
if (!user || user.is_bot !== true || user.deleted === true) {
|
|
98
|
-
throw new
|
|
172
|
+
throw new SlackBotCredentialVerificationError(
|
|
173
|
+
"identity_mismatch",
|
|
174
|
+
"Slack credential must identify an active bot user",
|
|
175
|
+
);
|
|
99
176
|
}
|
|
100
177
|
const profile = slackRecord(user.profile);
|
|
101
178
|
const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
|
|
102
179
|
if (displayName !== "OpenGeni") {
|
|
103
|
-
throw new
|
|
104
|
-
|
|
105
|
-
|
|
180
|
+
throw new SlackBotCredentialVerificationError(
|
|
181
|
+
"identity_mismatch",
|
|
182
|
+
'Slack bot display name must be exactly "OpenGeni"',
|
|
183
|
+
);
|
|
106
184
|
}
|
|
107
185
|
|
|
108
186
|
return {
|
|
@@ -355,7 +433,18 @@ export class OpenGeniSlackBotClient {
|
|
|
355
433
|
method: string,
|
|
356
434
|
params: Record<string, string>,
|
|
357
435
|
): Promise<SlackPayload> {
|
|
358
|
-
|
|
436
|
+
try {
|
|
437
|
+
return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (slackCredentialRejected(error)) {
|
|
440
|
+
await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
|
|
441
|
+
id: this.connection.id,
|
|
442
|
+
version: this.connection.version,
|
|
443
|
+
subjectId: null,
|
|
444
|
+
}).catch(() => false);
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
359
448
|
}
|
|
360
449
|
|
|
361
450
|
private async withAudit<T extends Record<string, unknown>>(
|
|
@@ -561,15 +650,19 @@ function assertExactOpenGeniSlackBotScopes(grantedScopes: string[]): void {
|
|
|
561
650
|
...(forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : []),
|
|
562
651
|
...(unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []),
|
|
563
652
|
];
|
|
564
|
-
throw new
|
|
565
|
-
|
|
566
|
-
|
|
653
|
+
throw new SlackBotCredentialVerificationError(
|
|
654
|
+
"scope_mismatch",
|
|
655
|
+
`Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
|
|
656
|
+
);
|
|
567
657
|
}
|
|
568
658
|
}
|
|
569
659
|
|
|
570
660
|
function parseGrantedScopes(header: string | null): string[] {
|
|
571
661
|
if (!header) {
|
|
572
|
-
throw new
|
|
662
|
+
throw new SlackBotCredentialVerificationError(
|
|
663
|
+
"scope_mismatch",
|
|
664
|
+
"Slack did not report granted bot scopes",
|
|
665
|
+
);
|
|
573
666
|
}
|
|
574
667
|
return [
|
|
575
668
|
...new Set(
|
package/src/mcp/server.ts
CHANGED
|
@@ -776,6 +776,26 @@ function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSur
|
|
|
776
776
|
if (!surface) {
|
|
777
777
|
return;
|
|
778
778
|
}
|
|
779
|
+
// McpServer installs its tools/list handler lazily on the first registered
|
|
780
|
+
// tool. A legitimate empty Toolspace surface (no selected proxyable servers,
|
|
781
|
+
// no active turn, or all optional upstreams unavailable) must therefore seed
|
|
782
|
+
// and disable one invisible tool; otherwise `ogtool list` receives JSON-RPC
|
|
783
|
+
// "Method not found" instead of the valid `{ tools: [] }` response.
|
|
784
|
+
if (surface.tools.length === 0) {
|
|
785
|
+
server
|
|
786
|
+
.registerTool(
|
|
787
|
+
"__opengeni_empty_toolspace_surface__",
|
|
788
|
+
{
|
|
789
|
+
description: "Internal disabled placeholder for an empty Toolspace surface.",
|
|
790
|
+
inputSchema: z4.object({}),
|
|
791
|
+
},
|
|
792
|
+
async () => ({
|
|
793
|
+
content: [{ type: "text" as const, text: '{"unavailable":true}' }],
|
|
794
|
+
}),
|
|
795
|
+
)
|
|
796
|
+
.disable();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
779
799
|
for (const tool of surface.tools) {
|
|
780
800
|
server.registerTool(
|
|
781
801
|
tool.name,
|
package/src/mcp/toolspace.ts
CHANGED
|
@@ -367,7 +367,13 @@ async function resolveToolListing(input: {
|
|
|
367
367
|
sessionId,
|
|
368
368
|
rootSessionId,
|
|
369
369
|
turn: activeTurn,
|
|
370
|
-
}).catch(() =>
|
|
370
|
+
}).catch((error) => {
|
|
371
|
+
deps.observability?.warn("toolspace upstream connection failed", {
|
|
372
|
+
serverId,
|
|
373
|
+
...toolspaceErrorAttributes(error),
|
|
374
|
+
});
|
|
375
|
+
return null;
|
|
376
|
+
});
|
|
371
377
|
if (!connection) {
|
|
372
378
|
aggregateBudget.replace(serverId, []);
|
|
373
379
|
return;
|
|
@@ -375,7 +381,13 @@ async function resolveToolListing(input: {
|
|
|
375
381
|
try {
|
|
376
382
|
const listed = await connection.client
|
|
377
383
|
.listTools(undefined, toolspaceRequestOptions(config))
|
|
378
|
-
.catch(() =>
|
|
384
|
+
.catch((error) => {
|
|
385
|
+
deps.observability?.warn("toolspace upstream tool list failed", {
|
|
386
|
+
serverId,
|
|
387
|
+
...toolspaceErrorAttributes(error),
|
|
388
|
+
});
|
|
389
|
+
return { tools: [] };
|
|
390
|
+
});
|
|
379
391
|
let boundedTools: readonly McpTool[];
|
|
380
392
|
try {
|
|
381
393
|
boundedTools = assertMcpToolListWithinBounds(listed.tools as McpTool[]) as McpTool[];
|
|
@@ -494,7 +506,18 @@ async function connectToolspaceServer(input: {
|
|
|
494
506
|
rootSessionId: string;
|
|
495
507
|
turn: SessionTurn;
|
|
496
508
|
}): Promise<ConnectedToolspaceServer> {
|
|
497
|
-
|
|
509
|
+
// npm Undici's dispatcher transport is not reliable under Bun. The worker's
|
|
510
|
+
// model-visible MCP path already uses Bun's native fetch while retaining the
|
|
511
|
+
// same pre-request destination-policy check; Toolspace must do the same or an
|
|
512
|
+
// embedded Bun API can expose a server to the model while silently dropping
|
|
513
|
+
// that exact server from `ogtool list`.
|
|
514
|
+
const useBunNativeFetch = !!process.versions.bun;
|
|
515
|
+
const mcpFetchImpl: FetchLike = useBunNativeFetch
|
|
516
|
+
? globalThis.fetch.bind(globalThis)
|
|
517
|
+
: undiciFetch;
|
|
518
|
+
const guardedFetch = guardedMcpFetch(input.deps.settings, mcpFetchImpl, {
|
|
519
|
+
...(useBunNativeFetch ? { pinResolvedDestination: false } : {}),
|
|
520
|
+
});
|
|
498
521
|
const baseFetch: FetchLike = input.config.connectionRef
|
|
499
522
|
? connectionBrokerFetch(guardedFetch, input)
|
|
500
523
|
: guardedFetch;
|
|
@@ -730,6 +753,24 @@ function toolspaceAuditSummary(value: unknown): {
|
|
|
730
753
|
};
|
|
731
754
|
}
|
|
732
755
|
|
|
756
|
+
function toolspaceErrorAttributes(error: unknown): {
|
|
757
|
+
errorClass: string;
|
|
758
|
+
errorCode?: string;
|
|
759
|
+
errorMessage?: string;
|
|
760
|
+
} {
|
|
761
|
+
if (!(error instanceof Error)) {
|
|
762
|
+
return { errorClass: typeof error };
|
|
763
|
+
}
|
|
764
|
+
const code = (error as Error & { code?: unknown }).code;
|
|
765
|
+
return {
|
|
766
|
+
errorClass: error.name,
|
|
767
|
+
...(typeof code === "string" ? { errorCode: code } : {}),
|
|
768
|
+
...(error.message
|
|
769
|
+
? { errorMessage: error.message.replaceAll(/[\r\n]+/gu, " ").slice(0, 512) }
|
|
770
|
+
: {}),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
733
774
|
type ToolspaceReservation =
|
|
734
775
|
| { status: "ok"; turn: SessionTurn }
|
|
735
776
|
| { status: "no_active_turn" }
|
|
@@ -844,8 +885,17 @@ export function connectionBrokerFetch(
|
|
|
844
885
|
if (!connectionRef) {
|
|
845
886
|
return baseFetch;
|
|
846
887
|
}
|
|
847
|
-
const
|
|
848
|
-
?
|
|
888
|
+
const credentialSubjectId =
|
|
889
|
+
input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : undefined;
|
|
890
|
+
if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
|
|
891
|
+
throw new Error(
|
|
892
|
+
`subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
|
|
896
|
+
const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
|
|
897
|
+
const resolveCredential = hostCredentialPort
|
|
898
|
+
? buildHostConnectionTokenResolver(hostCredentialPort, {
|
|
849
899
|
accountId: input.grant.accountId,
|
|
850
900
|
workspaceId: input.grant.workspaceId,
|
|
851
901
|
sessionId: input.sessionId,
|
|
@@ -868,7 +918,7 @@ export function connectionBrokerFetch(
|
|
|
868
918
|
destinationUrl,
|
|
869
919
|
forceRefresh: false,
|
|
870
920
|
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
871
|
-
subjectId:
|
|
921
|
+
...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
|
|
872
922
|
});
|
|
873
923
|
if (first.status === "auth_needed") {
|
|
874
924
|
return await authNeededFetchResponse(input, request, first);
|
|
@@ -886,7 +936,7 @@ export function connectionBrokerFetch(
|
|
|
886
936
|
destinationUrl,
|
|
887
937
|
forceRefresh: true,
|
|
888
938
|
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
889
|
-
subjectId:
|
|
939
|
+
...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
|
|
890
940
|
});
|
|
891
941
|
if (refreshed.status === "auth_needed") {
|
|
892
942
|
return await authNeededFetchResponse(input, request, refreshed);
|