@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001
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.d.ts +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- package/src/workspace-tool-gateway.ts +1 -0
|
@@ -48,8 +48,10 @@ import {
|
|
|
48
48
|
} from "../connection-ownership";
|
|
49
49
|
import {
|
|
50
50
|
integrationBaseUrl,
|
|
51
|
+
oauthStateFailureReturn,
|
|
51
52
|
oauthStateTtlMs,
|
|
52
53
|
requireIntegrationsStateSecret,
|
|
54
|
+
workspaceIntegrationsPath,
|
|
53
55
|
} from "./oauth-client";
|
|
54
56
|
|
|
55
57
|
const PERSONAL_GITHUB_OAUTH_STATE_KIND = "personal_github_oauth";
|
|
@@ -536,7 +538,7 @@ export async function completePersonalGitHubOAuthCallback(
|
|
|
536
538
|
return {
|
|
537
539
|
redirectTo: personalGitHubCallbackReturnUrl(
|
|
538
540
|
returnBaseUrl,
|
|
539
|
-
state?.returnPath ??
|
|
541
|
+
state?.returnPath ?? oauthStateFailureReturn(deps.settings, input.state).returnPath,
|
|
540
542
|
"error",
|
|
541
543
|
{ reason: personalGitHubFailureReason(error) },
|
|
542
544
|
),
|
|
@@ -752,7 +754,7 @@ function parseGitHubScopes(raw: string | null): string[] {
|
|
|
752
754
|
}
|
|
753
755
|
|
|
754
756
|
function personalGitHubReturnPath(workspaceId: string, raw?: string): string {
|
|
755
|
-
const fallback =
|
|
757
|
+
const fallback = workspaceIntegrationsPath(workspaceId);
|
|
756
758
|
if (!raw) return fallback;
|
|
757
759
|
let parsed: URL;
|
|
758
760
|
try {
|
|
@@ -54,8 +54,10 @@ import {
|
|
|
54
54
|
} from "../connection-ownership";
|
|
55
55
|
import {
|
|
56
56
|
integrationBaseUrl,
|
|
57
|
+
oauthStateFailureReturn,
|
|
57
58
|
oauthStateTtlMs,
|
|
58
59
|
requireIntegrationsStateSecret,
|
|
60
|
+
workspaceIntegrationsPath,
|
|
59
61
|
} from "./oauth-client";
|
|
60
62
|
|
|
61
63
|
const PROVIDER_OAUTH_CALLBACK_PATH = "/v1/integrations/provider-oauth/callback";
|
|
@@ -109,6 +111,7 @@ type ProviderIdentity = {
|
|
|
109
111
|
|
|
110
112
|
type ProviderOAuthFailureReason =
|
|
111
113
|
| "state_invalid"
|
|
114
|
+
| "state_expired"
|
|
112
115
|
| "state_replayed"
|
|
113
116
|
| "provider_denied"
|
|
114
117
|
| "missing_code"
|
|
@@ -202,7 +205,7 @@ export async function startApiIntegrationProviderOAuth(
|
|
|
202
205
|
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
203
206
|
const redirectUri = `${baseUrl}${providerOAuthCallbackPath(definition)}`;
|
|
204
207
|
const returnPath = safeReturnPath(
|
|
205
|
-
input.payload.returnPath ??
|
|
208
|
+
input.payload.returnPath ?? workspaceIntegrationsPath(input.workspaceId),
|
|
206
209
|
);
|
|
207
210
|
const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
|
|
208
211
|
accountId: input.accountId,
|
|
@@ -517,14 +520,18 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
517
520
|
};
|
|
518
521
|
} catch (error) {
|
|
519
522
|
if (exactReturnUrl !== undefined) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
523
|
+
const reason = providerOAuthFailureReason(error);
|
|
524
|
+
// An unreadable state carries no return path; an authentic but aged one
|
|
525
|
+
// still names its workspace and is reported as expired.
|
|
526
|
+
const failure = state ? null : oauthStateFailureReturn(deps.settings, input.state);
|
|
520
527
|
return {
|
|
521
528
|
redirectTo: providerOAuthReturnUrl(
|
|
522
529
|
returnBaseUrl,
|
|
523
|
-
state?.returnPath ??
|
|
530
|
+
state?.returnPath ?? failure!.returnPath,
|
|
524
531
|
"error",
|
|
525
532
|
{
|
|
526
533
|
...(state?.definitionId ? { definitionId: state.definitionId } : {}),
|
|
527
|
-
reason:
|
|
534
|
+
reason: failure && reason === "state_invalid" ? failure.reason : reason,
|
|
528
535
|
},
|
|
529
536
|
),
|
|
530
537
|
};
|
|
@@ -43,11 +43,14 @@ import {
|
|
|
43
43
|
type FetchLike,
|
|
44
44
|
} from "@opengeni/network";
|
|
45
45
|
import { HTTPException } from "hono/http-exception";
|
|
46
|
+
import sharp from "sharp";
|
|
46
47
|
|
|
47
48
|
const SLACK_API_BASE = "https://slack.com/api/";
|
|
48
49
|
const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
49
50
|
const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
|
|
50
51
|
export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
|
|
52
|
+
// Base64 plus MCP metadata must stay below the runtime's 1 MiB model-result cap.
|
|
53
|
+
export const SLACK_MCP_IMAGE_MAX_BYTES = 640 * 1024;
|
|
51
54
|
const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
|
|
52
55
|
const SLACK_TIMEOUT_MS = 10_000;
|
|
53
56
|
const MAX_CHANNEL_PAGE = 200;
|
|
@@ -991,6 +994,35 @@ export class OpenGeniSlackBotClient {
|
|
|
991
994
|
"file.content.read",
|
|
992
995
|
input,
|
|
993
996
|
);
|
|
997
|
+
if (SLACK_REACTION_IMAGE_MIME_TYPES.has(normalizedContentType(file.mimetype))) {
|
|
998
|
+
if (input.offset !== undefined && input.offset !== 0) {
|
|
999
|
+
throw new SlackBotProviderError("invalid_file_offset");
|
|
1000
|
+
}
|
|
1001
|
+
// A context file is read only on explicit request. Keep the same
|
|
1002
|
+
// non-shared channel boundary and byte validation as invocation images.
|
|
1003
|
+
await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
|
|
1004
|
+
if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
|
|
1005
|
+
throw new SlackBotProviderError("file_not_shared_to_channel");
|
|
1006
|
+
}
|
|
1007
|
+
if (file.size !== null && (file.size < 1 || file.size > SLACK_REACTION_IMAGE_MAX_BYTES)) {
|
|
1008
|
+
throw new SlackBotProviderError("invalid_file_size");
|
|
1009
|
+
}
|
|
1010
|
+
if (file.size !== null && file.size > SLACK_MCP_IMAGE_MAX_BYTES) {
|
|
1011
|
+
throw new SlackBotProviderError("image_result_too_large");
|
|
1012
|
+
}
|
|
1013
|
+
const image = await this.downloadReactionImage({
|
|
1014
|
+
fileId: file.id,
|
|
1015
|
+
filename: file.name || file.title || file.id,
|
|
1016
|
+
declaredMimeType: normalizedContentType(file.mimetype),
|
|
1017
|
+
declaredSizeBytes: file.size,
|
|
1018
|
+
downloadUrl: privateSlackFileUrl(fileRecord),
|
|
1019
|
+
});
|
|
1020
|
+
if (image.bytes.byteLength > SLACK_MCP_IMAGE_MAX_BYTES) {
|
|
1021
|
+
throw new SlackBotProviderError("image_result_too_large");
|
|
1022
|
+
}
|
|
1023
|
+
await validateSlackMcpImage(image.bytes);
|
|
1024
|
+
return { channel: info, file, image };
|
|
1025
|
+
}
|
|
994
1026
|
const embeddedTranscript = embeddedHuddleTranscription(fileRecord, parentFileRecord);
|
|
995
1027
|
const { contentType, content } =
|
|
996
1028
|
embeddedTranscript ?? (await this.readPrivateFileText(fileRecord, "file.content.read"));
|
|
@@ -1718,7 +1750,8 @@ export class OpenGeniSlackBotClient {
|
|
|
1718
1750
|
method: "GET",
|
|
1719
1751
|
headers: {
|
|
1720
1752
|
...headers,
|
|
1721
|
-
accept:
|
|
1753
|
+
accept:
|
|
1754
|
+
"image/png, image/jpeg, image/webp, text/*, application/json, application/xml, application/xhtml+xml",
|
|
1722
1755
|
},
|
|
1723
1756
|
redirect: "manual",
|
|
1724
1757
|
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
@@ -1921,6 +1954,15 @@ export class OpenGeniSlackBotClient {
|
|
|
1921
1954
|
}
|
|
1922
1955
|
}
|
|
1923
1956
|
|
|
1957
|
+
/** Full decode guards the model image block against header-only or corrupt files. */
|
|
1958
|
+
export async function validateSlackMcpImage(bytes: Uint8Array): Promise<void> {
|
|
1959
|
+
try {
|
|
1960
|
+
await sharp(bytes, { limitInputPixels: 16_000_000, failOn: "error" }).stats();
|
|
1961
|
+
} catch {
|
|
1962
|
+
throw new SlackBotProviderError("invalid_file_content");
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1924
1966
|
export function createOpenGeniSlackBotClient(
|
|
1925
1967
|
deps: {
|
|
1926
1968
|
db: Database;
|
|
@@ -36,8 +36,10 @@ import { ApiHttpError } from "../http/api-error";
|
|
|
36
36
|
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
37
37
|
import {
|
|
38
38
|
integrationBaseUrl,
|
|
39
|
+
oauthStateFailureReturn,
|
|
39
40
|
oauthStateTtlMs,
|
|
40
41
|
requireIntegrationsStateSecret,
|
|
42
|
+
workspaceIntegrationsPath,
|
|
41
43
|
} from "./oauth-client";
|
|
42
44
|
|
|
43
45
|
// Reddit requires a descriptive, stable User-Agent on every request (token
|
|
@@ -200,7 +202,9 @@ export async function startSocialOAuth(
|
|
|
200
202
|
await withOrganizationIntegrationAcquisition(deps.db, context, [provider.id], async () => {});
|
|
201
203
|
const client = socialOAuthClientFor(settings, provider.id);
|
|
202
204
|
const redirectUri = socialOAuthRedirectUri(settings, context.requestUrl);
|
|
203
|
-
const returnPath = safeReturnPath(
|
|
205
|
+
const returnPath = safeReturnPath(
|
|
206
|
+
context.payload.returnPath ?? workspaceIntegrationsPath(context.workspaceId),
|
|
207
|
+
);
|
|
204
208
|
const scopes = uniqueScopes(context.payload.scopes) ?? provider.defaultScopes;
|
|
205
209
|
const verifier = provider.pkce ? randomBytes(32).toString("base64url") : null;
|
|
206
210
|
// Require the encryption key for every provider (not just PKCE ones) so a
|
|
@@ -260,8 +264,9 @@ export async function completeSocialOAuthCallback(
|
|
|
260
264
|
state = readSocialOAuthState(input.state, settings);
|
|
261
265
|
} catch (error) {
|
|
262
266
|
logSocialOAuthFailure(observability, "state_verify", state, error);
|
|
267
|
+
const failure = oauthStateFailureReturn(settings, input.state);
|
|
263
268
|
return {
|
|
264
|
-
redirectTo: callbackReturnPath(
|
|
269
|
+
redirectTo: callbackReturnPath(failure.returnPath, "error", { reason: failure.reason }),
|
|
265
270
|
};
|
|
266
271
|
}
|
|
267
272
|
if (state.connectAttemptId) return completeSocialConnect(deps, state, input);
|
package/src/mcp/documents.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { searchKnowledgeEntries } from "@opengeni/core";
|
|
2
|
+
import type { Settings } from "@opengeni/config";
|
|
2
3
|
import { KnowledgeEntryListRequest } from "@opengeni/contracts";
|
|
3
4
|
import type { DocumentServices } from "@opengeni/documents";
|
|
4
5
|
import {
|
|
@@ -17,7 +18,7 @@ export function buildDocumentsMcpServer(
|
|
|
17
18
|
accountId: string,
|
|
18
19
|
workspaceId: string,
|
|
19
20
|
documentServices: DocumentServices,
|
|
20
|
-
options: { knowledge: KnowledgeContext },
|
|
21
|
+
options: { knowledge: KnowledgeContext; settings?: Settings },
|
|
21
22
|
): McpServer {
|
|
22
23
|
const context = options.knowledge;
|
|
23
24
|
if (context.accountId !== accountId || context.workspaceId !== workspaceId)
|
|
@@ -39,7 +40,13 @@ export function buildDocumentsMcpServer(
|
|
|
39
40
|
{
|
|
40
41
|
type: "text",
|
|
41
42
|
text: JSON.stringify(
|
|
42
|
-
await searchKnowledgeEntries(
|
|
43
|
+
await searchKnowledgeEntries(
|
|
44
|
+
db,
|
|
45
|
+
context,
|
|
46
|
+
input,
|
|
47
|
+
() => documentServices.embedder,
|
|
48
|
+
options.settings,
|
|
49
|
+
),
|
|
43
50
|
),
|
|
44
51
|
},
|
|
45
52
|
],
|
package/src/mcp/files.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@opengeni/core";
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import * as z from "zod/v4";
|
|
12
|
+
import { userContentSignedGetUrlOptions } from "../http/user-content";
|
|
12
13
|
|
|
13
14
|
/** Dedicated file MCP surface. The broad `opengeni` server never registers it. */
|
|
14
15
|
export function buildFilesMcpServer(
|
|
@@ -63,7 +64,10 @@ export function buildFilesMcpServer(
|
|
|
63
64
|
if (file.status !== "ready") {
|
|
64
65
|
throw new Error(`file is ${file.status}`);
|
|
65
66
|
}
|
|
66
|
-
const signed = await deps.objectStorage.createGetUrl({
|
|
67
|
+
const signed = await deps.objectStorage.createGetUrl({
|
|
68
|
+
key: file.objectKey,
|
|
69
|
+
...userContentSignedGetUrlOptions(file.contentType, file.filename),
|
|
70
|
+
});
|
|
67
71
|
// Principal-facing signed URL issuance is a metadata-only audit
|
|
68
72
|
// fact, awaited before the URL leaves the platform. Never the URL/key.
|
|
69
73
|
await recordAuditEvent(deps.db, {
|
|
@@ -117,6 +117,8 @@ export const FIRST_PARTY_TOOL_AUTHORIZATION = {
|
|
|
117
117
|
browser_open: { sessionRequired: true, allOf: ["sessions:control"] },
|
|
118
118
|
browser_tabs: { sessionRequired: true, allOf: ["sessions:control"] },
|
|
119
119
|
browser_observe: { sessionRequired: true, allOf: ["sessions:read"] },
|
|
120
|
+
browser_read: { sessionRequired: true, allOf: ["sessions:read"] },
|
|
121
|
+
browser_screenshot: { sessionRequired: true, allOf: ["sessions:read"] },
|
|
120
122
|
browser_act: { sessionRequired: true, allOf: ["sessions:control"] },
|
|
121
123
|
browser_clipboard: { sessionRequired: true, allOf: ["sessions:read"] },
|
|
122
124
|
browser_debug: { sessionRequired: true, allOf: ["sessions:read"] },
|
|
@@ -152,6 +154,10 @@ export const FIRST_PARTY_TOOL_AUTHORIZATION = {
|
|
|
152
154
|
sessionRequired: true,
|
|
153
155
|
allOf: ["workspace:read"],
|
|
154
156
|
},
|
|
157
|
+
custom_mcp_setup_request: {
|
|
158
|
+
sessionRequired: true,
|
|
159
|
+
allOf: ["workspace:read"],
|
|
160
|
+
},
|
|
155
161
|
github_connect_link: { allOf: ["github:use"] },
|
|
156
162
|
github_repositories_list: { allOf: ["github:use"] },
|
|
157
163
|
social_connections_list: { allOf: ["connections:read"] },
|
|
@@ -142,7 +142,14 @@ export function registerKnowledgeEntryTools(
|
|
|
142
142
|
},
|
|
143
143
|
(input) =>
|
|
144
144
|
run((context) =>
|
|
145
|
-
prepareKnowledgeSave(
|
|
145
|
+
prepareKnowledgeSave(
|
|
146
|
+
deps.db,
|
|
147
|
+
context,
|
|
148
|
+
input,
|
|
149
|
+
() => deps.getDocumentServices().embedder,
|
|
150
|
+
undefined,
|
|
151
|
+
deps.settings,
|
|
152
|
+
),
|
|
146
153
|
),
|
|
147
154
|
);
|
|
148
155
|
server.registerTool(
|
|
@@ -159,7 +166,13 @@ export function registerKnowledgeEntryTools(
|
|
|
159
166
|
},
|
|
160
167
|
(input) =>
|
|
161
168
|
run((context) =>
|
|
162
|
-
searchKnowledgeEntries(
|
|
169
|
+
searchKnowledgeEntries(
|
|
170
|
+
deps.db,
|
|
171
|
+
context,
|
|
172
|
+
input,
|
|
173
|
+
() => deps.getDocumentServices().embedder,
|
|
174
|
+
deps.settings,
|
|
175
|
+
),
|
|
163
176
|
),
|
|
164
177
|
);
|
|
165
178
|
server.registerTool(
|
package/src/mcp/server.ts
CHANGED
|
@@ -187,7 +187,12 @@ import {
|
|
|
187
187
|
searchCapabilityCatalogItems,
|
|
188
188
|
type ResolvedSessionAuthorization,
|
|
189
189
|
} from "@opengeni/core";
|
|
190
|
-
import {
|
|
190
|
+
import {
|
|
191
|
+
recordWorkspaceUsage,
|
|
192
|
+
requireLimit,
|
|
193
|
+
resolveScheduledTaskPreflightModel,
|
|
194
|
+
workflowIdForSession,
|
|
195
|
+
} from "@opengeni/core";
|
|
191
196
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
192
197
|
import {
|
|
193
198
|
githubBindingStatus,
|
|
@@ -307,6 +312,7 @@ import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../san
|
|
|
307
312
|
import {
|
|
308
313
|
createOpenGeniSlackBotClient,
|
|
309
314
|
resolveSlackBotConnectionForTool,
|
|
315
|
+
type OpenGeniSlackBotClient,
|
|
310
316
|
} from "../integrations/slack-bot";
|
|
311
317
|
import { createFikenClient, resolveFikenConnectionForTool } from "../integrations/fiken";
|
|
312
318
|
import {
|
|
@@ -574,6 +580,51 @@ class PolicyMcpServer extends McpServer {
|
|
|
574
580
|
}
|
|
575
581
|
}
|
|
576
582
|
|
|
583
|
+
export function slackBotFileContentResult(
|
|
584
|
+
result: Awaited<ReturnType<OpenGeniSlackBotClient["fileContent"]>>,
|
|
585
|
+
) {
|
|
586
|
+
if (!("image" in result)) {
|
|
587
|
+
return {
|
|
588
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
589
|
+
structuredContent: {
|
|
590
|
+
kind: "text" as const,
|
|
591
|
+
fileId: result.file.id,
|
|
592
|
+
contentType: result.contentType,
|
|
593
|
+
content: result.content,
|
|
594
|
+
sizeBytes: null,
|
|
595
|
+
nextOffset: result.nextOffset,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
structuredContent: {
|
|
601
|
+
kind: "image" as const,
|
|
602
|
+
fileId: result.file.id,
|
|
603
|
+
contentType: result.image.contentType,
|
|
604
|
+
content: null,
|
|
605
|
+
sizeBytes: result.image.bytes.byteLength,
|
|
606
|
+
nextOffset: null,
|
|
607
|
+
},
|
|
608
|
+
content: [
|
|
609
|
+
{
|
|
610
|
+
type: "text" as const,
|
|
611
|
+
text: JSON.stringify({
|
|
612
|
+
channel: result.channel,
|
|
613
|
+
file: result.file,
|
|
614
|
+
contentType: result.image.contentType,
|
|
615
|
+
sizeBytes: result.image.bytes.byteLength,
|
|
616
|
+
receipt: result.receipt,
|
|
617
|
+
}),
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
type: "image" as const,
|
|
621
|
+
mimeType: result.image.contentType,
|
|
622
|
+
data: Buffer.from(result.image.bytes).toString("base64"),
|
|
623
|
+
},
|
|
624
|
+
],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
577
628
|
export function buildOpenGeniMcpServer(
|
|
578
629
|
deps: ApiRouteDeps,
|
|
579
630
|
grant: AccessGrant,
|
|
@@ -1598,7 +1649,7 @@ export function buildOpenGeniMcpServer(
|
|
|
1598
1649
|
workspaceId: grant.workspaceId,
|
|
1599
1650
|
action: "agent_run:create",
|
|
1600
1651
|
quantity: 1,
|
|
1601
|
-
model:
|
|
1652
|
+
model: await resolveScheduledTaskPreflightModel(deps.db, catalogSettings, task),
|
|
1602
1653
|
});
|
|
1603
1654
|
}
|
|
1604
1655
|
const triggerToken = scheduledTaskTriggerToken(triggerId);
|
|
@@ -1944,7 +1995,7 @@ function registerSlackBotTools(
|
|
|
1944
1995
|
"slack_bot_file_content",
|
|
1945
1996
|
{
|
|
1946
1997
|
description:
|
|
1947
|
-
"Read a bounded page of
|
|
1998
|
+
"Read a bounded page of text or view a PNG, JPEG, or WebP image from a Slack file shared with a channel where the workspace-shared OpenGeni bot is already a member. Use the file ID from thread replies to view images in earlier thread messages. Images are returned as viewable content, only when directly shared to a non-shared channel, up to 640 KiB; offset must be 0. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so OpenGeni can verify the channel-to-canvas-to-transcript chain. Slack may still restrict a huddle transcript body to participants; that returns huddle_transcript_requires_participant_access. Private Slack URLs and credentials are never returned. Continue with nextOffset for truncated text.",
|
|
1948
1999
|
inputSchema: {
|
|
1949
2000
|
connectionId: z4.string().uuid().optional(),
|
|
1950
2001
|
channelId: z4.string().min(1).max(64),
|
|
@@ -1952,18 +2003,26 @@ function registerSlackBotTools(
|
|
|
1952
2003
|
parentFileId: z4.string().min(1).max(64).optional(),
|
|
1953
2004
|
offset: z4.number().int().min(0).max(4_000_000).optional(),
|
|
1954
2005
|
},
|
|
2006
|
+
outputSchema: {
|
|
2007
|
+
kind: z4.enum(["text", "image"]),
|
|
2008
|
+
fileId: z4.string(),
|
|
2009
|
+
contentType: z4.string(),
|
|
2010
|
+
content: z4.string().nullable(),
|
|
2011
|
+
sizeBytes: z4.number().int().nullable(),
|
|
2012
|
+
nextOffset: z4.number().int().nullable(),
|
|
2013
|
+
},
|
|
2014
|
+
},
|
|
2015
|
+
async ({ connectionId, channelId, fileId, parentFileId, offset }) => {
|
|
2016
|
+
const result = await (
|
|
2017
|
+
await clientFor(connectionId)
|
|
2018
|
+
).fileContent({
|
|
2019
|
+
channelId,
|
|
2020
|
+
fileId,
|
|
2021
|
+
...(parentFileId ? { parentFileId } : {}),
|
|
2022
|
+
...(offset !== undefined ? { offset } : {}),
|
|
2023
|
+
});
|
|
2024
|
+
return slackBotFileContentResult(result);
|
|
1955
2025
|
},
|
|
1956
|
-
async ({ connectionId, channelId, fileId, parentFileId, offset }) =>
|
|
1957
|
-
json(
|
|
1958
|
-
await (
|
|
1959
|
-
await clientFor(connectionId)
|
|
1960
|
-
).fileContent({
|
|
1961
|
-
channelId,
|
|
1962
|
-
fileId,
|
|
1963
|
-
...(parentFileId ? { parentFileId } : {}),
|
|
1964
|
-
...(offset !== undefined ? { offset } : {}),
|
|
1965
|
-
}),
|
|
1966
|
-
),
|
|
1967
2026
|
);
|
|
1968
2027
|
|
|
1969
2028
|
server.registerTool(
|
|
@@ -2521,7 +2580,7 @@ function registerGoalTools(
|
|
|
2521
2580
|
"goal_update",
|
|
2522
2581
|
{
|
|
2523
2582
|
description:
|
|
2524
|
-
"
|
|
2583
|
+
"Maintain your operational goal as user direction or meaningful new evidence clarifies the intended outcome. Changes apply directly unless the user explicitly configured review_changes; refinement, adaptation, and replacement are audit classifications, not approval gates under the default policy. Use the exact expected objective revision and a concise rationale. Updating a goal grants no additional authority and cannot change root constraints. Use goal_progress for an execution-progress audit fact rather than a goal rewrite.",
|
|
2525
2584
|
inputSchema: {
|
|
2526
2585
|
text: goalText.optional(),
|
|
2527
2586
|
successCriteria: successCriteriaSchema.nullable().optional(),
|
|
@@ -5854,9 +5913,14 @@ function registerCapabilityDiscoveryTools(
|
|
|
5854
5913
|
// This is a readiness projection, never credential resolution or a new grant.
|
|
5855
5914
|
const setupProjections = async (items: CapabilityCatalogItem[]) => {
|
|
5856
5915
|
const availableServerIds = new Set<string>();
|
|
5916
|
+
const availableFirstPartyTools = new Set<string>();
|
|
5857
5917
|
const acceptedServerIds = new Set<string>();
|
|
5858
5918
|
let accountSelectionKnown = false;
|
|
5859
|
-
if (
|
|
5919
|
+
if (
|
|
5920
|
+
items.some(
|
|
5921
|
+
(item) => item.enabled && (item.connectionRef || item.surfaceType === "first_party_fiken"),
|
|
5922
|
+
)
|
|
5923
|
+
) {
|
|
5860
5924
|
const claims = exactAgentCommandContext(grant, sessionId);
|
|
5861
5925
|
const attemptCatalog = await getAttemptToolCatalog(deps.db, {
|
|
5862
5926
|
accountId: grant.accountId,
|
|
@@ -5868,7 +5932,11 @@ function registerCapabilityDiscoveryTools(
|
|
|
5868
5932
|
attemptCatalog.turnId === claims.callerTurnId &&
|
|
5869
5933
|
attemptCatalog.executionGeneration === claims.callerExecutionGeneration
|
|
5870
5934
|
) {
|
|
5871
|
-
for (const entry of attemptCatalog.entries)
|
|
5935
|
+
for (const entry of attemptCatalog.entries) {
|
|
5936
|
+
availableServerIds.add(entry.identity.serverId);
|
|
5937
|
+
if (entry.identity.serverId === "opengeni")
|
|
5938
|
+
availableFirstPartyTools.add(entry.identity.toolName);
|
|
5939
|
+
}
|
|
5872
5940
|
const bindings = await getSessionTurnMcpAccountBindings(
|
|
5873
5941
|
deps.db,
|
|
5874
5942
|
grant.workspaceId,
|
|
@@ -5890,6 +5958,8 @@ function registerCapabilityDiscoveryTools(
|
|
|
5890
5958
|
availableServerIds,
|
|
5891
5959
|
acceptedServerIds,
|
|
5892
5960
|
accountSelectionKnown,
|
|
5961
|
+
availableFirstPartyTools,
|
|
5962
|
+
new Set((grant.metadata?.firstPartyMcpTools as string[] | undefined) ?? []),
|
|
5893
5963
|
),
|
|
5894
5964
|
),
|
|
5895
5965
|
);
|
|
@@ -6015,6 +6085,72 @@ function registerCapabilityDiscoveryTools(
|
|
|
6015
6085
|
});
|
|
6016
6086
|
},
|
|
6017
6087
|
);
|
|
6088
|
+
|
|
6089
|
+
server.registerTool(
|
|
6090
|
+
"custom_mcp_setup_request",
|
|
6091
|
+
{
|
|
6092
|
+
description:
|
|
6093
|
+
"Show a review card for a remote HTTPS MCP server that is not in the workspace catalog. Use only an endpoint supplied by the user or established by reliable documentation; do not invent a URL. Never include query parameters or secrets in this URL; the human can edit it in the protected setup form. The agent cannot add, enable, or contact the server. Search the catalog first and do not propose an already available integration.",
|
|
6094
|
+
inputSchema: {
|
|
6095
|
+
name: z4.string().trim().min(1).max(256),
|
|
6096
|
+
endpointUrl: z4
|
|
6097
|
+
.string()
|
|
6098
|
+
.url()
|
|
6099
|
+
.max(2048)
|
|
6100
|
+
.refine((url) => {
|
|
6101
|
+
const parsed = new URL(url);
|
|
6102
|
+
return (
|
|
6103
|
+
parsed.protocol === "https:" &&
|
|
6104
|
+
!parsed.username &&
|
|
6105
|
+
!parsed.password &&
|
|
6106
|
+
!parsed.hash &&
|
|
6107
|
+
!parsed.search
|
|
6108
|
+
);
|
|
6109
|
+
}),
|
|
6110
|
+
rationale: z4.string().trim().min(1).max(2000),
|
|
6111
|
+
},
|
|
6112
|
+
},
|
|
6113
|
+
async ({ name, endpointUrl, rationale }) => {
|
|
6114
|
+
await authorize();
|
|
6115
|
+
const current = await catalog();
|
|
6116
|
+
const existing = current.items.find(
|
|
6117
|
+
(item) => item.kind === "mcp" && item.endpointUrl === endpointUrl && !item.stale,
|
|
6118
|
+
);
|
|
6119
|
+
if (existing) {
|
|
6120
|
+
return json({
|
|
6121
|
+
status: "already_in_catalog",
|
|
6122
|
+
capabilityId: existing.id,
|
|
6123
|
+
message: "Use the catalog authorization flow for this server instead.",
|
|
6124
|
+
});
|
|
6125
|
+
}
|
|
6126
|
+
const claims = exactAgentCommandContext(grant, sessionId);
|
|
6127
|
+
const payload = ToolAuthNeededPayload.parse({
|
|
6128
|
+
serverId: "opengeni",
|
|
6129
|
+
toolName: "custom_mcp_setup_request",
|
|
6130
|
+
providerDomain: new URL(endpointUrl).hostname,
|
|
6131
|
+
reason: "missing_connection",
|
|
6132
|
+
setupRequest: { kind: "mcp", name, endpointUrl, rationale },
|
|
6133
|
+
});
|
|
6134
|
+
const appended = await appendAndPublishTurnEventsFenced(
|
|
6135
|
+
deps.db,
|
|
6136
|
+
deps.bus,
|
|
6137
|
+
grant.workspaceId,
|
|
6138
|
+
sessionId,
|
|
6139
|
+
claims.callerTurnId,
|
|
6140
|
+
claims.callerExecutionGeneration,
|
|
6141
|
+
claims.callerAttemptId,
|
|
6142
|
+
[{ type: "tool.auth_needed", payload }],
|
|
6143
|
+
);
|
|
6144
|
+
if (!appended.accepted) {
|
|
6145
|
+
throw new Error("The calling turn was replaced before the setup request committed.");
|
|
6146
|
+
}
|
|
6147
|
+
return json({
|
|
6148
|
+
status: "setup_requested",
|
|
6149
|
+
eventId: appended.events[0]?.id ?? null,
|
|
6150
|
+
message: "The human review card was posted. No server was added or contacted.",
|
|
6151
|
+
});
|
|
6152
|
+
},
|
|
6153
|
+
);
|
|
6018
6154
|
}
|
|
6019
6155
|
|
|
6020
6156
|
async function capabilitySetupProjection(
|
|
@@ -6024,7 +6160,45 @@ async function capabilitySetupProjection(
|
|
|
6024
6160
|
availableServerIds: ReadonlySet<string>,
|
|
6025
6161
|
acceptedServerIds: ReadonlySet<string>,
|
|
6026
6162
|
accountSelectionKnown: boolean,
|
|
6163
|
+
availableFirstPartyTools: ReadonlySet<string>,
|
|
6164
|
+
selectedFirstPartyTools: ReadonlySet<string>,
|
|
6027
6165
|
): Promise<CapabilitySetupProjection> {
|
|
6166
|
+
if (item.surfaceType === "first_party_fiken") {
|
|
6167
|
+
if (!item.enabled || item.metadata.connectionStatus !== "active") {
|
|
6168
|
+
return {
|
|
6169
|
+
status: "authorization_required",
|
|
6170
|
+
action: "connect",
|
|
6171
|
+
detail:
|
|
6172
|
+
item.metadata.connectionStatus === "needs_reauth"
|
|
6173
|
+
? "The workspace Fiken connection needs reconnection."
|
|
6174
|
+
: "Connect Fiken through the protected setup form.",
|
|
6175
|
+
};
|
|
6176
|
+
}
|
|
6177
|
+
const selected = FIRST_PARTY_MCP_TOOL_NAMES.filter(
|
|
6178
|
+
(name) => name.startsWith("fiken_") && selectedFirstPartyTools.has(name),
|
|
6179
|
+
);
|
|
6180
|
+
if (selected.length === 0) {
|
|
6181
|
+
return {
|
|
6182
|
+
status: "authorization_required",
|
|
6183
|
+
action: "enable",
|
|
6184
|
+
detail:
|
|
6185
|
+
"Fiken is connected, but its tools are not selected for this conversation. Ask the user to add its tools through the setup card, then send a new message.",
|
|
6186
|
+
};
|
|
6187
|
+
}
|
|
6188
|
+
if (selected.some((name) => availableFirstPartyTools.has(name))) {
|
|
6189
|
+
return {
|
|
6190
|
+
status: "ready",
|
|
6191
|
+
action: null,
|
|
6192
|
+
detail: "Fiken is connected and has tools available in this turn.",
|
|
6193
|
+
};
|
|
6194
|
+
}
|
|
6195
|
+
return {
|
|
6196
|
+
status: "unavailable",
|
|
6197
|
+
action: null,
|
|
6198
|
+
detail:
|
|
6199
|
+
"Fiken is connected, but its selected tools are unavailable in this execution. Check tool permissions and setup before retrying; reconnection is not required by this status.",
|
|
6200
|
+
};
|
|
6201
|
+
}
|
|
6028
6202
|
if (item.id === "api:github-app" || item.surfaceType === "first_party_github") {
|
|
6029
6203
|
const missing = githubAppMissingSettings(deps.settings);
|
|
6030
6204
|
if (missing.length > 0) {
|
package/src/mcp-oauth.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
} from "@opengeni/core";
|
|
42
42
|
import { Hono, type Context } from "hono";
|
|
43
43
|
import { HTTPException } from "hono/http-exception";
|
|
44
|
-
import {
|
|
44
|
+
import { trustedRequestSourceRateLimitKey, type RequestSourceTrust } from "./http/request-source";
|
|
45
45
|
import {
|
|
46
46
|
prepareWorkspaceToolGateway,
|
|
47
47
|
requireWorkspaceToolGatewayAuthorization,
|
|
@@ -126,9 +126,7 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
126
126
|
clientName: parsed.data.client_name ?? null,
|
|
127
127
|
grantTypes: parsed.data.grant_types,
|
|
128
128
|
responseTypes: ["code"],
|
|
129
|
-
registrationScopeHash: tokenHash(
|
|
130
|
-
mcpOAuthRegistrationClientKey(c, deps.settings.mcpOauthTrustedProxyHops),
|
|
131
|
-
),
|
|
129
|
+
registrationScopeHash: tokenHash(mcpOAuthRegistrationClientKey(c, deps.settings)),
|
|
132
130
|
});
|
|
133
131
|
} catch (error) {
|
|
134
132
|
if (error instanceof McpOAuthClientRegistrationRateLimitError) {
|
|
@@ -626,8 +624,8 @@ function tokenHash(value: string): string {
|
|
|
626
624
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
627
625
|
}
|
|
628
626
|
|
|
629
|
-
function mcpOAuthRegistrationClientKey(c: Context,
|
|
630
|
-
return `mcp-oauth-registration:${
|
|
627
|
+
function mcpOAuthRegistrationClientKey(c: Context, trust: RequestSourceTrust): string {
|
|
628
|
+
return `mcp-oauth-registration:${trustedRequestSourceRateLimitKey(c, trust)}`;
|
|
631
629
|
}
|
|
632
630
|
|
|
633
631
|
function expiresIn(seconds: number): Date {
|
package/src/model-catalog.ts
CHANGED
|
@@ -6,9 +6,14 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
ClientModel,
|
|
8
8
|
WorkspaceModelCatalogResponse,
|
|
9
|
+
type DefaultModelSelection,
|
|
9
10
|
type WorkspaceModelCatalogResponse as WorkspaceModelCatalogResponseType,
|
|
10
11
|
} from "@opengeni/contracts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
resolveWorkspaceModelSelection,
|
|
14
|
+
type WorkspaceModelSelection,
|
|
15
|
+
type WorkspaceModelSelectionInput,
|
|
16
|
+
} from "@opengeni/core";
|
|
12
17
|
|
|
13
18
|
export {
|
|
14
19
|
MODEL_CREDENTIAL_READINESS_OBSERVATION_MAX_AGE_MS,
|
|
@@ -81,12 +86,34 @@ export function projectClientModel(model: ConfiguredModel): ClientModel {
|
|
|
81
86
|
*/
|
|
82
87
|
export function buildWorkspaceModelCatalog(
|
|
83
88
|
input: WorkspaceModelSelectionInput,
|
|
89
|
+
defaults?: WorkspaceModelCatalogDefaults,
|
|
84
90
|
): WorkspaceModelCatalogResponseType {
|
|
85
|
-
|
|
91
|
+
return projectWorkspaceModelCatalog(resolveWorkspaceModelSelection(input), defaults);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolved new-chat defaults published beside the catalog rows. */
|
|
95
|
+
export type WorkspaceModelCatalogDefaults = {
|
|
96
|
+
defaultSelection: DefaultModelSelection;
|
|
97
|
+
creditsSelection: DefaultModelSelection | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function projectWorkspaceModelCatalog(
|
|
101
|
+
selections: readonly WorkspaceModelSelection[],
|
|
102
|
+
defaults?: WorkspaceModelCatalogDefaults,
|
|
103
|
+
): WorkspaceModelCatalogResponseType {
|
|
104
|
+
const models = selections.map((selection) => ({
|
|
86
105
|
...projectClientModel(selection.model),
|
|
87
106
|
credentialReadiness: selection.credentialReadiness,
|
|
88
107
|
policyAllowed: selection.policyAllowed,
|
|
89
108
|
availability: selection.availability,
|
|
90
109
|
}));
|
|
91
|
-
return WorkspaceModelCatalogResponse.parse({
|
|
110
|
+
return WorkspaceModelCatalogResponse.parse({
|
|
111
|
+
models,
|
|
112
|
+
...(defaults
|
|
113
|
+
? {
|
|
114
|
+
defaultSelection: defaults.defaultSelection,
|
|
115
|
+
creditsSelection: defaults.creditsSelection,
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
118
|
+
});
|
|
92
119
|
}
|