@opengeni/api-router 0.23.1 → 0.30.0

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 (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
package/src/mcp/server.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  CreateScheduledTaskRequest,
4
- DEFAULT_FIRST_PARTY_MCP_TOOLS,
5
4
  FIRST_PARTY_MCP_TOOL_NAMES,
6
5
  defaultRepositoryMountPath,
7
6
  SESSION_EVENT_RAW_DELTA_TYPES,
@@ -12,25 +11,41 @@ import {
12
11
  SessionEventResultMode,
13
12
  SessionEventSemanticClass,
14
13
  SessionEventType,
14
+ stableJson,
15
15
  compactSessionEventResult,
16
16
  sessionEventLatestClassToSemanticClass,
17
+ MemorySlackPublicationDistribution,
17
18
  SessionMcpCredentialUpdateInput,
19
+ ToolAuthNeededPayload,
18
20
  VariableSetVariableName,
21
+ capabilityCatalogItemIsTrustedForExposure,
19
22
  type AccessGrant,
23
+ type CapabilityCatalogItem,
20
24
  type GitHubRepository,
21
25
  type FirstPartyMcpToolName,
22
26
  type Permission,
23
27
  type ResourceRef,
24
28
  type SessionAuthorizationOperation,
29
+ type SessionAuthorizationActor,
25
30
  type SessionAuthorizationSurface,
26
31
  type Session,
32
+ type WorkspaceMemoryPromptMode,
27
33
  type ScheduledTask,
28
34
  UpdateScheduledTaskRequest,
29
35
  normalizeWorkspaceArtifactSlug,
30
36
  WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES,
37
+ SESSION_GOAL_PROGRESS_MAX_BYTES,
38
+ SESSION_GOAL_RATIONALE_MAX_BYTES,
39
+ SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
40
+ SESSION_GOAL_TEXT_MAX_BYTES,
41
+ sessionGoalUtf8Bytes,
42
+ TASK_NOTE_LIST_DEFAULT_LIMIT,
43
+ TASK_NOTE_LIST_MAX_LIMIT,
44
+ TASK_NOTE_MAX_LIFETIME_DAYS,
45
+ TASK_NOTE_REASON_MAX_BYTES,
46
+ TASK_NOTE_TEXT_MAX_BYTES,
31
47
  } from "@opengeni/contracts";
32
48
  import {
33
- correctWorkspaceMemory,
34
49
  countVariableSets,
35
50
  beginRigChangeVerificationAttempt,
36
51
  createVariableSet,
@@ -67,15 +82,14 @@ import {
67
82
  MEMORY_SEARCH_TOOL_DESCRIPTION,
68
83
  requireScheduledTask,
69
84
  requireSession,
70
- saveWorkspaceMemory,
71
85
  searchWorkspaceMemories,
72
86
  serializeEffectiveSessionControl,
73
87
  setSessionGoalStatusWithEvent,
88
+ recordSessionGoalProgressWithEvent,
74
89
  setVariableSetVariable,
75
90
  updateScheduledTask,
76
91
  updateSessionGoalWithEvent,
77
92
  upsertSessionGoalWithEvent,
78
- RigChangeAlreadyVerifyingError,
79
93
  RigChangeTransitionError,
80
94
  createWorkspaceArtifact,
81
95
  getWorkspaceArtifact,
@@ -83,8 +97,16 @@ import {
83
97
  listWorkspaceArtifacts,
84
98
  publishWorkspaceArtifactVersion,
85
99
  rollbackWorkspaceArtifact,
100
+ archiveTaskNote,
101
+ createTaskNote,
102
+ listTaskNotes,
86
103
  } from "@opengeni/db";
87
- import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
104
+ import {
105
+ appendAndPublishEvents,
106
+ appendAndPublishTurnEventsFenced,
107
+ publishDurableSessionEvents,
108
+ } from "@opengeni/events";
109
+ import { allowedFirstPartyMcpToolsForSession } from "@opengeni/config";
88
110
  import {
89
111
  createSignedState,
90
112
  GitHubAppConfigurationError,
@@ -97,14 +119,23 @@ import {
97
119
  } from "@modelcontextprotocol/sdk/server/mcp.js";
98
120
  import type { AnySchema, ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
99
121
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
122
+ import { HTTPException } from "hono/http-exception";
100
123
  import * as z4 from "zod/v4";
101
124
  import {
102
125
  hasLiteralPermission,
103
126
  hasPermission,
104
127
  authorizedSocialConnectionsForGrant,
128
+ authorizedAtlassianConnectionsForGrant,
129
+ buildCapabilityCatalog,
130
+ nativeConnectionCapabilityRecommendations,
131
+ correctWorkspaceMemoryWithSlackPublication,
105
132
  requireLiveAgentAttemptAuthorization,
106
133
  requireSessionAuthorization,
107
134
  requireSessionAuthorizationListScope,
135
+ SessionAuthorizationDeniedError,
136
+ SessionAuthorizationUnavailableError,
137
+ saveWorkspaceMemoryWithSlackPublication,
138
+ searchCapabilityCatalogItems,
108
139
  type ResolvedSessionAuthorization,
109
140
  } from "@opengeni/core";
110
141
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
@@ -116,12 +147,14 @@ import {
116
147
  } from "../github-access";
117
148
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
118
149
  import {
150
+ assertSocialConnectionProvider,
119
151
  socialMentionsLive,
120
152
  socialOwnPostsLive,
121
153
  socialPostReply,
122
154
  socialSearchLive,
123
155
  socialThreadLive,
124
156
  } from "../integrations/social-api";
157
+ import { revokeKnowledgeSourceScheduleAuthorization } from "../integrations/google-drive";
125
158
  import {
126
159
  promoteVerifiedDefinitionEditChangeForApi,
127
160
  proposeRigChangeForApi,
@@ -186,21 +219,140 @@ import {
186
219
  boundScheduledTaskMcpPage,
187
220
  scheduledTaskMcpSummary,
188
221
  } from "./scheduled-task-view";
189
- import type { ToolspaceMcpSurface } from "./toolspace";
190
222
  import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
191
223
  import {
192
224
  createOpenGeniSlackBotClient,
193
225
  resolveSlackBotConnectionForTool,
194
226
  } from "../integrations/slack-bot";
227
+ import { createFikenClient, resolveFikenConnectionForTool } from "../integrations/fiken";
228
+ import {
229
+ browseAtlassianSources,
230
+ getAtlassianLiveItem,
231
+ revokeAtlassianScheduleAuthorization,
232
+ searchAtlassianLive,
233
+ } from "../integrations/atlassian";
234
+ import { AtlassianConnectionMetadata } from "@opengeni/contracts/atlassian";
235
+ import { registerEditableArtifactAgentTools } from "./editable-artifacts";
195
236
 
196
237
  export type McpServerOptions = {
197
238
  // Origin of the HTTP request that reached the MCP route. Browser-oriented
198
239
  // tools use it only when no configured public base URL is available.
199
240
  requestOrigin?: string | null;
200
- toolspace?: ToolspaceMcpSurface | null;
201
241
  workspaceMemoryEnabled?: boolean | undefined;
242
+ workspaceMemoryPromptMode?: WorkspaceMemoryPromptMode | undefined;
202
243
  };
203
244
 
245
+ const ORCHESTRATION_FAILURE_CODE_MAX_LENGTH = 128;
246
+ const ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES = 1_024;
247
+
248
+ type OrchestrationToolName = "session_create" | "session_send_message";
249
+
250
+ function boundedOrchestrationFailureMessage(value: string): string {
251
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
252
+ if (!normalized) return "OpenGeni could not complete the request.";
253
+ const encoded = new TextEncoder().encode(normalized);
254
+ if (encoded.byteLength <= ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES) return normalized;
255
+ let end = ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES;
256
+ while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1;
257
+ return new TextDecoder().decode(encoded.slice(0, end)).trim();
258
+ }
259
+
260
+ function orchestrationFailureCode(tool: OrchestrationToolName, error: HTTPException): string {
261
+ const suffix =
262
+ error.status === 401
263
+ ? "unauthenticated"
264
+ : error.status === 403
265
+ ? "forbidden"
266
+ : error.status === 404
267
+ ? "not_found"
268
+ : error.status === 409
269
+ ? "conflict"
270
+ : error.status === 429
271
+ ? "limit_exceeded"
272
+ : error.status >= 500
273
+ ? "unavailable"
274
+ : "rejected";
275
+ return `${tool}_${suffix}`.slice(0, ORCHESTRATION_FAILURE_CODE_MAX_LENGTH);
276
+ }
277
+
278
+ function orchestrationFailureEnvelope(tool: OrchestrationToolName, error: unknown) {
279
+ if (error instanceof SessionSpawnDeniedError) {
280
+ const denial = sessionSpawnDenialEnvelope(error);
281
+ return {
282
+ error: {
283
+ ...denial.error,
284
+ message: boundedOrchestrationFailureMessage(denial.error.message),
285
+ },
286
+ };
287
+ }
288
+ if (error instanceof HTTPException) {
289
+ return {
290
+ error: {
291
+ code: orchestrationFailureCode(tool, error),
292
+ message:
293
+ error.status >= 500
294
+ ? "OpenGeni is temporarily unavailable — retry."
295
+ : boundedOrchestrationFailureMessage(error.message),
296
+ },
297
+ };
298
+ }
299
+ if (error instanceof SessionAuthorizationDeniedError) {
300
+ return {
301
+ error: {
302
+ code: `${tool}_not_found_or_denied`,
303
+ message: "Session not found or access denied.",
304
+ },
305
+ };
306
+ }
307
+ if (error instanceof SessionAuthorizationUnavailableError) {
308
+ return {
309
+ error: {
310
+ code: `${tool}_authorization_unavailable`,
311
+ message: "Session authorization is temporarily unavailable — retry.",
312
+ },
313
+ };
314
+ }
315
+ const typedCode =
316
+ error && typeof error === "object" && "code" in error
317
+ ? (error as { code?: unknown }).code
318
+ : null;
319
+ const knownFailure =
320
+ typedCode === "CALLER_STALE"
321
+ ? ["caller_stale", "The calling session no longer owns this attempt."]
322
+ : typedCode === "CALLER_INTERRUPTED"
323
+ ? ["caller_interrupted", "The calling session was interrupted before delivery."]
324
+ : typedCode === "TARGET_NOT_VERTICAL"
325
+ ? ["target_not_vertical", "Agents may message only their parent or immediate children."]
326
+ : typedCode === "CONTROL_CHANGED"
327
+ ? ["conflict", "The target session control state changed; refresh and retry."]
328
+ : typedCode === "IDEMPOTENCY_KEY_REUSED"
329
+ ? ["idempotency_key_reused", "The idempotency key was reused with different input."]
330
+ : null;
331
+ if (knownFailure) {
332
+ return {
333
+ error: {
334
+ code: `${tool}_${knownFailure[0]}`,
335
+ message: knownFailure[1],
336
+ },
337
+ };
338
+ }
339
+ return {
340
+ error: {
341
+ code: `${tool}_failed`,
342
+ message: "OpenGeni could not complete the request.",
343
+ },
344
+ };
345
+ }
346
+
347
+ function orchestrationFailureResult(tool: OrchestrationToolName, error: unknown) {
348
+ const envelope = orchestrationFailureEnvelope(tool, error);
349
+ return {
350
+ content: [{ type: "text" as const, text: JSON.stringify(envelope, null, 2) }],
351
+ structuredContent: envelope,
352
+ isError: true as const,
353
+ };
354
+ }
355
+
204
356
  type FirstPartyToolAuthorization = {
205
357
  sessionRequired?: true;
206
358
  allOf?: readonly Permission[];
@@ -216,6 +368,7 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
216
368
  set_session_title: { sessionRequired: true, allOf: ["sessions:control"] },
217
369
  goal_set: { sessionRequired: true, allOf: ["goals:manage"] },
218
370
  goal_update: { sessionRequired: true, allOf: ["goals:manage"] },
371
+ goal_progress: { sessionRequired: true, allOf: ["goals:manage"] },
219
372
  goal_complete: { sessionRequired: true, allOf: ["goals:manage"] },
220
373
  goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
221
374
  memory_search: { sessionRequired: true, allOf: ["documents:search"] },
@@ -226,6 +379,9 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
226
379
  allOf: ["workspace:read"],
227
380
  },
228
381
  preference_registry_get: { sessionRequired: true, allOf: ["workspace:read"] },
382
+ task_notes_list: { sessionRequired: true, allOf: ["sessions:read"] },
383
+ task_note_save: { sessionRequired: true, allOf: ["sessions:control"] },
384
+ task_note_archive: { sessionRequired: true, allOf: ["sessions:control"] },
229
385
  sandboxes_list: { sessionRequired: true, allOf: ["sessions:read"] },
230
386
  sandbox_attach: { sessionRequired: true, allOf: ["sessions:control"] },
231
387
  sandbox_swap: { sessionRequired: true, allOf: ["sessions:control"] },
@@ -246,6 +402,27 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
246
402
  session_resume: { allOf: ["sessions:control"] },
247
403
  session_steer: { sessionRequired: true, allOf: ["sessions:control"] },
248
404
  set_other_session_title: { allOf: ["sessions:control"] },
405
+ interaction_discover: { sessionRequired: true, allOf: ["sessions:read"] },
406
+ browser_open: { sessionRequired: true, allOf: ["sessions:control"] },
407
+ browser_tabs: { sessionRequired: true, allOf: ["sessions:control"] },
408
+ browser_observe: { sessionRequired: true, allOf: ["sessions:read"] },
409
+ browser_act: { sessionRequired: true, allOf: ["sessions:control"] },
410
+ browser_clipboard: { sessionRequired: true, allOf: ["sessions:read"] },
411
+ browser_debug: { sessionRequired: true, allOf: ["sessions:read"] },
412
+ browser_auth: { sessionRequired: true, allOf: ["sessions:control"] },
413
+ interaction_request_human: {
414
+ sessionRequired: true,
415
+ allOf: ["sessions:control"],
416
+ },
417
+ browser_identity: { sessionRequired: true, allOf: ["sessions:control"] },
418
+ browser_publish: { sessionRequired: true, allOf: ["sessions:control"] },
419
+ browser_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
420
+ computer_open: { sessionRequired: true, allOf: ["sessions:control"] },
421
+ computer_targets: { sessionRequired: true, allOf: ["sessions:read"] },
422
+ computer_observe: { sessionRequired: true, allOf: ["sessions:read"] },
423
+ computer_clipboard: { sessionRequired: true, allOf: ["sessions:read"] },
424
+ computer_act: { sessionRequired: true, allOf: ["sessions:control"] },
425
+ computer_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
249
426
  variable_set_list: { allOf: ["variable-sets:list", "secrets:list"] },
250
427
  environment_list: { allOf: ["variable-sets:list", "secrets:list"] },
251
428
  variable_set_get_variable: {
@@ -256,6 +433,14 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
256
433
  allOf: ["variable-sets:write", "secrets:write"],
257
434
  },
258
435
  environment_set_variable: { allOf: ["variable-sets:write", "secrets:write"] },
436
+ capability_catalog_search: {
437
+ sessionRequired: true,
438
+ allOf: ["workspace:read"],
439
+ },
440
+ capability_authorization_request: {
441
+ sessionRequired: true,
442
+ allOf: ["workspace:read"],
443
+ },
259
444
  github_connect_link: { allOf: ["github:use"] },
260
445
  github_repositories_list: { allOf: ["github:use"] },
261
446
  social_connections_list: { allOf: ["connections:read"] },
@@ -270,6 +455,18 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
270
455
  // Publishes under the user's identity: connections:write keeps it out of the
271
456
  // default agent permission set, unlike the read-only social tools above.
272
457
  social_post_reply: { allOf: ["connections:write"] },
458
+ x_accounts_list: { allOf: ["connections:read"] },
459
+ x_search_live: { allOf: ["connections:read"] },
460
+ x_mentions_live: { allOf: ["connections:read"] },
461
+ x_thread_fetch: { allOf: ["connections:read"] },
462
+ x_posts_sync: { allOf: ["connections:write"] },
463
+ x_post_reply: { allOf: ["connections:write"] },
464
+ reddit_accounts_list: { allOf: ["connections:read"] },
465
+ reddit_search_live: { allOf: ["connections:read"] },
466
+ reddit_mentions_live: { allOf: ["connections:read"] },
467
+ reddit_thread_fetch: { allOf: ["connections:read"] },
468
+ reddit_posts_sync: { allOf: ["connections:write"] },
469
+ reddit_post_reply: { allOf: ["connections:write"] },
273
470
  scheduled_tasks_list: {
274
471
  anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
275
472
  },
@@ -294,11 +491,52 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
294
491
  slack_bot_file_content: { allOf: ["connections:read"] },
295
492
  slack_bot_post_message: { allOf: ["connections:read"] },
296
493
  slack_bot_delete_message: { allOf: ["connections:read"] },
494
+ fiken_companies_list: { allOf: ["connections:read"] },
495
+ fiken_contacts_list: { allOf: ["connections:read"] },
496
+ fiken_products_list: { allOf: ["connections:read"] },
497
+ fiken_invoices_list: { allOf: ["connections:read"] },
498
+ fiken_invoice_get: { allOf: ["connections:read"] },
499
+ fiken_bank_accounts_list: { allOf: ["connections:read"] },
500
+ fiken_purchases_list: { allOf: ["connections:read"] },
501
+ fiken_sales_list: { allOf: ["connections:read"] },
502
+ // Writes into the workspace's real accounting ledger surface take the write
503
+ // scope, keeping them out of the default agent permission set.
504
+ fiken_contact_create: { allOf: ["connections:write"] },
505
+ fiken_invoice_draft_create: { allOf: ["connections:write"] },
506
+ atlassian_sources_list: { allOf: ["connections:read"] },
507
+ atlassian_search: { allOf: ["connections:read"] },
508
+ atlassian_get: { allOf: ["connections:read"] },
297
509
  artifacts_list: { sessionRequired: true, allOf: ["artifacts:read"] },
298
510
  artifacts_get_source: { sessionRequired: true, allOf: ["artifacts:read"] },
299
511
  artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
300
512
  artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
301
513
  artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
514
+ editable_artifact_list: { sessionRequired: true, allOf: ["artifacts:read"] },
515
+ editable_artifact_create: {
516
+ sessionRequired: true,
517
+ allOf: ["artifacts:publish"],
518
+ },
519
+ editable_artifact_import: {
520
+ sessionRequired: true,
521
+ allOf: ["artifacts:publish", "files:read"],
522
+ },
523
+ editable_artifact_get: { sessionRequired: true, allOf: ["artifacts:read"] },
524
+ editable_artifact_inspect: {
525
+ sessionRequired: true,
526
+ allOf: ["artifacts:read"],
527
+ },
528
+ editable_artifact_apply: {
529
+ sessionRequired: true,
530
+ allOf: ["artifacts:publish"],
531
+ },
532
+ editable_artifact_export: {
533
+ sessionRequired: true,
534
+ allOf: ["artifacts:read"],
535
+ },
536
+ editable_artifact_export_status: {
537
+ sessionRequired: true,
538
+ allOf: ["artifacts:read", "files:upload"],
539
+ },
302
540
  } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
303
541
 
304
542
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
@@ -310,7 +548,6 @@ class PolicyMcpServer extends McpServer {
310
548
  private readonly grant: AccessGrant,
311
549
  private readonly sessionId: string | null,
312
550
  private readonly selectedTools: ReadonlySet<FirstPartyMcpToolName> | null,
313
- private readonly allowUncatalogued: boolean,
314
551
  ) {
315
552
  super({ name: "opengeni", version: "1.0.0" });
316
553
  }
@@ -331,7 +568,7 @@ class PolicyMcpServer extends McpServer {
331
568
  cb: ToolCallback<InputArgs>,
332
569
  ): RegisteredTool {
333
570
  const catalogued = FIRST_PARTY_MCP_TOOL_NAME_SET.has(name);
334
- let admitted = this.allowUncatalogued && !catalogued;
571
+ let admitted = false;
335
572
  if (catalogued) {
336
573
  const toolName = name as FirstPartyMcpToolName;
337
574
  const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
@@ -388,7 +625,6 @@ export function buildOpenGeniMcpServer(
388
625
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
389
626
  });
390
627
  const can = (permission: Permission) => hasPermission(grant.permissions, permission);
391
- const toolspaceMode = options.toolspace != null;
392
628
  let socialConnectionsPromise: ReturnType<typeof authorizedSocialConnectionsForGrant> | undefined;
393
629
  const authorizedSocialConnections = () =>
394
630
  (socialConnectionsPromise ??= authorizedSocialConnectionsForGrant({
@@ -403,6 +639,14 @@ export function buildOpenGeniMcpServer(
403
639
  if (!authority) throw new Error(`Unknown or unavailable social connection: ${connectionId}`);
404
640
  return authority;
405
641
  };
642
+ const requireAuthorizedSocialConnectionForProvider = async (
643
+ provider: "x" | "reddit",
644
+ connectionId: string,
645
+ ) => {
646
+ const authority = await requireAuthorizedSocialConnection(connectionId);
647
+ assertSocialConnectionProvider(authority.connection, provider);
648
+ return authority;
649
+ };
406
650
 
407
651
  // Session-scoped tools key off the worker-asserted sessionId claim (signed
408
652
  // into the delegated token by the worker, never agent-controlled).
@@ -411,17 +655,28 @@ export function buildOpenGeniMcpServer(
411
655
  ? (grant.metadata["sessionId"] as string)
412
656
  : null;
413
657
  const selectedTools =
414
- sessionId !== null && !toolspaceMode
658
+ sessionId !== null
415
659
  ? new Set(
416
- (grant.metadata?.["firstPartyMcpTools"] as FirstPartyMcpToolName[] | undefined) ??
417
- DEFAULT_FIRST_PARTY_MCP_TOOLS,
660
+ allowedFirstPartyMcpToolsForSession(
661
+ deps.settings,
662
+ grant.metadata?.["firstPartyMcpTools"] as FirstPartyMcpToolName[] | undefined,
663
+ ),
418
664
  )
419
665
  : null;
420
- const server = new PolicyMcpServer(grant, sessionId, selectedTools, toolspaceMode);
666
+ const nestedAgentDepth = grant.metadata?.["nestedAgentDepth"];
667
+ const effectiveMaxNestedAgentDepth = grant.metadata?.["effectiveMaxNestedAgentDepth"];
668
+ // Optional claims keep rolling deployments compatible. When both trusted
669
+ // facts are present, an exhausted session does not receive an unusable spawn
670
+ // tool; stale/legacy callers still meet the authoritative DB admission gate.
671
+ const sessionCreateVisible =
672
+ typeof nestedAgentDepth !== "number" ||
673
+ typeof effectiveMaxNestedAgentDepth !== "number" ||
674
+ nestedAgentDepth < effectiveMaxNestedAgentDepth;
675
+ const server = new PolicyMcpServer(grant, sessionId, selectedTools);
421
676
  // set_session_title names the agent's OWN session — pure session metadata,
422
677
  // not a goal operation — so it is available on every session, gated only on
423
678
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
424
- if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
679
+ if (sessionId !== null) {
425
680
  server.registerTool(
426
681
  "set_session_title",
427
682
  {
@@ -444,18 +699,31 @@ export function buildOpenGeniMcpServer(
444
699
  if (sessionId !== null && can("goals:manage")) {
445
700
  registerGoalTools(server, deps, grant, sessionId, json);
446
701
  }
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) {
452
- registerMemoryTools(server, deps, grant, sessionId, json);
702
+ if (sessionId !== null && options.workspaceMemoryEnabled === true) {
703
+ registerMemoryTools(
704
+ server,
705
+ deps,
706
+ grant,
707
+ sessionId,
708
+ json,
709
+ options.workspaceMemoryPromptMode ?? "legacy_standing",
710
+ );
453
711
  }
454
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
712
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
455
713
  registerPreferenceRegistryTools(server, deps, grant, json);
714
+ registerTaskNoteTools(server, deps, grant, sessionId, json);
456
715
  }
457
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
716
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
458
717
  registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
718
+ registerEditableArtifactAgentTools({
719
+ server,
720
+ deps,
721
+ grant,
722
+ sessionId,
723
+ authorize: async () => {
724
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
725
+ },
726
+ });
459
727
  }
460
728
 
461
729
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
@@ -464,16 +732,16 @@ export function buildOpenGeniMcpServer(
464
732
  // so they register only when the grant carries the worker-signed sessionId claim
465
733
  // (never agent-controlled). Gated on the selfhosted feature flag: the active
466
734
  // pointer + swap are only meaningful when bring-your-own-compute is enabled.
467
- if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
735
+ if (sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
468
736
  registerFleetTools(server, deps, grant, sessionId, json);
469
737
  }
470
- if (!toolspaceMode && can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
738
+ if (can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
471
739
  registerConnectedMachineTools(server, deps, grant, json);
472
740
  }
473
- if (!toolspaceMode) {
474
- registerRigTools(server, deps, grant, can, sessionId, json);
475
- registerSlackBotTools(server, deps, grant, sessionId, json);
476
- }
741
+ registerRigTools(server, deps, grant, can, sessionId, json);
742
+ registerSlackBotTools(server, deps, grant, sessionId, json);
743
+ registerFikenTools(server, deps, grant, sessionId, json);
744
+ registerAtlassianTools(server, deps, grant, json);
477
745
 
478
746
  // Orchestration, variableSet, and GitHub status tools are permission-gated
479
747
  // at registration: a grant without the permission does not see the tool.
@@ -486,13 +754,24 @@ export function buildOpenGeniMcpServer(
486
754
  // never returned through a model-visible MCP tool. A user DEMOTES a specific
487
755
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
488
756
  // 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);
757
+ registerWorkspaceOrchestrationTools(
758
+ server,
759
+ deps,
760
+ grant,
761
+ can,
762
+ sessionId,
763
+ sessionCreateVisible,
764
+ json,
765
+ );
766
+ registerVariableSetTools(server, deps, grant, can, sessionId, json);
767
+ if (sessionId !== null && can("workspace:read")) {
768
+ registerCapabilityDiscoveryTools(server, deps, grant, sessionId, json);
769
+ }
491
770
  if (can("github:use")) {
492
771
  registerGitHubConnectTool(server, deps, grant, options, json);
493
772
  }
494
773
 
495
- if (!toolspaceMode || can("github:use")) {
774
+ if (can("github:use")) {
496
775
  server.registerTool(
497
776
  "github_repositories_list",
498
777
  {
@@ -521,7 +800,7 @@ export function buildOpenGeniMcpServer(
521
800
  );
522
801
  }
523
802
 
524
- if (!toolspaceMode || can("connections:read")) {
803
+ if (can("connections:read")) {
525
804
  server.registerTool(
526
805
  "social_connections_list",
527
806
  {
@@ -726,12 +1005,131 @@ export function buildOpenGeniMcpServer(
726
1005
  });
727
1006
  },
728
1007
  );
1008
+
1009
+ // Provider-scoped aliases are the canonical tools advertised by the X and
1010
+ // Reddit Integration cards. The legacy social_* names remain available to
1011
+ // existing Packs/sessions, but these names bind the provider in the tool
1012
+ // identity and reject a near-identical Connection from the other adapter.
1013
+ for (const provider of ["x", "reddit"] as const) {
1014
+ const providerName = provider === "x" ? "X" : "Reddit";
1015
+ server.registerTool(
1016
+ `${provider}_accounts_list`,
1017
+ {
1018
+ description: `List the exact visible ${providerName} accounts available to this work.`,
1019
+ inputSchema: { limit: z4.number().int().positive().optional() },
1020
+ },
1021
+ async ({ limit }) =>
1022
+ json({
1023
+ connections: (await authorizedSocialConnections())
1024
+ .filter(
1025
+ ({ connection }) =>
1026
+ connection.provider === provider && connection.status !== "disabled",
1027
+ )
1028
+ .slice(0, boundedMcpLimit(limit))
1029
+ .map(({ connection }) => connection),
1030
+ }),
1031
+ );
1032
+ server.registerTool(
1033
+ `${provider}_search_live`,
1034
+ {
1035
+ description:
1036
+ provider === "x"
1037
+ ? "Search recent X conversations through one exact connected X account."
1038
+ : "Search Reddit through one exact connected Reddit account; optionally scope to a subreddit.",
1039
+ inputSchema: {
1040
+ connectionId: z4.string().uuid(),
1041
+ query: z4.string().min(1).max(512),
1042
+ subreddit: z4.string().min(1).max(100).optional(),
1043
+ limit: z4.number().int().positive().optional(),
1044
+ },
1045
+ },
1046
+ async ({ connectionId, query, subreddit, limit }) => {
1047
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1048
+ provider,
1049
+ connectionId,
1050
+ );
1051
+ const result = await socialSearchLive(
1052
+ deps,
1053
+ {
1054
+ workspaceId: grant.workspaceId,
1055
+ connectionId,
1056
+ subjectId: authority.subjectId,
1057
+ },
1058
+ { query, subreddit, limit },
1059
+ );
1060
+ return json({
1061
+ provider: result.connection.provider,
1062
+ posts: result.posts,
1063
+ });
1064
+ },
1065
+ );
1066
+ server.registerTool(
1067
+ `${provider}_mentions_live`,
1068
+ {
1069
+ description: `Fetch live ${providerName} mentions and replies through one exact connected account.`,
1070
+ inputSchema: {
1071
+ connectionId: z4.string().uuid(),
1072
+ sinceId: z4.string().optional(),
1073
+ limit: z4.number().int().positive().optional(),
1074
+ },
1075
+ },
1076
+ async ({ connectionId, sinceId, limit }) => {
1077
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1078
+ provider,
1079
+ connectionId,
1080
+ );
1081
+ const result = await socialMentionsLive(
1082
+ deps,
1083
+ {
1084
+ workspaceId: grant.workspaceId,
1085
+ connectionId,
1086
+ subjectId: authority.subjectId,
1087
+ },
1088
+ { sinceId, limit },
1089
+ );
1090
+ return json({
1091
+ provider: result.connection.provider,
1092
+ posts: result.posts,
1093
+ });
1094
+ },
1095
+ );
1096
+ server.registerTool(
1097
+ `${provider}_thread_fetch`,
1098
+ {
1099
+ description: `Fetch one live ${providerName} conversation thread through an exact connected account.`,
1100
+ inputSchema: {
1101
+ connectionId: z4.string().uuid(),
1102
+ id: z4.string().min(1).max(100),
1103
+ limit: z4.number().int().positive().optional(),
1104
+ },
1105
+ },
1106
+ async ({ connectionId, id, limit }) => {
1107
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1108
+ provider,
1109
+ connectionId,
1110
+ );
1111
+ const result = await socialThreadLive(
1112
+ deps,
1113
+ {
1114
+ workspaceId: grant.workspaceId,
1115
+ connectionId,
1116
+ subjectId: authority.subjectId,
1117
+ },
1118
+ { id, limit },
1119
+ );
1120
+ return json({
1121
+ provider: result.connection.provider,
1122
+ posts: result.posts,
1123
+ });
1124
+ },
1125
+ );
1126
+ }
729
1127
  }
730
1128
 
731
1129
  // Writes are gated on connections:write (never in the default first-party
732
1130
  // agent permission set) so scheduled tasks must opt in, and deployments can
733
1131
  // additionally wrap posting in a requireApproval policy.
734
- if (!toolspaceMode || can("connections:write")) {
1132
+ if (can("connections:write")) {
735
1133
  server.registerTool(
736
1134
  "social_posts_sync",
737
1135
  {
@@ -827,9 +1225,106 @@ export function buildOpenGeniMcpServer(
827
1225
  });
828
1226
  },
829
1227
  );
1228
+
1229
+ for (const provider of ["x", "reddit"] as const) {
1230
+ const providerName = provider === "x" ? "X" : "Reddit";
1231
+ server.registerTool(
1232
+ `${provider}_posts_sync`,
1233
+ {
1234
+ description: `Sync one exact connected ${providerName} account's recent posts into OpenGeni (idempotent).`,
1235
+ inputSchema: {
1236
+ connectionId: z4.string().uuid(),
1237
+ limit: z4.number().int().positive().optional(),
1238
+ },
1239
+ },
1240
+ async ({ connectionId, limit }) => {
1241
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1242
+ provider,
1243
+ connectionId,
1244
+ );
1245
+ const result = await socialOwnPostsLive(
1246
+ deps,
1247
+ {
1248
+ workspaceId: grant.workspaceId,
1249
+ connectionId,
1250
+ subjectId: authority.subjectId,
1251
+ },
1252
+ { limit },
1253
+ );
1254
+ const datedPosts = result.posts.filter((post) => post.createdAt !== null);
1255
+ const synced = await recordSyncedSocialPosts(deps.db, {
1256
+ accountId: grant.accountId,
1257
+ workspaceId: grant.workspaceId,
1258
+ connectionId,
1259
+ subjectId: authority.subjectId,
1260
+ posts: datedPosts.map((post) => ({
1261
+ externalPostId: post.id,
1262
+ url: post.url,
1263
+ authorHandle: post.author,
1264
+ text: post.text,
1265
+ publishedAt: new Date(post.createdAt!),
1266
+ metrics: post.metrics,
1267
+ })),
1268
+ });
1269
+ return json({
1270
+ provider: result.connection.provider,
1271
+ fetched: result.posts.length,
1272
+ inserted: synced.inserted,
1273
+ skipped: synced.skipped,
1274
+ skippedMissingDate: result.posts.length - datedPosts.length,
1275
+ });
1276
+ },
1277
+ );
1278
+ server.registerTool(
1279
+ `${provider}_post_reply`,
1280
+ {
1281
+ description: `Publish a reply from one exact connected ${providerName} account. This is a public write and should require approval.`,
1282
+ inputSchema: {
1283
+ connectionId: z4.string().uuid(),
1284
+ inReplyToId: z4.string().min(1).max(100),
1285
+ text: z4.string().min(1).max(10000),
1286
+ },
1287
+ },
1288
+ async ({ connectionId, inReplyToId, text }) => {
1289
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1290
+ provider,
1291
+ connectionId,
1292
+ );
1293
+ const result = await socialPostReply(
1294
+ deps,
1295
+ {
1296
+ workspaceId: grant.workspaceId,
1297
+ connectionId,
1298
+ subjectId: authority.subjectId,
1299
+ },
1300
+ { inReplyToId, text },
1301
+ );
1302
+ await recordAuditEvent(deps.db, {
1303
+ accountId: grant.accountId,
1304
+ workspaceId: grant.workspaceId,
1305
+ subjectId: grant.subjectId,
1306
+ action: "social.post_reply",
1307
+ targetType: "social_connection",
1308
+ targetId: connectionId,
1309
+ metadata: {
1310
+ provider: result.connection.provider,
1311
+ adapterTool: `${provider}_post_reply`,
1312
+ inReplyToId,
1313
+ postedId: result.postedId,
1314
+ url: result.url,
1315
+ },
1316
+ });
1317
+ return json({
1318
+ provider: result.connection.provider,
1319
+ postedId: result.postedId,
1320
+ url: result.url,
1321
+ });
1322
+ },
1323
+ );
1324
+ }
830
1325
  }
831
1326
 
832
- if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
1327
+ if (can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
833
1328
  server.registerTool(
834
1329
  "scheduled_tasks_list",
835
1330
  {
@@ -977,6 +1472,9 @@ export function buildOpenGeniMcpServer(
977
1472
  sessionAuthorization: deps.sessionAuthorization,
978
1473
  authorizationSurface: "first_party_mcp",
979
1474
  });
1475
+ if (!scheduledTaskUpdateChangesState(existing, update)) {
1476
+ return json(scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false));
1477
+ }
980
1478
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
981
1479
  await syncUpdatedScheduledTask({
982
1480
  db: deps.db,
@@ -984,7 +1482,7 @@ export function buildOpenGeniMcpServer(
984
1482
  previous,
985
1483
  task,
986
1484
  });
987
- return json(scheduledTaskForGrant(task, grant));
1485
+ return json(scheduledTaskReceipt("scheduled_tasks_update", task, "updated", true));
988
1486
  },
989
1487
  );
990
1488
 
@@ -996,6 +1494,9 @@ export function buildOpenGeniMcpServer(
996
1494
  },
997
1495
  async ({ id }) => {
998
1496
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1497
+ if (existing.status === "paused") {
1498
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", existing, "unchanged", false));
1499
+ }
999
1500
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1000
1501
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1001
1502
  status: "paused",
@@ -1006,7 +1507,7 @@ export function buildOpenGeniMcpServer(
1006
1507
  previous,
1007
1508
  task,
1008
1509
  });
1009
- return json(scheduledTaskForGrant(task, grant));
1510
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", task, "updated", true));
1010
1511
  },
1011
1512
  );
1012
1513
 
@@ -1018,6 +1519,9 @@ export function buildOpenGeniMcpServer(
1018
1519
  },
1019
1520
  async ({ id }) => {
1020
1521
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1522
+ if (existing.status === "active") {
1523
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", existing, "unchanged", false));
1524
+ }
1021
1525
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
1022
1526
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
1023
1527
  status: "active",
@@ -1028,7 +1532,7 @@ export function buildOpenGeniMcpServer(
1028
1532
  previous,
1029
1533
  task,
1030
1534
  });
1031
- return json(scheduledTaskForGrant(task, grant));
1535
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", task, "updated", true));
1032
1536
  },
1033
1537
  );
1034
1538
 
@@ -1044,31 +1548,32 @@ export function buildOpenGeniMcpServer(
1044
1548
  },
1045
1549
  async ({ id, triggerId }) => {
1046
1550
  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
- });
1551
+ if (task.action.kind === "agent_turn") {
1552
+ await validateScheduledTaskTarget({
1553
+ db: deps.db,
1554
+ sessionAuthorization: deps.sessionAuthorization,
1555
+ authorizationSurface: "first_party_mcp",
1556
+ grant,
1557
+ targetSessionId: task.targetSessionId,
1558
+ runMode: task.runMode,
1559
+ variableSetId: task.variableSetId,
1560
+ rigId: task.rigId,
1561
+ agentConfig: task.agentConfig,
1562
+ missingTargetStatus: 404,
1563
+ });
1564
+ await requireLimit(deps, {
1565
+ accountId: grant.accountId,
1566
+ workspaceId: grant.workspaceId,
1567
+ action: "agent_run:create",
1568
+ quantity: 1,
1569
+ model: task.agentConfig.model ?? deps.settings.openaiModel,
1570
+ });
1571
+ }
1066
1572
  const triggerToken = scheduledTaskTriggerToken(triggerId);
1067
- const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(
1068
- grant.workspaceId,
1069
- task.id,
1070
- triggerToken,
1071
- );
1573
+ const agentRunUsageIdempotencyKey =
1574
+ task.action.kind === "agent_turn"
1575
+ ? manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken)
1576
+ : `knowledge-source-sync:manual:${grant.workspaceId}:${task.id}:${triggerToken}`;
1072
1577
  const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
1073
1578
  await deps.workflowClient.triggerScheduledTask({
1074
1579
  task,
@@ -1077,6 +1582,14 @@ export function buildOpenGeniMcpServer(
1077
1582
  initiator: { kind: "subject", subjectId: grant.subjectId },
1078
1583
  });
1079
1584
  try {
1585
+ if (task.action.kind !== "agent_turn") {
1586
+ return json(
1587
+ scheduledTaskReceipt("scheduled_tasks_trigger", task, "triggered", true, {
1588
+ idempotencyStatus: triggerId ? "unknown" : "not_requested",
1589
+ facts: { triggerWorkflowId },
1590
+ }),
1591
+ );
1592
+ }
1080
1593
  await recordWorkspaceUsage(deps, {
1081
1594
  accountId: grant.accountId,
1082
1595
  workspaceId: grant.workspaceId,
@@ -1117,6 +1630,17 @@ export function buildOpenGeniMcpServer(
1117
1630
  },
1118
1631
  async ({ id }) => {
1119
1632
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1633
+ if (task.metadata.connectorKind === "atlassian") {
1634
+ await revokeAtlassianScheduleAuthorization(deps, {
1635
+ task,
1636
+ subjectId: grant.subjectId,
1637
+ });
1638
+ } else {
1639
+ await revokeKnowledgeSourceScheduleAuthorization(deps, {
1640
+ task,
1641
+ subjectId: grant.subjectId,
1642
+ });
1643
+ }
1120
1644
  await deps.workflowClient.deleteScheduledTaskSchedule({
1121
1645
  temporalScheduleId: task.temporalScheduleId,
1122
1646
  });
@@ -1163,7 +1687,6 @@ export function buildOpenGeniMcpServer(
1163
1687
  );
1164
1688
  }
1165
1689
 
1166
- registerToolspaceProxyTools(server, options.toolspace ?? null);
1167
1690
  server.ensureToolsListHandler();
1168
1691
 
1169
1692
  return server;
@@ -1354,98 +1877,385 @@ function registerSlackBotTools(
1354
1877
  );
1355
1878
 
1356
1879
  server.registerTool(
1357
- "slack_bot_post_message",
1880
+ "slack_bot_delete_message",
1358
1881
  {
1359
1882
  description:
1360
- "Post as the workspace-shared OpenGeni bot. Pass channelId for a channel where the bot is already a member, or userId to open/post a DM; pass exactly one. To reply in a thread, also pass its parent message timestamp as threadTimestamp. Generate one operationId UUID per intended message and reuse that same UUID on every retry.",
1883
+ "Delete a message authored by the workspace-shared OpenGeni bot. Pass the channel ID and exact message timestamp returned by a prior post or channel/thread read. Generate one operationId UUID per intended deletion and reuse it on every retry, including after an unknown outcome. Slack refuses deletion of messages not authored by this bot.",
1361
1884
  inputSchema: {
1362
1885
  connectionId: z4.string().uuid().optional(),
1363
1886
  operationId: z4.string().uuid(),
1364
- channelId: z4.string().min(1).max(64).optional(),
1365
- userId: z4.string().min(1).max(64).optional(),
1366
- threadTimestamp: z4.string().min(1).max(64).optional(),
1367
- text: z4.string().min(1).max(40_000),
1887
+ channelId: z4.string().min(1).max(64),
1888
+ timestamp: z4.string().min(1).max(64),
1368
1889
  },
1369
1890
  },
1370
- async ({ connectionId, operationId, channelId, userId, threadTimestamp, text }) => {
1371
- if (Boolean(channelId) === Boolean(userId)) {
1372
- throw new Error("exactly one of channelId or userId is required");
1373
- }
1374
- return json(
1891
+ async ({ connectionId, operationId, channelId, timestamp }) =>
1892
+ json(
1893
+ await (await clientFor(connectionId)).deleteMessage({ operationId, channelId, timestamp }),
1894
+ ),
1895
+ );
1896
+ }
1897
+
1898
+ function registerFikenTools(
1899
+ server: McpServer,
1900
+ deps: ApiRouteDeps,
1901
+ grant: AccessGrant,
1902
+ sessionId: string | null,
1903
+ json: JsonResult,
1904
+ ): void {
1905
+ // One resolution per requested connection per MCP server instance: the
1906
+ // bound row cannot change mid-request, and re-resolving on every tool call
1907
+ // would pay an extra connections read each time.
1908
+ const clients = new Map<string, Promise<ReturnType<typeof createFikenClient>>>();
1909
+ const clientFor = (connectionId?: string) => {
1910
+ const cacheKey = connectionId ?? "";
1911
+ let client = clients.get(cacheKey);
1912
+ if (!client) {
1913
+ client = resolveFikenConnectionForTool({
1914
+ db: deps.db,
1915
+ grant,
1916
+ sessionId,
1917
+ ...(connectionId ? { requestedConnectionId: connectionId } : {}),
1918
+ }).then((resolved) => createFikenClient(deps, resolved));
1919
+ // A failed resolution must not be cached as a poisoned entry.
1920
+ client.catch(() => clients.delete(cacheKey));
1921
+ clients.set(cacheKey, client);
1922
+ }
1923
+ return client;
1924
+ };
1925
+ const companySlugInput = z4
1926
+ .string()
1927
+ .min(1)
1928
+ .max(128)
1929
+ .optional()
1930
+ .describe(
1931
+ "Fiken company slug. Optional when the connection has a default company or access to exactly one company.",
1932
+ );
1933
+ const pageInputs = {
1934
+ page: z4.number().int().min(0).optional(),
1935
+ pageSize: z4.number().int().min(1).max(100).optional(),
1936
+ };
1937
+ const isoDate = z4
1938
+ .string()
1939
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1940
+ .optional();
1941
+
1942
+ server.registerTool(
1943
+ "fiken_companies_list",
1944
+ {
1945
+ description:
1946
+ "List the Fiken companies this workspace's Fiken connection can act on. Use the returned slug as companySlug in other fiken tools.",
1947
+ inputSchema: {
1948
+ connectionId: z4.string().uuid().optional(),
1949
+ ...pageInputs,
1950
+ },
1951
+ },
1952
+ async ({ connectionId, page, pageSize }) =>
1953
+ json(
1375
1954
  await (
1376
1955
  await clientFor(connectionId)
1377
- ).postMessage({
1378
- operationId,
1379
- ...(channelId ? { channelId } : {}),
1380
- ...(userId ? { userId } : {}),
1381
- ...(threadTimestamp ? { threadTimestamp } : {}),
1382
- text,
1956
+ ).listCompanies({
1957
+ ...(page !== undefined ? { page } : {}),
1958
+ ...(pageSize !== undefined ? { pageSize } : {}),
1383
1959
  }),
1384
- );
1960
+ ),
1961
+ );
1962
+
1963
+ server.registerTool(
1964
+ "fiken_contacts_list",
1965
+ {
1966
+ description:
1967
+ "List or search contacts (customers and suppliers) in a Fiken company. Filters combine with AND. customerId used elsewhere equals a contact's contactId where customer is true.",
1968
+ inputSchema: {
1969
+ connectionId: z4.string().uuid().optional(),
1970
+ companySlug: companySlugInput,
1971
+ ...pageInputs,
1972
+ name: z4.string().min(1).max(256).optional(),
1973
+ email: z4.string().min(1).max(256).optional(),
1974
+ organizationNumber: z4.string().min(1).max(64).optional(),
1975
+ customer: z4.boolean().optional(),
1976
+ supplier: z4.boolean().optional(),
1977
+ inactive: z4.boolean().optional(),
1978
+ },
1385
1979
  },
1980
+ async ({ connectionId, ...input }) =>
1981
+ json(await (await clientFor(connectionId)).listContacts(input)),
1386
1982
  );
1387
1983
 
1388
1984
  server.registerTool(
1389
- "slack_bot_delete_message",
1985
+ "fiken_contact_create",
1390
1986
  {
1391
1987
  description:
1392
- "Delete a message authored by the workspace-shared OpenGeni bot. Pass the channel ID and exact message timestamp returned by a prior post or channel/thread read. Generate one operationId UUID per intended deletion and reuse it on every retry, including after an unknown outcome. Slack refuses deletion of messages not authored by this bot.",
1988
+ "Create a contact in a Fiken company. Retrying after an unknown outcome may create a duplicate; search fiken_contacts_list first when unsure.",
1989
+ inputSchema: {
1990
+ connectionId: z4.string().uuid().optional(),
1991
+ companySlug: companySlugInput,
1992
+ name: z4.string().min(1).max(256),
1993
+ email: z4.string().min(1).max(256).optional(),
1994
+ organizationNumber: z4.string().min(1).max(64).optional(),
1995
+ phoneNumber: z4.string().min(1).max(64).optional(),
1996
+ customer: z4.boolean().optional(),
1997
+ supplier: z4.boolean().optional(),
1998
+ },
1999
+ },
2000
+ async ({ connectionId, ...input }) =>
2001
+ json(await (await clientFor(connectionId)).createContact(input)),
2002
+ );
2003
+
2004
+ server.registerTool(
2005
+ "fiken_products_list",
2006
+ {
2007
+ description: "List products in a Fiken company.",
2008
+ inputSchema: {
2009
+ connectionId: z4.string().uuid().optional(),
2010
+ companySlug: companySlugInput,
2011
+ ...pageInputs,
2012
+ name: z4.string().min(1).max(256).optional(),
2013
+ active: z4.boolean().optional(),
2014
+ },
2015
+ },
2016
+ async ({ connectionId, ...input }) =>
2017
+ json(await (await clientFor(connectionId)).listProducts(input)),
2018
+ );
2019
+
2020
+ server.registerTool(
2021
+ "fiken_invoices_list",
2022
+ {
2023
+ description:
2024
+ "List invoices in a Fiken company. Dates are yyyy-mm-dd; monetary amounts in results are in cents/øre of the invoice currency.",
2025
+ inputSchema: {
2026
+ connectionId: z4.string().uuid().optional(),
2027
+ companySlug: companySlugInput,
2028
+ ...pageInputs,
2029
+ issueDateGe: isoDate,
2030
+ issueDateLe: isoDate,
2031
+ customerId: z4.number().int().positive().optional(),
2032
+ settled: z4.boolean().optional(),
2033
+ invoiceNumber: z4.string().min(1).max(64).optional(),
2034
+ },
2035
+ },
2036
+ async ({ connectionId, ...input }) =>
2037
+ json(await (await clientFor(connectionId)).listInvoices(input)),
2038
+ );
2039
+
2040
+ server.registerTool(
2041
+ "fiken_invoice_get",
2042
+ {
2043
+ description: "Read a single Fiken invoice by its invoiceId.",
1393
2044
  inputSchema: {
1394
2045
  connectionId: z4.string().uuid().optional(),
2046
+ companySlug: companySlugInput,
2047
+ invoiceId: z4.number().int().positive(),
2048
+ },
2049
+ },
2050
+ async ({ connectionId, ...input }) =>
2051
+ json(await (await clientFor(connectionId)).getInvoice(input)),
2052
+ );
2053
+
2054
+ server.registerTool(
2055
+ "fiken_invoice_draft_create",
2056
+ {
2057
+ description:
2058
+ "Create an invoice DRAFT in Fiken. Drafts are not sent to the customer; a human finishes and sends them from Fiken. unitPriceCents is the net price per unit in cents/øre. Each line needs either productId or an incomeAccount + vatType. Generate one operationId UUID per intended draft and reuse that same UUID on every retry; a retry returns the existing draft instead of duplicating it.",
2059
+ inputSchema: {
2060
+ connectionId: z4.string().uuid().optional(),
2061
+ companySlug: companySlugInput,
1395
2062
  operationId: z4.string().uuid(),
1396
- channelId: z4.string().min(1).max(64),
1397
- timestamp: z4.string().min(1).max(64),
2063
+ customerId: z4.number().int().positive(),
2064
+ daysUntilDueDate: z4.number().int().min(0).max(365),
2065
+ invoiceText: z4.string().max(500).optional(),
2066
+ yourReference: z4.string().max(128).optional(),
2067
+ ourReference: z4.string().max(128).optional(),
2068
+ currency: z4
2069
+ .string()
2070
+ .regex(/^[A-Z]{3}$/)
2071
+ .optional(),
2072
+ bankAccountNumber: z4.string().min(1).max(64).optional(),
2073
+ lines: z4
2074
+ .array(
2075
+ z4.object({
2076
+ description: z4.string().max(200).optional(),
2077
+ productId: z4.number().int().positive().optional(),
2078
+ unitPriceCents: z4.number().int().optional(),
2079
+ vatType: z4.string().min(1).max(64).optional(),
2080
+ quantity: z4.number().positive(),
2081
+ discountPercent: z4.number().min(0).max(100).optional(),
2082
+ incomeAccount: z4.string().min(1).max(16).optional(),
2083
+ comment: z4.string().max(200).optional(),
2084
+ }),
2085
+ )
2086
+ .min(1)
2087
+ .max(100),
1398
2088
  },
1399
2089
  },
1400
- async ({ connectionId, operationId, channelId, timestamp }) =>
1401
- json(
1402
- await (await clientFor(connectionId)).deleteMessage({ operationId, channelId, timestamp }),
1403
- ),
2090
+ async ({ connectionId, ...input }) =>
2091
+ json(await (await clientFor(connectionId)).createInvoiceDraft(input)),
2092
+ );
2093
+
2094
+ server.registerTool(
2095
+ "fiken_bank_accounts_list",
2096
+ {
2097
+ description:
2098
+ "List bank accounts in a Fiken company, including reconciled balances in cents/øre.",
2099
+ inputSchema: {
2100
+ connectionId: z4.string().uuid().optional(),
2101
+ companySlug: companySlugInput,
2102
+ ...pageInputs,
2103
+ inactive: z4.boolean().optional(),
2104
+ },
2105
+ },
2106
+ async ({ connectionId, ...input }) =>
2107
+ json(await (await clientFor(connectionId)).listBankAccounts(input)),
2108
+ );
2109
+
2110
+ server.registerTool(
2111
+ "fiken_purchases_list",
2112
+ {
2113
+ description: "List purchases in a Fiken company. Dates are yyyy-mm-dd.",
2114
+ inputSchema: {
2115
+ connectionId: z4.string().uuid().optional(),
2116
+ companySlug: companySlugInput,
2117
+ ...pageInputs,
2118
+ dateGe: isoDate,
2119
+ dateLe: isoDate,
2120
+ paid: z4.boolean().optional(),
2121
+ },
2122
+ },
2123
+ async ({ connectionId, ...input }) =>
2124
+ json(await (await clientFor(connectionId)).listPurchases(input)),
2125
+ );
2126
+
2127
+ server.registerTool(
2128
+ "fiken_sales_list",
2129
+ {
2130
+ description: "List sales in a Fiken company. Dates are yyyy-mm-dd.",
2131
+ inputSchema: {
2132
+ connectionId: z4.string().uuid().optional(),
2133
+ companySlug: companySlugInput,
2134
+ ...pageInputs,
2135
+ dateGe: isoDate,
2136
+ dateLe: isoDate,
2137
+ settled: z4.boolean().optional(),
2138
+ },
2139
+ },
2140
+ async ({ connectionId, ...input }) =>
2141
+ json(await (await clientFor(connectionId)).listSales(input)),
1404
2142
  );
1405
2143
  }
1406
2144
 
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}' }],
2145
+ function registerAtlassianTools(
2146
+ server: McpServer,
2147
+ deps: ApiRouteDeps,
2148
+ grant: AccessGrant,
2149
+ json: JsonResult,
2150
+ ): void {
2151
+ const connectionFor = async (connectionId?: string) => {
2152
+ const authorized = await authorizedAtlassianConnectionsForGrant({
2153
+ db: deps.db,
2154
+ grant,
2155
+ });
2156
+ const candidates = authorized.filter(({ connection }) =>
2157
+ connectionId ? connection.id === connectionId : true,
2158
+ );
2159
+ if (candidates.length === 0) {
2160
+ throw new Error(
2161
+ connectionId
2162
+ ? "the requested Atlassian connection is unavailable for this turn"
2163
+ : "no Atlassian connection is available for this turn",
2164
+ );
2165
+ }
2166
+ if (!connectionId && candidates.length > 1) {
2167
+ throw new Error(
2168
+ "connectionId is required because multiple Atlassian connections are available",
2169
+ );
2170
+ }
2171
+ const authority = candidates[0]!;
2172
+ const metadata = AtlassianConnectionMetadata.safeParse(authority.connection.metadata);
2173
+ if (!metadata.success) throw new Error("Atlassian connection metadata is invalid");
2174
+ return {
2175
+ connection: authority.connection,
2176
+ metadata: metadata.data,
2177
+ subjectId: authority.subjectId ?? grant.subjectId,
2178
+ };
2179
+ };
2180
+
2181
+ server.registerTool(
2182
+ "atlassian_sources_list",
2183
+ {
2184
+ description:
2185
+ "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.",
2186
+ inputSchema: { connectionId: z4.string().uuid().optional() },
2187
+ },
2188
+ async ({ connectionId }) => {
2189
+ const authority = await connectionFor(connectionId);
2190
+ const response = await browseAtlassianSources(deps, {
2191
+ workspaceId: grant.workspaceId,
2192
+ subjectId: authority.subjectId,
2193
+ connectionId: authority.connection.id,
2194
+ });
2195
+ const selected = new Set(authority.metadata.selectedSources.map((source) => source.id));
2196
+ return json({
2197
+ connectionId: authority.connection.id,
2198
+ account: authority.metadata.displayName,
2199
+ items: response.items.map((item) => ({
2200
+ ...item,
2201
+ selected: selected.has(item.id),
2202
+ })),
2203
+ });
2204
+ },
2205
+ );
2206
+
2207
+ server.registerTool(
2208
+ "atlassian_search",
2209
+ {
2210
+ description:
2211
+ "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.",
2212
+ inputSchema: {
2213
+ connectionId: z4.string().uuid().optional(),
2214
+ query: z4.string().min(1).max(500),
2215
+ product: z4.enum(["jira", "confluence"]).optional(),
2216
+ limit: z4.number().int().min(1).max(50).optional(),
2217
+ },
2218
+ },
2219
+ async ({ connectionId, query, product, limit }) => {
2220
+ const authority = await connectionFor(connectionId);
2221
+ return json({
2222
+ connectionId: authority.connection.id,
2223
+ results: await searchAtlassianLive(deps, {
2224
+ workspaceId: grant.workspaceId,
2225
+ subjectId: authority.subjectId,
2226
+ connectionId: authority.connection.id,
2227
+ query,
2228
+ ...(product ? { product } : {}),
2229
+ limit: limit ?? 20,
1426
2230
  }),
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
- },
2231
+ });
2232
+ },
2233
+ );
2234
+
2235
+ server.registerTool(
2236
+ "atlassian_get",
2237
+ {
2238
+ description:
2239
+ "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.",
2240
+ inputSchema: {
2241
+ connectionId: z4.string().uuid().optional(),
2242
+ kind: z4.enum(["jira_issue", "confluence_page"]),
2243
+ id: z4.string().min(1).max(256),
1445
2244
  },
1446
- async (args) => await tool.call(args),
1447
- );
1448
- }
2245
+ },
2246
+ async ({ connectionId, kind, id }) => {
2247
+ const authority = await connectionFor(connectionId);
2248
+ return json(
2249
+ await getAtlassianLiveItem(deps, {
2250
+ workspaceId: grant.workspaceId,
2251
+ subjectId: authority.subjectId,
2252
+ connectionId: authority.connection.id,
2253
+ kind,
2254
+ id,
2255
+ }),
2256
+ );
2257
+ },
2258
+ );
1449
2259
  }
1450
2260
 
1451
2261
  /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
@@ -1500,26 +2310,49 @@ function registerGoalTools(
1500
2310
  sessionId: string,
1501
2311
  json: (value: unknown) => { content: Array<{ type: "text"; text: string }> },
1502
2312
  ): void {
2313
+ const boundedGoalToolString = (maxBytes: number, field: string) =>
2314
+ z4
2315
+ .string()
2316
+ .min(1)
2317
+ .refine((value) => sessionGoalUtf8Bytes(value) <= maxBytes, {
2318
+ message: `${field} exceeds ${maxBytes} UTF-8 bytes`,
2319
+ });
2320
+ const goalText = boundedGoalToolString(SESSION_GOAL_TEXT_MAX_BYTES, "goal text");
2321
+ const successCriteriaSchema = boundedGoalToolString(
2322
+ SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
2323
+ "goal success criteria",
2324
+ );
2325
+ const goalRationale = boundedGoalToolString(SESSION_GOAL_RATIONALE_MAX_BYTES, "goal rationale");
2326
+ const progressNoteSchema = boundedGoalToolString(
2327
+ SESSION_GOAL_PROGRESS_MAX_BYTES,
2328
+ "goal progress note",
2329
+ );
1503
2330
  server.registerTool(
1504
2331
  "goal_set",
1505
2332
  {
1506
2333
  description:
1507
- "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
2334
+ "Create a goal when this session has none. While active, idle moments synthesize continuation turns until goal_complete or goal_pause. To change an existing goal, use goal_update with its objective revision, a change kind, and rationale.",
1508
2335
  inputSchema: {
1509
- text: z4.string().min(1),
1510
- successCriteria: z4.string().min(1).optional(),
2336
+ text: goalText,
2337
+ successCriteria: successCriteriaSchema.optional(),
1511
2338
  maxAutoContinuations: z4.number().int().positive().optional(),
1512
2339
  },
1513
2340
  },
1514
2341
  async ({ text, successCriteria, maxAutoContinuations }) => {
1515
2342
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
1516
2343
  await requireSession(deps.db, grant.workspaceId, sessionId);
2344
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
2345
+ if (existing) {
2346
+ throw new Error(
2347
+ `this session already has a goal at objective revision ${existing.objectiveRevision}; use goal_update to revise it`,
2348
+ );
2349
+ }
1517
2350
  const callerTurnId =
1518
2351
  typeof grant.metadata?.["turnId"] === "string"
1519
2352
  ? (grant.metadata["turnId"] as string)
1520
2353
  : null;
1521
2354
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
1522
- const { goal, events } = await upsertSessionGoalWithEvent(deps.db, {
2355
+ const { goal, replaced, events } = await upsertSessionGoalWithEvent(deps.db, {
1523
2356
  accountId: grant.accountId,
1524
2357
  workspaceId: grant.workspaceId,
1525
2358
  sessionId,
@@ -1532,7 +2365,24 @@ function registerGoalTools(
1532
2365
  if (events.length > 0) {
1533
2366
  await deps.bus.publish(grant.workspaceId, sessionId, events);
1534
2367
  }
1535
- return json(goal);
2368
+ return json(
2369
+ mcpMutationReceipt({
2370
+ operation: "goal_set",
2371
+ committed: true,
2372
+ outcome: replaced ? "updated" : "created",
2373
+ changed: true,
2374
+ resource: {
2375
+ type: "session_goal",
2376
+ id: goal.id,
2377
+ version: goal.version,
2378
+ state: goal.status,
2379
+ },
2380
+ timestamp: goal.updatedAt,
2381
+ idempotency: { status: "not_supported" },
2382
+ facts: { replaced },
2383
+ nextAction: { tool: "session_get", arguments: { sessionId } },
2384
+ }),
2385
+ );
1536
2386
  },
1537
2387
  );
1538
2388
 
@@ -1540,26 +2390,100 @@ function registerGoalTools(
1540
2390
  "goal_update",
1541
2391
  {
1542
2392
  description:
1543
- "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
2393
+ "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. A rewrite never counts as execution progress; use goal_progress for that.",
1544
2394
  inputSchema: {
1545
- text: z4.string().min(1).optional(),
1546
- successCriteria: z4.string().min(1).optional(),
1547
- progressNote: z4.string().min(1).optional(),
2395
+ text: goalText.optional(),
2396
+ successCriteria: successCriteriaSchema.nullable().optional(),
2397
+ // Optional for rolling compatibility with the former goal_update
2398
+ // surface. Omitted semantic metadata is classified as a refinement of
2399
+ // the currently fenced objective; new callers should always supply it.
2400
+ changeKind: z4.enum(["refinement", "adaptation", "replacement"]).optional(),
2401
+ rationale: goalRationale.optional(),
2402
+ expectedObjectiveRevision: z4.number().int().positive().optional(),
2403
+ // Deprecated compatibility input. It is committed through the new
2404
+ // progress operation and never makes a semantic rewrite count itself.
2405
+ progressNote: progressNoteSchema.optional(),
1548
2406
  idempotencyKey: z4.string().uuid(),
1549
2407
  },
1550
2408
  },
1551
- async ({ text, successCriteria, progressNote, idempotencyKey }) => {
2409
+ async ({
2410
+ text,
2411
+ successCriteria,
2412
+ changeKind,
2413
+ rationale,
2414
+ expectedObjectiveRevision,
2415
+ progressNote,
2416
+ idempotencyKey,
2417
+ }) => {
1552
2418
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2419
+ if (text === undefined && successCriteria === undefined && progressNote === undefined) {
2420
+ throw new Error("goal_update requires semantic content or a progressNote");
2421
+ }
1553
2422
  const context = exactAgentCommandContext(grant, sessionId);
1554
- const { goal, events, operationId, replay } = await updateSessionGoalWithEvent(
2423
+ const command = {
2424
+ accountId: grant.accountId,
2425
+ actor: {
2426
+ type: "agent_attempt" as const,
2427
+ attemptId: context.callerAttemptId,
2428
+ sessionId: context.callerSessionId,
2429
+ turnId: context.callerTurnId,
2430
+ executionGeneration: context.callerExecutionGeneration,
2431
+ },
2432
+ operationKey: idempotencyKey,
2433
+ };
2434
+ const semantic =
2435
+ text !== undefined || successCriteria !== undefined
2436
+ ? await updateSessionGoalWithEvent(deps.db, grant.workspaceId, sessionId, {
2437
+ ...(text !== undefined ? { text } : {}),
2438
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
2439
+ changeKind: changeKind ?? "refinement",
2440
+ rationale: rationale ?? "Compatibility refinement from goal_update",
2441
+ ...(expectedObjectiveRevision !== undefined ? { expectedObjectiveRevision } : {}),
2442
+ actor: "agent",
2443
+ command,
2444
+ })
2445
+ : null;
2446
+ const progress = progressNote
2447
+ ? await recordSessionGoalProgressWithEvent(deps.db, grant.workspaceId, sessionId, {
2448
+ progressNote,
2449
+ command,
2450
+ })
2451
+ : null;
2452
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, [
2453
+ ...(semantic?.events ?? []),
2454
+ ...(progress?.events ?? []),
2455
+ ]);
2456
+ const goal = progress?.goal ?? semantic?.goal;
2457
+ if (!goal) throw new Error("goal_update produced no mutation");
2458
+ return json({
2459
+ ...goal,
2460
+ operationId: semantic?.operationId ?? progress?.operationId ?? null,
2461
+ replay: Boolean(semantic?.replay || progress?.replay),
2462
+ outcome: semantic?.outcome ?? "progress_recorded",
2463
+ proposalId: semantic?.proposalId ?? null,
2464
+ });
2465
+ },
2466
+ );
2467
+
2468
+ server.registerTool(
2469
+ "goal_progress",
2470
+ {
2471
+ description:
2472
+ "Record concrete progress toward the unchanged active goal. This does not change goal text, success criteria, mutation policy, or objective revision. Do not use it merely to keep the continuation loop alive.",
2473
+ inputSchema: {
2474
+ progressNote: progressNoteSchema,
2475
+ idempotencyKey: z4.string().uuid(),
2476
+ },
2477
+ },
2478
+ async ({ progressNote, idempotencyKey }) => {
2479
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
2480
+ const context = exactAgentCommandContext(grant, sessionId);
2481
+ const { goal, events, operationId, replay } = await recordSessionGoalProgressWithEvent(
1555
2482
  deps.db,
1556
2483
  grant.workspaceId,
1557
2484
  sessionId,
1558
2485
  {
1559
- ...(text !== undefined ? { text } : {}),
1560
- ...(successCriteria !== undefined ? { successCriteria } : {}),
1561
- ...(progressNote !== undefined ? { progressNote } : {}),
1562
- actor: "agent",
2486
+ progressNote,
1563
2487
  command: {
1564
2488
  accountId: grant.accountId,
1565
2489
  actor: {
@@ -1631,7 +2555,7 @@ function registerGoalTools(
1631
2555
  {
1632
2556
  description:
1633
2557
  "Pause the session goal with a rationale (blocked, not productive, needs human input). No further continuation turns are synthesized until the goal is resumed or replaced.",
1634
- inputSchema: { rationale: z4.string().min(1) },
2558
+ inputSchema: { rationale: goalRationale },
1635
2559
  },
1636
2560
  async ({ rationale }) => {
1637
2561
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
@@ -1706,7 +2630,7 @@ function registerWorkspaceArtifactTools(
1706
2630
  json: JsonResult,
1707
2631
  ): void {
1708
2632
  const attempt = () => {
1709
- const claims = preferenceAttemptClaims(grant);
2633
+ const claims = exactAgentAttemptClaims(grant);
1710
2634
  if (!claims) throw new Error("Exact signed artifact attempt authority is required.");
1711
2635
  return claims;
1712
2636
  };
@@ -1806,7 +2730,7 @@ function registerWorkspaceArtifactTools(
1806
2730
  "artifacts_create",
1807
2731
  {
1808
2732
  description:
1809
- "Create and publish a generic static workspace artifact from a complete, self-contained HTML document with inline CSS. JavaScript and active or navigation-capable markup do not render in the MVP.",
2733
+ "Create and publish a generic workspace artifact from a complete HTML document. The exact HTML runs in an opaque-origin sandboxed iframe with JavaScript, external resources, forms, popups, and downloads enabled, but without parent-origin authority or top-level navigation.",
1810
2734
  inputSchema: {
1811
2735
  title: z4.string().min(1).max(120),
1812
2736
  description: z4.string().max(2000).nullable().optional(),
@@ -1841,7 +2765,7 @@ function registerWorkspaceArtifactTools(
1841
2765
  "artifacts_publish",
1842
2766
  {
1843
2767
  description:
1844
- "Publish a new immutable static HTML/CSS version. JavaScript and active or navigation-capable markup do not render in the MVP. First read the current source and pass its version id for optimistic concurrency.",
2768
+ "Publish a new immutable HTML version. The exact HTML runs in an opaque-origin sandboxed iframe. First read the current source and pass its version id for optimistic concurrency.",
1845
2769
  inputSchema: {
1846
2770
  artifactId: z4.string().uuid(),
1847
2771
  expectedCurrentVersionId: z4.string().uuid(),
@@ -1894,12 +2818,13 @@ function registerWorkspaceArtifactTools(
1894
2818
  );
1895
2819
  }
1896
2820
 
1897
- function preferenceAttemptClaims(grant: AccessGrant): {
2821
+ function exactAgentAttemptClaims(grant: AccessGrant): {
1898
2822
  sessionId: string;
1899
2823
  turnId: string;
1900
2824
  attemptId: string;
1901
2825
  executionGeneration: number;
1902
2826
  } | null {
2827
+ if (grant.principalKind !== "agent_attempt") return null;
1903
2828
  const metadata = grant.metadata ?? {};
1904
2829
  if (
1905
2830
  typeof metadata["sessionId"] !== "string" ||
@@ -1920,6 +2845,109 @@ function preferenceAttemptClaims(grant: AccessGrant): {
1920
2845
  };
1921
2846
  }
1922
2847
 
2848
+ function registerTaskNoteTools(
2849
+ server: McpServer,
2850
+ deps: ApiRouteDeps,
2851
+ grant: AccessGrant,
2852
+ sessionId: string,
2853
+ json: JsonResult,
2854
+ ): void {
2855
+ const attemptClaims = () => {
2856
+ const resolved = exactAgentAttemptClaims(grant);
2857
+ if (!resolved || resolved.sessionId !== sessionId) {
2858
+ throw new Error("Exact signed task-note attempt authority is required.");
2859
+ }
2860
+ return { accountId: grant.accountId, workspaceId: grant.workspaceId, ...resolved };
2861
+ };
2862
+ const authorize = async () => {
2863
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
2864
+ };
2865
+ const boundedUtf8 = (maxBytes: number, label: string) =>
2866
+ z4
2867
+ .string()
2868
+ .min(1)
2869
+ .refine((value) => value === value.trim(), {
2870
+ message: `${label} must not have leading or trailing whitespace`,
2871
+ })
2872
+ .refine((value) => new TextEncoder().encode(value).byteLength <= maxBytes, {
2873
+ message: `${label} must be at most ${maxBytes} UTF-8 bytes`,
2874
+ });
2875
+
2876
+ server.registerTool(
2877
+ "task_notes_list",
2878
+ {
2879
+ description:
2880
+ "Explicitly retrieve bounded, unexpired coordination notes shared by this session's root task tree. Notes are non-authoritative and are never injected into prompts automatically.",
2881
+ inputSchema: {
2882
+ includeArchived: z4.boolean().optional().default(false),
2883
+ limit: z4
2884
+ .number()
2885
+ .int()
2886
+ .min(1)
2887
+ .max(TASK_NOTE_LIST_MAX_LIMIT)
2888
+ .optional()
2889
+ .default(TASK_NOTE_LIST_DEFAULT_LIMIT),
2890
+ },
2891
+ },
2892
+ async ({ includeArchived, limit }) => {
2893
+ await authorize();
2894
+ return json(await listTaskNotes(deps.db, { ...attemptClaims(), includeArchived, limit }));
2895
+ },
2896
+ );
2897
+
2898
+ server.registerTool(
2899
+ "task_note_save",
2900
+ {
2901
+ description:
2902
+ "Save one bounded, expiring, non-authoritative coordination note for agents in this root task tree. Use a fresh operationId; exact retries of the same attempt and input replay safely.",
2903
+ inputSchema: {
2904
+ operationId: z4.string().uuid(),
2905
+ kind: z4.enum(["finding", "decision", "blocker", "ownership", "artifact", "handoff"]),
2906
+ text: boundedUtf8(TASK_NOTE_TEXT_MAX_BYTES, "Task note text"),
2907
+ expiresInDays: z4.number().int().min(1).max(TASK_NOTE_MAX_LIFETIME_DAYS),
2908
+ },
2909
+ },
2910
+ async ({ operationId, kind, text, expiresInDays }) => {
2911
+ await authorize();
2912
+ return json(
2913
+ await createTaskNote(deps.db, {
2914
+ ...attemptClaims(),
2915
+ operationId,
2916
+ kind,
2917
+ text,
2918
+ expiresInDays,
2919
+ }),
2920
+ );
2921
+ },
2922
+ );
2923
+
2924
+ server.registerTool(
2925
+ "task_note_archive",
2926
+ {
2927
+ description:
2928
+ "Archive a coordination note from this root task tree with optimistic version fencing. This preserves its immutable create receipt and records a separate archive receipt.",
2929
+ inputSchema: {
2930
+ operationId: z4.string().uuid(),
2931
+ noteId: z4.string().uuid(),
2932
+ expectedVersion: z4.number().int().min(1).max(1),
2933
+ reason: boundedUtf8(TASK_NOTE_REASON_MAX_BYTES, "Task note archive reason"),
2934
+ },
2935
+ },
2936
+ async ({ operationId, noteId, expectedVersion, reason }) => {
2937
+ await authorize();
2938
+ return json(
2939
+ await archiveTaskNote(deps.db, {
2940
+ ...attemptClaims(),
2941
+ operationId,
2942
+ noteId,
2943
+ expectedVersion,
2944
+ reason,
2945
+ }),
2946
+ );
2947
+ },
2948
+ );
2949
+ }
2950
+
1923
2951
  function registerPreferenceRegistryTools(
1924
2952
  server: McpServer,
1925
2953
  deps: ApiRouteDeps,
@@ -1927,7 +2955,7 @@ function registerPreferenceRegistryTools(
1927
2955
  json: JsonResult,
1928
2956
  ): void {
1929
2957
  const attemptClaims = () => {
1930
- const resolved = preferenceAttemptClaims(grant);
2958
+ const resolved = exactAgentAttemptClaims(grant);
1931
2959
  if (!resolved) throw new Error("Exact signed preference attempt authority is required.");
1932
2960
  return {
1933
2961
  accountId: grant.accountId,
@@ -1992,22 +3020,95 @@ function scheduledTaskReceipt(
1992
3020
  });
1993
3021
  }
1994
3022
 
3023
+ function scheduledTaskUpdateChangesState(
3024
+ task: ScheduledTask,
3025
+ update: Awaited<ReturnType<typeof validatedScheduledTaskUpdate>>,
3026
+ ): boolean {
3027
+ if (update.name !== undefined && update.name !== task.name) return true;
3028
+ if (update.status !== undefined && update.status !== task.status) return true;
3029
+ if (update.schedule !== undefined && stableJson(update.schedule) !== stableJson(task.schedule)) {
3030
+ return true;
3031
+ }
3032
+ if (update.runMode !== undefined && update.runMode !== task.runMode) return true;
3033
+ if (update.overlapPolicy !== undefined && update.overlapPolicy !== task.overlapPolicy)
3034
+ return true;
3035
+ if (
3036
+ update.agentConfig !== undefined &&
3037
+ stableJson(update.agentConfig) !== stableJson(task.agentConfig)
3038
+ ) {
3039
+ return true;
3040
+ }
3041
+ if (update.targetSessionId !== undefined && update.targetSessionId !== task.targetSessionId) {
3042
+ return true;
3043
+ }
3044
+ if (
3045
+ update.reusableSessionId !== undefined &&
3046
+ update.reusableSessionId !== task.reusableSessionId
3047
+ ) {
3048
+ return true;
3049
+ }
3050
+ if (update.variableSetId !== undefined && update.variableSetId !== task.variableSetId)
3051
+ return true;
3052
+ if (update.rigId !== undefined && update.rigId !== task.rigId) return true;
3053
+ if (update.metadata !== undefined && stableJson(update.metadata) !== stableJson(task.metadata)) {
3054
+ return true;
3055
+ }
3056
+ // Personal-connection delegations are recomputed with agentConfig and can
3057
+ // change even when the visible config is byte-identical (for example after a
3058
+ // connection rotation), so preserve that refresh as a real mutation.
3059
+ if (update.personalConnectionDelegations !== undefined) return true;
3060
+ return false;
3061
+ }
3062
+
1995
3063
  function memoryPreview(text: string): string {
1996
3064
  const normalized = text.replace(/\s+/g, " ").trim();
1997
3065
  return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
1998
3066
  }
1999
3067
 
3068
+ export function memorySlackPublicationActor(
3069
+ actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
3070
+ sessionId: string,
3071
+ fallbackOwnerLabel: string | null,
3072
+ ) {
3073
+ return {
3074
+ actor: {
3075
+ kind: actor.initiator.kind === "subject" ? ("human" as const) : ("service" as const),
3076
+ subjectId: actor.initiator.subjectId,
3077
+ initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
3078
+ sessionId,
3079
+ turnId: actor.turnId,
3080
+ attemptId: actor.attemptId,
3081
+ },
3082
+ ownerLabel: actor.initiator.label ?? fallbackOwnerLabel,
3083
+ };
3084
+ }
3085
+
2000
3086
  function registerMemoryTools(
2001
3087
  server: McpServer,
2002
3088
  deps: ApiRouteDeps,
2003
3089
  grant: AccessGrant,
2004
3090
  sessionId: string,
2005
3091
  json: JsonResult,
3092
+ promptMode: WorkspaceMemoryPromptMode,
2006
3093
  ): void {
3094
+ const publicationActor = async () => {
3095
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3096
+ return memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null);
3097
+ };
3098
+ const publicationInputSchema = z4.object({
3099
+ importance: z4.enum(["major", "normal", "minor"]),
3100
+ audience: z4.literal("workspace"),
3101
+ slackMode: z4.enum(["auto", "review", "never"]),
3102
+ shareSummary: z4.string().trim().min(1).max(4_096),
3103
+ });
3104
+
2007
3105
  server.registerTool(
2008
3106
  "memory_search",
2009
3107
  {
2010
- description: MEMORY_SEARCH_TOOL_DESCRIPTION,
3108
+ description:
3109
+ promptMode === "retrieval_only"
3110
+ ? `${MEMORY_SEARCH_TOOL_DESCRIPTION} Legacy preference-kind records are excluded here because structured preferences are the only behavioral authority.`
3111
+ : MEMORY_SEARCH_TOOL_DESCRIPTION,
2011
3112
  inputSchema: {
2012
3113
  query: z4.string().min(1),
2013
3114
  kind: MemoryKindSchema.optional(),
@@ -2023,6 +3124,7 @@ function registerMemoryTools(
2023
3124
  query,
2024
3125
  ...(kind ? { kind } : {}),
2025
3126
  ...(limit ? { limit } : {}),
3127
+ agentPromptMode: promptMode,
2026
3128
  },
2027
3129
  deps.getDocumentServices().embedder,
2028
3130
  ),
@@ -2032,16 +3134,21 @@ function registerMemoryTools(
2032
3134
  server.registerTool(
2033
3135
  "memory_save",
2034
3136
  {
2035
- description: MEMORY_SAVE_TOOL_DESCRIPTION,
3137
+ description:
3138
+ promptMode === "retrieval_only"
3139
+ ? `${MEMORY_SAVE_TOOL_DESCRIPTION} A preference-kind save is retained only as a legacy observation and cannot become behavioral authority; use the structured preference proposal surface for behavioral guidance.`
3140
+ : MEMORY_SAVE_TOOL_DESCRIPTION,
2036
3141
  inputSchema: {
2037
3142
  text: z4.string().min(1),
2038
3143
  kind: MemoryKindSchema,
2039
3144
  confidence: z4.number().min(0).max(1).optional(),
2040
3145
  replaces_id: z4.string().min(1).optional(),
3146
+ slack_publication: publicationInputSchema.optional(),
2041
3147
  },
2042
3148
  },
2043
- async ({ text, kind, confidence, replaces_id }) => {
2044
- const result = await saveWorkspaceMemory(
3149
+ async ({ text, kind, confidence, replaces_id, slack_publication }) => {
3150
+ const principal = slack_publication ? await publicationActor() : null;
3151
+ const result = await saveWorkspaceMemoryWithSlackPublication(
2045
3152
  deps.db,
2046
3153
  {
2047
3154
  accountId: grant.accountId,
@@ -2053,6 +3160,13 @@ function registerMemoryTools(
2053
3160
  ...(replaces_id ? { replacesId: replaces_id } : {}),
2054
3161
  origin: "agent",
2055
3162
  },
3163
+ slack_publication
3164
+ ? {
3165
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3166
+ actor: principal!.actor,
3167
+ ownerLabel: principal!.ownerLabel,
3168
+ }
3169
+ : null,
2056
3170
  deps.getDocumentServices().embedder,
2057
3171
  );
2058
3172
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -2104,6 +3218,19 @@ function registerMemoryTools(
2104
3218
  dedupeReason: result.dedupeReason,
2105
3219
  updatedInPlace: result.updated,
2106
3220
  embedded: result.embedded,
3221
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3222
+ ? "eligible"
3223
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3224
+ slackPublicationId:
3225
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3226
+ result.slackPublication.enqueue?.kind === "replayed"
3227
+ ? result.slackPublication.enqueue.publication.id
3228
+ : null,
3229
+ slackPublicationState:
3230
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3231
+ result.slackPublication.enqueue?.kind === "replayed"
3232
+ ? result.slackPublication.enqueue.publication.state
3233
+ : null,
2107
3234
  },
2108
3235
  }),
2109
3236
  );
@@ -2118,10 +3245,12 @@ function registerMemoryTools(
2118
3245
  id: z4.string().min(1),
2119
3246
  reason: z4.string().min(1).optional(),
2120
3247
  replacement_text: z4.string().min(1).optional(),
3248
+ slack_publication: publicationInputSchema.optional(),
2121
3249
  },
2122
3250
  },
2123
- async ({ id, reason, replacement_text }) => {
2124
- const result = await correctWorkspaceMemory(
3251
+ async ({ id, reason, replacement_text, slack_publication }) => {
3252
+ const principal = slack_publication ? await publicationActor() : null;
3253
+ const result = await correctWorkspaceMemoryWithSlackPublication(
2125
3254
  deps.db,
2126
3255
  {
2127
3256
  accountId: grant.accountId,
@@ -2131,6 +3260,13 @@ function registerMemoryTools(
2131
3260
  ...(reason ? { reason } : {}),
2132
3261
  ...(replacement_text ? { replacementText: replacement_text } : {}),
2133
3262
  },
3263
+ slack_publication
3264
+ ? {
3265
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3266
+ actor: principal!.actor,
3267
+ ownerLabel: principal!.ownerLabel,
3268
+ }
3269
+ : null,
2134
3270
  deps.getDocumentServices().embedder,
2135
3271
  );
2136
3272
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -2173,7 +3309,22 @@ function registerMemoryTools(
2173
3309
  : undefined,
2174
3310
  timestamp: (result.replacement ?? result.memory).updatedAt,
2175
3311
  idempotency: { status: "not_supported" },
2176
- facts: { correctionAction: result.action },
3312
+ facts: {
3313
+ correctionAction: result.action,
3314
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3315
+ ? "eligible"
3316
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3317
+ slackPublicationId:
3318
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3319
+ result.slackPublication.enqueue?.kind === "replayed"
3320
+ ? result.slackPublication.enqueue.publication.id
3321
+ : null,
3322
+ slackPublicationState:
3323
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3324
+ result.slackPublication.enqueue?.kind === "replayed"
3325
+ ? result.slackPublication.enqueue.publication.state
3326
+ : null,
3327
+ },
2177
3328
  }),
2178
3329
  );
2179
3330
  },
@@ -2309,7 +3460,7 @@ function registerConnectedMachineTools(
2309
3460
  "connected_machine_remove",
2310
3461
  {
2311
3462
  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.",
3463
+ "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
3464
  inputSchema: {
2314
3465
  enrollmentId: z4.string().uuid(),
2315
3466
  expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
@@ -2341,12 +3492,10 @@ async function beginMcpRigVerificationAttempt(
2341
3492
  try {
2342
3493
  return await beginRigChangeVerificationAttempt(deps.db, workspaceId, changeId, {
2343
3494
  startedAt: new Date().toISOString(),
3495
+ allowAlreadyVerifying: true,
2344
3496
  });
2345
3497
  } catch (error) {
2346
- if (
2347
- error instanceof RigChangeAlreadyVerifyingError ||
2348
- error instanceof RigChangeTransitionError
2349
- ) {
3498
+ if (error instanceof RigChangeTransitionError) {
2350
3499
  throw new Error(error.message, { cause: error });
2351
3500
  }
2352
3501
  throw error;
@@ -2685,7 +3834,7 @@ function registerWorkspaceOrchestrationTools(
2685
3834
  grant: AccessGrant,
2686
3835
  can: (permission: Permission) => boolean,
2687
3836
  callerSessionId: string | null,
2688
- toolspaceMode: boolean,
3837
+ sessionCreateVisible: boolean,
2689
3838
  json: JsonResult,
2690
3839
  ): void {
2691
3840
  if (can("sessions:read")) {
@@ -2763,7 +3912,7 @@ function registerWorkspaceOrchestrationTools(
2763
3912
  );
2764
3913
  return json(
2765
3914
  boundSessionDetailMcp(
2766
- await withMcpEffectivePolicy(deps, grant.workspaceId, {
3915
+ await withMcpEffectivePolicy(deps, grant.workspaceId, grant.subjectId, {
2767
3916
  ...projected,
2768
3917
  effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
2769
3918
  }),
@@ -2885,129 +4034,100 @@ function registerWorkspaceOrchestrationTools(
2885
4034
  );
2886
4035
  }
2887
4036
 
2888
- if (can("sessions:create")) {
4037
+ if (can("sessions:create") && sessionCreateVisible) {
4038
+ const sessionCreateInput = z4
4039
+ .object({
4040
+ initialMessage: z4.string().min(1),
4041
+ instructions: z4.string().min(1).max(32768).optional(),
4042
+ goal: z4.unknown().optional(),
4043
+ resources: z4.array(z4.unknown()).optional(),
4044
+ tools: z4.array(z4.unknown()).optional(),
4045
+ mcpServers: z4.array(z4.unknown()).optional(),
4046
+ variableSetId: z4.string().uuid().optional(),
4047
+ environmentId: z4.string().uuid().optional(),
4048
+ rigId: z4.string().uuid().optional(),
4049
+ model: z4
4050
+ .string()
4051
+ .min(1)
4052
+ .optional()
4053
+ .describe(
4054
+ "Model for the worker. Omit to inherit the exact calling turn's model, including its Codex subscription billing path.",
4055
+ ),
4056
+ reasoningEffort: z4
4057
+ .string()
4058
+ .optional()
4059
+ .describe("Omit to inherit the exact calling turn's reasoning effort."),
4060
+ latencyMode: z4
4061
+ .enum(["standard", "priority", "fast"])
4062
+ .optional()
4063
+ .describe("Omit to inherit the exact calling turn's latency mode."),
4064
+ sandboxBackend: z4.string().optional(),
4065
+ // Model-only structural coupling: workingDir cannot exist without a
4066
+ // targetSandboxId because both live inside one optional object. The
4067
+ // handler maps this back to the stable public REST/SDK request fields.
4068
+ machineTarget: z4
4069
+ .object({
4070
+ targetSandboxId: z4.string().uuid(),
4071
+ workingDir: z4.string().optional(),
4072
+ })
4073
+ .strict()
4074
+ .optional(),
4075
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
4076
+ idempotencyKey: z4.string().min(1).max(200).optional(),
4077
+ firstPartyMcpPermissions: z4
4078
+ .array(z4.string())
4079
+ .optional()
4080
+ .describe(
4081
+ "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
4082
+ ),
4083
+ firstPartyMcpTools: z4
4084
+ .array(z4.enum(FIRST_PARTY_MCP_TOOL_NAMES))
4085
+ .optional()
4086
+ .describe(
4087
+ "Exact model-visible first-party tool selection for the child. Omit to inherit this session's effective selection. To create a non-delegating leaf, provide a selection that omits session_create. This does not grant permissions.",
4088
+ ),
4089
+ // Omission is the ordinary safe sharing path. Literal "shared" remains
4090
+ // available to advanced REST/SDK callers but is intentionally absent
4091
+ // from the model surface because it turns compatibility drift into a
4092
+ // deterministic failure instead of the omission path's safe own-box fallback.
4093
+ sandbox: z4
4094
+ .union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })])
4095
+ .optional(),
4096
+ })
4097
+ .strict();
2889
4098
  server.registerTool(
2890
4099
  "session_create",
2891
4100
  {
2892
4101
  description:
2893
- "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and variable set attachment. VariableSet attachment happens at creation only it cannot be added to a running session and requires the variable-sets:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
2894
- inputSchema: {
2895
- initialMessage: z4.string().min(1),
2896
- // Per-session agent persona/system instructions for the spawned worker
2897
- // (a per-agent-type prompt). Delivered system-level, composed AFTER the
2898
- // workspace persona; never shown in the worker's timeline. Trimmed,
2899
- // non-empty, max 32768 chars (re-validated by the contracts schema).
2900
- instructions: z4.string().min(1).max(32768).optional(),
2901
- goal: z4.unknown().optional(),
2902
- resources: z4.array(z4.unknown()).optional(),
2903
- tools: z4.array(z4.unknown()).optional(),
2904
- // Per-session third-party MCP servers. Credential header values are
2905
- // accepted only at create and never appear in responses/events.
2906
- mcpServers: z4.array(z4.unknown()).optional(),
2907
- variableSetId: z4.string().uuid().optional(),
2908
- // Deprecated alias of variableSetId (rename back-compat); declared so MCP
2909
- // validation doesn't strip it before createSessionForRequest maps it.
2910
- environmentId: z4.string().uuid().optional(),
2911
- // Bind the spawned session to a rig (freezes its active version);
2912
- // declared so MCP validation doesn't strip it before the domain reads it.
2913
- 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(),
2917
- sandboxBackend: z4.string().optional(),
2918
- // Create-time machine targeting: an enrolled sandbox id (from
2919
- // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
2920
- // pointer at creation so the FIRST turn lands on the chosen machine
2921
- // (race-free). Ownership + liveness are validated in the domain via the
2922
- // same path as sandbox_swap; an unowned/offline/unknown target 422s.
2923
- targetSandboxId: z4.string().uuid().optional(),
2924
- // The working directory (cwd) for a machine target: the path/cwd base the
2925
- // spawned session's agent exec, terminal, and file dock run under. A
2926
- // workspace_root-relative subdir or an absolute machine path. Only valid
2927
- // WITH targetSandboxId (workingDir alone 422s); omitted ⇒ workspace_root.
2928
- workingDir: z4.string().optional(),
2929
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
2930
- // Workspace-scoped CREATE idempotency key: a retried session_create with
2931
- // the same key returns the already-spawned worker instead of a duplicate.
2932
- idempotencyKey: z4.string().min(1).max(200).optional(),
2933
- // Per-session/agent descendant policy. Reductions need only create;
2934
- // increases are authorized server-side with workspace:admin.
2935
- maxNestedAgentDepth: z4.number().int().nonnegative().optional(),
2936
- // First-party MCP token permissions for the spawned session; every
2937
- // permission must be held by this grant (validated in the domain).
2938
- // A goal requires goals:manage in the resulting set; it is never
2939
- // silently added beyond the inherited or explicit authority.
2940
- firstPartyMcpPermissions: z4
2941
- .array(z4.string())
2942
- .optional()
2943
- .describe(
2944
- "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
2945
- ),
2946
- firstPartyMcpTools: z4
2947
- .array(z4.enum(FIRST_PARTY_MCP_TOOL_NAMES))
2948
- .optional()
2949
- .describe(
2950
- "Exact model-visible first-party tool selection for the child. Omit to inherit this session's effective selection. This does not grant permissions.",
2951
- ),
2952
- // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
2953
- // creator's box — one filesystem/repo/desktop, N independent conversations;
2954
- // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
2955
- // repo set or a genuinely separate filesystem), or {groupId} (a sibling
2956
- // session's `sandboxGroupId` from a prior session_create response) to join
2957
- // that specific sibling's box.
2958
- // Shared state must be compatible: a shared box requires the SAME image
2959
- // (rejected at the lease layer, B3) and — because the box's variable set is
2960
- // fixed at creation under the current mechanics — the SAME workspace
2961
- // VariableSet. The domain layer is env-aware: an inherited default with a
2962
- // different variableSetId silently gets its OWN box (the spawn still works),
2963
- // while an explicit shared/{groupId} with a mismatched variableSet 422s at
2964
- // create. When the VariableSet is eventually evicted from the box manifest
2965
- // (per-exec, like the git token), the env check dissolves on its own.
2966
- // The description below is what the AGENT sees (this comment is invisible to
2967
- // it); keep the two in sync.
2968
- sandbox: z4
2969
- .union([
2970
- z4.literal("shared"),
2971
- z4.literal("new"),
2972
- z4.object({ groupId: z4.string().uuid() }),
2973
- ])
2974
- .describe(
2975
- "Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT variableSet than the creator's box, the platform automatically gives it its own box (the box variable set is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same variableSet (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
2976
- )
2977
- .optional(),
2978
- // The parent (manager) session is auto-inferred from the caller's
2979
- // worker-signed sessionId claim, so a spawned worker's completion wakes
2980
- // its manager automatically. There is deliberately no caller-supplied
2981
- // parent parameter: it would let a sessions:create grant target an
2982
- // arbitrary session's wake channel without sessions:control on it.
2983
- },
4102
+ "Spawn a new agent session (a worker). Omit sandbox for the safe default: compatible children share the creator's box, while a different Variable Set or Rig gets its own compatible box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget. To create a non-delegating leaf, pass a narrowed firstPartyMcpTools list that omits session_create; do not use a child-local depth override. Public REST/SDK callers retain advanced absolute depth and explicit shared-placement controls.",
4103
+ inputSchema: sessionCreateInput,
2984
4104
  },
2985
4105
  async (args) => {
2986
4106
  try {
2987
4107
  if (callerSessionId !== null) {
2988
4108
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
2989
4109
  }
2990
- const result = await createSessionForRequestWithOutcome(
2991
- deps,
2992
- grant,
2993
- grant.workspaceId,
2994
- args,
2995
- );
2996
- return json(sessionCreateMutationReceipt(result, Boolean(args.idempotencyKey)));
4110
+ const { machineTarget, ...request } = args;
4111
+ const result = await createSessionForRequestWithOutcome(deps, grant, grant.workspaceId, {
4112
+ ...request,
4113
+ ...(machineTarget
4114
+ ? {
4115
+ targetSandboxId: machineTarget.targetSandboxId,
4116
+ ...(machineTarget.workingDir !== undefined
4117
+ ? { workingDir: machineTarget.workingDir }
4118
+ : {}),
4119
+ }
4120
+ : {}),
4121
+ });
4122
+ return json(sessionCreateMutationReceipt(result, Boolean(request.idempotencyKey)));
2997
4123
  } catch (error) {
2998
- if (error instanceof SessionSpawnDeniedError) {
2999
- return {
3000
- ...json(sessionSpawnDenialEnvelope(error)),
3001
- isError: true,
3002
- };
3003
- }
3004
- throw error;
4124
+ return orchestrationFailureResult("session_create", error);
3005
4125
  }
3006
4126
  },
3007
4127
  );
3008
4128
  }
3009
4129
 
3010
- if (can("sessions:control") && !toolspaceMode) {
4130
+ if (can("sessions:control")) {
3011
4131
  server.registerTool(
3012
4132
  "session_send_message",
3013
4133
  {
@@ -3023,81 +4143,87 @@ function registerWorkspaceOrchestrationTools(
3023
4143
  },
3024
4144
  },
3025
4145
  async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
3026
- await authorizeFirstPartySession(deps, grant, targetSessionId, "session.append");
3027
- if (callerSessionId !== null) {
3028
- if ((mcpCredentialUpdates?.length ?? 0) > 0) {
3029
- throw new Error("internal session updates cannot change MCP credentials");
4146
+ try {
4147
+ await authorizeFirstPartySession(deps, grant, targetSessionId, "session.append");
4148
+ if (callerSessionId !== null) {
4149
+ if ((mcpCredentialUpdates?.length ?? 0) > 0) {
4150
+ throw new HTTPException(422, {
4151
+ message: "internal session updates cannot change MCP credentials",
4152
+ });
4153
+ }
4154
+ const result = await sendAgentSessionMessage(
4155
+ deps,
4156
+ exactAgentCommandContext(grant, callerSessionId),
4157
+ { targetSessionId, text, idempotencyKey },
4158
+ );
4159
+ return json(
4160
+ mcpMutationReceipt({
4161
+ operation: "session_send_message",
4162
+ committed: true,
4163
+ outcome: result.replay ? "replayed" : "accepted",
4164
+ changed: !result.replay,
4165
+ resource: {
4166
+ type: "session_system_update",
4167
+ id: result.updateId,
4168
+ state: result.effectiveState,
4169
+ },
4170
+ relatedResources: [{ type: "session", id: targetSessionId }],
4171
+ timestamp: result.receipt.createdAt.toISOString(),
4172
+ idempotency: { status: result.replay ? "replayed" : "applied" },
4173
+ facts: {
4174
+ delivery: "coalesced_internal_update",
4175
+ wakeRequested: result.wakeRevision !== null,
4176
+ resumeRequired: result.effectiveState === "paused",
4177
+ },
4178
+ nextAction: {
4179
+ tool: "session_get",
4180
+ arguments: { sessionId: targetSessionId },
4181
+ },
4182
+ }),
4183
+ );
3030
4184
  }
3031
- const result = await sendAgentSessionMessage(
4185
+ const { accepted, turn, replay } = await acceptSessionUserMessageWithOutcome(
3032
4186
  deps,
3033
- exactAgentCommandContext(grant, callerSessionId),
3034
- { targetSessionId, text, idempotencyKey },
4187
+ grant,
4188
+ grant.workspaceId,
4189
+ targetSessionId,
4190
+ {
4191
+ text,
4192
+ delivery: "send",
4193
+ origin: "operator",
4194
+ clientEventId: idempotencyKey,
4195
+ mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) =>
4196
+ SessionMcpCredentialUpdateInput.parse(update),
4197
+ ),
4198
+ },
3035
4199
  );
3036
4200
  return json(
3037
4201
  mcpMutationReceipt({
3038
4202
  operation: "session_send_message",
3039
4203
  committed: true,
3040
- outcome: result.replay ? "replayed" : "accepted",
3041
- changed: !result.replay,
4204
+ outcome: replay ? "replayed" : "accepted",
4205
+ changed: !replay,
3042
4206
  resource: {
3043
- type: "session_system_update",
3044
- id: result.updateId,
3045
- state: result.effectiveState,
3046
- },
3047
- relatedResources: [{ type: "session", id: targetSessionId }],
3048
- timestamp: result.receipt.createdAt.toISOString(),
3049
- idempotency: { status: result.replay ? "replayed" : "applied" },
3050
- facts: {
3051
- delivery: "coalesced_internal_update",
3052
- wakeRequested: result.wakeRevision !== null,
3053
- resumeRequired: result.effectiveState === "paused",
4207
+ type: "session_turn",
4208
+ id: turn.id,
4209
+ version: turn.version,
4210
+ state: turn.status,
3054
4211
  },
4212
+ relatedResources: [
4213
+ { type: "session", id: targetSessionId },
4214
+ { type: "session_event", id: accepted.id, state: accepted.type },
4215
+ ],
4216
+ timestamp: accepted.occurredAt,
4217
+ idempotency: { status: replay ? "replayed" : "applied" },
3055
4218
  nextAction: {
3056
4219
  tool: "session_get",
3057
4220
  arguments: { sessionId: targetSessionId },
3058
4221
  },
3059
4222
  }),
3060
4223
  );
4224
+ } catch (error) {
4225
+ return orchestrationFailureResult("session_send_message", error);
3061
4226
  }
3062
- const { accepted, turn, replay } = await acceptSessionUserMessageWithOutcome(
3063
- deps,
3064
- grant,
3065
- grant.workspaceId,
3066
- targetSessionId,
3067
- {
3068
- text,
3069
- delivery: "send",
3070
- origin: "operator",
3071
- clientEventId: idempotencyKey,
3072
- mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) =>
3073
- SessionMcpCredentialUpdateInput.parse(update),
3074
- ),
3075
- },
3076
- );
3077
- return json(
3078
- mcpMutationReceipt({
3079
- operation: "session_send_message",
3080
- committed: true,
3081
- outcome: replay ? "replayed" : "accepted",
3082
- changed: !replay,
3083
- resource: {
3084
- type: "session_turn",
3085
- id: turn.id,
3086
- version: turn.version,
3087
- state: turn.status,
3088
- },
3089
- relatedResources: [
3090
- { type: "session", id: targetSessionId },
3091
- { type: "session_event", id: accepted.id, state: accepted.type },
3092
- ],
3093
- timestamp: accepted.occurredAt,
3094
- idempotency: { status: replay ? "replayed" : "applied" },
3095
- nextAction: {
3096
- tool: "session_get",
3097
- arguments: { sessionId: targetSessionId },
3098
- },
3099
- }),
3100
- );
3101
4227
  },
3102
4228
  );
3103
4229
 
@@ -3123,16 +4249,31 @@ function registerWorkspaceOrchestrationTools(
3123
4249
  reason: reason ?? "agent_mcp_pause",
3124
4250
  },
3125
4251
  );
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
- });
4252
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
4253
+ serializeEffectiveSessionControl(controlled.control),
4254
+ sessionId,
4255
+ controlled.authorization?.relatedSessionAccess ?? "root",
4256
+ );
4257
+ return json(
4258
+ mcpMutationReceipt({
4259
+ operation: "session_pause",
4260
+ committed: true,
4261
+ outcome: controlled.replay ? "replayed" : "updated",
4262
+ changed: !controlled.replay,
4263
+ resource: {
4264
+ type: "session",
4265
+ id: sessionId,
4266
+ state: effectiveControl.state,
4267
+ },
4268
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
4269
+ timestamp: controlled.receipt.createdAt.toISOString(),
4270
+ idempotency: {
4271
+ status: controlled.replay ? "replayed" : "applied",
4272
+ },
4273
+ facts: { interruptionCount: controlled.interruptionCount },
4274
+ nextAction: { tool: "session_get", arguments: { sessionId } },
4275
+ }),
4276
+ );
3136
4277
  }
3137
4278
  const controlled = await controlHumanSessionWorkstream(
3138
4279
  deps,
@@ -3176,16 +4317,31 @@ function registerWorkspaceOrchestrationTools(
3176
4317
  reason: reason ?? "agent_mcp_resume",
3177
4318
  },
3178
4319
  );
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
- });
4320
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
4321
+ serializeEffectiveSessionControl(controlled.control),
4322
+ sessionId,
4323
+ controlled.authorization?.relatedSessionAccess ?? "root",
4324
+ );
4325
+ return json(
4326
+ mcpMutationReceipt({
4327
+ operation: "session_resume",
4328
+ committed: true,
4329
+ outcome: controlled.replay ? "replayed" : "updated",
4330
+ changed: !controlled.replay,
4331
+ resource: {
4332
+ type: "session",
4333
+ id: sessionId,
4334
+ state: effectiveControl.state,
4335
+ },
4336
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
4337
+ timestamp: controlled.receipt.createdAt.toISOString(),
4338
+ idempotency: {
4339
+ status: controlled.replay ? "replayed" : "applied",
4340
+ },
4341
+ facts: { interruptionCount: controlled.interruptionCount },
4342
+ nextAction: { tool: "session_get", arguments: { sessionId } },
4343
+ }),
4344
+ );
3189
4345
  }
3190
4346
  const controlled = await controlHumanSessionWorkstream(
3191
4347
  deps,
@@ -3283,7 +4439,6 @@ function registerVariableSetTools(
3283
4439
  grant: AccessGrant,
3284
4440
  can: (permission: Permission) => boolean,
3285
4441
  sessionId: string | null,
3286
- toolspaceMode: boolean,
3287
4442
  json: JsonResult,
3288
4443
  ): void {
3289
4444
  const registerListTool = (name: string, description: string): void => {
@@ -3435,7 +4590,6 @@ function registerVariableSetTools(
3435
4590
  }
3436
4591
 
3437
4592
  if (
3438
- !toolspaceMode &&
3439
4593
  sessionId !== null &&
3440
4594
  can("variable-sets:read") &&
3441
4595
  hasLiteralPermission(grant.permissions, "secrets:read")
@@ -3475,7 +4629,7 @@ function registerVariableSetTools(
3475
4629
  throw new Error("variable set variable names must match ^[A-Z][A-Z0-9_]*$");
3476
4630
  }
3477
4631
  assertAllowedVariableSetVariableName(parsedName.data);
3478
- const claims = preferenceAttemptClaims(grant);
4632
+ const claims = exactAgentAttemptClaims(grant);
3479
4633
  if (!claims || claims.sessionId !== sessionId) {
3480
4634
  throw new Error("Exact signed secret-read attempt authority is required.");
3481
4635
  }
@@ -3515,6 +4669,252 @@ function registerVariableSetTools(
3515
4669
  }
3516
4670
  }
3517
4671
 
4672
+ type CapabilitySetupProjection =
4673
+ | { status: "ready"; action: null; detail: string }
4674
+ | {
4675
+ status: "authorization_required";
4676
+ action: "connect" | "add_credentials" | "enable";
4677
+ detail: string;
4678
+ }
4679
+ | { status: "unavailable"; action: null; detail: string };
4680
+
4681
+ function registerCapabilityDiscoveryTools(
4682
+ server: McpServer,
4683
+ deps: ApiRouteDeps,
4684
+ grant: AccessGrant,
4685
+ sessionId: string,
4686
+ json: JsonResult,
4687
+ ): void {
4688
+ const catalog = () =>
4689
+ buildCapabilityCatalog({
4690
+ db: deps.db,
4691
+ workspaceId: grant.workspaceId,
4692
+ settings: deps.settings,
4693
+ subjectId: grant.subjectId,
4694
+ });
4695
+ const authorize = async () => {
4696
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
4697
+ };
4698
+
4699
+ server.registerTool(
4700
+ "capability_catalog_search",
4701
+ {
4702
+ description:
4703
+ "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.",
4704
+ inputSchema: {
4705
+ query: z4.string().min(1).max(500),
4706
+ limit: z4.number().int().min(1).max(20).optional(),
4707
+ },
4708
+ },
4709
+ async ({ query, limit }) => {
4710
+ await authorize();
4711
+ const current = await catalog();
4712
+ const ranked = searchCapabilityCatalogItems(
4713
+ [...current.items, ...nativeConnectionCapabilityRecommendations()],
4714
+ query,
4715
+ limit ?? 8,
4716
+ );
4717
+ const matches = await Promise.all(
4718
+ ranked.map(async ({ item, matchedOn }) => ({
4719
+ capabilityId: item.id,
4720
+ name: item.name,
4721
+ description: item.description,
4722
+ kind: item.kind,
4723
+ source: item.source,
4724
+ category: item.category,
4725
+ tags: item.tags.slice(0, 16),
4726
+ providerDomain: item.providerDomain,
4727
+ authKind: item.authKind,
4728
+ tier: item.tier,
4729
+ matchedOn,
4730
+ setup: {
4731
+ ...(await capabilitySetupProjection(deps, grant.workspaceId, item)),
4732
+ requiredVariables: capabilityRequiredVariables(item),
4733
+ },
4734
+ })),
4735
+ );
4736
+ return json({ query, matches });
4737
+ },
4738
+ );
4739
+
4740
+ server.registerTool(
4741
+ "capability_authorization_request",
4742
+ {
4743
+ description:
4744
+ "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.",
4745
+ inputSchema: {
4746
+ capabilityId: z4.string().min(1).max(512),
4747
+ rationale: z4.string().min(1).max(2000),
4748
+ },
4749
+ },
4750
+ async ({ capabilityId, rationale }) => {
4751
+ await authorize();
4752
+ const current = await catalog();
4753
+ const item = [...current.items, ...nativeConnectionCapabilityRecommendations()].find(
4754
+ (candidate) => candidate.id === capabilityId,
4755
+ );
4756
+ if (!item || !capabilityCatalogItemIsTrustedForExposure(item)) {
4757
+ throw new Error("Unknown or untrusted capability; search the catalog again.");
4758
+ }
4759
+ const setup = await capabilitySetupProjection(deps, grant.workspaceId, item);
4760
+ if (setup.status === "ready") {
4761
+ return json({
4762
+ capabilityId: item.id,
4763
+ status: "ready",
4764
+ message: setup.detail,
4765
+ });
4766
+ }
4767
+ if (setup.status === "unavailable") {
4768
+ return json({
4769
+ capabilityId: item.id,
4770
+ status: "unavailable",
4771
+ message: setup.detail,
4772
+ });
4773
+ }
4774
+ const claims = exactAgentCommandContext(grant, sessionId);
4775
+ const payload = ToolAuthNeededPayload.parse({
4776
+ serverId: item.runtime.mcpServerId ?? "opengeni",
4777
+ toolName: "capability_authorization_request",
4778
+ providerDomain: capabilityProviderDomain(item),
4779
+ reason: "missing_connection",
4780
+ capability: {
4781
+ id: item.id,
4782
+ name: item.name,
4783
+ kind: item.kind,
4784
+ source: item.source,
4785
+ action: setup.action,
4786
+ rationale,
4787
+ requiredVariables: capabilityRequiredVariables(item),
4788
+ },
4789
+ });
4790
+ const appended = await appendAndPublishTurnEventsFenced(
4791
+ deps.db,
4792
+ deps.bus,
4793
+ grant.workspaceId,
4794
+ sessionId,
4795
+ claims.callerTurnId,
4796
+ claims.callerExecutionGeneration,
4797
+ claims.callerAttemptId,
4798
+ [{ type: "tool.auth_needed", payload }],
4799
+ );
4800
+ if (!appended.accepted) {
4801
+ throw new Error(
4802
+ "The calling turn was replaced before the authorization request committed.",
4803
+ );
4804
+ }
4805
+ return json({
4806
+ capabilityId: item.id,
4807
+ status: "authorization_requested",
4808
+ action: setup.action,
4809
+ eventId: appended.events[0]?.id ?? null,
4810
+ message:
4811
+ "The recommendation was posted for human confirmation. No access has been granted yet.",
4812
+ });
4813
+ },
4814
+ );
4815
+ }
4816
+
4817
+ async function capabilitySetupProjection(
4818
+ deps: ApiRouteDeps,
4819
+ workspaceId: string,
4820
+ item: CapabilityCatalogItem,
4821
+ ): Promise<CapabilitySetupProjection> {
4822
+ if (item.id === "api:github-app" || item.surfaceType === "first_party_github") {
4823
+ const missing = githubAppMissingSettings(deps.settings);
4824
+ if (missing.length > 0) {
4825
+ return {
4826
+ status: "unavailable",
4827
+ action: null,
4828
+ detail:
4829
+ deps.settings.productAccessMode === "managed"
4830
+ ? "GitHub is not available on this deployment."
4831
+ : `The operator must configure the GitHub App first (${missing.join(", ")}).`,
4832
+ };
4833
+ }
4834
+ const installations = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
4835
+ if (githubBindingStatus(true, installations) === "bound") {
4836
+ return {
4837
+ status: "ready",
4838
+ action: null,
4839
+ detail: "GitHub is connected and ready.",
4840
+ };
4841
+ }
4842
+ return {
4843
+ status: "authorization_required",
4844
+ action: "connect",
4845
+ detail: "A GitHub owner must approve an installation and repository allowlist.",
4846
+ };
4847
+ }
4848
+ if (item.surfaceType === "codex_apps") {
4849
+ return item.enabled
4850
+ ? {
4851
+ status: "ready",
4852
+ action: null,
4853
+ detail: "Codex Apps is connected and ready.",
4854
+ }
4855
+ : {
4856
+ status: "authorization_required",
4857
+ action: "connect",
4858
+ detail: "A workspace admin must designate an authorized Codex Apps subscription.",
4859
+ };
4860
+ }
4861
+ if (item.enabled) {
4862
+ return {
4863
+ status: "ready",
4864
+ action: null,
4865
+ detail: "This capability is already enabled.",
4866
+ };
4867
+ }
4868
+ if (!item.runtime.available) {
4869
+ return {
4870
+ status: "unavailable",
4871
+ action: null,
4872
+ detail: item.runtime.notes ?? "This catalog entry has no executable runtime adapter.",
4873
+ };
4874
+ }
4875
+ if (item.authKind === "oauth2" || item.surfaceType === "first_party_social") {
4876
+ return {
4877
+ status: "authorization_required",
4878
+ action: "connect",
4879
+ detail: "The user must confirm the provider domain and complete sign-in.",
4880
+ };
4881
+ }
4882
+ if (item.authKind === "api_key" || item.authModel?.toLowerCase().includes("key")) {
4883
+ return {
4884
+ status: "authorization_required",
4885
+ action: "add_credentials",
4886
+ detail: "The user must provide the required credential through the protected setup form.",
4887
+ };
4888
+ }
4889
+ return {
4890
+ status: "authorization_required",
4891
+ action: "enable",
4892
+ detail: "A workspace admin must review and enable this capability.",
4893
+ };
4894
+ }
4895
+
4896
+ function capabilityRequiredVariables(item: CapabilityCatalogItem): string[] {
4897
+ const variableSet = item.metadata.variableSet;
4898
+ if (!variableSet || typeof variableSet !== "object" || Array.isArray(variableSet)) return [];
4899
+ const required = (variableSet as Record<string, unknown>).requiredVariables;
4900
+ return Array.isArray(required)
4901
+ ? required.filter((name): name is string => typeof name === "string").slice(0, 64)
4902
+ : [];
4903
+ }
4904
+
4905
+ function capabilityProviderDomain(item: CapabilityCatalogItem): string {
4906
+ if (item.providerDomain?.trim()) return item.providerDomain.trim();
4907
+ for (const candidate of [item.homepageUrl, item.endpointUrl, item.mcpUrl]) {
4908
+ if (!candidate) continue;
4909
+ try {
4910
+ return new URL(candidate).hostname;
4911
+ } catch {
4912
+ // Continue to the local, non-provider fallback below.
4913
+ }
4914
+ }
4915
+ return "opengeni.local";
4916
+ }
4917
+
3518
4918
  function registerGitHubConnectTool(
3519
4919
  server: McpServer,
3520
4920
  deps: ApiRouteDeps,
@@ -4051,11 +5451,12 @@ function parseMcpDate(raw: string, label: string): Date {
4051
5451
  async function withMcpEffectivePolicy(
4052
5452
  deps: ApiRouteDeps,
4053
5453
  workspaceId: string,
5454
+ subjectId: string,
4054
5455
  session: Session,
4055
5456
  ): Promise<Session> {
4056
5457
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
4057
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
4058
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
5458
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
5459
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
4059
5460
  ]);
4060
5461
  return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
4061
5462
  }