@opengeni/api-router 0.12.2 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
+ DEFAULT_FIRST_PARTY_MCP_TOOLS,
4
+ FIRST_PARTY_MCP_TOOL_NAMES,
3
5
  defaultRepositoryMountPath,
4
6
  SESSION_EVENT_RAW_DELTA_TYPES,
5
7
  SessionEventLatestClass,
@@ -15,6 +17,7 @@ import {
15
17
  VariableSetVariableName,
16
18
  type AccessGrant,
17
19
  type GitHubRepository,
20
+ type FirstPartyMcpToolName,
18
21
  type Permission,
19
22
  type ResourceRef,
20
23
  type SessionAuthorizationOperation,
@@ -52,7 +55,6 @@ import {
52
55
  MEMORY_CORRECT_TOOL_DESCRIPTION,
53
56
  MEMORY_SAVE_TOOL_DESCRIPTION,
54
57
  MEMORY_SEARCH_TOOL_DESCRIPTION,
55
- requireFile,
56
58
  requireScheduledTask,
57
59
  requireSession,
58
60
  saveWorkspaceMemory,
@@ -73,7 +75,13 @@ import {
73
75
  GitHubAppConfigurationError,
74
76
  githubAppMissingSettings,
75
77
  } from "@opengeni/github";
76
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
78
+ import {
79
+ McpServer,
80
+ type RegisteredTool,
81
+ type ToolCallback,
82
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
83
+ import type { AnySchema, ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
84
+ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
77
85
  import * as z4 from "zod/v4";
78
86
  import {
79
87
  hasPermission,
@@ -157,15 +165,153 @@ export type McpServerOptions = {
157
165
  workspaceMemoryEnabled?: boolean | undefined;
158
166
  };
159
167
 
168
+ type FirstPartyToolAuthorization = {
169
+ sessionRequired?: true;
170
+ allOf?: readonly Permission[];
171
+ anyOf?: readonly Permission[];
172
+ };
173
+
174
+ /**
175
+ * Authorization is deliberately complete and separate from visibility. The
176
+ * `satisfies Record` check makes every catalog addition choose an explicit
177
+ * registration predicate before it can compile.
178
+ */
179
+ const FIRST_PARTY_TOOL_AUTHORIZATION = {
180
+ set_session_title: { sessionRequired: true, allOf: ["sessions:control"] },
181
+ goal_set: { sessionRequired: true, allOf: ["goals:manage"] },
182
+ goal_update: { sessionRequired: true, allOf: ["goals:manage"] },
183
+ goal_complete: { sessionRequired: true, allOf: ["goals:manage"] },
184
+ goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
185
+ memory_search: { sessionRequired: true, allOf: ["documents:search"] },
186
+ memory_save: { sessionRequired: true, allOf: ["documents:search"] },
187
+ memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
188
+ sandboxes_list: { sessionRequired: true, allOf: ["sessions:read"] },
189
+ sandbox_attach: { sessionRequired: true, allOf: ["sessions:control"] },
190
+ sandbox_swap: { sessionRequired: true, allOf: ["sessions:control"] },
191
+ run_on: { sessionRequired: true, allOf: ["sessions:control"] },
192
+ sandbox_provision: { sessionRequired: true, allOf: ["sessions:control"] },
193
+ rig_list: { allOf: ["rigs:use"] },
194
+ rig_get: { allOf: ["rigs:use"] },
195
+ rig_propose_change: { allOf: ["rigs:use"] },
196
+ rig_verify: { allOf: ["rigs:use"] },
197
+ rig_promote: { allOf: ["rigs:manage"] },
198
+ sessions_list: { allOf: ["sessions:read"] },
199
+ session_get: { allOf: ["sessions:read"] },
200
+ session_events: { allOf: ["sessions:read"] },
201
+ session_create: { allOf: ["sessions:create"] },
202
+ session_send_message: { allOf: ["sessions:control"] },
203
+ session_pause: { allOf: ["sessions:control"] },
204
+ session_resume: { allOf: ["sessions:control"] },
205
+ session_steer: { sessionRequired: true, allOf: ["sessions:control"] },
206
+ set_other_session_title: { allOf: ["sessions:control"] },
207
+ variable_set_list: { allOf: ["variable-sets:use"] },
208
+ environment_list: { allOf: ["variable-sets:use"] },
209
+ variable_set_set_variable: { allOf: ["variable-sets:manage"] },
210
+ environment_set_variable: { allOf: ["variable-sets:manage"] },
211
+ github_connect_link: { allOf: ["github:use"] },
212
+ github_token: { sessionRequired: true, allOf: ["github:use"] },
213
+ github_repositories_list: { allOf: ["github:use"] },
214
+ social_connections_list: { allOf: ["connections:read"] },
215
+ social_posts_recent: { allOf: ["connections:read"] },
216
+ social_daily_analysis_context: { allOf: ["connections:read"] },
217
+ scheduled_tasks_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
218
+ scheduled_tasks_get: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
219
+ scheduled_tasks_create: { allOf: ["scheduled_tasks:manage"] },
220
+ scheduled_tasks_update: { allOf: ["scheduled_tasks:manage"] },
221
+ scheduled_tasks_pause: { allOf: ["scheduled_tasks:manage"] },
222
+ scheduled_tasks_resume: { allOf: ["scheduled_tasks:manage"] },
223
+ scheduled_tasks_trigger: { allOf: ["scheduled_tasks:run"] },
224
+ scheduled_tasks_delete: { allOf: ["scheduled_tasks:manage"] },
225
+ scheduled_task_runs_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
226
+ slack_bot_list_channels: { allOf: ["connections:read"] },
227
+ slack_bot_channel_history: { allOf: ["connections:read"] },
228
+ slack_bot_list_users: { allOf: ["connections:read"] },
229
+ slack_bot_post_message: { allOf: ["connections:read"] },
230
+ } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
231
+
232
+ const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
233
+
234
+ class PolicyMcpServer extends McpServer {
235
+ private registeredToolCount = 0;
236
+
237
+ constructor(
238
+ private readonly grant: AccessGrant,
239
+ private readonly sessionId: string | null,
240
+ private readonly selectedTools: ReadonlySet<FirstPartyMcpToolName> | null,
241
+ private readonly allowUncatalogued: boolean,
242
+ ) {
243
+ super({ name: "opengeni", version: "1.0.0" });
244
+ }
245
+
246
+ override registerTool<
247
+ OutputArgs extends ZodRawShapeCompat | AnySchema,
248
+ InputArgs extends undefined | ZodRawShapeCompat | AnySchema = undefined,
249
+ >(
250
+ name: string,
251
+ config: {
252
+ title?: string;
253
+ description?: string;
254
+ inputSchema?: InputArgs;
255
+ outputSchema?: OutputArgs;
256
+ annotations?: ToolAnnotations;
257
+ _meta?: Record<string, unknown>;
258
+ },
259
+ cb: ToolCallback<InputArgs>,
260
+ ): RegisteredTool {
261
+ const catalogued = FIRST_PARTY_MCP_TOOL_NAME_SET.has(name);
262
+ let admitted = this.allowUncatalogued && !catalogued;
263
+ if (catalogued) {
264
+ const toolName = name as FirstPartyMcpToolName;
265
+ const policy: FirstPartyToolAuthorization = FIRST_PARTY_TOOL_AUTHORIZATION[toolName];
266
+ const authorized =
267
+ (!policy.sessionRequired || this.sessionId !== null) &&
268
+ (policy.allOf?.every((permission) => hasPermission(this.grant.permissions, permission)) ??
269
+ true) &&
270
+ (policy.anyOf?.some((permission) => hasPermission(this.grant.permissions, permission)) ??
271
+ true);
272
+ const selected = this.selectedTools === null || this.selectedTools.has(toolName);
273
+ admitted = authorized && selected;
274
+ }
275
+ if (!admitted) {
276
+ return {
277
+ ...(config.title ? { title: config.title } : {}),
278
+ ...(config.description ? { description: config.description } : {}),
279
+ ...(config.annotations ? { annotations: config.annotations } : {}),
280
+ ...(config._meta ? { _meta: config._meta } : {}),
281
+ handler: cb as RegisteredTool["handler"],
282
+ enabled: false,
283
+ enable() {},
284
+ disable() {},
285
+ update() {},
286
+ remove() {},
287
+ };
288
+ }
289
+ this.registeredToolCount += 1;
290
+ return super.registerTool(name, config, cb);
291
+ }
292
+
293
+ ensureToolsListHandler(): void {
294
+ if (this.registeredToolCount > 0) return;
295
+ super
296
+ .registerTool(
297
+ "__opengeni_empty_first_party_surface__",
298
+ {
299
+ description: "Internal disabled placeholder for an empty first-party surface.",
300
+ inputSchema: z4.object({}),
301
+ },
302
+ async () => ({
303
+ content: [{ type: "text" as const, text: '{"unavailable":true}' }],
304
+ }),
305
+ )
306
+ .disable();
307
+ }
308
+ }
309
+
160
310
  export function buildOpenGeniMcpServer(
161
311
  deps: ApiRouteDeps,
162
312
  grant: AccessGrant,
163
313
  options: McpServerOptions = {},
164
314
  ): McpServer {
165
- const server = new McpServer({
166
- name: "opengeni",
167
- version: "1.0.0",
168
- });
169
315
  const json = (value: unknown) => ({
170
316
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
171
317
  });
@@ -178,6 +324,14 @@ export function buildOpenGeniMcpServer(
178
324
  typeof grant.metadata?.["sessionId"] === "string"
179
325
  ? (grant.metadata["sessionId"] as string)
180
326
  : null;
327
+ const selectedTools =
328
+ sessionId !== null && !toolspaceMode
329
+ ? new Set(
330
+ (grant.metadata?.["firstPartyMcpTools"] as FirstPartyMcpToolName[] | undefined) ??
331
+ DEFAULT_FIRST_PARTY_MCP_TOOLS,
332
+ )
333
+ : null;
334
+ const server = new PolicyMcpServer(grant, sessionId, selectedTools, toolspaceMode);
181
335
  // set_session_title names the agent's OWN session — pure session metadata,
182
336
  // not a goal operation — so it is available on every session, gated only on
183
337
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
@@ -248,45 +402,6 @@ export function buildOpenGeniMcpServer(
248
402
  }
249
403
  }
250
404
 
251
- if (!toolspaceMode || can("files:read")) {
252
- server.registerTool(
253
- "files_get_download_url",
254
- {
255
- description: "Create a short-lived download URL for a ready file asset.",
256
- inputSchema: { fileId: z4.string().uuid() },
257
- },
258
- async ({ fileId }) => {
259
- if (!deps.objectStorage) {
260
- throw new Error("object storage is not configured");
261
- }
262
- const file = await requireFile(deps.db, grant.workspaceId, fileId);
263
- if (file.status !== "ready") {
264
- throw new Error(`file is ${file.status}`);
265
- }
266
- const signed = await deps.objectStorage.createGetUrl({
267
- key: file.objectKey,
268
- });
269
- return json({
270
- file: {
271
- id: file.id,
272
- filename: file.filename,
273
- safeFilename: file.safeFilename,
274
- contentType: file.contentType,
275
- sizeBytes: file.sizeBytes,
276
- sha256: file.sha256,
277
- status: file.status,
278
- createdAt: file.createdAt,
279
- updatedAt: file.updatedAt,
280
- },
281
- downloadUrl: {
282
- url: signed.url,
283
- expiresAt: signed.expiresAt.toISOString(),
284
- },
285
- });
286
- },
287
- );
288
- }
289
-
290
405
  if (!toolspaceMode || can("github:use")) {
291
406
  server.registerTool(
292
407
  "github_repositories_list",
@@ -604,6 +719,7 @@ export function buildOpenGeniMcpServer(
604
719
  task,
605
720
  agentRunUsageIdempotencyKey,
606
721
  triggerWorkflowId,
722
+ initiator: { kind: "subject", subjectId: grant.subjectId },
607
723
  });
608
724
  await recordWorkspaceUsage(deps, {
609
725
  accountId: grant.accountId,
@@ -653,6 +769,7 @@ export function buildOpenGeniMcpServer(
653
769
  }
654
770
 
655
771
  registerToolspaceProxyTools(server, options.toolspace ?? null);
772
+ server.ensureToolsListHandler();
656
773
 
657
774
  return server;
658
775
  }
@@ -1734,6 +1851,12 @@ function registerWorkspaceOrchestrationTools(
1734
1851
  .describe(
1735
1852
  "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
1736
1853
  ),
1854
+ firstPartyMcpTools: z4
1855
+ .array(z4.enum(FIRST_PARTY_MCP_TOOL_NAMES))
1856
+ .optional()
1857
+ .describe(
1858
+ "Exact model-visible first-party tool selection for the child. Omit to inherit this session's effective selection. This does not grant permissions.",
1859
+ ),
1737
1860
  // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
1738
1861
  // creator's box — one filesystem/repo/desktop, N independent conversations;
1739
1862
  // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
@@ -1792,7 +1915,7 @@ function registerWorkspaceOrchestrationTools(
1792
1915
  "session_send_message",
1793
1916
  {
1794
1917
  description:
1795
- "Send information to another session. From an OpenGeni worker this becomes a coalescible internal update, never a visible prompt-queue row; pending updates are delivered together on the target's next inference. A sessionless operator call appends one visible prompt.",
1918
+ "Send information to another session. From an OpenGeni worker this becomes a canonical coalescible machine input: it appears in the target's compact incoming queue group, is durably added to model history when claimed, and remains visible in the timeline. A sessionless operator call appends one human/API prompt.",
1796
1919
  inputSchema: {
1797
1920
  sessionId: z4.string().uuid(),
1798
1921
  text: z4.string().min(1),
@@ -1977,7 +2100,7 @@ function registerWorkspaceOrchestrationTools(
1977
2100
  "session_steer",
1978
2101
  {
1979
2102
  description:
1980
- "Atomically replace another session's current direction and resume it. The instruction is an internal update, never a human queue row.",
2103
+ "Atomically replace another session's current direction and resume it. The Agent Steer is a typed machine input shown in the target queue/timeline and durably retained in model history; it never impersonates a human prompt.",
1981
2104
  inputSchema: {
1982
2105
  sessionId: z4.string().uuid(),
1983
2106
  instruction: z4.string().min(1),
@@ -885,8 +885,17 @@ export function connectionBrokerFetch(
885
885
  if (!connectionRef) {
886
886
  return baseFetch;
887
887
  }
888
- const resolveCredential = input.deps.connectionCredentials?.mcpCredentials
889
- ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
888
+ const credentialSubjectId =
889
+ input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : undefined;
890
+ if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
891
+ throw new Error(
892
+ `subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`,
893
+ );
894
+ }
895
+ const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
896
+ const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
897
+ const resolveCredential = hostCredentialPort
898
+ ? buildHostConnectionTokenResolver(hostCredentialPort, {
890
899
  accountId: input.grant.accountId,
891
900
  workspaceId: input.grant.workspaceId,
892
901
  sessionId: input.sessionId,
@@ -909,7 +918,7 @@ export function connectionBrokerFetch(
909
918
  destinationUrl,
910
919
  forceRefresh: false,
911
920
  ...(request.toolName ? { toolName: request.toolName } : {}),
912
- subjectId: input.grant.subjectId,
921
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
913
922
  });
914
923
  if (first.status === "auth_needed") {
915
924
  return await authNeededFetchResponse(input, request, first);
@@ -927,7 +936,7 @@ export function connectionBrokerFetch(
927
936
  destinationUrl,
928
937
  forceRefresh: true,
929
938
  ...(request.toolName ? { toolName: request.toolName } : {}),
930
- subjectId: input.grant.subjectId,
939
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
931
940
  });
932
941
  if (refreshed.status === "auth_needed") {
933
942
  return await authNeededFetchResponse(input, request, refreshed);