@opengeni/api-router 0.23.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
package/src/mcp/server.ts CHANGED
@@ -12,16 +12,22 @@ import {
12
12
  SessionEventResultMode,
13
13
  SessionEventSemanticClass,
14
14
  SessionEventType,
15
+ stableJson,
15
16
  compactSessionEventResult,
16
17
  sessionEventLatestClassToSemanticClass,
18
+ MemorySlackPublicationDistribution,
17
19
  SessionMcpCredentialUpdateInput,
20
+ ToolAuthNeededPayload,
18
21
  VariableSetVariableName,
22
+ capabilityCatalogItemIsTrustedForExposure,
19
23
  type AccessGrant,
24
+ type CapabilityCatalogItem,
20
25
  type GitHubRepository,
21
26
  type FirstPartyMcpToolName,
22
27
  type Permission,
23
28
  type ResourceRef,
24
29
  type SessionAuthorizationOperation,
30
+ type SessionAuthorizationActor,
25
31
  type SessionAuthorizationSurface,
26
32
  type Session,
27
33
  type ScheduledTask,
@@ -30,7 +36,6 @@ import {
30
36
  WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES,
31
37
  } from "@opengeni/contracts";
32
38
  import {
33
- correctWorkspaceMemory,
34
39
  countVariableSets,
35
40
  beginRigChangeVerificationAttempt,
36
41
  createVariableSet,
@@ -67,7 +72,6 @@ import {
67
72
  MEMORY_SEARCH_TOOL_DESCRIPTION,
68
73
  requireScheduledTask,
69
74
  requireSession,
70
- saveWorkspaceMemory,
71
75
  searchWorkspaceMemories,
72
76
  serializeEffectiveSessionControl,
73
77
  setSessionGoalStatusWithEvent,
@@ -75,7 +79,6 @@ import {
75
79
  updateScheduledTask,
76
80
  updateSessionGoalWithEvent,
77
81
  upsertSessionGoalWithEvent,
78
- RigChangeAlreadyVerifyingError,
79
82
  RigChangeTransitionError,
80
83
  createWorkspaceArtifact,
81
84
  getWorkspaceArtifact,
@@ -84,7 +87,11 @@ import {
84
87
  publishWorkspaceArtifactVersion,
85
88
  rollbackWorkspaceArtifact,
86
89
  } from "@opengeni/db";
87
- import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
90
+ import {
91
+ appendAndPublishEvents,
92
+ appendAndPublishTurnEventsFenced,
93
+ publishDurableSessionEvents,
94
+ } from "@opengeni/events";
88
95
  import {
89
96
  createSignedState,
90
97
  GitHubAppConfigurationError,
@@ -102,9 +109,15 @@ import {
102
109
  hasLiteralPermission,
103
110
  hasPermission,
104
111
  authorizedSocialConnectionsForGrant,
112
+ authorizedAtlassianConnectionsForGrant,
113
+ buildCapabilityCatalog,
114
+ nativeConnectionCapabilityRecommendations,
115
+ correctWorkspaceMemoryWithSlackPublication,
105
116
  requireLiveAgentAttemptAuthorization,
106
117
  requireSessionAuthorization,
107
118
  requireSessionAuthorizationListScope,
119
+ saveWorkspaceMemoryWithSlackPublication,
120
+ searchCapabilityCatalogItems,
108
121
  type ResolvedSessionAuthorization,
109
122
  } from "@opengeni/core";
110
123
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
@@ -116,12 +129,14 @@ import {
116
129
  } from "../github-access";
117
130
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
118
131
  import {
132
+ assertSocialConnectionProvider,
119
133
  socialMentionsLive,
120
134
  socialOwnPostsLive,
121
135
  socialPostReply,
122
136
  socialSearchLive,
123
137
  socialThreadLive,
124
138
  } from "../integrations/social-api";
139
+ import { revokeKnowledgeSourceScheduleAuthorization } from "../integrations/google-drive";
125
140
  import {
126
141
  promoteVerifiedDefinitionEditChangeForApi,
127
142
  proposeRigChangeForApi,
@@ -186,18 +201,24 @@ import {
186
201
  boundScheduledTaskMcpPage,
187
202
  scheduledTaskMcpSummary,
188
203
  } from "./scheduled-task-view";
189
- import type { ToolspaceMcpSurface } from "./toolspace";
190
204
  import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
191
205
  import {
192
206
  createOpenGeniSlackBotClient,
193
207
  resolveSlackBotConnectionForTool,
194
208
  } from "../integrations/slack-bot";
209
+ import {
210
+ browseAtlassianSources,
211
+ getAtlassianLiveItem,
212
+ revokeAtlassianScheduleAuthorization,
213
+ searchAtlassianLive,
214
+ } from "../integrations/atlassian";
215
+ import { AtlassianConnectionMetadata } from "@opengeni/contracts/atlassian";
216
+ import { registerEditableArtifactAgentTools } from "./editable-artifacts";
195
217
 
196
218
  export type McpServerOptions = {
197
219
  // Origin of the HTTP request that reached the MCP route. Browser-oriented
198
220
  // tools use it only when no configured public base URL is available.
199
221
  requestOrigin?: string | null;
200
- toolspace?: ToolspaceMcpSurface | null;
201
222
  workspaceMemoryEnabled?: boolean | undefined;
202
223
  };
203
224
 
@@ -246,6 +267,20 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
246
267
  session_resume: { allOf: ["sessions:control"] },
247
268
  session_steer: { sessionRequired: true, allOf: ["sessions:control"] },
248
269
  set_other_session_title: { allOf: ["sessions:control"] },
270
+ interaction_discover: { sessionRequired: true, allOf: ["sessions:read"] },
271
+ browser_open: { sessionRequired: true, allOf: ["sessions:control"] },
272
+ browser_tabs: { sessionRequired: true, allOf: ["sessions:control"] },
273
+ browser_observe: { sessionRequired: true, allOf: ["sessions:read"] },
274
+ browser_act: { sessionRequired: true, allOf: ["sessions:control"] },
275
+ browser_debug: { sessionRequired: true, allOf: ["sessions:read"] },
276
+ browser_identity: { sessionRequired: true, allOf: ["sessions:control"] },
277
+ browser_publish: { sessionRequired: true, allOf: ["sessions:control"] },
278
+ browser_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
279
+ computer_open: { sessionRequired: true, allOf: ["sessions:control"] },
280
+ computer_targets: { sessionRequired: true, allOf: ["sessions:read"] },
281
+ computer_observe: { sessionRequired: true, allOf: ["sessions:read"] },
282
+ computer_act: { sessionRequired: true, allOf: ["sessions:control"] },
283
+ computer_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
249
284
  variable_set_list: { allOf: ["variable-sets:list", "secrets:list"] },
250
285
  environment_list: { allOf: ["variable-sets:list", "secrets:list"] },
251
286
  variable_set_get_variable: {
@@ -256,6 +291,11 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
256
291
  allOf: ["variable-sets:write", "secrets:write"],
257
292
  },
258
293
  environment_set_variable: { allOf: ["variable-sets:write", "secrets:write"] },
294
+ capability_catalog_search: { sessionRequired: true, allOf: ["workspace:read"] },
295
+ capability_authorization_request: {
296
+ sessionRequired: true,
297
+ allOf: ["workspace:read"],
298
+ },
259
299
  github_connect_link: { allOf: ["github:use"] },
260
300
  github_repositories_list: { allOf: ["github:use"] },
261
301
  social_connections_list: { allOf: ["connections:read"] },
@@ -270,6 +310,18 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
270
310
  // Publishes under the user's identity: connections:write keeps it out of the
271
311
  // default agent permission set, unlike the read-only social tools above.
272
312
  social_post_reply: { allOf: ["connections:write"] },
313
+ x_accounts_list: { allOf: ["connections:read"] },
314
+ x_search_live: { allOf: ["connections:read"] },
315
+ x_mentions_live: { allOf: ["connections:read"] },
316
+ x_thread_fetch: { allOf: ["connections:read"] },
317
+ x_posts_sync: { allOf: ["connections:write"] },
318
+ x_post_reply: { allOf: ["connections:write"] },
319
+ reddit_accounts_list: { allOf: ["connections:read"] },
320
+ reddit_search_live: { allOf: ["connections:read"] },
321
+ reddit_mentions_live: { allOf: ["connections:read"] },
322
+ reddit_thread_fetch: { allOf: ["connections:read"] },
323
+ reddit_posts_sync: { allOf: ["connections:write"] },
324
+ reddit_post_reply: { allOf: ["connections:write"] },
273
325
  scheduled_tasks_list: {
274
326
  anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
275
327
  },
@@ -294,11 +346,31 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
294
346
  slack_bot_file_content: { allOf: ["connections:read"] },
295
347
  slack_bot_post_message: { allOf: ["connections:read"] },
296
348
  slack_bot_delete_message: { allOf: ["connections:read"] },
349
+ atlassian_sources_list: { allOf: ["connections:read"] },
350
+ atlassian_search: { allOf: ["connections:read"] },
351
+ atlassian_get: { allOf: ["connections:read"] },
297
352
  artifacts_list: { sessionRequired: true, allOf: ["artifacts:read"] },
298
353
  artifacts_get_source: { sessionRequired: true, allOf: ["artifacts:read"] },
299
354
  artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
300
355
  artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
301
356
  artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
357
+ editable_artifact_list: { sessionRequired: true, allOf: ["artifacts:read"] },
358
+ editable_artifact_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
359
+ editable_artifact_import: {
360
+ sessionRequired: true,
361
+ allOf: ["artifacts:publish", "files:read"],
362
+ },
363
+ editable_artifact_get: { sessionRequired: true, allOf: ["artifacts:read"] },
364
+ editable_artifact_inspect: { sessionRequired: true, allOf: ["artifacts:read"] },
365
+ editable_artifact_apply: { sessionRequired: true, allOf: ["artifacts:publish"] },
366
+ editable_artifact_export: {
367
+ sessionRequired: true,
368
+ allOf: ["artifacts:read"],
369
+ },
370
+ editable_artifact_export_status: {
371
+ sessionRequired: true,
372
+ allOf: ["artifacts:read", "files:upload"],
373
+ },
302
374
  } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
303
375
 
304
376
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
@@ -310,7 +382,6 @@ class PolicyMcpServer extends McpServer {
310
382
  private readonly grant: AccessGrant,
311
383
  private readonly sessionId: string | null,
312
384
  private readonly selectedTools: ReadonlySet<FirstPartyMcpToolName> | null,
313
- private readonly allowUncatalogued: boolean,
314
385
  ) {
315
386
  super({ name: "opengeni", version: "1.0.0" });
316
387
  }
@@ -331,7 +402,7 @@ class PolicyMcpServer extends McpServer {
331
402
  cb: ToolCallback<InputArgs>,
332
403
  ): RegisteredTool {
333
404
  const catalogued = FIRST_PARTY_MCP_TOOL_NAME_SET.has(name);
334
- let admitted = this.allowUncatalogued && !catalogued;
405
+ let admitted = false;
335
406
  if (catalogued) {
336
407
  const toolName = name as FirstPartyMcpToolName;
337
408
  const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
@@ -388,7 +459,6 @@ export function buildOpenGeniMcpServer(
388
459
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
389
460
  });
390
461
  const can = (permission: Permission) => hasPermission(grant.permissions, permission);
391
- const toolspaceMode = options.toolspace != null;
392
462
  let socialConnectionsPromise: ReturnType<typeof authorizedSocialConnectionsForGrant> | undefined;
393
463
  const authorizedSocialConnections = () =>
394
464
  (socialConnectionsPromise ??= authorizedSocialConnectionsForGrant({
@@ -403,6 +473,14 @@ export function buildOpenGeniMcpServer(
403
473
  if (!authority) throw new Error(`Unknown or unavailable social connection: ${connectionId}`);
404
474
  return authority;
405
475
  };
476
+ const requireAuthorizedSocialConnectionForProvider = async (
477
+ provider: "x" | "reddit",
478
+ connectionId: string,
479
+ ) => {
480
+ const authority = await requireAuthorizedSocialConnection(connectionId);
481
+ assertSocialConnectionProvider(authority.connection, provider);
482
+ return authority;
483
+ };
406
484
 
407
485
  // Session-scoped tools key off the worker-asserted sessionId claim (signed
408
486
  // into the delegated token by the worker, never agent-controlled).
@@ -411,17 +489,17 @@ export function buildOpenGeniMcpServer(
411
489
  ? (grant.metadata["sessionId"] as string)
412
490
  : null;
413
491
  const selectedTools =
414
- sessionId !== null && !toolspaceMode
492
+ sessionId !== null
415
493
  ? new Set(
416
494
  (grant.metadata?.["firstPartyMcpTools"] as FirstPartyMcpToolName[] | undefined) ??
417
495
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
418
496
  )
419
497
  : null;
420
- const server = new PolicyMcpServer(grant, sessionId, selectedTools, toolspaceMode);
498
+ const server = new PolicyMcpServer(grant, sessionId, selectedTools);
421
499
  // set_session_title names the agent's OWN session — pure session metadata,
422
500
  // not a goal operation — so it is available on every session, gated only on
423
501
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
424
- if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
502
+ if (sessionId !== null) {
425
503
  server.registerTool(
426
504
  "set_session_title",
427
505
  {
@@ -444,18 +522,23 @@ export function buildOpenGeniMcpServer(
444
522
  if (sessionId !== null && can("goals:manage")) {
445
523
  registerGoalTools(server, deps, grant, sessionId, json);
446
524
  }
447
- // Toolspace grants are the sandbox's narrowed proxy surface. Unlike the
448
- // normal first-party worker token, a bare toolspace:call token does not see
449
- // unpermissioned session tools; memory follows that title/goal parity and
450
- // stays on the normal first-party MCP surface only.
451
- if (!toolspaceMode && sessionId !== null && options.workspaceMemoryEnabled === true) {
525
+ if (sessionId !== null && options.workspaceMemoryEnabled === true) {
452
526
  registerMemoryTools(server, deps, grant, sessionId, json);
453
527
  }
454
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
528
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
455
529
  registerPreferenceRegistryTools(server, deps, grant, json);
456
530
  }
457
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
531
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
458
532
  registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
533
+ registerEditableArtifactAgentTools({
534
+ server,
535
+ deps,
536
+ grant,
537
+ sessionId,
538
+ authorize: async () => {
539
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
540
+ },
541
+ });
459
542
  }
460
543
 
461
544
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
@@ -464,16 +547,15 @@ export function buildOpenGeniMcpServer(
464
547
  // so they register only when the grant carries the worker-signed sessionId claim
465
548
  // (never agent-controlled). Gated on the selfhosted feature flag: the active
466
549
  // pointer + swap are only meaningful when bring-your-own-compute is enabled.
467
- if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
550
+ if (sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
468
551
  registerFleetTools(server, deps, grant, sessionId, json);
469
552
  }
470
- if (!toolspaceMode && can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
553
+ if (can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
471
554
  registerConnectedMachineTools(server, deps, grant, json);
472
555
  }
473
- if (!toolspaceMode) {
474
- registerRigTools(server, deps, grant, can, sessionId, json);
475
- registerSlackBotTools(server, deps, grant, sessionId, json);
476
- }
556
+ registerRigTools(server, deps, grant, can, sessionId, json);
557
+ registerSlackBotTools(server, deps, grant, sessionId, json);
558
+ registerAtlassianTools(server, deps, grant, json);
477
559
 
478
560
  // Orchestration, variableSet, and GitHub status tools are permission-gated
479
561
  // at registration: a grant without the permission does not see the tool.
@@ -486,13 +568,16 @@ export function buildOpenGeniMcpServer(
486
568
  // never returned through a model-visible MCP tool. A user DEMOTES a specific
487
569
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
488
570
  // the creator's own grant); operators still cap what any session can be given.
489
- registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
490
- registerVariableSetTools(server, deps, grant, can, sessionId, toolspaceMode, json);
571
+ registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, json);
572
+ registerVariableSetTools(server, deps, grant, can, sessionId, json);
573
+ if (sessionId !== null && can("workspace:read")) {
574
+ registerCapabilityDiscoveryTools(server, deps, grant, sessionId, json);
575
+ }
491
576
  if (can("github:use")) {
492
577
  registerGitHubConnectTool(server, deps, grant, options, json);
493
578
  }
494
579
 
495
- if (!toolspaceMode || can("github:use")) {
580
+ if (can("github:use")) {
496
581
  server.registerTool(
497
582
  "github_repositories_list",
498
583
  {
@@ -521,7 +606,7 @@ export function buildOpenGeniMcpServer(
521
606
  );
522
607
  }
523
608
 
524
- if (!toolspaceMode || can("connections:read")) {
609
+ if (can("connections:read")) {
525
610
  server.registerTool(
526
611
  "social_connections_list",
527
612
  {
@@ -726,12 +811,122 @@ export function buildOpenGeniMcpServer(
726
811
  });
727
812
  },
728
813
  );
814
+
815
+ // Provider-scoped aliases are the canonical tools advertised by the X and
816
+ // Reddit Integration cards. The legacy social_* names remain available to
817
+ // existing Packs/sessions, but these names bind the provider in the tool
818
+ // identity and reject a near-identical Connection from the other adapter.
819
+ for (const provider of ["x", "reddit"] as const) {
820
+ const providerName = provider === "x" ? "X" : "Reddit";
821
+ server.registerTool(
822
+ `${provider}_accounts_list`,
823
+ {
824
+ description: `List the exact visible ${providerName} accounts available to this work.`,
825
+ inputSchema: { limit: z4.number().int().positive().optional() },
826
+ },
827
+ async ({ limit }) =>
828
+ json({
829
+ connections: (await authorizedSocialConnections())
830
+ .filter(
831
+ ({ connection }) =>
832
+ connection.provider === provider && connection.status !== "disabled",
833
+ )
834
+ .slice(0, boundedMcpLimit(limit))
835
+ .map(({ connection }) => connection),
836
+ }),
837
+ );
838
+ server.registerTool(
839
+ `${provider}_search_live`,
840
+ {
841
+ description:
842
+ provider === "x"
843
+ ? "Search recent X conversations through one exact connected X account."
844
+ : "Search Reddit through one exact connected Reddit account; optionally scope to a subreddit.",
845
+ inputSchema: {
846
+ connectionId: z4.string().uuid(),
847
+ query: z4.string().min(1).max(512),
848
+ subreddit: z4.string().min(1).max(100).optional(),
849
+ limit: z4.number().int().positive().optional(),
850
+ },
851
+ },
852
+ async ({ connectionId, query, subreddit, limit }) => {
853
+ const authority = await requireAuthorizedSocialConnectionForProvider(
854
+ provider,
855
+ connectionId,
856
+ );
857
+ const result = await socialSearchLive(
858
+ deps,
859
+ {
860
+ workspaceId: grant.workspaceId,
861
+ connectionId,
862
+ subjectId: authority.subjectId,
863
+ },
864
+ { query, subreddit, limit },
865
+ );
866
+ return json({ provider: result.connection.provider, posts: result.posts });
867
+ },
868
+ );
869
+ server.registerTool(
870
+ `${provider}_mentions_live`,
871
+ {
872
+ description: `Fetch live ${providerName} mentions and replies through one exact connected account.`,
873
+ inputSchema: {
874
+ connectionId: z4.string().uuid(),
875
+ sinceId: z4.string().optional(),
876
+ limit: z4.number().int().positive().optional(),
877
+ },
878
+ },
879
+ async ({ connectionId, sinceId, limit }) => {
880
+ const authority = await requireAuthorizedSocialConnectionForProvider(
881
+ provider,
882
+ connectionId,
883
+ );
884
+ const result = await socialMentionsLive(
885
+ deps,
886
+ {
887
+ workspaceId: grant.workspaceId,
888
+ connectionId,
889
+ subjectId: authority.subjectId,
890
+ },
891
+ { sinceId, limit },
892
+ );
893
+ return json({ provider: result.connection.provider, posts: result.posts });
894
+ },
895
+ );
896
+ server.registerTool(
897
+ `${provider}_thread_fetch`,
898
+ {
899
+ description: `Fetch one live ${providerName} conversation thread through an exact connected account.`,
900
+ inputSchema: {
901
+ connectionId: z4.string().uuid(),
902
+ id: z4.string().min(1).max(100),
903
+ limit: z4.number().int().positive().optional(),
904
+ },
905
+ },
906
+ async ({ connectionId, id, limit }) => {
907
+ const authority = await requireAuthorizedSocialConnectionForProvider(
908
+ provider,
909
+ connectionId,
910
+ );
911
+ const result = await socialThreadLive(
912
+ deps,
913
+ {
914
+ workspaceId: grant.workspaceId,
915
+ connectionId,
916
+ subjectId: authority.subjectId,
917
+ },
918
+ { id, limit },
919
+ );
920
+ return json({ provider: result.connection.provider, posts: result.posts });
921
+ },
922
+ );
923
+ }
729
924
  }
730
925
 
731
926
  // Writes are gated on connections:write (never in the default first-party
732
927
  // agent permission set) so scheduled tasks must opt in, and deployments can
733
928
  // additionally wrap posting in a requireApproval policy.
734
- if (!toolspaceMode || can("connections:write")) {
929
+ if (can("connections:write")) {
735
930
  server.registerTool(
736
931
  "social_posts_sync",
737
932
  {
@@ -827,9 +1022,106 @@ export function buildOpenGeniMcpServer(
827
1022
  });
828
1023
  },
829
1024
  );
1025
+
1026
+ for (const provider of ["x", "reddit"] as const) {
1027
+ const providerName = provider === "x" ? "X" : "Reddit";
1028
+ server.registerTool(
1029
+ `${provider}_posts_sync`,
1030
+ {
1031
+ description: `Sync one exact connected ${providerName} account's recent posts into OpenGeni (idempotent).`,
1032
+ inputSchema: {
1033
+ connectionId: z4.string().uuid(),
1034
+ limit: z4.number().int().positive().optional(),
1035
+ },
1036
+ },
1037
+ async ({ connectionId, limit }) => {
1038
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1039
+ provider,
1040
+ connectionId,
1041
+ );
1042
+ const result = await socialOwnPostsLive(
1043
+ deps,
1044
+ {
1045
+ workspaceId: grant.workspaceId,
1046
+ connectionId,
1047
+ subjectId: authority.subjectId,
1048
+ },
1049
+ { limit },
1050
+ );
1051
+ const datedPosts = result.posts.filter((post) => post.createdAt !== null);
1052
+ const synced = await recordSyncedSocialPosts(deps.db, {
1053
+ accountId: grant.accountId,
1054
+ workspaceId: grant.workspaceId,
1055
+ connectionId,
1056
+ subjectId: authority.subjectId,
1057
+ posts: datedPosts.map((post) => ({
1058
+ externalPostId: post.id,
1059
+ url: post.url,
1060
+ authorHandle: post.author,
1061
+ text: post.text,
1062
+ publishedAt: new Date(post.createdAt!),
1063
+ metrics: post.metrics,
1064
+ })),
1065
+ });
1066
+ return json({
1067
+ provider: result.connection.provider,
1068
+ fetched: result.posts.length,
1069
+ inserted: synced.inserted,
1070
+ skipped: synced.skipped,
1071
+ skippedMissingDate: result.posts.length - datedPosts.length,
1072
+ });
1073
+ },
1074
+ );
1075
+ server.registerTool(
1076
+ `${provider}_post_reply`,
1077
+ {
1078
+ description: `Publish a reply from one exact connected ${providerName} account. This is a public write and should require approval.`,
1079
+ inputSchema: {
1080
+ connectionId: z4.string().uuid(),
1081
+ inReplyToId: z4.string().min(1).max(100),
1082
+ text: z4.string().min(1).max(10000),
1083
+ },
1084
+ },
1085
+ async ({ connectionId, inReplyToId, text }) => {
1086
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1087
+ provider,
1088
+ connectionId,
1089
+ );
1090
+ const result = await socialPostReply(
1091
+ deps,
1092
+ {
1093
+ workspaceId: grant.workspaceId,
1094
+ connectionId,
1095
+ subjectId: authority.subjectId,
1096
+ },
1097
+ { inReplyToId, text },
1098
+ );
1099
+ await recordAuditEvent(deps.db, {
1100
+ accountId: grant.accountId,
1101
+ workspaceId: grant.workspaceId,
1102
+ subjectId: grant.subjectId,
1103
+ action: "social.post_reply",
1104
+ targetType: "social_connection",
1105
+ targetId: connectionId,
1106
+ metadata: {
1107
+ provider: result.connection.provider,
1108
+ adapterTool: `${provider}_post_reply`,
1109
+ inReplyToId,
1110
+ postedId: result.postedId,
1111
+ url: result.url,
1112
+ },
1113
+ });
1114
+ return json({
1115
+ provider: result.connection.provider,
1116
+ postedId: result.postedId,
1117
+ url: result.url,
1118
+ });
1119
+ },
1120
+ );
1121
+ }
830
1122
  }
831
1123
 
832
- if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
1124
+ if (can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
833
1125
  server.registerTool(
834
1126
  "scheduled_tasks_list",
835
1127
  {
@@ -977,6 +1269,9 @@ export function buildOpenGeniMcpServer(
977
1269
  sessionAuthorization: deps.sessionAuthorization,
978
1270
  authorizationSurface: "first_party_mcp",
979
1271
  });
1272
+ if (!scheduledTaskUpdateChangesState(existing, update)) {
1273
+ return json(scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false));
1274
+ }
980
1275
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
981
1276
  await syncUpdatedScheduledTask({
982
1277
  db: deps.db,
@@ -984,7 +1279,7 @@ export function buildOpenGeniMcpServer(
984
1279
  previous,
985
1280
  task,
986
1281
  });
987
- return json(scheduledTaskForGrant(task, grant));
1282
+ return json(scheduledTaskReceipt("scheduled_tasks_update", task, "updated", true));
988
1283
  },
989
1284
  );
990
1285
 
@@ -996,6 +1291,9 @@ export function buildOpenGeniMcpServer(
996
1291
  },
997
1292
  async ({ id }) => {
998
1293
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1294
+ if (existing.status === "paused") {
1295
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", existing, "unchanged", false));
1296
+ }
999
1297
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1000
1298
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1001
1299
  status: "paused",
@@ -1006,7 +1304,7 @@ export function buildOpenGeniMcpServer(
1006
1304
  previous,
1007
1305
  task,
1008
1306
  });
1009
- return json(scheduledTaskForGrant(task, grant));
1307
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", task, "updated", true));
1010
1308
  },
1011
1309
  );
1012
1310
 
@@ -1018,6 +1316,9 @@ export function buildOpenGeniMcpServer(
1018
1316
  },
1019
1317
  async ({ id }) => {
1020
1318
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1319
+ if (existing.status === "active") {
1320
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", existing, "unchanged", false));
1321
+ }
1021
1322
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1022
1323
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1023
1324
  status: "active",
@@ -1028,7 +1329,7 @@ export function buildOpenGeniMcpServer(
1028
1329
  previous,
1029
1330
  task,
1030
1331
  });
1031
- return json(scheduledTaskForGrant(task, grant));
1332
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", task, "updated", true));
1032
1333
  },
1033
1334
  );
1034
1335
 
@@ -1044,31 +1345,32 @@ export function buildOpenGeniMcpServer(
1044
1345
  },
1045
1346
  async ({ id, triggerId }) => {
1046
1347
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1047
- await validateScheduledTaskTarget({
1048
- db: deps.db,
1049
- sessionAuthorization: deps.sessionAuthorization,
1050
- authorizationSurface: "first_party_mcp",
1051
- grant,
1052
- targetSessionId: task.targetSessionId,
1053
- runMode: task.runMode,
1054
- variableSetId: task.variableSetId,
1055
- rigId: task.rigId,
1056
- agentConfig: task.agentConfig,
1057
- missingTargetStatus: 404,
1058
- });
1059
- await requireLimit(deps, {
1060
- accountId: grant.accountId,
1061
- workspaceId: grant.workspaceId,
1062
- action: "agent_run:create",
1063
- quantity: 1,
1064
- model: task.agentConfig.model ?? deps.settings.openaiModel,
1065
- });
1348
+ if (task.action.kind === "agent_turn") {
1349
+ await validateScheduledTaskTarget({
1350
+ db: deps.db,
1351
+ sessionAuthorization: deps.sessionAuthorization,
1352
+ authorizationSurface: "first_party_mcp",
1353
+ grant,
1354
+ targetSessionId: task.targetSessionId,
1355
+ runMode: task.runMode,
1356
+ variableSetId: task.variableSetId,
1357
+ rigId: task.rigId,
1358
+ agentConfig: task.agentConfig,
1359
+ missingTargetStatus: 404,
1360
+ });
1361
+ await requireLimit(deps, {
1362
+ accountId: grant.accountId,
1363
+ workspaceId: grant.workspaceId,
1364
+ action: "agent_run:create",
1365
+ quantity: 1,
1366
+ model: task.agentConfig.model ?? deps.settings.openaiModel,
1367
+ });
1368
+ }
1066
1369
  const triggerToken = scheduledTaskTriggerToken(triggerId);
1067
- const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(
1068
- grant.workspaceId,
1069
- task.id,
1070
- triggerToken,
1071
- );
1370
+ const agentRunUsageIdempotencyKey =
1371
+ task.action.kind === "agent_turn"
1372
+ ? manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken)
1373
+ : `knowledge-source-sync:manual:${grant.workspaceId}:${task.id}:${triggerToken}`;
1072
1374
  const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
1073
1375
  await deps.workflowClient.triggerScheduledTask({
1074
1376
  task,
@@ -1077,6 +1379,14 @@ export function buildOpenGeniMcpServer(
1077
1379
  initiator: { kind: "subject", subjectId: grant.subjectId },
1078
1380
  });
1079
1381
  try {
1382
+ if (task.action.kind !== "agent_turn") {
1383
+ return json(
1384
+ scheduledTaskReceipt("scheduled_tasks_trigger", task, "triggered", true, {
1385
+ idempotencyStatus: triggerId ? "unknown" : "not_requested",
1386
+ facts: { triggerWorkflowId },
1387
+ }),
1388
+ );
1389
+ }
1080
1390
  await recordWorkspaceUsage(deps, {
1081
1391
  accountId: grant.accountId,
1082
1392
  workspaceId: grant.workspaceId,
@@ -1117,6 +1427,17 @@ export function buildOpenGeniMcpServer(
1117
1427
  },
1118
1428
  async ({ id }) => {
1119
1429
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1430
+ if (task.metadata.connectorKind === "atlassian") {
1431
+ await revokeAtlassianScheduleAuthorization(deps, {
1432
+ task,
1433
+ subjectId: grant.subjectId,
1434
+ });
1435
+ } else {
1436
+ await revokeKnowledgeSourceScheduleAuthorization(deps, {
1437
+ task,
1438
+ subjectId: grant.subjectId,
1439
+ });
1440
+ }
1120
1441
  await deps.workflowClient.deleteScheduledTaskSchedule({
1121
1442
  temporalScheduleId: task.temporalScheduleId,
1122
1443
  });
@@ -1163,7 +1484,6 @@ export function buildOpenGeniMcpServer(
1163
1484
  );
1164
1485
  }
1165
1486
 
1166
- registerToolspaceProxyTools(server, options.toolspace ?? null);
1167
1487
  server.ensureToolsListHandler();
1168
1488
 
1169
1489
  return server;
@@ -1404,48 +1724,114 @@ function registerSlackBotTools(
1404
1724
  );
1405
1725
  }
1406
1726
 
1407
- function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSurface | null): void {
1408
- if (!surface) {
1409
- return;
1410
- }
1411
- // McpServer installs its tools/list handler lazily on the first registered
1412
- // tool. A legitimate empty Toolspace surface (no selected proxyable servers,
1413
- // no active turn, or all optional upstreams unavailable) must therefore seed
1414
- // and disable one invisible tool; otherwise `ogtool list` receives JSON-RPC
1415
- // "Method not found" instead of the valid `{ tools: [] }` response.
1416
- if (surface.tools.length === 0) {
1417
- server
1418
- .registerTool(
1419
- "__opengeni_empty_toolspace_surface__",
1420
- {
1421
- description: "Internal disabled placeholder for an empty Toolspace surface.",
1422
- inputSchema: z4.object({}),
1423
- },
1424
- async () => ({
1425
- content: [{ type: "text" as const, text: '{"unavailable":true}' }],
1727
+ function registerAtlassianTools(
1728
+ server: McpServer,
1729
+ deps: ApiRouteDeps,
1730
+ grant: AccessGrant,
1731
+ json: JsonResult,
1732
+ ): void {
1733
+ const connectionFor = async (connectionId?: string) => {
1734
+ const authorized = await authorizedAtlassianConnectionsForGrant({ db: deps.db, grant });
1735
+ const candidates = authorized.filter(({ connection }) =>
1736
+ connectionId ? connection.id === connectionId : true,
1737
+ );
1738
+ if (candidates.length === 0) {
1739
+ throw new Error(
1740
+ connectionId
1741
+ ? "the requested Atlassian connection is unavailable for this turn"
1742
+ : "no Atlassian connection is available for this turn",
1743
+ );
1744
+ }
1745
+ if (!connectionId && candidates.length > 1) {
1746
+ throw new Error(
1747
+ "connectionId is required because multiple Atlassian connections are available",
1748
+ );
1749
+ }
1750
+ const authority = candidates[0]!;
1751
+ const metadata = AtlassianConnectionMetadata.safeParse(authority.connection.metadata);
1752
+ if (!metadata.success) throw new Error("Atlassian connection metadata is invalid");
1753
+ return {
1754
+ connection: authority.connection,
1755
+ metadata: metadata.data,
1756
+ subjectId: authority.subjectId ?? grant.subjectId,
1757
+ };
1758
+ };
1759
+
1760
+ server.registerTool(
1761
+ "atlassian_sources_list",
1762
+ {
1763
+ description:
1764
+ "List the Jira projects and Confluence spaces available through the authorized Atlassian connection, including which sources are selected for OpenGeni. Use this before search when the site or boundary is unclear.",
1765
+ inputSchema: { connectionId: z4.string().uuid().optional() },
1766
+ },
1767
+ async ({ connectionId }) => {
1768
+ const authority = await connectionFor(connectionId);
1769
+ const response = await browseAtlassianSources(deps, {
1770
+ workspaceId: grant.workspaceId,
1771
+ subjectId: authority.subjectId,
1772
+ connectionId: authority.connection.id,
1773
+ });
1774
+ const selected = new Set(authority.metadata.selectedSources.map((source) => source.id));
1775
+ return json({
1776
+ connectionId: authority.connection.id,
1777
+ account: authority.metadata.displayName,
1778
+ items: response.items.map((item) => ({ ...item, selected: selected.has(item.id) })),
1779
+ });
1780
+ },
1781
+ );
1782
+
1783
+ server.registerTool(
1784
+ "atlassian_search",
1785
+ {
1786
+ description:
1787
+ "Search Jira issues and Confluence pages live within the projects and spaces selected for OpenGeni. Results reflect current Atlassian data and permissions, independent of the knowledge sync index.",
1788
+ inputSchema: {
1789
+ connectionId: z4.string().uuid().optional(),
1790
+ query: z4.string().min(1).max(500),
1791
+ product: z4.enum(["jira", "confluence"]).optional(),
1792
+ limit: z4.number().int().min(1).max(50).optional(),
1793
+ },
1794
+ },
1795
+ async ({ connectionId, query, product, limit }) => {
1796
+ const authority = await connectionFor(connectionId);
1797
+ return json({
1798
+ connectionId: authority.connection.id,
1799
+ results: await searchAtlassianLive(deps, {
1800
+ workspaceId: grant.workspaceId,
1801
+ subjectId: authority.subjectId,
1802
+ connectionId: authority.connection.id,
1803
+ query,
1804
+ ...(product ? { product } : {}),
1805
+ limit: limit ?? 20,
1426
1806
  }),
1427
- )
1428
- .disable();
1429
- return;
1430
- }
1431
- for (const tool of surface.tools) {
1432
- server.registerTool(
1433
- tool.name,
1434
- {
1435
- ...(tool.description ? { description: tool.description } : {}),
1436
- inputSchema: z4.object({}).passthrough(),
1437
- _meta: {
1438
- opengeni: {
1439
- origin: "toolspace",
1440
- subjectId: surface.subjectId,
1441
- sessionId: surface.sessionId,
1442
- ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
1443
- },
1444
- },
1807
+ });
1808
+ },
1809
+ );
1810
+
1811
+ server.registerTool(
1812
+ "atlassian_get",
1813
+ {
1814
+ description:
1815
+ "Open one current Jira issue or Confluence page, including description or page content and comments. The item must belong to a project or space selected for OpenGeni.",
1816
+ inputSchema: {
1817
+ connectionId: z4.string().uuid().optional(),
1818
+ kind: z4.enum(["jira_issue", "confluence_page"]),
1819
+ id: z4.string().min(1).max(256),
1445
1820
  },
1446
- async (args) => await tool.call(args),
1447
- );
1448
- }
1821
+ },
1822
+ async ({ connectionId, kind, id }) => {
1823
+ const authority = await connectionFor(connectionId);
1824
+ return json(
1825
+ await getAtlassianLiveItem(deps, {
1826
+ workspaceId: grant.workspaceId,
1827
+ subjectId: authority.subjectId,
1828
+ connectionId: authority.connection.id,
1829
+ kind,
1830
+ id,
1831
+ }),
1832
+ );
1833
+ },
1834
+ );
1449
1835
  }
1450
1836
 
1451
1837
  /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
@@ -1519,7 +1905,7 @@ function registerGoalTools(
1519
1905
  ? (grant.metadata["turnId"] as string)
1520
1906
  : null;
1521
1907
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
1522
- const { goal, events } = await upsertSessionGoalWithEvent(deps.db, {
1908
+ const { goal, replaced, events } = await upsertSessionGoalWithEvent(deps.db, {
1523
1909
  accountId: grant.accountId,
1524
1910
  workspaceId: grant.workspaceId,
1525
1911
  sessionId,
@@ -1532,7 +1918,24 @@ function registerGoalTools(
1532
1918
  if (events.length > 0) {
1533
1919
  await deps.bus.publish(grant.workspaceId, sessionId, events);
1534
1920
  }
1535
- return json(goal);
1921
+ return json(
1922
+ mcpMutationReceipt({
1923
+ operation: "goal_set",
1924
+ committed: true,
1925
+ outcome: replaced ? "updated" : "created",
1926
+ changed: true,
1927
+ resource: {
1928
+ type: "session_goal",
1929
+ id: goal.id,
1930
+ version: goal.version,
1931
+ state: goal.status,
1932
+ },
1933
+ timestamp: goal.updatedAt,
1934
+ idempotency: { status: "not_supported" },
1935
+ facts: { replaced },
1936
+ nextAction: { tool: "session_get", arguments: { sessionId } },
1937
+ }),
1938
+ );
1536
1939
  },
1537
1940
  );
1538
1941
 
@@ -1706,7 +2109,7 @@ function registerWorkspaceArtifactTools(
1706
2109
  json: JsonResult,
1707
2110
  ): void {
1708
2111
  const attempt = () => {
1709
- const claims = preferenceAttemptClaims(grant);
2112
+ const claims = exactAgentAttemptClaims(grant);
1710
2113
  if (!claims) throw new Error("Exact signed artifact attempt authority is required.");
1711
2114
  return claims;
1712
2115
  };
@@ -1894,7 +2297,7 @@ function registerWorkspaceArtifactTools(
1894
2297
  );
1895
2298
  }
1896
2299
 
1897
- function preferenceAttemptClaims(grant: AccessGrant): {
2300
+ function exactAgentAttemptClaims(grant: AccessGrant): {
1898
2301
  sessionId: string;
1899
2302
  turnId: string;
1900
2303
  attemptId: string;
@@ -1927,7 +2330,7 @@ function registerPreferenceRegistryTools(
1927
2330
  json: JsonResult,
1928
2331
  ): void {
1929
2332
  const attemptClaims = () => {
1930
- const resolved = preferenceAttemptClaims(grant);
2333
+ const resolved = exactAgentAttemptClaims(grant);
1931
2334
  if (!resolved) throw new Error("Exact signed preference attempt authority is required.");
1932
2335
  return {
1933
2336
  accountId: grant.accountId,
@@ -1992,11 +2395,69 @@ function scheduledTaskReceipt(
1992
2395
  });
1993
2396
  }
1994
2397
 
2398
+ function scheduledTaskUpdateChangesState(
2399
+ task: ScheduledTask,
2400
+ update: Awaited<ReturnType<typeof validatedScheduledTaskUpdate>>,
2401
+ ): boolean {
2402
+ if (update.name !== undefined && update.name !== task.name) return true;
2403
+ if (update.status !== undefined && update.status !== task.status) return true;
2404
+ if (update.schedule !== undefined && stableJson(update.schedule) !== stableJson(task.schedule)) {
2405
+ return true;
2406
+ }
2407
+ if (update.runMode !== undefined && update.runMode !== task.runMode) return true;
2408
+ if (update.overlapPolicy !== undefined && update.overlapPolicy !== task.overlapPolicy)
2409
+ return true;
2410
+ if (
2411
+ update.agentConfig !== undefined &&
2412
+ stableJson(update.agentConfig) !== stableJson(task.agentConfig)
2413
+ ) {
2414
+ return true;
2415
+ }
2416
+ if (update.targetSessionId !== undefined && update.targetSessionId !== task.targetSessionId) {
2417
+ return true;
2418
+ }
2419
+ if (
2420
+ update.reusableSessionId !== undefined &&
2421
+ update.reusableSessionId !== task.reusableSessionId
2422
+ ) {
2423
+ return true;
2424
+ }
2425
+ if (update.variableSetId !== undefined && update.variableSetId !== task.variableSetId)
2426
+ return true;
2427
+ if (update.rigId !== undefined && update.rigId !== task.rigId) return true;
2428
+ if (update.metadata !== undefined && stableJson(update.metadata) !== stableJson(task.metadata)) {
2429
+ return true;
2430
+ }
2431
+ // Personal-connection delegations are recomputed with agentConfig and can
2432
+ // change even when the visible config is byte-identical (for example after a
2433
+ // connection rotation), so preserve that refresh as a real mutation.
2434
+ if (update.personalConnectionDelegations !== undefined) return true;
2435
+ return false;
2436
+ }
2437
+
1995
2438
  function memoryPreview(text: string): string {
1996
2439
  const normalized = text.replace(/\s+/g, " ").trim();
1997
2440
  return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
1998
2441
  }
1999
2442
 
2443
+ export function memorySlackPublicationActor(
2444
+ actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
2445
+ sessionId: string,
2446
+ fallbackOwnerLabel: string | null,
2447
+ ) {
2448
+ return {
2449
+ actor: {
2450
+ kind: actor.initiator.kind === "subject" ? ("human" as const) : ("service" as const),
2451
+ subjectId: actor.initiator.subjectId,
2452
+ initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
2453
+ sessionId,
2454
+ turnId: actor.turnId,
2455
+ attemptId: actor.attemptId,
2456
+ },
2457
+ ownerLabel: actor.initiator.label ?? fallbackOwnerLabel,
2458
+ };
2459
+ }
2460
+
2000
2461
  function registerMemoryTools(
2001
2462
  server: McpServer,
2002
2463
  deps: ApiRouteDeps,
@@ -2004,6 +2465,17 @@ function registerMemoryTools(
2004
2465
  sessionId: string,
2005
2466
  json: JsonResult,
2006
2467
  ): void {
2468
+ const publicationActor = async () => {
2469
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
2470
+ return memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null);
2471
+ };
2472
+ const publicationInputSchema = z4.object({
2473
+ importance: z4.enum(["major", "normal", "minor"]),
2474
+ audience: z4.literal("workspace"),
2475
+ slackMode: z4.enum(["auto", "review", "never"]),
2476
+ shareSummary: z4.string().trim().min(1).max(4_096),
2477
+ });
2478
+
2007
2479
  server.registerTool(
2008
2480
  "memory_search",
2009
2481
  {
@@ -2038,10 +2510,12 @@ function registerMemoryTools(
2038
2510
  kind: MemoryKindSchema,
2039
2511
  confidence: z4.number().min(0).max(1).optional(),
2040
2512
  replaces_id: z4.string().min(1).optional(),
2513
+ slack_publication: publicationInputSchema.optional(),
2041
2514
  },
2042
2515
  },
2043
- async ({ text, kind, confidence, replaces_id }) => {
2044
- const result = await saveWorkspaceMemory(
2516
+ async ({ text, kind, confidence, replaces_id, slack_publication }) => {
2517
+ const principal = slack_publication ? await publicationActor() : null;
2518
+ const result = await saveWorkspaceMemoryWithSlackPublication(
2045
2519
  deps.db,
2046
2520
  {
2047
2521
  accountId: grant.accountId,
@@ -2053,6 +2527,13 @@ function registerMemoryTools(
2053
2527
  ...(replaces_id ? { replacesId: replaces_id } : {}),
2054
2528
  origin: "agent",
2055
2529
  },
2530
+ slack_publication
2531
+ ? {
2532
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
2533
+ actor: principal!.actor,
2534
+ ownerLabel: principal!.ownerLabel,
2535
+ }
2536
+ : null,
2056
2537
  deps.getDocumentServices().embedder,
2057
2538
  );
2058
2539
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -2104,6 +2585,19 @@ function registerMemoryTools(
2104
2585
  dedupeReason: result.dedupeReason,
2105
2586
  updatedInPlace: result.updated,
2106
2587
  embedded: result.embedded,
2588
+ slackPublicationDecision: result.slackPublication.decision?.eligible
2589
+ ? "eligible"
2590
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
2591
+ slackPublicationId:
2592
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2593
+ result.slackPublication.enqueue?.kind === "replayed"
2594
+ ? result.slackPublication.enqueue.publication.id
2595
+ : null,
2596
+ slackPublicationState:
2597
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2598
+ result.slackPublication.enqueue?.kind === "replayed"
2599
+ ? result.slackPublication.enqueue.publication.state
2600
+ : null,
2107
2601
  },
2108
2602
  }),
2109
2603
  );
@@ -2118,10 +2612,12 @@ function registerMemoryTools(
2118
2612
  id: z4.string().min(1),
2119
2613
  reason: z4.string().min(1).optional(),
2120
2614
  replacement_text: z4.string().min(1).optional(),
2615
+ slack_publication: publicationInputSchema.optional(),
2121
2616
  },
2122
2617
  },
2123
- async ({ id, reason, replacement_text }) => {
2124
- const result = await correctWorkspaceMemory(
2618
+ async ({ id, reason, replacement_text, slack_publication }) => {
2619
+ const principal = slack_publication ? await publicationActor() : null;
2620
+ const result = await correctWorkspaceMemoryWithSlackPublication(
2125
2621
  deps.db,
2126
2622
  {
2127
2623
  accountId: grant.accountId,
@@ -2131,6 +2627,13 @@ function registerMemoryTools(
2131
2627
  ...(reason ? { reason } : {}),
2132
2628
  ...(replacement_text ? { replacementText: replacement_text } : {}),
2133
2629
  },
2630
+ slack_publication
2631
+ ? {
2632
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
2633
+ actor: principal!.actor,
2634
+ ownerLabel: principal!.ownerLabel,
2635
+ }
2636
+ : null,
2134
2637
  deps.getDocumentServices().embedder,
2135
2638
  );
2136
2639
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -2173,7 +2676,22 @@ function registerMemoryTools(
2173
2676
  : undefined,
2174
2677
  timestamp: (result.replacement ?? result.memory).updatedAt,
2175
2678
  idempotency: { status: "not_supported" },
2176
- facts: { correctionAction: result.action },
2679
+ facts: {
2680
+ correctionAction: result.action,
2681
+ slackPublicationDecision: result.slackPublication.decision?.eligible
2682
+ ? "eligible"
2683
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
2684
+ slackPublicationId:
2685
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2686
+ result.slackPublication.enqueue?.kind === "replayed"
2687
+ ? result.slackPublication.enqueue.publication.id
2688
+ : null,
2689
+ slackPublicationState:
2690
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2691
+ result.slackPublication.enqueue?.kind === "replayed"
2692
+ ? result.slackPublication.enqueue.publication.state
2693
+ : null,
2694
+ },
2177
2695
  }),
2178
2696
  );
2179
2697
  },
@@ -2309,7 +2827,7 @@ function registerConnectedMachineTools(
2309
2827
  "connected_machine_remove",
2310
2828
  {
2311
2829
  description:
2312
- "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, session/route/lease/archive history is retained, and a fresh human-approved device-flow enrollment is required to reconnect. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Blocked outcomes include the exact active dependency and the action needed before retrying.",
2830
+ "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, session/route/lease/archive history is retained, and a fresh human-approved device-flow enrollment is required to reconnect. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Blocked outcomes include every dependent session and the action needed before retrying. Move each dependent session through the canonical sandbox_swap target=default path before retrying removal; the removal authority never rewrites routes directly.",
2313
2831
  inputSchema: {
2314
2832
  enrollmentId: z4.string().uuid(),
2315
2833
  expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
@@ -2341,12 +2859,10 @@ async function beginMcpRigVerificationAttempt(
2341
2859
  try {
2342
2860
  return await beginRigChangeVerificationAttempt(deps.db, workspaceId, changeId, {
2343
2861
  startedAt: new Date().toISOString(),
2862
+ allowAlreadyVerifying: true,
2344
2863
  });
2345
2864
  } catch (error) {
2346
- if (
2347
- error instanceof RigChangeAlreadyVerifyingError ||
2348
- error instanceof RigChangeTransitionError
2349
- ) {
2865
+ if (error instanceof RigChangeTransitionError) {
2350
2866
  throw new Error(error.message, { cause: error });
2351
2867
  }
2352
2868
  throw error;
@@ -2685,7 +3201,6 @@ function registerWorkspaceOrchestrationTools(
2685
3201
  grant: AccessGrant,
2686
3202
  can: (permission: Permission) => boolean,
2687
3203
  callerSessionId: string | null,
2688
- toolspaceMode: boolean,
2689
3204
  json: JsonResult,
2690
3205
  ): void {
2691
3206
  if (can("sessions:read")) {
@@ -2763,7 +3278,7 @@ function registerWorkspaceOrchestrationTools(
2763
3278
  );
2764
3279
  return json(
2765
3280
  boundSessionDetailMcp(
2766
- await withMcpEffectivePolicy(deps, grant.workspaceId, {
3281
+ await withMcpEffectivePolicy(deps, grant.workspaceId, grant.subjectId, {
2767
3282
  ...projected,
2768
3283
  effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
2769
3284
  }),
@@ -2911,9 +3426,21 @@ function registerWorkspaceOrchestrationTools(
2911
3426
  // Bind the spawned session to a rig (freezes its active version);
2912
3427
  // declared so MCP validation doesn't strip it before the domain reads it.
2913
3428
  rigId: z4.string().uuid().optional(),
2914
- model: z4.string().min(1).optional(),
2915
- reasoningEffort: z4.string().optional(),
2916
- latencyMode: z4.enum(["standard", "priority", "fast"]).optional(),
3429
+ model: z4
3430
+ .string()
3431
+ .min(1)
3432
+ .optional()
3433
+ .describe(
3434
+ "Model for the worker. Omit to inherit the exact calling turn's model, including its Codex subscription billing path.",
3435
+ ),
3436
+ reasoningEffort: z4
3437
+ .string()
3438
+ .optional()
3439
+ .describe("Omit to inherit the exact calling turn's reasoning effort."),
3440
+ latencyMode: z4
3441
+ .enum(["standard", "priority", "fast"])
3442
+ .optional()
3443
+ .describe("Omit to inherit the exact calling turn's latency mode."),
2917
3444
  sandboxBackend: z4.string().optional(),
2918
3445
  // Create-time machine targeting: an enrolled sandbox id (from
2919
3446
  // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
@@ -3007,7 +3534,7 @@ function registerWorkspaceOrchestrationTools(
3007
3534
  );
3008
3535
  }
3009
3536
 
3010
- if (can("sessions:control") && !toolspaceMode) {
3537
+ if (can("sessions:control")) {
3011
3538
  server.registerTool(
3012
3539
  "session_send_message",
3013
3540
  {
@@ -3123,16 +3650,25 @@ function registerWorkspaceOrchestrationTools(
3123
3650
  reason: reason ?? "agent_mcp_pause",
3124
3651
  },
3125
3652
  );
3126
- return json({
3127
- receiptId: controlled.receipt.id,
3128
- effectiveControl: projectEffectiveControlForRelatedAccess(
3129
- serializeEffectiveSessionControl(controlled.control),
3130
- sessionId,
3131
- controlled.authorization?.relatedSessionAccess ?? "root",
3132
- ),
3133
- interruptionCount: controlled.interruptionCount,
3134
- replay: controlled.replay,
3135
- });
3653
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
3654
+ serializeEffectiveSessionControl(controlled.control),
3655
+ sessionId,
3656
+ controlled.authorization?.relatedSessionAccess ?? "root",
3657
+ );
3658
+ return json(
3659
+ mcpMutationReceipt({
3660
+ operation: "session_pause",
3661
+ committed: true,
3662
+ outcome: controlled.replay ? "replayed" : "updated",
3663
+ changed: !controlled.replay,
3664
+ resource: { type: "session", id: sessionId, state: effectiveControl.state },
3665
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
3666
+ timestamp: controlled.receipt.createdAt.toISOString(),
3667
+ idempotency: { status: controlled.replay ? "replayed" : "applied" },
3668
+ facts: { interruptionCount: controlled.interruptionCount },
3669
+ nextAction: { tool: "session_get", arguments: { sessionId } },
3670
+ }),
3671
+ );
3136
3672
  }
3137
3673
  const controlled = await controlHumanSessionWorkstream(
3138
3674
  deps,
@@ -3176,16 +3712,25 @@ function registerWorkspaceOrchestrationTools(
3176
3712
  reason: reason ?? "agent_mcp_resume",
3177
3713
  },
3178
3714
  );
3179
- return json({
3180
- receiptId: controlled.receipt.id,
3181
- effectiveControl: projectEffectiveControlForRelatedAccess(
3182
- serializeEffectiveSessionControl(controlled.control),
3183
- sessionId,
3184
- controlled.authorization?.relatedSessionAccess ?? "root",
3185
- ),
3186
- interruptionCount: controlled.interruptionCount,
3187
- replay: controlled.replay,
3188
- });
3715
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
3716
+ serializeEffectiveSessionControl(controlled.control),
3717
+ sessionId,
3718
+ controlled.authorization?.relatedSessionAccess ?? "root",
3719
+ );
3720
+ return json(
3721
+ mcpMutationReceipt({
3722
+ operation: "session_resume",
3723
+ committed: true,
3724
+ outcome: controlled.replay ? "replayed" : "updated",
3725
+ changed: !controlled.replay,
3726
+ resource: { type: "session", id: sessionId, state: effectiveControl.state },
3727
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
3728
+ timestamp: controlled.receipt.createdAt.toISOString(),
3729
+ idempotency: { status: controlled.replay ? "replayed" : "applied" },
3730
+ facts: { interruptionCount: controlled.interruptionCount },
3731
+ nextAction: { tool: "session_get", arguments: { sessionId } },
3732
+ }),
3733
+ );
3189
3734
  }
3190
3735
  const controlled = await controlHumanSessionWorkstream(
3191
3736
  deps,
@@ -3283,7 +3828,6 @@ function registerVariableSetTools(
3283
3828
  grant: AccessGrant,
3284
3829
  can: (permission: Permission) => boolean,
3285
3830
  sessionId: string | null,
3286
- toolspaceMode: boolean,
3287
3831
  json: JsonResult,
3288
3832
  ): void {
3289
3833
  const registerListTool = (name: string, description: string): void => {
@@ -3435,7 +3979,6 @@ function registerVariableSetTools(
3435
3979
  }
3436
3980
 
3437
3981
  if (
3438
- !toolspaceMode &&
3439
3982
  sessionId !== null &&
3440
3983
  can("variable-sets:read") &&
3441
3984
  hasLiteralPermission(grant.permissions, "secrets:read")
@@ -3475,7 +4018,7 @@ function registerVariableSetTools(
3475
4018
  throw new Error("variable set variable names must match ^[A-Z][A-Z0-9_]*$");
3476
4019
  }
3477
4020
  assertAllowedVariableSetVariableName(parsedName.data);
3478
- const claims = preferenceAttemptClaims(grant);
4021
+ const claims = exactAgentAttemptClaims(grant);
3479
4022
  if (!claims || claims.sessionId !== sessionId) {
3480
4023
  throw new Error("Exact signed secret-read attempt authority is required.");
3481
4024
  }
@@ -3515,6 +4058,240 @@ function registerVariableSetTools(
3515
4058
  }
3516
4059
  }
3517
4060
 
4061
+ type CapabilitySetupProjection =
4062
+ | { status: "ready"; action: null; detail: string }
4063
+ | {
4064
+ status: "authorization_required";
4065
+ action: "connect" | "add_credentials" | "enable";
4066
+ detail: string;
4067
+ }
4068
+ | { status: "unavailable"; action: null; detail: string };
4069
+
4070
+ function registerCapabilityDiscoveryTools(
4071
+ server: McpServer,
4072
+ deps: ApiRouteDeps,
4073
+ grant: AccessGrant,
4074
+ sessionId: string,
4075
+ json: JsonResult,
4076
+ ): void {
4077
+ const catalog = () =>
4078
+ buildCapabilityCatalog({
4079
+ db: deps.db,
4080
+ workspaceId: grant.workspaceId,
4081
+ settings: deps.settings,
4082
+ subjectId: grant.subjectId,
4083
+ });
4084
+ const authorize = async () => {
4085
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
4086
+ };
4087
+
4088
+ server.registerTool(
4089
+ "capability_catalog_search",
4090
+ {
4091
+ description:
4092
+ "Search OpenGeni's reviewed workspace capability catalog when the user asks to add an integration or the task needs a capability that is not currently usable. Search by the outcome needed (for example `GitHub repositories`, `product analytics`, or `Slack notifications`), compare the returned candidates, and prefer a ready or verified exact match. This only reads secret-free metadata; it never installs, connects, or authorizes anything.",
4093
+ inputSchema: {
4094
+ query: z4.string().min(1).max(500),
4095
+ limit: z4.number().int().min(1).max(20).optional(),
4096
+ },
4097
+ },
4098
+ async ({ query, limit }) => {
4099
+ await authorize();
4100
+ const current = await catalog();
4101
+ const ranked = searchCapabilityCatalogItems(
4102
+ [...current.items, ...nativeConnectionCapabilityRecommendations()],
4103
+ query,
4104
+ limit ?? 8,
4105
+ );
4106
+ const matches = await Promise.all(
4107
+ ranked.map(async ({ item, matchedOn }) => ({
4108
+ capabilityId: item.id,
4109
+ name: item.name,
4110
+ description: item.description,
4111
+ kind: item.kind,
4112
+ source: item.source,
4113
+ category: item.category,
4114
+ tags: item.tags.slice(0, 16),
4115
+ providerDomain: item.providerDomain,
4116
+ authKind: item.authKind,
4117
+ tier: item.tier,
4118
+ matchedOn,
4119
+ setup: {
4120
+ ...(await capabilitySetupProjection(deps, grant.workspaceId, item)),
4121
+ requiredVariables: capabilityRequiredVariables(item),
4122
+ },
4123
+ })),
4124
+ );
4125
+ return json({ query, matches });
4126
+ },
4127
+ );
4128
+
4129
+ server.registerTool(
4130
+ "capability_authorization_request",
4131
+ {
4132
+ description:
4133
+ "After capability_catalog_search and after explaining one chosen recommendation to the user, post exactly one in-session human authorization card for that catalog capability. This tool never grants access, enables a capability, reads a secret, or mints provider credentials; the authenticated user must click and confirm the provider/domain flow. Do not call it for a candidate reported ready or unavailable.",
4134
+ inputSchema: {
4135
+ capabilityId: z4.string().min(1).max(512),
4136
+ rationale: z4.string().min(1).max(2000),
4137
+ },
4138
+ },
4139
+ async ({ capabilityId, rationale }) => {
4140
+ await authorize();
4141
+ const current = await catalog();
4142
+ const item = [...current.items, ...nativeConnectionCapabilityRecommendations()].find(
4143
+ (candidate) => candidate.id === capabilityId,
4144
+ );
4145
+ if (!item || !capabilityCatalogItemIsTrustedForExposure(item)) {
4146
+ throw new Error("Unknown or untrusted capability; search the catalog again.");
4147
+ }
4148
+ const setup = await capabilitySetupProjection(deps, grant.workspaceId, item);
4149
+ if (setup.status === "ready") {
4150
+ return json({
4151
+ capabilityId: item.id,
4152
+ status: "ready",
4153
+ message: setup.detail,
4154
+ });
4155
+ }
4156
+ if (setup.status === "unavailable") {
4157
+ return json({
4158
+ capabilityId: item.id,
4159
+ status: "unavailable",
4160
+ message: setup.detail,
4161
+ });
4162
+ }
4163
+ const claims = exactAgentCommandContext(grant, sessionId);
4164
+ const payload = ToolAuthNeededPayload.parse({
4165
+ serverId: item.runtime.mcpServerId ?? "opengeni",
4166
+ toolName: "capability_authorization_request",
4167
+ providerDomain: capabilityProviderDomain(item),
4168
+ reason: "missing_connection",
4169
+ capability: {
4170
+ id: item.id,
4171
+ name: item.name,
4172
+ kind: item.kind,
4173
+ source: item.source,
4174
+ action: setup.action,
4175
+ rationale,
4176
+ requiredVariables: capabilityRequiredVariables(item),
4177
+ },
4178
+ });
4179
+ const appended = await appendAndPublishTurnEventsFenced(
4180
+ deps.db,
4181
+ deps.bus,
4182
+ grant.workspaceId,
4183
+ sessionId,
4184
+ claims.callerTurnId,
4185
+ claims.callerExecutionGeneration,
4186
+ claims.callerAttemptId,
4187
+ [{ type: "tool.auth_needed", payload }],
4188
+ );
4189
+ if (!appended.accepted) {
4190
+ throw new Error(
4191
+ "The calling turn was replaced before the authorization request committed.",
4192
+ );
4193
+ }
4194
+ return json({
4195
+ capabilityId: item.id,
4196
+ status: "authorization_requested",
4197
+ action: setup.action,
4198
+ eventId: appended.events[0]?.id ?? null,
4199
+ message:
4200
+ "The recommendation was posted for human confirmation. No access has been granted yet.",
4201
+ });
4202
+ },
4203
+ );
4204
+ }
4205
+
4206
+ async function capabilitySetupProjection(
4207
+ deps: ApiRouteDeps,
4208
+ workspaceId: string,
4209
+ item: CapabilityCatalogItem,
4210
+ ): Promise<CapabilitySetupProjection> {
4211
+ if (item.id === "api:github-app" || item.surfaceType === "first_party_github") {
4212
+ const missing = githubAppMissingSettings(deps.settings);
4213
+ if (missing.length > 0) {
4214
+ return {
4215
+ status: "unavailable",
4216
+ action: null,
4217
+ detail:
4218
+ deps.settings.productAccessMode === "managed"
4219
+ ? "GitHub is not available on this deployment."
4220
+ : `The operator must configure the GitHub App first (${missing.join(", ")}).`,
4221
+ };
4222
+ }
4223
+ const installations = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
4224
+ if (githubBindingStatus(true, installations) === "bound") {
4225
+ return { status: "ready", action: null, detail: "GitHub is connected and ready." };
4226
+ }
4227
+ return {
4228
+ status: "authorization_required",
4229
+ action: "connect",
4230
+ detail: "A GitHub owner must approve an installation and repository allowlist.",
4231
+ };
4232
+ }
4233
+ if (item.surfaceType === "codex_apps") {
4234
+ return item.enabled
4235
+ ? { status: "ready", action: null, detail: "Codex Apps is connected and ready." }
4236
+ : {
4237
+ status: "authorization_required",
4238
+ action: "connect",
4239
+ detail: "A workspace admin must designate an authorized Codex Apps subscription.",
4240
+ };
4241
+ }
4242
+ if (item.enabled) {
4243
+ return { status: "ready", action: null, detail: "This capability is already enabled." };
4244
+ }
4245
+ if (!item.runtime.available) {
4246
+ return {
4247
+ status: "unavailable",
4248
+ action: null,
4249
+ detail: item.runtime.notes ?? "This catalog entry has no executable runtime adapter.",
4250
+ };
4251
+ }
4252
+ if (item.authKind === "oauth2" || item.surfaceType === "first_party_social") {
4253
+ return {
4254
+ status: "authorization_required",
4255
+ action: "connect",
4256
+ detail: "The user must confirm the provider domain and complete sign-in.",
4257
+ };
4258
+ }
4259
+ if (item.authKind === "api_key" || item.authModel?.toLowerCase().includes("key")) {
4260
+ return {
4261
+ status: "authorization_required",
4262
+ action: "add_credentials",
4263
+ detail: "The user must provide the required credential through the protected setup form.",
4264
+ };
4265
+ }
4266
+ return {
4267
+ status: "authorization_required",
4268
+ action: "enable",
4269
+ detail: "A workspace admin must review and enable this capability.",
4270
+ };
4271
+ }
4272
+
4273
+ function capabilityRequiredVariables(item: CapabilityCatalogItem): string[] {
4274
+ const variableSet = item.metadata.variableSet;
4275
+ if (!variableSet || typeof variableSet !== "object" || Array.isArray(variableSet)) return [];
4276
+ const required = (variableSet as Record<string, unknown>).requiredVariables;
4277
+ return Array.isArray(required)
4278
+ ? required.filter((name): name is string => typeof name === "string").slice(0, 64)
4279
+ : [];
4280
+ }
4281
+
4282
+ function capabilityProviderDomain(item: CapabilityCatalogItem): string {
4283
+ if (item.providerDomain?.trim()) return item.providerDomain.trim();
4284
+ for (const candidate of [item.homepageUrl, item.endpointUrl, item.mcpUrl]) {
4285
+ if (!candidate) continue;
4286
+ try {
4287
+ return new URL(candidate).hostname;
4288
+ } catch {
4289
+ // Continue to the local, non-provider fallback below.
4290
+ }
4291
+ }
4292
+ return "opengeni.local";
4293
+ }
4294
+
3518
4295
  function registerGitHubConnectTool(
3519
4296
  server: McpServer,
3520
4297
  deps: ApiRouteDeps,
@@ -4051,11 +4828,12 @@ function parseMcpDate(raw: string, label: string): Date {
4051
4828
  async function withMcpEffectivePolicy(
4052
4829
  deps: ApiRouteDeps,
4053
4830
  workspaceId: string,
4831
+ subjectId: string,
4054
4832
  session: Session,
4055
4833
  ): Promise<Session> {
4056
4834
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
4057
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
4058
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
4835
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
4836
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
4059
4837
  ]);
4060
4838
  return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
4061
4839
  }