@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.
Files changed (93) hide show
  1. package/dist/app.d.ts +7 -1
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  4. package/dist/auth/managed-auth.d.ts +37 -1
  5. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  6. package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
  7. package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
  8. package/dist/http/cors.d.ts +12 -0
  9. package/dist/http/local-browser-boundary.d.ts +72 -0
  10. package/dist/http/metrics-listener.d.ts +22 -0
  11. package/dist/http/registered-route-label.d.ts +16 -0
  12. package/dist/http/request-source.d.ts +41 -3
  13. package/dist/http/user-content.d.ts +36 -0
  14. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  15. package/dist/index.js +90 -31
  16. package/dist/index.js.map +1 -1
  17. package/dist/integrations/github-lens-connect.d.ts +1 -1
  18. package/dist/integrations/oauth-client.d.ts +30 -0
  19. package/dist/integrations/slack-bot.d.ts +48 -2
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/documents.d.ts +2 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
  23. package/dist/mcp/server.d.ts +35 -0
  24. package/dist/model-catalog.d.ts +9 -3
  25. package/dist/observability.d.ts +3 -1
  26. package/dist/routes/billing.d.ts +27 -0
  27. package/dist/routes/browser-sessions.d.ts +3 -0
  28. package/dist/routes/client-errors.d.ts +42 -0
  29. package/dist/routes/computer-sessions.d.ts +2 -0
  30. package/dist/routes/github-browser-pages.d.ts +12 -0
  31. package/dist/routes/insights.d.ts +26 -0
  32. package/dist/sandbox/auth-callout.d.ts +8 -0
  33. package/dist/sandbox-file-artifacts.d.ts +17 -1
  34. package/package.json +19 -19
  35. package/src/app.ts +124 -12
  36. package/src/auth/managed-auth-rate-limits.ts +252 -0
  37. package/src/auth/managed-auth.ts +137 -17
  38. package/src/auth/signup-funnel-metrics.ts +164 -0
  39. package/src/http/auth.ts +6 -0
  40. package/src/http/cors.ts +37 -5
  41. package/src/http/local-browser-boundary.ts +343 -0
  42. package/src/http/metrics-listener.ts +57 -0
  43. package/src/http/registered-route-label.ts +34 -0
  44. package/src/http/request-source.ts +154 -13
  45. package/src/http/sse.ts +36 -16
  46. package/src/http/user-content.ts +158 -0
  47. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  48. package/src/index.ts +22 -8
  49. package/src/integrations/atlassian.ts +7 -3
  50. package/src/integrations/fiken.ts +9 -3
  51. package/src/integrations/github-app-connect.ts +12 -2
  52. package/src/integrations/google-drive.ts +7 -3
  53. package/src/integrations/oauth-client.ts +99 -12
  54. package/src/integrations/personal-github.ts +4 -2
  55. package/src/integrations/provider-oauth.ts +10 -3
  56. package/src/integrations/slack-bot.ts +43 -1
  57. package/src/integrations/social-oauth.ts +7 -2
  58. package/src/mcp/documents.ts +9 -2
  59. package/src/mcp/files.ts +5 -1
  60. package/src/mcp/first-party-tool-permissions.ts +6 -0
  61. package/src/mcp/knowledge-entries.ts +15 -2
  62. package/src/mcp/server.ts +191 -17
  63. package/src/mcp-oauth.ts +4 -6
  64. package/src/model-catalog.ts +30 -3
  65. package/src/observability.ts +15 -1
  66. package/src/routes/billing.ts +70 -9
  67. package/src/routes/browser-sessions.ts +169 -0
  68. package/src/routes/client-errors.ts +182 -0
  69. package/src/routes/company-brain.ts +4 -0
  70. package/src/routes/computer-sessions.ts +37 -9
  71. package/src/routes/connections.ts +7 -1
  72. package/src/routes/documents.ts +6 -1
  73. package/src/routes/editable-artifacts.ts +2 -1
  74. package/src/routes/enrollments.ts +2 -13
  75. package/src/routes/files.ts +17 -3
  76. package/src/routes/github-browser-pages.ts +162 -0
  77. package/src/routes/github.ts +122 -49
  78. package/src/routes/insights.ts +70 -6
  79. package/src/routes/knowledge.ts +24 -1
  80. package/src/routes/managed-auth-session-sets.ts +26 -6
  81. package/src/routes/managed-onboarding.ts +23 -19
  82. package/src/routes/organization-memberships.ts +3 -0
  83. package/src/routes/scheduled-tasks.ts +9 -2
  84. package/src/routes/sessions.ts +89 -0
  85. package/src/routes/workspace-artifacts.ts +9 -0
  86. package/src/routes/workspace-state.ts +4 -0
  87. package/src/routes/workspaces.ts +38 -15
  88. package/src/sandbox/auth-callout.ts +62 -11
  89. package/src/sandbox/channel-a.ts +8 -0
  90. package/src/sandbox/viewer.ts +16 -3
  91. package/src/sandbox-file-artifacts.ts +37 -26
  92. package/src/site-uploads.ts +8 -1
  93. 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 ?? "/integrations",
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 = `/workspaces/${workspaceId}/capabilities`;
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 ?? `/workspaces/${input.workspaceId}/capabilities`,
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 ?? "/integrations",
530
+ state?.returnPath ?? failure!.returnPath,
524
531
  "error",
