@opengeni/api-router 0.22.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/browser-controller-authority.d.ts +43 -0
  5. package/dist/browser-state-authority.d.ts +27 -0
  6. package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
  7. package/dist/chunk-JIKNR5YL.js.map +1 -0
  8. package/dist/codemode.d.ts +23 -0
  9. package/dist/editable-artifact-live-hints.d.ts +11 -0
  10. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  11. package/dist/editable-artifact-office-import.d.ts +22 -0
  12. package/dist/editable-artifact-production.d.ts +29 -0
  13. package/dist/editable-artifact-websocket.d.ts +49 -0
  14. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  15. package/dist/github-browser-flow.d.ts +6 -0
  16. package/dist/http/cors.d.ts +1 -0
  17. package/dist/http/sse.d.ts +2 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1824 -30
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/api-integrations.d.ts +24 -0
  22. package/dist/integrations/atlassian.d.ts +176 -0
  23. package/dist/integrations/github-skill-source.d.ts +5 -0
  24. package/dist/integrations/google-drive.d.ts +85 -0
  25. package/dist/integrations/oauth-client.d.ts +30 -1
  26. package/dist/integrations/provider-oauth.d.ts +19 -0
  27. package/dist/integrations/slack-bot.d.ts +4 -0
  28. package/dist/integrations/slack-interactions.d.ts +14 -2
  29. package/dist/integrations/social-api.d.ts +2 -1
  30. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  31. package/dist/mcp/editable-artifacts.d.ts +13 -0
  32. package/dist/mcp/receipts.d.ts +28 -0
  33. package/dist/mcp/scheduled-task-view.d.ts +518 -0
  34. package/dist/mcp/server.d.ts +14 -3
  35. package/dist/memory-slack-delivery.d.ts +9 -0
  36. package/dist/routes/api-integrations.d.ts +8 -0
  37. package/dist/routes/browser-identities.d.ts +5 -0
  38. package/dist/routes/browser-sessions.d.ts +6 -0
  39. package/dist/routes/company-profile.d.ts +3 -0
  40. package/dist/routes/computer-sessions.d.ts +6 -0
  41. package/dist/routes/editable-artifacts.d.ts +44 -0
  42. package/dist/routes/integration-features.d.ts +3 -0
  43. package/dist/routes/memory-slack-publications.d.ts +6 -0
  44. package/dist/routes/plugins.d.ts +8 -0
  45. package/dist/routes/sessions.d.ts +17 -2
  46. package/dist/routes/skills.d.ts +6 -0
  47. package/dist/routes/video-generation.d.ts +3 -0
  48. package/dist/sandbox/auth-callout.d.ts +2 -0
  49. package/dist/sandbox/channel-a.d.ts +59 -2
  50. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  51. package/dist/sandbox/viewer.d.ts +4 -2
  52. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  53. package/package.json +19 -14
  54. package/src/app.ts +277 -41
  55. package/src/auth/managed-auth.ts +0 -16
  56. package/src/browser-controller-authority.ts +137 -0
  57. package/src/browser-state-authority.ts +236 -0
  58. package/src/codemode.ts +186 -0
  59. package/src/editable-artifact-live-hints.ts +64 -0
  60. package/src/editable-artifact-native-kernel.ts +659 -0
  61. package/src/editable-artifact-office-import.ts +230 -0
  62. package/src/editable-artifact-production.ts +419 -0
  63. package/src/editable-artifact-websocket.ts +311 -0
  64. package/src/editable-artifact-workspace-files.ts +186 -0
  65. package/src/github-browser-flow.ts +35 -6
  66. package/src/http/auth.ts +2 -0
  67. package/src/http/cors.ts +3 -0
  68. package/src/http/sse.ts +101 -6
  69. package/src/index.ts +147 -23
  70. package/src/integrations/api-integrations.ts +350 -0
  71. package/src/integrations/atlassian.ts +1621 -0
  72. package/src/integrations/github-skill-source.ts +142 -0
  73. package/src/integrations/google-drive.ts +1000 -64
  74. package/src/integrations/oauth-client.ts +159 -89
  75. package/src/integrations/provider-oauth.ts +777 -0
  76. package/src/integrations/slack-bot.ts +31 -2
  77. package/src/integrations/slack-interactions.ts +610 -42
  78. package/src/integrations/social-api.ts +11 -0
  79. package/src/mcp/documents.ts +74 -26
  80. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  81. package/src/mcp/editable-artifacts.ts +448 -0
  82. package/src/mcp/receipts.ts +95 -0
  83. package/src/mcp/scheduled-task-view.ts +642 -0
  84. package/src/mcp/server.ts +1718 -310
  85. package/src/memory-slack-delivery.ts +209 -0
  86. package/src/observability.ts +3 -3
  87. package/src/routes/api-integrations.ts +407 -0
  88. package/src/routes/api-keys.ts +7 -1
  89. package/src/routes/browser-identities.ts +136 -0
  90. package/src/routes/browser-sessions.ts +2543 -0
  91. package/src/routes/codex.ts +7 -4
  92. package/src/routes/company-profile.ts +255 -0
  93. package/src/routes/computer-sessions.ts +1247 -0
  94. package/src/routes/connections.ts +358 -102
  95. package/src/routes/documents.ts +22 -3
  96. package/src/routes/editable-artifacts.ts +1159 -0
  97. package/src/routes/enrollments.ts +54 -12
  98. package/src/routes/environments.ts +60 -11
  99. package/src/routes/files.ts +277 -65
  100. package/src/routes/github.ts +18 -2
  101. package/src/routes/install.ts +38 -2
  102. package/src/routes/integration-features.ts +258 -0
  103. package/src/routes/machines.ts +1 -1
  104. package/src/routes/memory-slack-publications.ts +216 -0
  105. package/src/routes/packs.ts +437 -7
  106. package/src/routes/plugins.ts +751 -0
  107. package/src/routes/rigs.ts +77 -20
  108. package/src/routes/scheduled-tasks.ts +94 -42
  109. package/src/routes/sessions.ts +475 -234
  110. package/src/routes/skills.ts +174 -0
  111. package/src/routes/transcription-recordings.ts +65 -33
  112. package/src/routes/video-generation.ts +132 -0
  113. package/src/routes/workspaces.ts +46 -24
  114. package/src/sandbox/auth-callout.ts +16 -4
  115. package/src/sandbox/channel-a.ts +809 -85
  116. package/src/sandbox/enrollment.ts +13 -3
  117. package/src/sandbox/machines.ts +1 -1
  118. package/src/sandbox/metrics-ingestion.ts +121 -3
  119. package/src/sandbox/rematerialize.ts +35 -47
  120. package/src/sandbox/viewer.ts +58 -29
  121. package/src/temporal-schedule-cleanup.ts +135 -0
  122. package/dist/chunk-HWXJW5C7.js.map +0 -1
  123. package/dist/mcp/toolspace.d.ts +0 -62
  124. package/src/mcp/toolspace.ts +0 -1186
package/src/mcp/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  CreateScheduledTaskRequest,
4
4
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
@@ -12,27 +12,34 @@ import {
12
12
  SessionEventResultMode,
13
13
  SessionEventSemanticClass,
14
14
  SessionEventType,
15
+ stableJson,
15
16
  compactSessionEventResult,
16
17
  sessionEventLatestClassToSemanticClass,
18
+ MemorySlackPublicationDistribution,
17
19
  SessionMcpCredentialUpdateInput,
20
+ ToolAuthNeededPayload,
18
21
  VariableSetVariableName,
22
+ capabilityCatalogItemIsTrustedForExposure,
19
23
  type AccessGrant,
24
+ type CapabilityCatalogItem,
20
25
  type GitHubRepository,
21
26
  type FirstPartyMcpToolName,
22
27
  type Permission,
23
28
  type ResourceRef,
24
29
  type SessionAuthorizationOperation,
30
+ type SessionAuthorizationActor,
25
31
  type SessionAuthorizationSurface,
26
32
  type Session,
33
+ type ScheduledTask,
27
34
  UpdateScheduledTaskRequest,
28
35
  normalizeWorkspaceArtifactSlug,
29
36
  WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES,
30
37
  } from "@opengeni/contracts";