525
532
  {
526
533
  ...(state?.definitionId ? { definitionId: state.definitionId } : {}),
527
- reason: providerOAuthFailureReason(error),
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: "text/*, application/json, application/xml, application/xhtml+xml",
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(context.payload.returnPath ?? "/integrations");
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("/integrations", "error", { reason: "state_invalid" }),
269
+ redirectTo: callbackReturnPath(failure.returnPath, "error", { reason: failure.reason }),
265
270
  };
266
271
  }
267
272
  if (state.connectAttemptId) return completeSocialConnect(deps, state, input);
@@ -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(db, context, input, () => documentServices.embedder),
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({ key: file.objectKey });
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(deps.db, context, input, () => deps.getDocumentServices().embedder),
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(deps.db, context, input, () => deps.getDocumentServices().embedder),
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 { recordWorkspaceUsage, requireLimit, workflowIdForSession } from "@opengeni/core";
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: task.agentConfig.model ?? deps.settings.openaiModel,
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 UTF-8 text from a Slack file or canvas shared with a channel where the workspace-shared OpenGeni bot is already a member. 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 when truncated is true.",
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
- "Propose or apply a semantic goal revision under the session's mutation policy. Retain the standing goal unless explicit user direction or meaningful new evidence justifies the declared refinement, adaptation, or replacement. Every rewrite must use the exact expected objective revision and a concise rationale. Root constraints cannot be changed by an agent. A rewrite is not an execution-progress audit fact; use the optional goal_progress tool when such a fact should be recorded.",
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 (items.some((item) => item.enabled && item.connectionRef)) {
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) availableServerIds.add(entry.identity.serverId);
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 { trustedRequestSourceAddress } from "./http/request-source";
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, trustedProxyHops: number): string {
630
- return `mcp-oauth-registration:${trustedRequestSourceAddress(c, trustedProxyHops)}`;
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 {
@@ -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 { resolveWorkspaceModelSelection, type WorkspaceModelSelectionInput } from "@opengeni/core";
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
- const models = resolveWorkspaceModelSelection(input).map((selection) => ({
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({ models });
110
+ return WorkspaceModelCatalogResponse.parse({
111
+ models,
112
+ ...(defaults
113
+ ? {
114
+ defaultSelection: defaults.defaultSelection,
115
+ creditsSelection: defaults.creditsSelection,
116
+ }
117
+ : {}),
118
+ });
92
119
  }