31
38
  import {
32
- correctWorkspaceMemory,
33
39
  countVariableSets,
34
40
  beginRigChangeVerificationAttempt,
35
41
  createVariableSet,
42
+ decryptVariableSetValue,
36
43
  deleteScheduledTask,
37
44
  encryptVariableSetValue,
38
45
  getSession,
@@ -56,6 +63,8 @@ import {
56
63
  listRigVersionMonitoringSummaries,
57
64
  listSocialPosts,
58
65
  recordAuditEvent,
66
+ removeEnrollment,
67
+ readVariableSetSecretAtomically,
59
68
  recordSyncedSocialPosts,
60
69
  listVariableSets,
61
70
  MEMORY_CORRECT_TOOL_DESCRIPTION,
@@ -63,7 +72,6 @@ import {
63
72
  MEMORY_SEARCH_TOOL_DESCRIPTION,
64
73
  requireScheduledTask,
65
74
  requireSession,
66
- saveWorkspaceMemory,
67
75
  searchWorkspaceMemories,
68
76
  serializeEffectiveSessionControl,
69
77
  setSessionGoalStatusWithEvent,
@@ -71,7 +79,6 @@ import {
71
79
  updateScheduledTask,
72
80
  updateSessionGoalWithEvent,
73
81
  upsertSessionGoalWithEvent,
74
- RigChangeAlreadyVerifyingError,
75
82
  RigChangeTransitionError,
76
83
  createWorkspaceArtifact,
77
84
  getWorkspaceArtifact,
@@ -80,7 +87,11 @@ import {
80
87
  publishWorkspaceArtifactVersion,
81
88
  rollbackWorkspaceArtifact,
82
89
  } from "@opengeni/db";
83
- import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
90
+ import {
91
+ appendAndPublishEvents,
92
+ appendAndPublishTurnEventsFenced,
93
+ publishDurableSessionEvents,
94
+ } from "@opengeni/events";
84
95
  import {
85
96
  createSignedState,
86
97
  GitHubAppConfigurationError,
@@ -95,10 +106,18 @@ import type { AnySchema, ZodRawShapeCompat } from "@modelcontextprotocol/sdk/ser
95
106
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
96
107
  import * as z4 from "zod/v4";
97
108
  import {
109
+ hasLiteralPermission,
98
110
  hasPermission,
99
111
  authorizedSocialConnectionsForGrant,
112
+ authorizedAtlassianConnectionsForGrant,
113
+ buildCapabilityCatalog,
114
+ nativeConnectionCapabilityRecommendations,
115
+ correctWorkspaceMemoryWithSlackPublication,
116
+ requireLiveAgentAttemptAuthorization,
100
117
  requireSessionAuthorization,
101
118
  requireSessionAuthorizationListScope,
119
+ saveWorkspaceMemoryWithSlackPublication,
120
+ searchCapabilityCatalogItems,
102
121
  type ResolvedSessionAuthorization,
103
122
  } from "@opengeni/core";
104
123
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
@@ -110,12 +129,14 @@ import {
110
129
  } from "../github-access";
111
130
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
112
131
  import {
132
+ assertSocialConnectionProvider,
113
133
  socialMentionsLive,
114
134
  socialOwnPostsLive,
115
135
  socialPostReply,
116
136
  socialSearchLive,
117
137
  socialThreadLive,
118
138
  } from "../integrations/social-api";
139
+ import { revokeKnowledgeSourceScheduleAuthorization } from "../integrations/google-drive";
119
140
  import {
120
141
  promoteVerifiedDefinitionEditChangeForApi,
121
142
  proposeRigChangeForApi,
@@ -132,17 +153,21 @@ import {
132
153
  createValidatedScheduledTask,
133
154
  manualScheduledTaskTriggerUsageKey,
134
155
  manualScheduledTaskTriggerWorkflowId,
156
+ scheduledTaskForGrant,
157
+ scheduledTaskRunForGrant,
135
158
  scheduledTaskToolsProvided,
136
159
  scheduledTaskTriggerToken,
160
+ ScheduledTaskSyncError,
137
161
  syncCreatedScheduledTask,
138
162
  syncUpdatedScheduledTask,
163
+ validateScheduledTaskTarget,
139
164
  validatedScheduledTaskUpdate,
140
165
  } from "@opengeni/core";
141
166
  import {
142
- acceptSessionUserMessage,
167
+ acceptSessionUserMessageWithOutcome,
143
168
  controlAgentSessionWorkstream,
144
169
  controlHumanSessionWorkstream,
145
- createSessionForRequest,
170
+ createSessionForRequestWithOutcome,
146
171
  SessionSpawnDeniedError,
147
172
  sessionSpawnDenialEnvelope,
148
173
  sendAgentSessionMessage,
@@ -170,18 +195,30 @@ import {
170
195
  boundRigDetailMcp,
171
196
  SESSION_EVENT_MCP_MAX_BYTES,
172
197
  } from "./session-view";
173
- import type { ToolspaceMcpSurface } from "./toolspace";
198
+ import { mcpMutationReceipt, sessionCreateMutationReceipt } from "./receipts";
199
+ import {
200
+ boundScheduledTaskDetailMcp,
201
+ boundScheduledTaskMcpPage,
202
+ scheduledTaskMcpSummary,
203
+ } from "./scheduled-task-view";
174
204
  import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
175
205
  import {
176
206
  createOpenGeniSlackBotClient,
177
207
  resolveSlackBotConnectionForTool,
178
208
  } from "../integrations/slack-bot";
209
+ import {
210
+ browseAtlassianSources,
211
+ getAtlassianLiveItem,
212
+ revokeAtlassianScheduleAuthorization,
213
+ searchAtlassianLive,
214
+ } from "../integrations/atlassian";
215
+ import { AtlassianConnectionMetadata } from "@opengeni/contracts/atlassian";
216
+ import { registerEditableArtifactAgentTools } from "./editable-artifacts";
179
217
 
180
218
  export type McpServerOptions = {
181
219
  // Origin of the HTTP request that reached the MCP route. Browser-oriented
182
220
  // tools use it only when no configured public base URL is available.
183
221
  requestOrigin?: string | null;
184
- toolspace?: ToolspaceMcpSurface | null;
185
222
  workspaceMemoryEnabled?: boolean | undefined;
186
223
  };
187
224
 
@@ -205,13 +242,17 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
205
242
  memory_search: { sessionRequired: true, allOf: ["documents:search"] },
206
243
  memory_save: { sessionRequired: true, allOf: ["documents:search"] },
207
244
  memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
208
- preference_registry_summary: { sessionRequired: true, allOf: ["workspace:read"] },
245
+ preference_registry_summary: {
246
+ sessionRequired: true,
247
+ allOf: ["workspace:read"],
248
+ },
209
249
  preference_registry_get: { sessionRequired: true, allOf: ["workspace:read"] },
210
250
  sandboxes_list: { sessionRequired: true, allOf: ["sessions:read"] },
211
251
  sandbox_attach: { sessionRequired: true, allOf: ["sessions:control"] },
212
252
  sandbox_swap: { sessionRequired: true, allOf: ["sessions:control"] },
213
253
  run_on: { sessionRequired: true, allOf: ["sessions:control"] },
214
254
  sandbox_provision: { sessionRequired: true, allOf: ["sessions:control"] },
255
+ connected_machine_remove: { allOf: ["enrollments:manage"] },
215
256
  rig_list: { allOf: ["rigs:use"] },
216
257
  rig_get: { allOf: ["rigs:use"] },
217
258
  rig_propose_change: { allOf: ["rigs:use"] },
@@ -226,10 +267,35 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
226
267
  session_resume: { allOf: ["sessions:control"] },
227
268
  session_steer: { sessionRequired: true, allOf: ["sessions:control"] },
228
269
  set_other_session_title: { allOf: ["sessions:control"] },
229
- variable_set_list: { allOf: ["variable-sets:use"] },
230
- environment_list: { allOf: ["variable-sets:use"] },
231
- variable_set_set_variable: { allOf: ["variable-sets:manage"] },
232
- environment_set_variable: { allOf: ["variable-sets:manage"] },
270
+ interaction_discover: { sessionRequired: true, allOf: ["sessions:read"] },
271
+ browser_open: { sessionRequired: true, allOf: ["sessions:control"] },
272
+ browser_tabs: { sessionRequired: true, allOf: ["sessions:control"] },
273
+ browser_observe: { sessionRequired: true, allOf: ["sessions:read"] },
274
+ browser_act: { sessionRequired: true, allOf: ["sessions:control"] },
275
+ browser_debug: { sessionRequired: true, allOf: ["sessions:read"] },
276
+ browser_identity: { sessionRequired: true, allOf: ["sessions:control"] },
277
+ browser_publish: { sessionRequired: true, allOf: ["sessions:control"] },
278
+ browser_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
279
+ computer_open: { sessionRequired: true, allOf: ["sessions:control"] },
280
+ computer_targets: { sessionRequired: true, allOf: ["sessions:read"] },
281
+ computer_observe: { sessionRequired: true, allOf: ["sessions:read"] },
282
+ computer_act: { sessionRequired: true, allOf: ["sessions:control"] },
283
+ computer_lifecycle: { sessionRequired: true, allOf: ["sessions:control"] },
284
+ variable_set_list: { allOf: ["variable-sets:list", "secrets:list"] },
285
+ environment_list: { allOf: ["variable-sets:list", "secrets:list"] },
286
+ variable_set_get_variable: {
287
+ sessionRequired: true,
288
+ allOf: ["variable-sets:read", "secrets:read"],
289
+ },
290
+ variable_set_set_variable: {
291
+ allOf: ["variable-sets:write", "secrets:write"],
292
+ },
293
+ environment_set_variable: { allOf: ["variable-sets:write", "secrets:write"] },
294
+ capability_catalog_search: { sessionRequired: true, allOf: ["workspace:read"] },
295
+ capability_authorization_request: {
296
+ sessionRequired: true,
297
+ allOf: ["workspace:read"],
298
+ },
233
299
  github_connect_link: { allOf: ["github:use"] },
234
300
  github_repositories_list: { allOf: ["github:use"] },
235
301
  social_connections_list: { allOf: ["connections:read"] },
@@ -244,15 +310,33 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
244
310
  // Publishes under the user's identity: connections:write keeps it out of the
245
311
  // default agent permission set, unlike the read-only social tools above.
246
312
  social_post_reply: { allOf: ["connections:write"] },
247
- scheduled_tasks_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
248
- scheduled_tasks_get: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
313
+ x_accounts_list: { allOf: ["connections:read"] },
314
+ x_search_live: { allOf: ["connections:read"] },
315
+ x_mentions_live: { allOf: ["connections:read"] },
316
+ x_thread_fetch: { allOf: ["connections:read"] },
317
+ x_posts_sync: { allOf: ["connections:write"] },
318
+ x_post_reply: { allOf: ["connections:write"] },
319
+ reddit_accounts_list: { allOf: ["connections:read"] },
320
+ reddit_search_live: { allOf: ["connections:read"] },
321
+ reddit_mentions_live: { allOf: ["connections:read"] },
322
+ reddit_thread_fetch: { allOf: ["connections:read"] },
323
+ reddit_posts_sync: { allOf: ["connections:write"] },
324
+ reddit_post_reply: { allOf: ["connections:write"] },
325
+ scheduled_tasks_list: {
326
+ anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
327
+ },
328
+ scheduled_tasks_get: {
329
+ anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
330
+ },
249
331
  scheduled_tasks_create: { allOf: ["scheduled_tasks:manage"] },
250
332
  scheduled_tasks_update: { allOf: ["scheduled_tasks:manage"] },
251
333
  scheduled_tasks_pause: { allOf: ["scheduled_tasks:manage"] },
252
334
  scheduled_tasks_resume: { allOf: ["scheduled_tasks:manage"] },
253
335
  scheduled_tasks_trigger: { allOf: ["scheduled_tasks:run"] },
254
336
  scheduled_tasks_delete: { allOf: ["scheduled_tasks:manage"] },
255
- scheduled_task_runs_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
337
+ scheduled_task_runs_list: {
338
+ anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"],
339
+ },
256
340
  slack_bot_list_channels: { allOf: ["connections:read"] },
257
341
  slack_bot_channel_history: { allOf: ["connections:read"] },
258
342
  slack_bot_thread_replies: { allOf: ["connections:read"] },
@@ -262,11 +346,31 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
262
346
  slack_bot_file_content: { allOf: ["connections:read"] },
263
347
  slack_bot_post_message: { allOf: ["connections:read"] },
264
348
  slack_bot_delete_message: { allOf: ["connections:read"] },
349
+ atlassian_sources_list: { allOf: ["connections:read"] },
350
+ atlassian_search: { allOf: ["connections:read"] },
351
+ atlassian_get: { allOf: ["connections:read"] },
265
352
  artifacts_list: { sessionRequired: true, allOf: ["artifacts:read"] },
266
353
  artifacts_get_source: { sessionRequired: true, allOf: ["artifacts:read"] },
267
354
  artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
268
355
  artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
269
356
  artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
357
+ editable_artifact_list: { sessionRequired: true, allOf: ["artifacts:read"] },
358
+ editable_artifact_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
359
+ editable_artifact_import: {
360
+ sessionRequired: true,
361
+ allOf: ["artifacts:publish", "files:read"],
362
+ },
363
+ editable_artifact_get: { sessionRequired: true, allOf: ["artifacts:read"] },
364
+ editable_artifact_inspect: { sessionRequired: true, allOf: ["artifacts:read"] },
365
+ editable_artifact_apply: { sessionRequired: true, allOf: ["artifacts:publish"] },
366
+ editable_artifact_export: {
367
+ sessionRequired: true,
368
+ allOf: ["artifacts:read"],
369
+ },
370
+ editable_artifact_export_status: {
371
+ sessionRequired: true,
372
+ allOf: ["artifacts:read", "files:upload"],
373
+ },
270
374
  } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
271
375
 
272
376
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
@@ -278,7 +382,6 @@ class PolicyMcpServer extends McpServer {
278
382
  private readonly grant: AccessGrant,
279
383
  private readonly sessionId: string | null,
280
384
  private readonly selectedTools: ReadonlySet<FirstPartyMcpToolName> | null,
281
- private readonly allowUncatalogued: boolean,
282
385
  ) {
283
386
  super({ name: "opengeni", version: "1.0.0" });
284
387
  }
@@ -299,7 +402,7 @@ class PolicyMcpServer extends McpServer {
299
402
  cb: ToolCallback<InputArgs>,
300
403
  ): RegisteredTool {
301
404
  const catalogued = FIRST_PARTY_MCP_TOOL_NAME_SET.has(name);
302
- let admitted = this.allowUncatalogued && !catalogued;
405
+ let admitted = false;
303
406
  if (catalogued) {
304
407
  const toolName = name as FirstPartyMcpToolName;
305
408
  const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
@@ -356,7 +459,6 @@ export function buildOpenGeniMcpServer(
356
459
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
357
460
  });
358
461
  const can = (permission: Permission) => hasPermission(grant.permissions, permission);
359
- const toolspaceMode = options.toolspace != null;
360
462
  let socialConnectionsPromise: ReturnType<typeof authorizedSocialConnectionsForGrant> | undefined;
361
463
  const authorizedSocialConnections = () =>
362
464
  (socialConnectionsPromise ??= authorizedSocialConnectionsForGrant({
@@ -371,6 +473,14 @@ export function buildOpenGeniMcpServer(
371
473
  if (!authority) throw new Error(`Unknown or unavailable social connection: ${connectionId}`);
372
474
  return authority;
373
475
  };
476
+ const requireAuthorizedSocialConnectionForProvider = async (
477
+ provider: "x" | "reddit",
478
+ connectionId: string,
479
+ ) => {
480
+ const authority = await requireAuthorizedSocialConnection(connectionId);
481
+ assertSocialConnectionProvider(authority.connection, provider);
482
+ return authority;
483
+ };
374
484
 
375
485
  // Session-scoped tools key off the worker-asserted sessionId claim (signed
376
486
  // into the delegated token by the worker, never agent-controlled).
@@ -379,17 +489,17 @@ export function buildOpenGeniMcpServer(
379
489
  ? (grant.metadata["sessionId"] as string)
380
490
  : null;
381
491
  const selectedTools =
382
- sessionId !== null && !toolspaceMode
492
+ sessionId !== null
383
493
  ? new Set(
384
494
  (grant.metadata?.["firstPartyMcpTools"] as FirstPartyMcpToolName[] | undefined) ??
385
495
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
386
496
  )
387
497
  : null;
388
- const server = new PolicyMcpServer(grant, sessionId, selectedTools, toolspaceMode);
498
+ const server = new PolicyMcpServer(grant, sessionId, selectedTools);
389
499
  // set_session_title names the agent's OWN session — pure session metadata,
390
500
  // not a goal operation — so it is available on every session, gated only on
391
501
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
392
- if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
502
+ if (sessionId !== null) {
393
503
  server.registerTool(
394
504
  "set_session_title",
395
505
  {
@@ -412,18 +522,23 @@ export function buildOpenGeniMcpServer(
412
522
  if (sessionId !== null && can("goals:manage")) {
413
523
  registerGoalTools(server, deps, grant, sessionId, json);
414
524
  }
415
- // Toolspace grants are the sandbox's narrowed proxy surface. Unlike the
416
- // normal first-party worker token, a bare toolspace:call token does not see
417
- // unpermissioned session tools; memory follows that title/goal parity and
418
- // stays on the normal first-party MCP surface only.
419
- if (!toolspaceMode && sessionId !== null && options.workspaceMemoryEnabled === true) {
525
+ if (sessionId !== null && options.workspaceMemoryEnabled === true) {
420
526
  registerMemoryTools(server, deps, grant, sessionId, json);
421
527
  }
422
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
528
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
423
529
  registerPreferenceRegistryTools(server, deps, grant, json);
424
530
  }
425
- if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
531
+ if (sessionId !== null && exactAgentAttemptClaims(grant) !== null) {
426
532
  registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
533
+ registerEditableArtifactAgentTools({
534
+ server,
535
+ deps,
536
+ grant,
537
+ sessionId,
538
+ authorize: async () => {
539
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
540
+ },
541
+ });
427
542
  }
428
543
 
429
544
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
@@ -432,13 +547,15 @@ export function buildOpenGeniMcpServer(
432
547
  // so they register only when the grant carries the worker-signed sessionId claim
433
548
  // (never agent-controlled). Gated on the selfhosted feature flag: the active
434
549
  // pointer + swap are only meaningful when bring-your-own-compute is enabled.
435
- if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
550
+ if (sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
436
551
  registerFleetTools(server, deps, grant, sessionId, json);
437
552
  }
438
- if (!toolspaceMode) {
439
- registerRigTools(server, deps, grant, can, sessionId, json);
440
- registerSlackBotTools(server, deps, grant, sessionId, json);
553
+ if (can("enrollments:manage") && deps.settings.sandboxSelfhostedEnabled) {
554
+ registerConnectedMachineTools(server, deps, grant, json);
441
555
  }
556
+ registerRigTools(server, deps, grant, can, sessionId, json);
557
+ registerSlackBotTools(server, deps, grant, sessionId, json);
558
+ registerAtlassianTools(server, deps, grant, json);
442
559
 
443
560
  // Orchestration, variableSet, and GitHub status tools are permission-gated
444
561
  // at registration: a grant without the permission does not see the tool.
@@ -451,13 +568,16 @@ export function buildOpenGeniMcpServer(
451
568
  // never returned through a model-visible MCP tool. A user DEMOTES a specific
452
569
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
453
570
  // the creator's own grant); operators still cap what any session can be given.
454
- registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
455
- registerVariableSetTools(server, deps, grant, can, json);
571
+ registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, json);
572
+ registerVariableSetTools(server, deps, grant, can, sessionId, json);
573
+ if (sessionId !== null && can("workspace:read")) {
574
+ registerCapabilityDiscoveryTools(server, deps, grant, sessionId, json);
575
+ }
456
576
  if (can("github:use")) {
457
577
  registerGitHubConnectTool(server, deps, grant, options, json);
458
578
  }
459
579
 
460
- if (!toolspaceMode || can("github:use")) {
580
+ if (can("github:use")) {
461
581
  server.registerTool(
462
582
  "github_repositories_list",
463
583
  {
@@ -486,7 +606,7 @@ export function buildOpenGeniMcpServer(
486
606
  );
487
607
  }
488
608
 
489
- if (!toolspaceMode || can("connections:read")) {
609
+ if (can("connections:read")) {
490
610
  server.registerTool(
491
611
  "social_connections_list",
492
612
  {
@@ -620,10 +740,17 @@ export function buildOpenGeniMcpServer(
620
740
  const authority = await requireAuthorizedSocialConnection(connectionId);
621
741
  const result = await socialSearchLive(
622
742
  deps,
623
- { workspaceId: grant.workspaceId, connectionId, subjectId: authority.subjectId },
743
+ {
744
+ workspaceId: grant.workspaceId,
745
+ connectionId,
746
+ subjectId: authority.subjectId,
747
+ },
624
748
  { query, subreddit, limit },
625
749
  );
626
- return json({ provider: result.connection.provider, posts: result.posts });
750
+ return json({
751
+ provider: result.connection.provider,
752
+ posts: result.posts,
753
+ });
627
754
  },
628
755
  );
629
756
 
@@ -642,10 +769,17 @@ export function buildOpenGeniMcpServer(
642
769
  const authority = await requireAuthorizedSocialConnection(connectionId);
643
770
  const result = await socialMentionsLive(
644
771
  deps,
645
- { workspaceId: grant.workspaceId, connectionId, subjectId: authority.subjectId },
772
+ {
773
+ workspaceId: grant.workspaceId,
774
+ connectionId,
775
+ subjectId: authority.subjectId,
776
+ },
646
777
  { sinceId, limit },
647
778
  );
648
- return json({ provider: result.connection.provider, posts: result.posts });
779
+ return json({
780
+ provider: result.connection.provider,
781
+ posts: result.posts,
782
+ });
649
783
  },
650
784
  );
651
785
 
@@ -664,18 +798,135 @@ export function buildOpenGeniMcpServer(
664
798
  const authority = await requireAuthorizedSocialConnection(connectionId);
665
799
  const result = await socialThreadLive(
666
800
  deps,
667
- { workspaceId: grant.workspaceId, connectionId, subjectId: authority.subjectId },
801
+ {
802
+ workspaceId: grant.workspaceId,
803
+ connectionId,
804
+ subjectId: authority.subjectId,
805
+ },
668
806
  { id, limit },
669
807
  );
670
- return json({ provider: result.connection.provider, posts: result.posts });
808
+ return json({
809
+ provider: result.connection.provider,
810
+ posts: result.posts,
811
+ });
671
812
  },
672
813
  );
814
+
815
+ // Provider-scoped aliases are the canonical tools advertised by the X and
816
+ // Reddit Integration cards. The legacy social_* names remain available to
817
+ // existing Packs/sessions, but these names bind the provider in the tool
818
+ // identity and reject a near-identical Connection from the other adapter.
819
+ for (const provider of ["x", "reddit"] as const) {
820
+ const providerName = provider === "x" ? "X" : "Reddit";
821
+ server.registerTool(
822
+ `${provider}_accounts_list`,
823
+ {
824
+ description: `List the exact visible ${providerName} accounts available to this work.`,
825
+ inputSchema: { limit: z4.number().int().positive().optional() },
826
+ },
827
+ async ({ limit }) =>
828
+ json({
829
+ connections: (await authorizedSocialConnections())
830
+ .filter(
831
+ ({ connection }) =>
832
+ connection.provider === provider && connection.status !== "disabled",
833
+ )
834
+ .slice(0, boundedMcpLimit(limit))
835
+ .map(({ connection }) => connection),
836
+ }),
837
+ );
838
+ server.registerTool(
839
+ `${provider}_search_live`,
840
+ {
841
+ description:
842
+ provider === "x"
843
+ ? "Search recent X conversations through one exact connected X account."
844
+ : "Search Reddit through one exact connected Reddit account; optionally scope to a subreddit.",
845
+ inputSchema: {
846
+ connectionId: z4.string().uuid(),
847
+ query: z4.string().min(1).max(512),
848
+ subreddit: z4.string().min(1).max(100).optional(),
849
+ limit: z4.number().int().positive().optional(),
850
+ },
851
+ },
852
+ async ({ connectionId, query, subreddit, limit }) => {
853
+ const authority = await requireAuthorizedSocialConnectionForProvider(
854
+ provider,
855
+ connectionId,
856
+ );
857
+ const result = await socialSearchLive(
858
+ deps,
859
+ {
860
+ workspaceId: grant.workspaceId,
861
+ connectionId,
862
+ subjectId: authority.subjectId,
863
+ },
864
+ { query, subreddit, limit },
865
+ );
866
+ return json({ provider: result.connection.provider, posts: result.posts });
867
+ },
868
+ );
869
+ server.registerTool(
870
+ `${provider}_mentions_live`,
871
+ {
872
+ description: `Fetch live ${providerName} mentions and replies through one exact connected account.`,
873
+ inputSchema: {
874
+ connectionId: z4.string().uuid(),
875
+ sinceId: z4.string().optional(),
876
+ limit: z4.number().int().positive().optional(),
877
+ },
878
+ },
879
+ async ({ connectionId, sinceId, limit }) => {
880
+ const authority = await requireAuthorizedSocialConnectionForProvider(
881
+ provider,
882
+ connectionId,
883
+ );
884
+ const result = await socialMentionsLive(
885
+ deps,
886
+ {
887
+ workspaceId: grant.workspaceId,
888
+ connectionId,
889
+ subjectId: authority.subjectId,
890
+ },
891
+ { sinceId, limit },
892
+ );
893
+ return json({ provider: result.connection.provider, posts: result.posts });
894
+ },
895
+ );
896
+ server.registerTool(
897
+ `${provider}_thread_fetch`,
898
+ {
899
+ description: `Fetch one live ${providerName} conversation thread through an exact connected account.`,
900
+ inputSchema: {
901
+ connectionId: z4.string().uuid(),
902
+ id: z4.string().min(1).max(100),
903
+ limit: z4.number().int().positive().optional(),
904
+ },
905
+ },
906
+ async ({ connectionId, id, limit }) => {
907
+ const authority = await requireAuthorizedSocialConnectionForProvider(
908
+ provider,
909
+ connectionId,
910
+ );
911
+ const result = await socialThreadLive(
912
+ deps,
913
+ {
914
+ workspaceId: grant.workspaceId,
915
+ connectionId,
916
+ subjectId: authority.subjectId,
917
+ },
918
+ { id, limit },
919
+ );
920
+ return json({ provider: result.connection.provider, posts: result.posts });
921
+ },
922
+ );
923
+ }
673
924
  }
674
925
 
675
926
  // Writes are gated on connections:write (never in the default first-party
676
927
  // agent permission set) so scheduled tasks must opt in, and deployments can
677
928
  // additionally wrap posting in a requireApproval policy.
678
- if (!toolspaceMode || can("connections:write")) {
929
+ if (can("connections:write")) {
679
930
  server.registerTool(
680
931
  "social_posts_sync",
681
932
  {
@@ -690,7 +941,11 @@ export function buildOpenGeniMcpServer(
690
941
  const authority = await requireAuthorizedSocialConnection(connectionId);
691
942
  const result = await socialOwnPostsLive(
692
943
  deps,
693
- { workspaceId: grant.workspaceId, connectionId, subjectId: authority.subjectId },
944
+ {
945
+ workspaceId: grant.workspaceId,
946
+ connectionId,
947
+ subjectId: authority.subjectId,
948
+ },
694
949
  { limit },
695
950
  );
696
951
  // A post without a provider timestamp is skipped rather than recorded
@@ -736,7 +991,11 @@ export function buildOpenGeniMcpServer(
736
991
  const authority = await requireAuthorizedSocialConnection(connectionId);
737
992
  const result = await socialPostReply(
738
993
  deps,
739
- { workspaceId: grant.workspaceId, connectionId, subjectId: authority.subjectId },
994
+ {
995
+ workspaceId: grant.workspaceId,
996
+ connectionId,
997
+ subjectId: authority.subjectId,
998
+ },
740
999
  { inReplyToId, text },
741
1000
  );
742
1001
  // Outbound publishes leave a durable, secret-free receipt (house
@@ -763,28 +1022,150 @@ export function buildOpenGeniMcpServer(
763
1022
  });
764
1023
  },
765
1024
  );
1025
+
1026
+ for (const provider of ["x", "reddit"] as const) {
1027
+ const providerName = provider === "x" ? "X" : "Reddit";
1028
+ server.registerTool(
1029
+ `${provider}_posts_sync`,
1030
+ {
1031
+ description: `Sync one exact connected ${providerName} account's recent posts into OpenGeni (idempotent).`,
1032
+ inputSchema: {
1033
+ connectionId: z4.string().uuid(),
1034
+ limit: z4.number().int().positive().optional(),
1035
+ },
1036
+ },
1037
+ async ({ connectionId, limit }) => {
1038
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1039
+ provider,
1040
+ connectionId,
1041
+ );
1042
+ const result = await socialOwnPostsLive(
1043
+ deps,
1044
+ {
1045
+ workspaceId: grant.workspaceId,
1046
+ connectionId,
1047
+ subjectId: authority.subjectId,
1048
+ },
1049
+ { limit },
1050
+ );
1051
+ const datedPosts = result.posts.filter((post) => post.createdAt !== null);
1052
+ const synced = await recordSyncedSocialPosts(deps.db, {
1053
+ accountId: grant.accountId,
1054
+ workspaceId: grant.workspaceId,
1055
+ connectionId,
1056
+ subjectId: authority.subjectId,
1057
+ posts: datedPosts.map((post) => ({
1058
+ externalPostId: post.id,
1059
+ url: post.url,
1060
+ authorHandle: post.author,
1061
+ text: post.text,
1062
+ publishedAt: new Date(post.createdAt!),
1063
+ metrics: post.metrics,
1064
+ })),
1065
+ });
1066
+ return json({
1067
+ provider: result.connection.provider,
1068
+ fetched: result.posts.length,
1069
+ inserted: synced.inserted,
1070
+ skipped: synced.skipped,
1071
+ skippedMissingDate: result.posts.length - datedPosts.length,
1072
+ });
1073
+ },
1074
+ );
1075
+ server.registerTool(
1076
+ `${provider}_post_reply`,
1077
+ {
1078
+ description: `Publish a reply from one exact connected ${providerName} account. This is a public write and should require approval.`,
1079
+ inputSchema: {
1080
+ connectionId: z4.string().uuid(),
1081
+ inReplyToId: z4.string().min(1).max(100),
1082
+ text: z4.string().min(1).max(10000),
1083
+ },
1084
+ },
1085
+ async ({ connectionId, inReplyToId, text }) => {
1086
+ const authority = await requireAuthorizedSocialConnectionForProvider(
1087
+ provider,
1088
+ connectionId,
1089
+ );
1090
+ const result = await socialPostReply(
1091
+ deps,
1092
+ {
1093
+ workspaceId: grant.workspaceId,
1094
+ connectionId,
1095
+ subjectId: authority.subjectId,
1096
+ },
1097
+ { inReplyToId, text },
1098
+ );
1099
+ await recordAuditEvent(deps.db, {
1100
+ accountId: grant.accountId,
1101
+ workspaceId: grant.workspaceId,
1102
+ subjectId: grant.subjectId,
1103
+ action: "social.post_reply",
1104
+ targetType: "social_connection",
1105
+ targetId: connectionId,
1106
+ metadata: {
1107
+ provider: result.connection.provider,
1108
+ adapterTool: `${provider}_post_reply`,
1109
+ inReplyToId,
1110
+ postedId: result.postedId,
1111
+ url: result.url,
1112
+ },
1113
+ });
1114
+ return json({
1115
+ provider: result.connection.provider,
1116
+ postedId: result.postedId,
1117
+ url: result.url,
1118
+ });
1119
+ },
1120
+ );
1121
+ }
766
1122
  }
767
1123
 
768
- if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
1124
+ if (can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
769
1125
  server.registerTool(
770
1126
  "scheduled_tasks_list",
771
1127
  {
772
- description: "List scheduled tasks.",
773
- inputSchema: { limit: z4.number().int().positive().optional() },
1128
+ description:
1129
+ "List compact scheduled-task summaries. Prompts, goal text, resource/tool bodies, and metadata values are represented by byte/count facts; page with offset and use scheduled_tasks_get for a bounded explicit detail projection.",
1130
+ inputSchema: {
1131
+ limit: z4.number().int().positive().max(50).optional(),
1132
+ offset: z4.number().int().nonnegative().max(10_000).optional(),
1133
+ },
1134
+ },
1135
+ async ({ limit: requestedLimit, offset: requestedOffset }) => {
1136
+ const limit = requestedLimit ?? 25;
1137
+ const offset = requestedOffset ?? 0;
1138
+ const rows = await listScheduledTasks(deps.db, grant.workspaceId, limit + 1, offset);
1139
+ return json(
1140
+ boundScheduledTaskMcpPage({
1141
+ tasks: rows.slice(0, limit).map((task) => scheduledTaskForGrant(task, grant)),
1142
+ limit,
1143
+ offset,
1144
+ sourceHasMore: rows.length > limit,
1145
+ }),
1146
+ );
774
1147
  },
775
- async ({ limit }) =>
776
- json({
777
- tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100),
778
- }),
779
1148
  );
780
1149
 
781
1150
  server.registerTool(
782
1151
  "scheduled_tasks_get",
783
1152
  {
784
- description: "Get one scheduled task.",
785
- inputSchema: { id: z4.string().uuid() },
1153
+ description:
1154
+ "Get one scheduled task. The default is the same compact summary used by scheduled_tasks_list; pass includeEntity=true for a bounded projection with an 8 KiB prompt preview, bounded goal fields, resource/tool identity previews, and metadata keys without values.",
1155
+ inputSchema: {
1156
+ id: z4.string().uuid(),
1157
+ includeEntity: z4.boolean().optional(),
1158
+ },
1159
+ },
1160
+ async ({ id, includeEntity }) => {
1161
+ const task = scheduledTaskForGrant(
1162
+ await requireScheduledTask(deps.db, grant.workspaceId, id),
1163
+ grant,
1164
+ );
1165
+ return json(
1166
+ includeEntity ? boundScheduledTaskDetailMcp(task) : scheduledTaskMcpSummary(task),
1167
+ );
786
1168
  },
787
- async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)),
788
1169
  );
789
1170
 
790
1171
  server.registerTool(
@@ -795,6 +1176,7 @@ export function buildOpenGeniMcpServer(
795
1176
  name: z4.string(),
796
1177
  schedule: z4.unknown(),
797
1178
  runMode: z4.string().optional(),
1179
+ targetSessionId: z4.string().uuid().nullable().optional(),
798
1180
  overlapPolicy: z4.string().optional(),
799
1181
  agentConfig: z4.unknown(),
800
1182
  status: z4.string().optional(),
@@ -823,13 +1205,29 @@ export function buildOpenGeniMcpServer(
823
1205
  grant,
824
1206
  payload,
825
1207
  toolsProvided: scheduledTaskToolsProvided(args),
1208
+ sessionAuthorization: deps.sessionAuthorization,
1209
+ authorizationSurface: "first_party_mcp",
826
1210
  });
827
- await syncCreatedScheduledTask({
828
- db: deps.db,
829
- workflowClient: deps.workflowClient,
830
- task,
831
- });
832
- return json(task);
1211
+ try {
1212
+ await syncCreatedScheduledTask({
1213
+ db: deps.db,
1214
+ workflowClient: deps.workflowClient,
1215
+ task,
1216
+ });
1217
+ } catch (error) {
1218
+ if (!(error instanceof ScheduledTaskSyncError) || error.persistenceRestored) {
1219
+ throw error;
1220
+ }
1221
+ return json(
1222
+ scheduledTaskReceipt("scheduled_tasks_create", task, "partial_failure", true, {
1223
+ partialFailure: { stage: "schedule_sync", retryable: true },
1224
+ warnings: [
1225
+ "The task database record committed, but Temporal schedule synchronization failed.",
1226
+ ],
1227
+ }),
1228
+ );
1229
+ }
1230
+ return json(scheduledTaskReceipt("scheduled_tasks_create", task, "created", true));
833
1231
  },
834
1232
  );
835
1233
 
@@ -842,6 +1240,7 @@ export function buildOpenGeniMcpServer(
842
1240
  name: z4.string().optional(),
843
1241
  schedule: z4.unknown().optional(),
844
1242
  runMode: z4.string().optional(),
1243
+ targetSessionId: z4.string().uuid().nullable().optional(),
845
1244
  overlapPolicy: z4.string().optional(),
846
1245
  agentConfig: z4.unknown().optional(),
847
1246
  status: z4.string().optional(),
@@ -867,7 +1266,12 @@ export function buildOpenGeniMcpServer(
867
1266
  existing,
868
1267
  payload,
869
1268
  toolsProvided: scheduledTaskToolsProvided(raw),
1269
+ sessionAuthorization: deps.sessionAuthorization,
1270
+ authorizationSurface: "first_party_mcp",
870
1271
  });
1272
+ if (!scheduledTaskUpdateChangesState(existing, update)) {
1273
+ return json(scheduledTaskReceipt("scheduled_tasks_update", existing, "unchanged", false));
1274
+ }
871
1275
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
872
1276
  await syncUpdatedScheduledTask({
873
1277
  db: deps.db,
@@ -875,7 +1279,7 @@ export function buildOpenGeniMcpServer(
875
1279
  previous,
876
1280
  task,
877
1281
  });
878
- return json(task);
1282
+ return json(scheduledTaskReceipt("scheduled_tasks_update", task, "updated", true));
879
1283
  },
880
1284
  );
881
1285
 
@@ -887,6 +1291,9 @@ export function buildOpenGeniMcpServer(
887
1291
  },
888
1292
  async ({ id }) => {
889
1293
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1294
+ if (existing.status === "paused") {
1295
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", existing, "unchanged", false));
1296
+ }
890
1297
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
891
1298
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
892
1299
  status: "paused",
@@ -897,7 +1304,7 @@ export function buildOpenGeniMcpServer(
897
1304
  previous,
898
1305
  task,
899
1306
  });
900
- return json(task);
1307
+ return json(scheduledTaskReceipt("scheduled_tasks_pause", task, "updated", true));
901
1308
  },
902
1309
  );
903
1310
 
@@ -909,6 +1316,9 @@ export function buildOpenGeniMcpServer(
909
1316
  },
910
1317
  async ({ id }) => {
911
1318
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
1319
+ if (existing.status === "active") {
1320
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", existing, "unchanged", false));
1321
+ }
912
1322
  const previous = await captureScheduledTaskRestoreState(deps.db, existing);
913
1323
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
914
1324
  status: "active",
@@ -919,7 +1329,7 @@ export function buildOpenGeniMcpServer(
919
1329
  previous,
920
1330
  task,
921
1331
  });
922
- return json(task);
1332
+ return json(scheduledTaskReceipt("scheduled_tasks_resume", task, "updated", true));
923
1333
  },
924
1334
  );
925
1335
 
@@ -935,19 +1345,32 @@ export function buildOpenGeniMcpServer(
935
1345
  },
936
1346
  async ({ id, triggerId }) => {
937
1347
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
938
- await requireLimit(deps, {
939
- accountId: grant.accountId,
940
- workspaceId: grant.workspaceId,
941
- action: "agent_run:create",
942
- quantity: 1,
943
- model: task.agentConfig.model ?? deps.settings.openaiModel,
944
- });
1348
+ if (task.action.kind === "agent_turn") {
1349
+ await validateScheduledTaskTarget({
1350
+ db: deps.db,
1351
+ sessionAuthorization: deps.sessionAuthorization,
1352
+ authorizationSurface: "first_party_mcp",
1353
+ grant,
1354
+ targetSessionId: task.targetSessionId,
1355
+ runMode: task.runMode,
1356
+ variableSetId: task.variableSetId,
1357
+ rigId: task.rigId,
1358
+ agentConfig: task.agentConfig,
1359
+ missingTargetStatus: 404,
1360
+ });
1361
+ await requireLimit(deps, {
1362
+ accountId: grant.accountId,
1363
+ workspaceId: grant.workspaceId,
1364
+ action: "agent_run:create",
1365
+ quantity: 1,
1366
+ model: task.agentConfig.model ?? deps.settings.openaiModel,
1367
+ });
1368
+ }
945
1369
  const triggerToken = scheduledTaskTriggerToken(triggerId);
946
- const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(
947
- grant.workspaceId,
948
- task.id,
949
- triggerToken,
950
- );
1370
+ const agentRunUsageIdempotencyKey =
1371
+ task.action.kind === "agent_turn"
1372
+ ? manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken)
1373
+ : `knowledge-source-sync:manual:${grant.workspaceId}:${task.id}:${triggerToken}`;
951
1374
  const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
952
1375
  await deps.workflowClient.triggerScheduledTask({
953
1376
  task,
@@ -955,18 +1378,44 @@ export function buildOpenGeniMcpServer(
955
1378
  triggerWorkflowId,
956
1379
  initiator: { kind: "subject", subjectId: grant.subjectId },
957
1380
  });
958
- await recordWorkspaceUsage(deps, {
959
- accountId: grant.accountId,
960
- workspaceId: grant.workspaceId,
961
- subjectId: grant.subjectId,
962
- eventType: "agent_run.created",
963
- quantity: 1,
964
- unit: "run",
965
- sourceResourceType: "scheduled_task",
966
- sourceResourceId: task.id,
967
- idempotencyKey: agentRunUsageIdempotencyKey,
968
- });
969
- return json(task);
1381
+ try {
1382
+ if (task.action.kind !== "agent_turn") {
1383
+ return json(
1384
+ scheduledTaskReceipt("scheduled_tasks_trigger", task, "triggered", true, {
1385
+ idempotencyStatus: triggerId ? "unknown" : "not_requested",
1386
+ facts: { triggerWorkflowId },
1387
+ }),
1388
+ );
1389
+ }
1390
+ await recordWorkspaceUsage(deps, {
1391
+ accountId: grant.accountId,
1392
+ workspaceId: grant.workspaceId,
1393
+ subjectId: grant.subjectId,
1394
+ eventType: "agent_run.created",
1395
+ quantity: 1,
1396
+ unit: "run",
1397
+ sourceResourceType: "scheduled_task",
1398
+ sourceResourceId: task.id,
1399
+ idempotencyKey: agentRunUsageIdempotencyKey,
1400
+ });
1401
+ } catch {
1402
+ return json(
1403
+ scheduledTaskReceipt("scheduled_tasks_trigger", task, "partial_failure", true, {
1404
+ partialFailure: { stage: "usage_recording", retryable: true },
1405
+ warnings: [
1406
+ "The Temporal run trigger completed, but usage recording failed; retry with the same triggerId.",
1407
+ ],
1408
+ idempotencyStatus: triggerId ? "unknown" : "not_requested",
1409
+ facts: { triggerWorkflowId },
1410
+ }),
1411
+ );
1412
+ }
1413
+ return json(
1414
+ scheduledTaskReceipt("scheduled_tasks_trigger", task, "triggered", true, {
1415
+ idempotencyStatus: triggerId ? "unknown" : "not_requested",
1416
+ facts: { triggerWorkflowId },
1417
+ }),
1418
+ );
970
1419
  },
971
1420
  );
972
1421
 
@@ -978,11 +1427,42 @@ export function buildOpenGeniMcpServer(
978
1427
  },
979
1428
  async ({ id }) => {
980
1429
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1430
+ if (task.metadata.connectorKind === "atlassian") {
1431
+ await revokeAtlassianScheduleAuthorization(deps, {
1432
+ task,
1433
+ subjectId: grant.subjectId,
1434
+ });
1435
+ } else {
1436
+ await revokeKnowledgeSourceScheduleAuthorization(deps, {
1437
+ task,
1438
+ subjectId: grant.subjectId,
1439
+ });
1440
+ }
981
1441
  await deps.workflowClient.deleteScheduledTaskSchedule({
982
1442
  temporalScheduleId: task.temporalScheduleId,
983
1443
  });
984
- await deleteScheduledTask(deps.db, grant.workspaceId, id);
985
- return json({ ok: true });
1444
+ try {
1445
+ await deleteScheduledTask(deps.db, grant.workspaceId, id);
1446
+ } catch {
1447
+ return json(
1448
+ scheduledTaskReceipt("scheduled_tasks_delete", task, "partial_failure", true, {
1449
+ partialFailure: { stage: "database_delete", retryable: true },
1450
+ warnings: [
1451
+ "The Temporal schedule was deleted, but the task database record remains.",
1452
+ ],
1453
+ }),
1454
+ );
1455
+ }
1456
+ return json(
1457
+ mcpMutationReceipt({
1458
+ operation: "scheduled_tasks_delete",
1459
+ committed: true,
1460
+ outcome: "deleted",
1461
+ changed: true,
1462
+ resource: { type: "scheduled_task", id: task.id, state: "deleted" },
1463
+ idempotency: { status: "not_supported" },
1464
+ }),
1465
+ );
986
1466
  },
987
1467
  );
988
1468
 
@@ -997,12 +1477,13 @@ export function buildOpenGeniMcpServer(
997
1477
  },
998
1478
  async ({ taskId, limit }) =>
999
1479
  json({
1000
- runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100),
1480
+ runs: (await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100)).map(
1481
+ (run) => scheduledTaskRunForGrant(run, grant),
1482
+ ),
1001
1483
  }),
1002
1484
  );
1003
1485
  }
1004
1486
 
1005
- registerToolspaceProxyTools(server, options.toolspace ?? null);
1006
1487
  server.ensureToolsListHandler();
1007
1488
 
1008
1489
  return server;
@@ -1243,54 +1724,120 @@ function registerSlackBotTools(
1243
1724
  );
1244
1725
  }
1245
1726
 
1246
- function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSurface | null): void {
1247
- if (!surface) {
1248
- return;
1249
- }
1250
- // McpServer installs its tools/list handler lazily on the first registered
1251
- // tool. A legitimate empty Toolspace surface (no selected proxyable servers,
1252
- // no active turn, or all optional upstreams unavailable) must therefore seed
1253
- // and disable one invisible tool; otherwise `ogtool list` receives JSON-RPC
1254
- // "Method not found" instead of the valid `{ tools: [] }` response.
1255
- if (surface.tools.length === 0) {
1256
- server
1257
- .registerTool(
1258
- "__opengeni_empty_toolspace_surface__",
1259
- {
1260
- description: "Internal disabled placeholder for an empty Toolspace surface.",
1261
- inputSchema: z4.object({}),
1262
- },
1263
- async () => ({
1264
- content: [{ type: "text" as const, text: '{"unavailable":true}' }],
1265
- }),
1266
- )
1267
- .disable();
1268
- return;
1269
- }
1270
- for (const tool of surface.tools) {
1271
- server.registerTool(
1272
- tool.name,
1273
- {
1274
- ...(tool.description ? { description: tool.description } : {}),
1275
- inputSchema: z4.object({}).passthrough(),
1276
- _meta: {
1277
- opengeni: {
1278
- origin: "toolspace",
1279
- subjectId: surface.subjectId,
1280
- sessionId: surface.sessionId,
1281
- ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
1282
- },
1283
- },
1284
- },
1285
- async (args) => await tool.call(args),
1727
+ function registerAtlassianTools(
1728
+ server: McpServer,
1729
+ deps: ApiRouteDeps,
1730
+ grant: AccessGrant,
1731
+ json: JsonResult,
1732
+ ): void {
1733
+ const connectionFor = async (connectionId?: string) => {
1734
+ const authorized = await authorizedAtlassianConnectionsForGrant({ db: deps.db, grant });
1735
+ const candidates = authorized.filter(({ connection }) =>
1736
+ connectionId ? connection.id === connectionId : true,
1286
1737
  );
1287
- }
1288
- }
1738
+ if (candidates.length === 0) {
1739
+ throw new Error(
1740
+ connectionId
1741
+ ? "the requested Atlassian connection is unavailable for this turn"
1742
+ : "no Atlassian connection is available for this turn",
1743
+ );
1744
+ }
1745
+ if (!connectionId && candidates.length > 1) {
1746
+ throw new Error(
1747
+ "connectionId is required because multiple Atlassian connections are available",
1748
+ );
1749
+ }
1750
+ const authority = candidates[0]!;
1751
+ const metadata = AtlassianConnectionMetadata.safeParse(authority.connection.metadata);
1752
+ if (!metadata.success) throw new Error("Atlassian connection metadata is invalid");
1753
+ return {
1754
+ connection: authority.connection,
1755
+ metadata: metadata.data,
1756
+ subjectId: authority.subjectId ?? grant.subjectId,
1757
+ };
1758
+ };
1289
1759
 
1290
- /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
1291
- export function isHumanDirectedTurn(turn: { source: string }): boolean {
1292
- return turn.source === "user" || turn.source === "api";
1293
- }
1760
+ server.registerTool(
1761
+ "atlassian_sources_list",
1762
+ {
1763
+ description:
1764
+ "List the Jira projects and Confluence spaces available through the authorized Atlassian connection, including which sources are selected for OpenGeni. Use this before search when the site or boundary is unclear.",
1765
+ inputSchema: { connectionId: z4.string().uuid().optional() },
1766
+ },
1767
+ async ({ connectionId }) => {
1768
+ const authority = await connectionFor(connectionId);
1769
+ const response = await browseAtlassianSources(deps, {
1770
+ workspaceId: grant.workspaceId,
1771
+ subjectId: authority.subjectId,
1772
+ connectionId: authority.connection.id,
1773
+ });
1774
+ const selected = new Set(authority.metadata.selectedSources.map((source) => source.id));
1775
+ return json({
1776
+ connectionId: authority.connection.id,
1777
+ account: authority.metadata.displayName,
1778
+ items: response.items.map((item) => ({ ...item, selected: selected.has(item.id) })),
1779
+ });
1780
+ },
1781
+ );
1782
+
1783
+ server.registerTool(
1784
+ "atlassian_search",
1785
+ {
1786
+ description:
1787
+ "Search Jira issues and Confluence pages live within the projects and spaces selected for OpenGeni. Results reflect current Atlassian data and permissions, independent of the knowledge sync index.",
1788
+ inputSchema: {
1789
+ connectionId: z4.string().uuid().optional(),
1790
+ query: z4.string().min(1).max(500),
1791
+ product: z4.enum(["jira", "confluence"]).optional(),
1792
+ limit: z4.number().int().min(1).max(50).optional(),
1793
+ },
1794
+ },
1795
+ async ({ connectionId, query, product, limit }) => {
1796
+ const authority = await connectionFor(connectionId);
1797
+ return json({
1798
+ connectionId: authority.connection.id,
1799
+ results: await searchAtlassianLive(deps, {
1800
+ workspaceId: grant.workspaceId,
1801
+ subjectId: authority.subjectId,
1802
+ connectionId: authority.connection.id,
1803
+ query,
1804
+ ...(product ? { product } : {}),
1805
+ limit: limit ?? 20,
1806
+ }),
1807
+ });
1808
+ },
1809
+ );
1810
+
1811
+ server.registerTool(
1812
+ "atlassian_get",
1813
+ {
1814
+ description:
1815
+ "Open one current Jira issue or Confluence page, including description or page content and comments. The item must belong to a project or space selected for OpenGeni.",
1816
+ inputSchema: {
1817
+ connectionId: z4.string().uuid().optional(),
1818
+ kind: z4.enum(["jira_issue", "confluence_page"]),
1819
+ id: z4.string().min(1).max(256),
1820
+ },
1821
+ },
1822
+ async ({ connectionId, kind, id }) => {
1823
+ const authority = await connectionFor(connectionId);
1824
+ return json(
1825
+ await getAtlassianLiveItem(deps, {
1826
+ workspaceId: grant.workspaceId,
1827
+ subjectId: authority.subjectId,
1828
+ connectionId: authority.connection.id,
1829
+ kind,
1830
+ id,
1831
+ }),
1832
+ );
1833
+ },
1834
+ );
1835
+ }
1836
+
1837
+ /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
1838
+ export function isHumanDirectedTurn(turn: { source: string }): boolean {
1839
+ return turn.source === "user" || turn.source === "api";
1840
+ }
1294
1841
 
1295
1842
  /**
1296
1843
  * Sacred user pause: a goal a human paused (pausedReason 'user_pause') must
@@ -1358,7 +1905,7 @@ function registerGoalTools(
1358
1905
  ? (grant.metadata["turnId"] as string)
1359
1906
  : null;
1360
1907
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
1361
- const { goal, events } = await upsertSessionGoalWithEvent(deps.db, {
1908
+ const { goal, replaced, events } = await upsertSessionGoalWithEvent(deps.db, {
1362
1909
  accountId: grant.accountId,
1363
1910
  workspaceId: grant.workspaceId,
1364
1911
  sessionId,
@@ -1371,7 +1918,24 @@ function registerGoalTools(
1371
1918
  if (events.length > 0) {
1372
1919
  await deps.bus.publish(grant.workspaceId, sessionId, events);
1373
1920
  }
1374
- return json(goal);
1921
+ return json(
1922
+ mcpMutationReceipt({
1923
+ operation: "goal_set",
1924
+ committed: true,
1925
+ outcome: replaced ? "updated" : "created",
1926
+ changed: true,
1927
+ resource: {
1928
+ type: "session_goal",
1929
+ id: goal.id,
1930
+ version: goal.version,
1931
+ state: goal.status,
1932
+ },
1933
+ timestamp: goal.updatedAt,
1934
+ idempotency: { status: "not_supported" },
1935
+ facts: { replaced },
1936
+ nextAction: { tool: "session_get", arguments: { sessionId } },
1937
+ }),
1938
+ );
1375
1939
  },
1376
1940
  );
1377
1941
 
@@ -1441,10 +2005,27 @@ function registerGoalTools(
1441
2005
  event: { type: "goal.completed", evidence },
1442
2006
  },
1443
2007
  );
2008
+ const changed = events.length > 0;
1444
2009
  if (events.length > 0) {
1445
2010
  await deps.bus.publish(grant.workspaceId, sessionId, events);
1446
2011
  }
1447
- return json(goal);
2012
+ return json(
2013
+ mcpMutationReceipt({
2014
+ operation: "goal_complete",
2015
+ committed: true,
2016
+ outcome: changed ? "updated" : "unchanged",
2017
+ changed,
2018
+ resource: {
2019
+ type: "session_goal",
2020
+ id: goal.id,
2021
+ version: goal.version,
2022
+ state: goal.status,
2023
+ },
2024
+ timestamp: goal.updatedAt,
2025
+ idempotency: { status: "not_supported" },
2026
+ nextAction: { tool: "session_get", arguments: { sessionId } },
2027
+ }),
2028
+ );
1448
2029
  },
1449
2030
  );
1450
2031
 
@@ -1478,10 +2059,27 @@ function registerGoalTools(
1478
2059
  },
1479
2060
  },
1480
2061
  );
2062
+ const changed = events.length > 0;
1481
2063
  if (events.length > 0) {
1482
2064
  await deps.bus.publish(grant.workspaceId, sessionId, events);
1483
2065
  }
1484
- return json(goal);
2066
+ return json(
2067
+ mcpMutationReceipt({
2068
+ operation: "goal_pause",
2069
+ committed: true,
2070
+ outcome: changed ? "updated" : "unchanged",
2071
+ changed,
2072
+ resource: {
2073
+ type: "session_goal",
2074
+ id: goal.id,
2075
+ version: goal.version,
2076
+ state: goal.status,
2077
+ },
2078
+ timestamp: goal.updatedAt,
2079
+ idempotency: { status: "not_supported" },
2080
+ nextAction: { tool: "session_get", arguments: { sessionId } },
2081
+ }),
2082
+ );
1485
2083
  },
1486
2084
  );
1487
2085
  }
@@ -1511,7 +2109,7 @@ function registerWorkspaceArtifactTools(
1511
2109
  json: JsonResult,
1512
2110
  ): void {
1513
2111
  const attempt = () => {
1514
- const claims = preferenceAttemptClaims(grant);
2112
+ const claims = exactAgentAttemptClaims(grant);
1515
2113
  if (!claims) throw new Error("Exact signed artifact attempt authority is required.");
1516
2114
  return claims;
1517
2115
  };
@@ -1599,7 +2197,11 @@ function registerWorkspaceArtifactTools(
1599
2197
  const actualHash = createHash("sha256").update(object.bytes).digest("hex");
1600
2198
  if (actualHash !== ref.version.contentSha256)
1601
2199
  throw new Error("Artifact content failed integrity verification");
1602
- return json({ detail, version: ref.version, html: new TextDecoder().decode(object.bytes) });
2200
+ return json({
2201
+ detail,
2202
+ version: ref.version,
2203
+ html: new TextDecoder().decode(object.bytes),
2204
+ });
1603
2205
  },
1604
2206
  );
1605
2207
 
@@ -1695,7 +2297,7 @@ function registerWorkspaceArtifactTools(
1695
2297
  );
1696
2298
  }
1697
2299
 
1698
- function preferenceAttemptClaims(grant: AccessGrant): {
2300
+ function exactAgentAttemptClaims(grant: AccessGrant): {
1699
2301
  sessionId: string;
1700
2302
  turnId: string;
1701
2303
  attemptId: string;
@@ -1728,7 +2330,7 @@ function registerPreferenceRegistryTools(
1728
2330
  json: JsonResult,
1729
2331
  ): void {
1730
2332
  const attemptClaims = () => {
1731
- const resolved = preferenceAttemptClaims(grant);
2333
+ const resolved = exactAgentAttemptClaims(grant);
1732
2334
  if (!resolved) throw new Error("Exact signed preference attempt authority is required.");
1733
2335
  return {
1734
2336
  accountId: grant.accountId,
@@ -1761,11 +2363,101 @@ function registerPreferenceRegistryTools(
1761
2363
 
1762
2364
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
1763
2365
 
2366
+ function scheduledTaskReceipt(
2367
+ operation: string,
2368
+ task: ScheduledTask,
2369
+ outcome: "created" | "updated" | "unchanged" | "triggered" | "partial_failure",
2370
+ changed: boolean,
2371
+ options: {
2372
+ partialFailure?: { stage: string; retryable: boolean };
2373
+ warnings?: string[];
2374
+ idempotencyStatus?: "not_supported" | "not_requested" | "applied" | "replayed" | "unknown";
2375
+ facts?: Record<string, string | number | boolean | null>;
2376
+ } = {},
2377
+ ) {
2378
+ return mcpMutationReceipt({
2379
+ operation,
2380
+ committed: true,
2381
+ outcome,
2382
+ changed,
2383
+ resource: {
2384
+ type: "scheduled_task",
2385
+ id: task.id,
2386
+ version: task.updatedAt,
2387
+ state: task.status,
2388
+ },
2389
+ timestamp: task.updatedAt,
2390
+ idempotency: { status: options.idempotencyStatus ?? "not_supported" },
2391
+ ...(options.partialFailure ? { partialFailure: options.partialFailure } : {}),
2392
+ ...(options.warnings ? { warnings: options.warnings } : {}),
2393
+ ...(options.facts ? { facts: options.facts } : {}),
2394
+ nextAction: { tool: "scheduled_tasks_get", arguments: { id: task.id } },
2395
+ });
2396
+ }
2397
+
2398
+ function scheduledTaskUpdateChangesState(
2399
+ task: ScheduledTask,
2400
+ update: Awaited<ReturnType<typeof validatedScheduledTaskUpdate>>,
2401
+ ): boolean {
2402
+ if (update.name !== undefined && update.name !== task.name) return true;
2403
+ if (update.status !== undefined && update.status !== task.status) return true;
2404
+ if (update.schedule !== undefined && stableJson(update.schedule) !== stableJson(task.schedule)) {
2405
+ return true;
2406
+ }
2407
+ if (update.runMode !== undefined && update.runMode !== task.runMode) return true;
2408
+ if (update.overlapPolicy !== undefined && update.overlapPolicy !== task.overlapPolicy)
2409
+ return true;
2410
+ if (
2411
+ update.agentConfig !== undefined &&
2412
+ stableJson(update.agentConfig) !== stableJson(task.agentConfig)
2413
+ ) {
2414
+ return true;
2415
+ }
2416
+ if (update.targetSessionId !== undefined && update.targetSessionId !== task.targetSessionId) {
2417
+ return true;
2418
+ }
2419
+ if (
2420
+ update.reusableSessionId !== undefined &&
2421
+ update.reusableSessionId !== task.reusableSessionId
2422
+ ) {
2423
+ return true;
2424
+ }
2425
+ if (update.variableSetId !== undefined && update.variableSetId !== task.variableSetId)
2426
+ return true;
2427
+ if (update.rigId !== undefined && update.rigId !== task.rigId) return true;
2428
+ if (update.metadata !== undefined && stableJson(update.metadata) !== stableJson(task.metadata)) {
2429
+ return true;
2430
+ }
2431
+ // Personal-connection delegations are recomputed with agentConfig and can
2432
+ // change even when the visible config is byte-identical (for example after a
2433
+ // connection rotation), so preserve that refresh as a real mutation.
2434
+ if (update.personalConnectionDelegations !== undefined) return true;
2435
+ return false;
2436
+ }
2437
+
1764
2438
  function memoryPreview(text: string): string {
1765
2439
  const normalized = text.replace(/\s+/g, " ").trim();
1766
2440
  return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
1767
2441
  }
1768
2442
 
2443
+ export function memorySlackPublicationActor(
2444
+ actor: Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>,
2445
+ sessionId: string,
2446
+ fallbackOwnerLabel: string | null,
2447
+ ) {
2448
+ return {
2449
+ actor: {
2450
+ kind: actor.initiator.kind === "subject" ? ("human" as const) : ("service" as const),
2451
+ subjectId: actor.initiator.subjectId,
2452
+ initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
2453
+ sessionId,
2454
+ turnId: actor.turnId,
2455
+ attemptId: actor.attemptId,
2456
+ },
2457
+ ownerLabel: actor.initiator.label ?? fallbackOwnerLabel,
2458
+ };
2459
+ }
2460
+
1769
2461
  function registerMemoryTools(
1770
2462
  server: McpServer,
1771
2463
  deps: ApiRouteDeps,
@@ -1773,6 +2465,17 @@ function registerMemoryTools(
1773
2465
  sessionId: string,
1774
2466
  json: JsonResult,
1775
2467
  ): void {
2468
+ const publicationActor = async () => {
2469
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
2470
+ return memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null);
2471
+ };
2472
+ const publicationInputSchema = z4.object({
2473
+ importance: z4.enum(["major", "normal", "minor"]),
2474
+ audience: z4.literal("workspace"),
2475
+ slackMode: z4.enum(["auto", "review", "never"]),
2476
+ shareSummary: z4.string().trim().min(1).max(4_096),
2477
+ });
2478
+
1776
2479
  server.registerTool(
1777
2480
  "memory_search",
1778
2481
  {
@@ -1807,10 +2510,12 @@ function registerMemoryTools(
1807
2510
  kind: MemoryKindSchema,
1808
2511
  confidence: z4.number().min(0).max(1).optional(),
1809
2512
  replaces_id: z4.string().min(1).optional(),
2513
+ slack_publication: publicationInputSchema.optional(),
1810
2514
  },
1811
2515
  },
1812
- async ({ text, kind, confidence, replaces_id }) => {
1813
- const result = await saveWorkspaceMemory(
2516
+ async ({ text, kind, confidence, replaces_id, slack_publication }) => {
2517
+ const principal = slack_publication ? await publicationActor() : null;
2518
+ const result = await saveWorkspaceMemoryWithSlackPublication(
1814
2519
  deps.db,
1815
2520
  {
1816
2521
  accountId: grant.accountId,
@@ -1822,6 +2527,13 @@ function registerMemoryTools(
1822
2527
  ...(replaces_id ? { replacesId: replaces_id } : {}),
1823
2528
  origin: "agent",
1824
2529
  },
2530
+ slack_publication
2531
+ ? {
2532
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
2533
+ actor: principal!.actor,
2534
+ ownerLabel: principal!.ownerLabel,
2535
+ }
2536
+ : null,
1825
2537
  deps.getDocumentServices().embedder,
1826
2538
  );
1827
2539
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -1836,7 +2548,59 @@ function registerMemoryTools(
1836
2548
  },
1837
2549
  },
1838
2550
  ]);
1839
- return json(result);
2551
+ const changed = !result.deduped || result.updated || result.superseded !== null;
2552
+ const outcome =
2553
+ result.updated || result.superseded !== null
2554
+ ? "updated"
2555
+ : result.deduped
2556
+ ? "unchanged"
2557
+ : "created";
2558
+ return json(
2559
+ mcpMutationReceipt({
2560
+ operation: "memory_save",
2561
+ committed: true,
2562
+ outcome,
2563
+ changed,
2564
+ resource: {
2565
+ type: "knowledge_memory",
2566
+ id: result.memory.id,
2567
+ state: result.memory.status,
2568
+ },
2569
+ relatedResources: result.superseded
2570
+ ? [
2571
+ {
2572
+ type: "knowledge_memory",
2573
+ id: result.superseded.id,
2574
+ state: result.superseded.status,
2575
+ },
2576
+ ]
2577
+ : undefined,
2578
+ timestamp: result.memory.updatedAt,
2579
+ idempotency: { status: "not_supported" },
2580
+ warnings: !result.embedded
2581
+ ? ["Memory committed without a vector embedding; keyword search remains available."]
2582
+ : [],
2583
+ facts: {
2584
+ deduped: result.deduped,
2585
+ dedupeReason: result.dedupeReason,
2586
+ updatedInPlace: result.updated,
2587
+ embedded: result.embedded,
2588
+ slackPublicationDecision: result.slackPublication.decision?.eligible
2589
+ ? "eligible"
2590
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
2591
+ slackPublicationId:
2592
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2593
+ result.slackPublication.enqueue?.kind === "replayed"
2594
+ ? result.slackPublication.enqueue.publication.id
2595
+ : null,
2596
+ slackPublicationState:
2597
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2598
+ result.slackPublication.enqueue?.kind === "replayed"
2599
+ ? result.slackPublication.enqueue.publication.state
2600
+ : null,
2601
+ },
2602
+ }),
2603
+ );
1840
2604
  },
1841
2605
  );
1842
2606
 
@@ -1848,10 +2612,12 @@ function registerMemoryTools(
1848
2612
  id: z4.string().min(1),
1849
2613
  reason: z4.string().min(1).optional(),
1850
2614
  replacement_text: z4.string().min(1).optional(),
2615
+ slack_publication: publicationInputSchema.optional(),
1851
2616
  },
1852
2617
  },
1853
- async ({ id, reason, replacement_text }) => {
1854
- const result = await correctWorkspaceMemory(
2618
+ async ({ id, reason, replacement_text, slack_publication }) => {
2619
+ const principal = slack_publication ? await publicationActor() : null;
2620
+ const result = await correctWorkspaceMemoryWithSlackPublication(
1855
2621
  deps.db,
1856
2622
  {
1857
2623
  accountId: grant.accountId,
@@ -1861,6 +2627,13 @@ function registerMemoryTools(
1861
2627
  ...(reason ? { reason } : {}),
1862
2628
  ...(replacement_text ? { replacementText: replacement_text } : {}),
1863
2629
  },
2630
+ slack_publication
2631
+ ? {
2632
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
2633
+ actor: principal!.actor,
2634
+ ownerLabel: principal!.ownerLabel,
2635
+ }
2636
+ : null,
1864
2637
  deps.getDocumentServices().embedder,
1865
2638
  );
1866
2639
  await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
@@ -1881,7 +2654,46 @@ function registerMemoryTools(
1881
2654
  },
1882
2655
  },
1883
2656
  ]);
1884
- return json(result);
2657
+ return json(
2658
+ mcpMutationReceipt({
2659
+ operation: "memory_correct",
2660
+ committed: true,
2661
+ outcome: "updated",
2662
+ changed: true,
2663
+ resource: {
2664
+ type: "knowledge_memory",
2665
+ id: result.memory.id,
2666
+ state: result.memory.status,
2667
+ },
2668
+ relatedResources: result.replacement
2669
+ ? [
2670
+ {
2671
+ type: "knowledge_memory",
2672
+ id: result.replacement.id,
2673
+ state: result.replacement.status,
2674
+ },
2675
+ ]
2676
+ : undefined,
2677
+ timestamp: (result.replacement ?? result.memory).updatedAt,
2678
+ idempotency: { status: "not_supported" },
2679
+ facts: {
2680
+ correctionAction: result.action,
2681
+ slackPublicationDecision: result.slackPublication.decision?.eligible
2682
+ ? "eligible"
2683
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
2684
+ slackPublicationId:
2685
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2686
+ result.slackPublication.enqueue?.kind === "replayed"
2687
+ ? result.slackPublication.enqueue.publication.id
2688
+ : null,
2689
+ slackPublicationState:
2690
+ result.slackPublication.enqueue?.kind === "enqueued" ||
2691
+ result.slackPublication.enqueue?.kind === "replayed"
2692
+ ? result.slackPublication.enqueue.publication.state
2693
+ : null,
2694
+ },
2695
+ }),
2696
+ );
1885
2697
  },
1886
2698
  );
1887
2699
  }
@@ -1928,7 +2740,7 @@ function registerFleetTools(
1928
2740
  "sandboxes_list",
1929
2741
  {
1930
2742
  description:
1931
- "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. An idle session-home sandbox may report offline/cold/draining and still wake or restore on the next ordinary sandbox operation; never infer that shell/files are unavailable from list liveness alone—only a typed operation/attach failure proves that. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
2743
+ "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `operationAvailability` is authoritative for ordinary shell/files use: `wakeable` means the next ordinary operation will wake or restore the idle managed home sandbox, even when `liveness=offline`, `leaseLiveness=cold|draining`, or `attachable=false`. `attachable` describes an already-live swap target, not ordinary operation availability. `recovering` requires a bounded retry/typed recovery result; `unavailable` is not usable. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch remain separate truth dimensions. Use an entry `id` as an attach/swap/run_on target.",
1932
2744
  inputSchema: {},
1933
2745
  },
1934
2746
  async () => json(await listFleet(services, await fleetContext())),
@@ -2000,6 +2812,45 @@ function registerFleetTools(
2000
2812
  );
2001
2813
  }
2002
2814
 
2815
+ // Workspace-admin/operator surface for removing a connected machine. This is
2816
+ // deliberately separate from the session-scoped fleet tools: a worker can list,
2817
+ // attach, or run on a machine only with session authority, while removal requires
2818
+ // the explicit high-trust enrollments:manage permission and never accepts a
2819
+ // sandbox id. A Modal record therefore cannot be removed through this operation.
2820
+ function registerConnectedMachineTools(
2821
+ server: McpServer,
2822
+ deps: ApiRouteDeps,
2823
+ grant: AccessGrant,
2824
+ json: JsonResult,
2825
+ ): void {
2826
+ server.registerTool(
2827
+ "connected_machine_remove",
2828
+ {
2829
+ description:
2830
+ "Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, session/route/lease/archive history is retained, and a fresh human-approved device-flow enrollment is required to reconnect. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Blocked outcomes include every dependent session and the action needed before retrying. Move each dependent session through the canonical sandbox_swap target=default path before retrying removal; the removal authority never rewrites routes directly.",
2831
+ inputSchema: {
2832
+ enrollmentId: z4.string().uuid(),
2833
+ expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
2834
+ idempotencyKey: z4.string().trim().min(1).max(200).optional(),
2835
+ },
2836
+ },
2837
+ async ({ enrollmentId, expectedUpdatedAt, idempotencyKey }) => {
2838
+ const result = await removeEnrollment(deps.db, {
2839
+ accountId: grant.accountId,
2840
+ workspaceId: grant.workspaceId,
2841
+ enrollmentId,
2842
+ operationKey: idempotencyKey?.trim() || randomUUID(),
2843
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
2844
+ subjectId: grant.subjectId,
2845
+ });
2846
+ if (!result) {
2847
+ throw new Error("machine enrollment not found in this workspace");
2848
+ }
2849
+ return json({ revoked: result.removed, ...result });
2850
+ },
2851
+ );
2852
+ }
2853
+
2003
2854
  async function beginMcpRigVerificationAttempt(
2004
2855
  deps: ApiRouteDeps,
2005
2856
  workspaceId: string,
@@ -2008,12 +2859,10 @@ async function beginMcpRigVerificationAttempt(
2008
2859
  try {
2009
2860
  return await beginRigChangeVerificationAttempt(deps.db, workspaceId, changeId, {
2010
2861
  startedAt: new Date().toISOString(),
2862
+ allowAlreadyVerifying: true,
2011
2863
  });
2012
2864
  } catch (error) {
2013
- if (
2014
- error instanceof RigChangeAlreadyVerifyingError ||
2015
- error instanceof RigChangeTransitionError
2016
- ) {
2865
+ if (error instanceof RigChangeTransitionError) {
2017
2866
  throw new Error(error.message, { cause: error });
2018
2867
  }
2019
2868
  throw error;
@@ -2101,12 +2950,60 @@ function registerRigTools(
2101
2950
  sessionId ? { proposedBy: `session:${sessionId}` } : {},
2102
2951
  );
2103
2952
  const verifying = await beginMcpRigVerificationAttempt(deps, grant.workspaceId, change.id);
2104
- await deps.workflowClient.startRigVerification({
2105
- workspaceId: grant.workspaceId,
2106
- changeId: change.id,
2107
- workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
2108
- });
2109
- return json({ change: verifying, verificationStarted: true });
2953
+ const attempt = verificationAttempt(verifying);
2954
+ try {
2955
+ await deps.workflowClient.startRigVerification({
2956
+ workspaceId: grant.workspaceId,
2957
+ changeId: change.id,
2958
+ workflowId: `rig-verification-change-${change.id}-attempt-${attempt}`,
2959
+ });
2960
+ } catch {
2961
+ return json(
2962
+ mcpMutationReceipt({
2963
+ operation: "rig_propose_change",
2964
+ committed: true,
2965
+ outcome: "partial_failure",
2966
+ changed: true,
2967
+ resource: {
2968
+ type: "rig_change",
2969
+ id: verifying.id,
2970
+ version: verifying.updatedAt,
2971
+ state: verifying.status,
2972
+ },
2973
+ relatedResources: [{ type: "rig", id: rig.id }],
2974
+ timestamp: verifying.updatedAt,
2975
+ idempotency: { status: "not_supported" },
2976
+ partialFailure: {
2977
+ stage: "verification_workflow_start",
2978
+ retryable: true,
2979
+ },
2980
+ warnings: [
2981
+ "The rig change and verifying transition committed, but verification workflow start failed.",
2982
+ ],
2983
+ facts: { verificationAttempt: attempt },
2984
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
2985
+ }),
2986
+ );
2987
+ }
2988
+ return json(
2989
+ mcpMutationReceipt({
2990
+ operation: "rig_propose_change",
2991
+ committed: true,
2992
+ outcome: "created",
2993
+ changed: true,
2994
+ resource: {
2995
+ type: "rig_change",
2996
+ id: verifying.id,
2997
+ version: verifying.updatedAt,
2998
+ state: verifying.status,
2999
+ },
3000
+ relatedResources: [{ type: "rig", id: rig.id }],
3001
+ timestamp: verifying.updatedAt,
3002
+ idempotency: { status: "not_supported" },
3003
+ facts: { verificationStarted: true, verificationAttempt: attempt },
3004
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
3005
+ }),
3006
+ );
2110
3007
  },
2111
3008
  );
2112
3009
 
@@ -2129,12 +3026,63 @@ function registerRigTools(
2129
3026
  grant.workspaceId,
2130
3027
  change.id,
2131
3028
  );
2132
- await deps.workflowClient.startRigVerification({
2133
- workspaceId: grant.workspaceId,
2134
- changeId: change.id,
2135
- workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
2136
- });
2137
- return json({ ok: true, changeId: change.id });
3029
+ const attempt = verificationAttempt(verifying);
3030
+ try {
3031
+ await deps.workflowClient.startRigVerification({
3032
+ workspaceId: grant.workspaceId,
3033
+ changeId: change.id,
3034
+ workflowId: `rig-verification-change-${change.id}-attempt-${attempt}`,
3035
+ });
3036
+ } catch {
3037
+ return json(
3038
+ mcpMutationReceipt({
3039
+ operation: "rig_verify",
3040
+ committed: true,
3041
+ outcome: "partial_failure",
3042
+ changed: true,
3043
+ resource: {
3044
+ type: "rig_change",
3045
+ id: verifying.id,
3046
+ version: verifying.updatedAt,
3047
+ state: verifying.status,
3048
+ },
3049
+ relatedResources: [{ type: "rig", id: rig.id }],
3050
+ timestamp: verifying.updatedAt,
3051
+ idempotency: { status: "not_supported" },
3052
+ partialFailure: {
3053
+ stage: "verification_workflow_start",
3054
+ retryable: true,
3055
+ },
3056
+ warnings: [
3057
+ "The verifying transition committed, but verification workflow start failed.",
3058
+ ],
3059
+ facts: { verificationAttempt: attempt },
3060
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
3061
+ }),
3062
+ );
3063
+ }
3064
+ return json(
3065
+ mcpMutationReceipt({
3066
+ operation: "rig_verify",
3067
+ committed: true,
3068
+ outcome: "accepted",
3069
+ changed: true,
3070
+ resource: {
3071
+ type: "rig_change",
3072
+ id: verifying.id,
3073
+ version: verifying.updatedAt,
3074
+ state: verifying.status,
3075
+ },
3076
+ relatedResources: [{ type: "rig", id: rig.id }],
3077
+ timestamp: verifying.updatedAt,
3078
+ idempotency: { status: "not_supported" },
3079
+ facts: {
3080
+ verificationStarted: true,
3081
+ verificationAttempt: attempt,
3082
+ },
3083
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
3084
+ }),
3085
+ );
2138
3086
  }
2139
3087
  if (!rig.activeVersion) {
2140
3088
  throw new Error("rig has no active version");
@@ -2144,7 +3092,23 @@ function registerRigTools(
2144
3092
  versionId: rig.activeVersion.id,
2145
3093
  workflowId: `rig-verification-version-${rig.activeVersion.id}-${crypto.randomUUID()}`,
2146
3094
  });
2147
- return json({ ok: true, versionId: rig.activeVersion.id });
3095
+ return json(
3096
+ mcpMutationReceipt({
3097
+ operation: "rig_verify",
3098
+ committed: true,
3099
+ outcome: "accepted",
3100
+ changed: true,
3101
+ resource: {
3102
+ type: "rig_version",
3103
+ id: rig.activeVersion.id,
3104
+ version: rig.activeVersion.version,
3105
+ state: "verification_started",
3106
+ },
3107
+ relatedResources: [{ type: "rig", id: rig.id }],
3108
+ idempotency: { status: "not_supported" },
3109
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
3110
+ }),
3111
+ );
2148
3112
  },
2149
3113
  );
2150
3114
  }
@@ -2163,8 +3127,36 @@ function registerRigTools(
2163
3127
  async ({ rigId, changeId }) => {
2164
3128
  const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
2165
3129
  const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
3130
+ const promoted = await promoteVerifiedDefinitionEditChangeForApi(
3131
+ { db: deps.db },
3132
+ grant,
3133
+ rig,
3134
+ change,
3135
+ );
2166
3136
  return json(
2167
- await promoteVerifiedDefinitionEditChangeForApi({ db: deps.db }, grant, rig, change),
3137
+ mcpMutationReceipt({
3138
+ operation: "rig_promote",
3139
+ committed: true,
3140
+ outcome: "updated",
3141
+ changed: true,
3142
+ resource: {
3143
+ type: "rig_version",
3144
+ id: promoted.version.id,
3145
+ version: promoted.version.version,
3146
+ state: "active",
3147
+ },
3148
+ relatedResources: [
3149
+ {
3150
+ type: "rig_change",
3151
+ id: promoted.change.id,
3152
+ state: promoted.change.status,
3153
+ },
3154
+ { type: "rig", id: rig.id },
3155
+ ],
3156
+ timestamp: promoted.version.createdAt,
3157
+ idempotency: { status: "not_supported" },
3158
+ nextAction: { tool: "rig_get", arguments: { rigId: rig.id } },
3159
+ }),
2168
3160
  );
2169
3161
  },
2170
3162
  );
@@ -2209,7 +3201,6 @@ function registerWorkspaceOrchestrationTools(
2209
3201
  grant: AccessGrant,
2210
3202
  can: (permission: Permission) => boolean,
2211
3203
  callerSessionId: string | null,
2212
- toolspaceMode: boolean,
2213
3204
  json: JsonResult,
2214
3205
  ): void {
2215
3206
  if (can("sessions:read")) {
@@ -2287,7 +3278,7 @@ function registerWorkspaceOrchestrationTools(
2287
3278
  );
2288
3279
  return json(
2289
3280
  boundSessionDetailMcp(
2290
- await withMcpEffectivePolicy(deps, grant.workspaceId, {
3281
+ await withMcpEffectivePolicy(deps, grant.workspaceId, grant.subjectId, {
2291
3282
  ...projected,
2292
3283
  effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
2293
3284
  }),
@@ -2384,7 +3375,10 @@ function registerWorkspaceOrchestrationTools(
2384
3375
  compactSessionEventResult(
2385
3376
  event,
2386
3377
  latestClass!,
2387
- dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
3378
+ dbPage.coveredSequence ?? {
3379
+ first: event.sequence,
3380
+ last: event.sequence,
3381
+ },
2388
3382
  ),
2389
3383
  )
2390
3384
  : null,
@@ -2432,9 +3426,21 @@ function registerWorkspaceOrchestrationTools(
2432
3426
  // Bind the spawned session to a rig (freezes its active version);
2433
3427
  // declared so MCP validation doesn't strip it before the domain reads it.
2434
3428
  rigId: z4.string().uuid().optional(),
2435
- model: z4.string().min(1).optional(),
2436
- reasoningEffort: z4.string().optional(),
2437
- latencyMode: z4.enum(["standard", "priority", "fast"]).optional(),
3429
+ model: z4
3430
+ .string()
3431
+ .min(1)
3432
+ .optional()
3433
+ .describe(
3434
+ "Model for the worker. Omit to inherit the exact calling turn's model, including its Codex subscription billing path.",
3435
+ ),
3436
+ reasoningEffort: z4
3437
+ .string()
3438
+ .optional()
3439
+ .describe("Omit to inherit the exact calling turn's reasoning effort."),
3440
+ latencyMode: z4
3441
+ .enum(["standard", "priority", "fast"])
3442
+ .optional()
3443
+ .describe("Omit to inherit the exact calling turn's latency mode."),
2438
3444
  sandboxBackend: z4.string().optional(),
2439
3445
  // Create-time machine targeting: an enrolled sandbox id (from
2440
3446
  // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
@@ -2508,8 +3514,13 @@ function registerWorkspaceOrchestrationTools(
2508
3514
  if (callerSessionId !== null) {
2509
3515
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
2510
3516
  }
2511
- const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
2512
- return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
3517
+ const result = await createSessionForRequestWithOutcome(
3518
+ deps,
3519
+ grant,
3520
+ grant.workspaceId,
3521
+ args,
3522
+ );
3523
+ return json(sessionCreateMutationReceipt(result, Boolean(args.idempotencyKey)));
2513
3524
  } catch (error) {
2514
3525
  if (error instanceof SessionSpawnDeniedError) {
2515
3526
  return {
@@ -2523,7 +3534,7 @@ function registerWorkspaceOrchestrationTools(
2523
3534
  );
2524
3535
  }
2525
3536
 
2526
- if (can("sessions:control") && !toolspaceMode) {
3537
+ if (can("sessions:control")) {
2527
3538
  server.registerTool(
2528
3539
  "session_send_message",
2529
3540
  {
@@ -2549,17 +3560,33 @@ function registerWorkspaceOrchestrationTools(
2549
3560
  exactAgentCommandContext(grant, callerSessionId),
2550
3561
  { targetSessionId, text, idempotencyKey },
2551
3562
  );
2552
- return json({
2553
- delivered: true,
2554
- updateId: result.updateId,
2555
- delivery: "coalesced_internal_update",
2556
- effectiveState: result.effectiveState,
2557
- wakeRequested: result.wakeRevision !== null,
2558
- resumeRequired: result.effectiveState === "paused",
2559
- replay: result.replay,
2560
- });
3563
+ return json(
3564
+ mcpMutationReceipt({
3565
+ operation: "session_send_message",
3566
+ committed: true,
3567
+ outcome: result.replay ? "replayed" : "accepted",
3568
+ changed: !result.replay,
3569
+ resource: {
3570
+ type: "session_system_update",
3571
+ id: result.updateId,
3572
+ state: result.effectiveState,
3573
+ },
3574
+ relatedResources: [{ type: "session", id: targetSessionId }],
3575
+ timestamp: result.receipt.createdAt.toISOString(),
3576
+ idempotency: { status: result.replay ? "replayed" : "applied" },
3577
+ facts: {
3578
+ delivery: "coalesced_internal_update",
3579
+ wakeRequested: result.wakeRevision !== null,
3580
+ resumeRequired: result.effectiveState === "paused",
3581
+ },
3582
+ nextAction: {
3583
+ tool: "session_get",
3584
+ arguments: { sessionId: targetSessionId },
3585
+ },
3586
+ }),
3587
+ );
2561
3588
  }
2562
- const { accepted, turn } = await acceptSessionUserMessage(
3589
+ const { accepted, turn, replay } = await acceptSessionUserMessageWithOutcome(
2563
3590
  deps,
2564
3591
  grant,
2565
3592
  grant.workspaceId,
@@ -2574,7 +3601,30 @@ function registerWorkspaceOrchestrationTools(
2574
3601
  ),
2575
3602
  },
2576
3603
  );
2577
- return json({ event: accepted, turnId: turn.id });
3604
+ return json(
3605
+ mcpMutationReceipt({
3606
+ operation: "session_send_message",
3607
+ committed: true,
3608
+ outcome: replay ? "replayed" : "accepted",
3609
+ changed: !replay,
3610
+ resource: {
3611
+ type: "session_turn",
3612
+ id: turn.id,
3613
+ version: turn.version,
3614
+ state: turn.status,
3615
+ },
3616
+ relatedResources: [
3617
+ { type: "session", id: targetSessionId },
3618
+ { type: "session_event", id: accepted.id, state: accepted.type },
3619
+ ],
3620
+ timestamp: accepted.occurredAt,
3621
+ idempotency: { status: replay ? "replayed" : "applied" },
3622
+ nextAction: {
3623
+ tool: "session_get",
3624
+ arguments: { sessionId: targetSessionId },
3625
+ },
3626
+ }),
3627
+ );
2578
3628
  },
2579
3629
  );
2580
3630
 
@@ -2600,16 +3650,25 @@ function registerWorkspaceOrchestrationTools(
2600
3650
  reason: reason ?? "agent_mcp_pause",
2601
3651
  },
2602
3652
  );
2603
- return json({
2604
- receiptId: controlled.receipt.id,
2605
- effectiveControl: projectEffectiveControlForRelatedAccess(
2606
- serializeEffectiveSessionControl(controlled.control),
2607
- sessionId,
2608
- controlled.authorization?.relatedSessionAccess ?? "root",
2609
- ),
2610
- interruptionCount: controlled.interruptionCount,
2611
- replay: controlled.replay,
2612
- });
3653
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
3654
+ serializeEffectiveSessionControl(controlled.control),
3655
+ sessionId,
3656
+ controlled.authorization?.relatedSessionAccess ?? "root",
3657
+ );
3658
+ return json(
3659
+ mcpMutationReceipt({
3660
+ operation: "session_pause",
3661
+ committed: true,
3662
+ outcome: controlled.replay ? "replayed" : "updated",
3663
+ changed: !controlled.replay,
3664
+ resource: { type: "session", id: sessionId, state: effectiveControl.state },
3665
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
3666
+ timestamp: controlled.receipt.createdAt.toISOString(),
3667
+ idempotency: { status: controlled.replay ? "replayed" : "applied" },
3668
+ facts: { interruptionCount: controlled.interruptionCount },
3669
+ nextAction: { tool: "session_get", arguments: { sessionId } },
3670
+ }),
3671
+ );
2613
3672
  }
2614
3673
  const controlled = await controlHumanSessionWorkstream(
2615
3674
  deps,
@@ -2653,16 +3712,25 @@ function registerWorkspaceOrchestrationTools(
2653
3712
  reason: reason ?? "agent_mcp_resume",
2654
3713
  },
2655
3714
  );
2656
- return json({
2657
- receiptId: controlled.receipt.id,
2658
- effectiveControl: projectEffectiveControlForRelatedAccess(
2659
- serializeEffectiveSessionControl(controlled.control),
2660
- sessionId,
2661
- controlled.authorization?.relatedSessionAccess ?? "root",
2662
- ),
2663
- interruptionCount: controlled.interruptionCount,
2664
- replay: controlled.replay,
2665
- });
3715
+ const effectiveControl = projectEffectiveControlForRelatedAccess(
3716
+ serializeEffectiveSessionControl(controlled.control),
3717
+ sessionId,
3718
+ controlled.authorization?.relatedSessionAccess ?? "root",
3719
+ );
3720
+ return json(
3721
+ mcpMutationReceipt({
3722
+ operation: "session_resume",
3723
+ committed: true,
3724
+ outcome: controlled.replay ? "replayed" : "updated",
3725
+ changed: !controlled.replay,
3726
+ resource: { type: "session", id: sessionId, state: effectiveControl.state },
3727
+ relatedResources: [{ type: "session_command_receipt", id: controlled.receipt.id }],
3728
+ timestamp: controlled.receipt.createdAt.toISOString(),
3729
+ idempotency: { status: controlled.replay ? "replayed" : "applied" },
3730
+ facts: { interruptionCount: controlled.interruptionCount },
3731
+ nextAction: { tool: "session_get", arguments: { sessionId } },
3732
+ }),
3733
+ );
2666
3734
  }
2667
3735
  const controlled = await controlHumanSessionWorkstream(
2668
3736
  deps,
@@ -2701,13 +3769,28 @@ function registerWorkspaceOrchestrationTools(
2701
3769
  exactAgentCommandContext(grant, callerSessionId, "first_party_mcp"),
2702
3770
  { targetSessionId: sessionId, instruction, idempotencyKey },
2703
3771
  );
2704
- return json({
2705
- updateId: result.updateId,
2706
- interruptionCount: result.interruptionCount,
2707
- stoppingPreviousAttempt: result.interruptionCount > 0,
2708
- effectiveState: result.effectiveState,
2709
- replay: result.replay,
2710
- });
3772
+ return json(
3773
+ mcpMutationReceipt({
3774
+ operation: "session_steer",
3775
+ committed: true,
3776
+ outcome: result.replay ? "replayed" : "updated",
3777
+ changed: !result.replay,
3778
+ resource: {
3779
+ type: "session_system_update",
3780
+ id: result.updateId,
3781
+ state: result.effectiveState,
3782
+ },
3783
+ relatedResources: [{ type: "session", id: sessionId }],
3784
+ timestamp: result.receipt.createdAt.toISOString(),
3785
+ idempotency: { status: result.replay ? "replayed" : "applied" },
3786
+ facts: {
3787
+ interruptionCount: result.interruptionCount,
3788
+ stoppingPreviousAttempt: result.interruptionCount > 0,
3789
+ },
3790
+ updateId: result.updateId,
3791
+ nextAction: { tool: "session_get", arguments: { sessionId } },
3792
+ }),
3793
+ );
2711
3794
  },
2712
3795
  );
2713
3796
  }
@@ -2736,15 +3819,15 @@ function registerWorkspaceOrchestrationTools(
2736
3819
  }
2737
3820
  }
2738
3821
 
2739
- // VariableSet management for manager-style agents. v1 deliberately accepts
2740
- // variable VALUES in plain tool arguments: the calling model is trusted with
2741
- // the secrets it is persisting (see docs/variable-sets.md). Reads stay
2742
- // write-only — responses carry names and metadata, never values.
3822
+ // Variable-set management for agents. Generic reads remain metadata-only.
3823
+ // Plaintext has one dedicated tool that additionally requires literal
3824
+ // secrets:read plus exact live attempt/session authorization.
2743
3825
  function registerVariableSetTools(
2744
3826
  server: McpServer,
2745
3827
  deps: ApiRouteDeps,
2746
3828
  grant: AccessGrant,
2747
3829
  can: (permission: Permission) => boolean,
3830
+ sessionId: string | null,
2748
3831
  json: JsonResult,
2749
3832
  ): void {
2750
3833
  const registerListTool = (name: string, description: string): void => {
@@ -2760,93 +3843,114 @@ function registerVariableSetTools(
2760
3843
  },
2761
3844
  );
2762
3845
  };
2763
- const setVariableHandler = async ({
2764
- variableSetId,
2765
- variableSetName,
2766
- environmentId,
2767
- environmentName,
2768
- name,
2769
- value,
2770
- }: {
2771
- variableSetId?: string | undefined;
2772
- variableSetName?: string | undefined;
2773
- environmentId?: string | undefined;
2774
- environmentName?: string | undefined;
2775
- name: string;
2776
- value: string;
2777
- }) => {
2778
- const key = requireVariableSetEncryption(deps.settings);
2779
- const parsedName = VariableSetVariableName.safeParse(name);
2780
- if (!parsedName.success) {
2781
- throw new Error("variable set/environment variable names must match ^[A-Z][A-Z0-9_]*$");
2782
- }
2783
- assertAllowedVariableSetVariableName(parsedName.data);
2784
- const targetId = variableSetId ?? environmentId;
2785
- const targetName = variableSetName ?? environmentName;
2786
- if ((targetId === undefined) === (targetName === undefined)) {
2787
- throw new Error(
2788
- "provide exactly one of variableSetId or variableSetName; deprecated aliases must provide exactly one of environmentId or environmentName",
2789
- );
2790
- }
2791
- const trimmedVariableSetName = targetName?.trim();
2792
- if (targetName !== undefined && !trimmedVariableSetName) {
2793
- throw new Error("variable set name is required");
2794
- }
2795
- let created = false;
2796
- let variableSet =
2797
- targetId !== undefined
2798
- ? await getVariableSet(deps.db, grant.workspaceId, targetId)
2799
- : await getVariableSetByName(deps.db, grant.workspaceId, trimmedVariableSetName!);
2800
- if (!variableSet && targetId !== undefined) {
2801
- throw new Error("variable set/environment not found");
2802
- }
2803
- if (!variableSet) {
2804
- if ((await countVariableSets(deps.db, grant.workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
3846
+ const setVariableHandler =
3847
+ (operation: "variable_set_set_variable" | "environment_set_variable") =>
3848
+ async ({
3849
+ variableSetId,
3850
+ variableSetName,
3851
+ environmentId,
3852
+ environmentName,
3853
+ name,
3854
+ value,
3855
+ }: {
3856
+ variableSetId?: string | undefined;
3857
+ variableSetName?: string | undefined;
3858
+ environmentId?: string | undefined;
3859
+ environmentName?: string | undefined;
3860
+ name: string;
3861
+ value: string;
3862
+ }) => {
3863
+ const key = requireVariableSetEncryption(deps.settings);
3864
+ const parsedName = VariableSetVariableName.safeParse(name);
3865
+ if (!parsedName.success) {
3866
+ throw new Error("variable set/environment variable names must match ^[A-Z][A-Z0-9_]*$");
3867
+ }
3868
+ assertAllowedVariableSetVariableName(parsedName.data);
3869
+ const targetId = variableSetId ?? environmentId;
3870
+ const targetName = variableSetName ?? environmentName;
3871
+ if ((targetId === undefined) === (targetName === undefined)) {
3872
+ throw new Error(
3873
+ "provide exactly one of variableSetId or variableSetName; deprecated aliases must provide exactly one of environmentId or environmentName",
3874
+ );
3875
+ }
3876
+ const trimmedVariableSetName = targetName?.trim();
3877
+ if (targetName !== undefined && !trimmedVariableSetName) {
3878
+ throw new Error("variable set name is required");
3879
+ }
3880
+ let created = false;
3881
+ let variableSet =
3882
+ targetId !== undefined
3883
+ ? await getVariableSet(deps.db, grant.workspaceId, targetId)
3884
+ : await getVariableSetByName(deps.db, grant.workspaceId, trimmedVariableSetName!);
3885
+ if (!variableSet && targetId !== undefined) {
3886
+ throw new Error("variable set/environment not found");
3887
+ }
3888
+ if (!variableSet) {
3889
+ if (
3890
+ (await countVariableSets(deps.db, grant.workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE
3891
+ ) {
3892
+ throw new Error(
3893
+ `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
3894
+ );
3895
+ }
3896
+ variableSet = await createVariableSet(deps.db, {
3897
+ accountId: grant.accountId,
3898
+ workspaceId: grant.workspaceId,
3899
+ name: trimmedVariableSetName!,
3900
+ });
3901
+ created = true;
3902
+ await recordVariableSetAuditEvent(deps.db, {
3903
+ grant,
3904
+ action: "variable_set.created",
3905
+ variableSetId: variableSet.id,
3906
+ });
3907
+ }
3908
+ const exists = variableSet.variables.some((variable) => variable.name === parsedName.data);
3909
+ if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
2805
3910
  throw new Error(
2806
- `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
3911
+ `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`,
2807
3912
  );
2808
3913
  }
2809
- variableSet = await createVariableSet(deps.db, {
3914
+ const metadata = await setVariableSetVariable(deps.db, {
2810
3915
  accountId: grant.accountId,
2811
3916
  workspaceId: grant.workspaceId,
2812
- name: trimmedVariableSetName!,
3917
+ variableSetId: variableSet.id,
3918
+ name: parsedName.data,
3919
+ valueEncrypted: encryptVariableSetValue(key, value),
2813
3920
  });
2814
- created = true;
2815
3921
  await recordVariableSetAuditEvent(deps.db, {
2816
3922
  grant,
2817
- action: "variable_set.created",
3923
+ action: "variable_set.variable.set",
2818
3924
  variableSetId: variableSet.id,
3925
+ variableName: parsedName.data,
2819
3926
  });
2820
- }
2821
- const exists = variableSet.variables.some((variable) => variable.name === parsedName.data);
2822
- if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
2823
- throw new Error(`a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
2824
- }
2825
- const metadata = await setVariableSetVariable(deps.db, {
2826
- accountId: grant.accountId,
2827
- workspaceId: grant.workspaceId,
2828
- variableSetId: variableSet.id,
2829
- name: parsedName.data,
2830
- valueEncrypted: encryptVariableSetValue(key, value),
2831
- });
2832
- await recordVariableSetAuditEvent(deps.db, {
2833
- grant,
2834
- action: "variable_set.variable.set",
2835
- variableSetId: variableSet.id,
2836
- variableName: parsedName.data,
2837
- });
2838
- const responseVariableSet = {
2839
- id: variableSet.id,
2840
- name: variableSet.name,
2841
- created,
3927
+ return json(
3928
+ mcpMutationReceipt({
3929
+ operation,
3930
+ committed: true,
3931
+ outcome: exists ? "updated" : "created",
3932
+ changed: true,
3933
+ resource: {
3934
+ type: "variable_set",
3935
+ id: variableSet.id,
3936
+ version: metadata.version,
3937
+ state: "variable_written",
3938
+ },
3939
+ timestamp: metadata.updatedAt,
3940
+ idempotency: { status: "not_supported" },
3941
+ facts: {
3942
+ variableCreated: !exists,
3943
+ variableSetCreated: created,
3944
+ deprecatedAlias: operation === "environment_set_variable",
3945
+ },
3946
+ nextAction: { tool: "variable_set_list", arguments: {} },
3947
+ }),
3948
+ );
2842
3949
  };
2843
- return json({
2844
- variableSet: responseVariableSet,
2845
- environment: responseVariableSet,
2846
- variable: metadata,
2847
- });
2848
- };
2849
- const registerSetTool = (name: string, description: string): void => {
3950
+ const registerSetTool = (
3951
+ name: "variable_set_set_variable" | "environment_set_variable",
3952
+ description: string,
3953
+ ): void => {
2850
3954
  server.registerTool(
2851
3955
  name,
2852
3956
  {
@@ -2860,32 +3964,334 @@ function registerVariableSetTools(
2860
3964
  value: z4.string().min(1).max(32768),
2861
3965
  },
2862
3966
  },
2863
- setVariableHandler,
3967
+ setVariableHandler(name),
2864
3968
  );
2865
3969
  };
2866
- if (can("variable-sets:use")) {
3970
+ if (can("variable-sets:list") && can("secrets:list")) {
2867
3971
  registerListTool(
2868
3972
  "variable_set_list",
2869
- "List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
3973
+ "List variable sets with variable names and metadata (versions, timestamps). Plaintext values are never returned by list operations.",
2870
3974
  );
2871
3975
  registerListTool(
2872
3976
  "environment_list",
2873
- "(deprecated alias of variable_set_list) List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
3977
+ "(deprecated alias of variable_set_list) List variable sets with variable names and metadata (versions, timestamps). Plaintext values are never returned by list operations.",
3978
+ );
3979
+ }
3980
+
3981
+ if (
3982
+ sessionId !== null &&
3983
+ can("variable-sets:read") &&
3984
+ hasLiteralPermission(grant.permissions, "secrets:read")
3985
+ ) {
3986
+ server.registerTool(
3987
+ "variable_set_get_variable",
3988
+ {
3989
+ description:
3990
+ "Retrieve one exact plaintext variable value. This is a dedicated high-trust read: use it only when the current task requires the configured value. The access is audited against this exact live attempt.",
3991
+ inputSchema: {
3992
+ variableSetId: z4.string().uuid().optional(),
3993
+ variableSetName: z4.string().min(1).max(120).optional(),
3994
+ name: z4.string().min(1),
3995
+ },
3996
+ },
3997
+ async ({ variableSetId, variableSetName, name }) => {
3998
+ let target: { variableSetId: string } | { variableSetName: string };
3999
+ if (variableSetId !== undefined) {
4000
+ if (variableSetName !== undefined) {
4001
+ throw new Error("provide exactly one of variableSetId or variableSetName");
4002
+ }
4003
+ target = { variableSetId };
4004
+ } else {
4005
+ if (variableSetName === undefined) {
4006
+ throw new Error("provide exactly one of variableSetId or variableSetName");
4007
+ }
4008
+ target = { variableSetName };
4009
+ }
4010
+ if (
4011
+ ("variableSetId" in target && target.variableSetId.length === 0) ||
4012
+ ("variableSetName" in target && target.variableSetName.trim().length === 0)
4013
+ ) {
4014
+ throw new Error("provide exactly one of variableSetId or variableSetName");
4015
+ }
4016
+ const parsedName = VariableSetVariableName.safeParse(name);
4017
+ if (!parsedName.success) {
4018
+ throw new Error("variable set variable names must match ^[A-Z][A-Z0-9_]*$");
4019
+ }
4020
+ assertAllowedVariableSetVariableName(parsedName.data);
4021
+ const claims = exactAgentAttemptClaims(grant);
4022
+ if (!claims || claims.sessionId !== sessionId) {
4023
+ throw new Error("Exact signed secret-read attempt authority is required.");
4024
+ }
4025
+ await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
4026
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.secret.read");
4027
+ const key = requireVariableSetEncryption(deps.settings);
4028
+ const secret = await readVariableSetSecretAtomically(deps.db, {
4029
+ accountId: grant.accountId,
4030
+ workspaceId: grant.workspaceId,
4031
+ subjectId: grant.subjectId,
4032
+ ...target,
4033
+ name: parsedName.data,
4034
+ actor: {
4035
+ kind: "agent_attempt",
4036
+ sessionId: claims.sessionId,
4037
+ turnId: claims.turnId,
4038
+ attemptId: claims.attemptId,
4039
+ executionGeneration: claims.executionGeneration,
4040
+ },
4041
+ decrypt: (valueEncrypted) => decryptVariableSetValue(key, valueEncrypted),
4042
+ });
4043
+ if (!secret) throw new Error("variable set variable not found");
4044
+ return json(secret);
4045
+ },
2874
4046
  );
2875
4047
  }
2876
4048
 
2877
- if (can("variable-sets:manage")) {
4049
+ if (can("variable-sets:write") && can("secrets:write")) {
2878
4050
  registerSetTool(
2879
4051
  "variable_set_set_variable",
2880
- "Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
4052
+ "Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to. Reading it requires the dedicated permissioned secret-read operation.",
2881
4053
  );
2882
4054
  registerSetTool(
2883
4055
  "environment_set_variable",
2884
- "(deprecated alias of variable_set_set_variable) Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
4056
+ "(deprecated alias of variable_set_set_variable) Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to. Reading it requires the dedicated permissioned secret-read operation.",
2885
4057
  );
2886
4058
  }
2887
4059
  }
2888
4060
 
4061
+ type CapabilitySetupProjection =
4062
+ | { status: "ready"; action: null; detail: string }
4063
+ | {
4064
+ status: "authorization_required";
4065
+ action: "connect" | "add_credentials" | "enable";
4066
+ detail: string;
4067
+ }
4068
+ | { status: "unavailable"; action: null; detail: string };
4069
+
4070
+ function registerCapabilityDiscoveryTools(
4071
+ server: McpServer,
4072
+ deps: ApiRouteDeps,
4073
+ grant: AccessGrant,
4074
+ sessionId: string,
4075
+ json: JsonResult,
4076
+ ): void {
4077
+ const catalog = () =>
4078
+ buildCapabilityCatalog({
4079
+ db: deps.db,
4080
+ workspaceId: grant.workspaceId,
4081
+ settings: deps.settings,
4082
+ subjectId: grant.subjectId,
4083
+ });
4084
+ const authorize = async () => {
4085
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
4086
+ };
4087
+
4088
+ server.registerTool(
4089
+ "capability_catalog_search",
4090
+ {
4091
+ description:
4092
+ "Search OpenGeni's reviewed workspace capability catalog when the user asks to add an integration or the task needs a capability that is not currently usable. Search by the outcome needed (for example `GitHub repositories`, `product analytics`, or `Slack notifications`), compare the returned candidates, and prefer a ready or verified exact match. This only reads secret-free metadata; it never installs, connects, or authorizes anything.",
4093
+ inputSchema: {
4094
+ query: z4.string().min(1).max(500),
4095
+ limit: z4.number().int().min(1).max(20).optional(),
4096
+ },
4097
+ },
4098
+ async ({ query, limit }) => {
4099
+ await authorize();
4100
+ const current = await catalog();
4101
+ const ranked = searchCapabilityCatalogItems(
4102
+ [...current.items, ...nativeConnectionCapabilityRecommendations()],
4103
+ query,
4104
+ limit ?? 8,
4105
+ );
4106
+ const matches = await Promise.all(
4107
+ ranked.map(async ({ item, matchedOn }) => ({
4108
+ capabilityId: item.id,
4109
+ name: item.name,
4110
+ description: item.description,
4111
+ kind: item.kind,
4112
+ source: item.source,
4113
+ category: item.category,
4114
+ tags: item.tags.slice(0, 16),
4115
+ providerDomain: item.providerDomain,
4116
+ authKind: item.authKind,
4117
+ tier: item.tier,
4118
+ matchedOn,
4119
+ setup: {
4120
+ ...(await capabilitySetupProjection(deps, grant.workspaceId, item)),
4121
+ requiredVariables: capabilityRequiredVariables(item),
4122
+ },
4123
+ })),
4124
+ );
4125
+ return json({ query, matches });
4126
+ },
4127
+ );
4128
+
4129
+ server.registerTool(
4130
+ "capability_authorization_request",
4131
+ {
4132
+ description:
4133
+ "After capability_catalog_search and after explaining one chosen recommendation to the user, post exactly one in-session human authorization card for that catalog capability. This tool never grants access, enables a capability, reads a secret, or mints provider credentials; the authenticated user must click and confirm the provider/domain flow. Do not call it for a candidate reported ready or unavailable.",
4134
+ inputSchema: {
4135
+ capabilityId: z4.string().min(1).max(512),
4136
+ rationale: z4.string().min(1).max(2000),
4137
+ },
4138
+ },
4139
+ async ({ capabilityId, rationale }) => {
4140
+ await authorize();
4141
+ const current = await catalog();
4142
+ const item = [...current.items, ...nativeConnectionCapabilityRecommendations()].find(
4143
+ (candidate) => candidate.id === capabilityId,
4144
+ );
4145
+ if (!item || !capabilityCatalogItemIsTrustedForExposure(item)) {
4146
+ throw new Error("Unknown or untrusted capability; search the catalog again.");
4147
+ }
4148
+ const setup = await capabilitySetupProjection(deps, grant.workspaceId, item);
4149
+ if (setup.status === "ready") {
4150
+ return json({
4151
+ capabilityId: item.id,
4152
+ status: "ready",
4153
+ message: setup.detail,
4154
+ });
4155
+ }
4156
+ if (setup.status === "unavailable") {
4157
+ return json({
4158
+ capabilityId: item.id,
4159
+ status: "unavailable",
4160
+ message: setup.detail,
4161
+ });
4162
+ }
4163
+ const claims = exactAgentCommandContext(grant, sessionId);
4164
+ const payload = ToolAuthNeededPayload.parse({
4165
+ serverId: item.runtime.mcpServerId ?? "opengeni",
4166
+ toolName: "capability_authorization_request",
4167
+ providerDomain: capabilityProviderDomain(item),
4168
+ reason: "missing_connection",
4169
+ capability: {
4170
+ id: item.id,
4171
+ name: item.name,
4172
+ kind: item.kind,
4173
+ source: item.source,
4174
+ action: setup.action,
4175
+ rationale,
4176
+ requiredVariables: capabilityRequiredVariables(item),
4177
+ },
4178
+ });
4179
+ const appended = await appendAndPublishTurnEventsFenced(
4180
+ deps.db,
4181
+ deps.bus,
4182
+ grant.workspaceId,
4183
+ sessionId,
4184
+ claims.callerTurnId,
4185
+ claims.callerExecutionGeneration,
4186
+ claims.callerAttemptId,
4187
+ [{ type: "tool.auth_needed", payload }],
4188
+ );
4189
+ if (!appended.accepted) {
4190
+ throw new Error(
4191
+ "The calling turn was replaced before the authorization request committed.",
4192
+ );
4193
+ }
4194
+ return json({
4195
+ capabilityId: item.id,
4196
+ status: "authorization_requested",
4197
+ action: setup.action,
4198
+ eventId: appended.events[0]?.id ?? null,
4199
+ message:
4200
+ "The recommendation was posted for human confirmation. No access has been granted yet.",
4201
+ });
4202
+ },
4203
+ );
4204
+ }
4205
+
4206
+ async function capabilitySetupProjection(
4207
+ deps: ApiRouteDeps,
4208
+ workspaceId: string,
4209
+ item: CapabilityCatalogItem,
4210
+ ): Promise<CapabilitySetupProjection> {
4211
+ if (item.id === "api:github-app" || item.surfaceType === "first_party_github") {
4212
+ const missing = githubAppMissingSettings(deps.settings);
4213
+ if (missing.length > 0) {
4214
+ return {
4215
+ status: "unavailable",
4216
+ action: null,
4217
+ detail:
4218
+ deps.settings.productAccessMode === "managed"
4219
+ ? "GitHub is not available on this deployment."
4220
+ : `The operator must configure the GitHub App first (${missing.join(", ")}).`,
4221
+ };
4222
+ }
4223
+ const installations = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
4224
+ if (githubBindingStatus(true, installations) === "bound") {
4225
+ return { status: "ready", action: null, detail: "GitHub is connected and ready." };
4226
+ }
4227
+ return {
4228
+ status: "authorization_required",
4229
+ action: "connect",
4230
+ detail: "A GitHub owner must approve an installation and repository allowlist.",
4231
+ };
4232
+ }
4233
+ if (item.surfaceType === "codex_apps") {
4234
+ return item.enabled
4235
+ ? { status: "ready", action: null, detail: "Codex Apps is connected and ready." }
4236
+ : {
4237
+ status: "authorization_required",
4238
+ action: "connect",
4239
+ detail: "A workspace admin must designate an authorized Codex Apps subscription.",
4240
+ };
4241
+ }
4242
+ if (item.enabled) {
4243
+ return { status: "ready", action: null, detail: "This capability is already enabled." };
4244
+ }
4245
+ if (!item.runtime.available) {
4246
+ return {
4247
+ status: "unavailable",
4248
+ action: null,
4249
+ detail: item.runtime.notes ?? "This catalog entry has no executable runtime adapter.",
4250
+ };
4251
+ }
4252
+ if (item.authKind === "oauth2" || item.surfaceType === "first_party_social") {
4253
+ return {
4254
+ status: "authorization_required",
4255
+ action: "connect",
4256
+ detail: "The user must confirm the provider domain and complete sign-in.",
4257
+ };
4258
+ }
4259
+ if (item.authKind === "api_key" || item.authModel?.toLowerCase().includes("key")) {
4260
+ return {
4261
+ status: "authorization_required",
4262
+ action: "add_credentials",
4263
+ detail: "The user must provide the required credential through the protected setup form.",
4264
+ };
4265
+ }
4266
+ return {
4267
+ status: "authorization_required",
4268
+ action: "enable",
4269
+ detail: "A workspace admin must review and enable this capability.",
4270
+ };
4271
+ }
4272
+
4273
+ function capabilityRequiredVariables(item: CapabilityCatalogItem): string[] {
4274
+ const variableSet = item.metadata.variableSet;
4275
+ if (!variableSet || typeof variableSet !== "object" || Array.isArray(variableSet)) return [];
4276
+ const required = (variableSet as Record<string, unknown>).requiredVariables;
4277
+ return Array.isArray(required)
4278
+ ? required.filter((name): name is string => typeof name === "string").slice(0, 64)
4279
+ : [];
4280
+ }
4281
+
4282
+ function capabilityProviderDomain(item: CapabilityCatalogItem): string {
4283
+ if (item.providerDomain?.trim()) return item.providerDomain.trim();
4284
+ for (const candidate of [item.homepageUrl, item.endpointUrl, item.mcpUrl]) {
4285
+ if (!candidate) continue;
4286
+ try {
4287
+ return new URL(candidate).hostname;
4288
+ } catch {
4289
+ // Continue to the local, non-provider fallback below.
4290
+ }
4291
+ }
4292
+ return "opengeni.local";
4293
+ }
4294
+
2889
4295
  function registerGitHubConnectTool(
2890
4296
  server: McpServer,
2891
4297
  deps: ApiRouteDeps,
@@ -2976,7 +4382,8 @@ function repositoryWithScheduledTaskResource(
2976
4382
  kind: "repository",
2977
4383
  uri,
2978
4384
  ref: repository.defaultBranch,
2979
- mountPath: defaultRepositoryMountPath(uri),
4385
+ provider: "github",
4386
+ mountPath: defaultRepositoryMountPath(uri, "github"),
2980
4387
  ...(repository.private
2981
4388
  ? {
2982
4389
  githubInstallationId: repository.installationId,
@@ -3421,11 +4828,12 @@ function parseMcpDate(raw: string, label: string): Date {
3421
4828
  async function withMcpEffectivePolicy(
3422
4829
  deps: ApiRouteDeps,
3423
4830
  workspaceId: string,
4831
+ subjectId: string,
3424
4832
  session: Session,
3425
4833
  ): Promise<Session> {
3426
4834
  const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
3427
- workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
3428
- workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
4835
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings, subjectId),
4836
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings, subjectId),
3429
4837
  ]);
3430
4838
  return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
3431
4839
  }