@opengeni/api-router 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/server.ts CHANGED
@@ -2,11 +2,15 @@ import {
2
2
  CreateScheduledTaskRequest,
3
3
  defaultRepositoryMountPath,
4
4
  SESSION_EVENT_RAW_DELTA_TYPES,
5
+ SessionEventLatestClass,
5
6
  SessionEventPayloadMode,
6
7
  SessionEventReadDirection,
7
8
  SessionEventReadMode,
9
+ SessionEventResultMode,
8
10
  SessionEventSemanticClass,
9
11
  SessionEventType,
12
+ compactSessionEventResult,
13
+ sessionEventLatestClassToSemanticClass,
10
14
  SessionMcpCredentialUpdateInput,
11
15
  VariableSetVariableName,
12
16
  type AccessGrant,
@@ -14,6 +18,7 @@ import {
14
18
  type Permission,
15
19
  type ResourceRef,
16
20
  type SessionAuthorizationOperation,
21
+ type Session,
17
22
  UpdateScheduledTaskRequest,
18
23
  } from "@opengeni/contracts";
19
24
  import {
@@ -107,6 +112,9 @@ import {
107
112
  sendAgentSessionMessage,
108
113
  steerAgentSession,
109
114
  updateSessionTitle,
115
+ sessionWithEffectiveToolPolicy,
116
+ workspaceSessionToolPolicyDefaultServerIds,
117
+ workspaceSessionToolPolicyServerIds,
110
118
  type AgentSessionCommandContext,
111
119
  } from "@opengeni/core";
112
120
  import {
@@ -120,6 +128,7 @@ import {
120
128
  type RunOnOp,
121
129
  } from "@opengeni/core";
122
130
  import {
131
+ boundSessionEventCompactResult,
123
132
  boundSessionEventMcpPage,
124
133
  boundSessionDetailMcp,
125
134
  boundRigDetailMcp,
@@ -1401,7 +1410,14 @@ function registerWorkspaceOrchestrationTools(
1401
1410
  },
1402
1411
  authorization?.relatedSessionAccess ?? "root",
1403
1412
  );
1404
- return json(boundSessionDetailMcp(projected));
1413
+ return json(
1414
+ boundSessionDetailMcp(
1415
+ await withMcpEffectivePolicy(deps, grant.workspaceId, {
1416
+ ...projected,
1417
+ effectiveControl: queue?.effectiveControl ?? projected.effectiveControl,
1418
+ }),
1419
+ ),
1420
+ );
1405
1421
  },
1406
1422
  );
1407
1423
 
@@ -1409,7 +1425,7 @@ function registerWorkspaceOrchestrationTools(
1409
1425
  "session_events",
1410
1426
  {
1411
1427
  description:
1412
- "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the newest event in exactly one semantic class; it cannot be combined with type or class filters. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1428
+ "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the authoritative newest durable sequence in exactly one semantic class; `receipt` is the concise alias for `tool_receipt`, and latest cannot be combined with type or class filters. Add `resultMode=compact` to latest for one bounded result-bearing completion/checkpoint/receipt without another inference. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1413
1429
  inputSchema: {
1414
1430
  sessionId: z4.string().uuid(),
1415
1431
  after: z4.number().int().nonnegative().optional(),
@@ -1418,6 +1434,7 @@ function registerWorkspaceOrchestrationTools(
1418
1434
  direction: z4.enum(SessionEventReadDirection.options).optional(),
1419
1435
  mode: z4.enum(SessionEventReadMode.options).optional(),
1420
1436
  payloadMode: z4.enum(SessionEventPayloadMode.options).optional(),
1437
+ resultMode: z4.enum(SessionEventResultMode.options).optional(),
1421
1438
  includeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1422
1439
  excludeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1423
1440
  includeClasses: z4
@@ -1428,7 +1445,7 @@ function registerWorkspaceOrchestrationTools(
1428
1445
  .array(z4.enum(SessionEventSemanticClass.options))
1429
1446
  .max(SessionEventSemanticClass.options.length)
1430
1447
  .optional(),
1431
- latest: z4.enum(SessionEventSemanticClass.options).optional(),
1448
+ latest: z4.enum(SessionEventLatestClass.options).optional(),
1432
1449
  },
1433
1450
  },
1434
1451
  async ({
@@ -1439,6 +1456,7 @@ function registerWorkspaceOrchestrationTools(
1439
1456
  direction: requestedDirection,
1440
1457
  mode: requestedMode,
1441
1458
  payloadMode: requestedPayloadMode,
1459
+ resultMode: requestedResultMode,
1442
1460
  includeTypes,
1443
1461
  excludeTypes,
1444
1462
  includeClasses,
@@ -1446,6 +1464,11 @@ function registerWorkspaceOrchestrationTools(
1446
1464
  latest,
1447
1465
  }) => {
1448
1466
  await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
1467
+ const latestClass =
1468
+ latest === undefined ? undefined : sessionEventLatestClassToSemanticClass(latest);
1469
+ if (requestedResultMode === "compact" && latestClass === undefined) {
1470
+ throw new Error("resultMode=compact requires latest");
1471
+ }
1449
1472
  await requireSession(deps.db, grant.workspaceId, sessionId);
1450
1473
  if (
1451
1474
  latest &&
@@ -1456,24 +1479,42 @@ function registerWorkspaceOrchestrationTools(
1456
1479
  throw new Error("latest cannot be combined with event filters");
1457
1480
  }
1458
1481
  const mode = requestedMode ?? (after !== undefined ? "forensic" : "monitoring");
1459
- const direction = latest
1482
+ const direction = latestClass
1460
1483
  ? "before"
1461
1484
  : (requestedDirection ??
1462
1485
  (before !== undefined ? "before" : after !== undefined ? "after" : "before"));
1463
- const payloadMode = requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full");
1486
+ const payloadMode =
1487
+ requestedResultMode === "compact"
1488
+ ? "full"
1489
+ : (requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full"));
1464
1490
  const dbPage = await listSessionEventPage(deps.db, grant.workspaceId, sessionId, {
1465
1491
  after: after ?? 0,
1466
1492
  ...(before !== undefined ? { before } : {}),
1467
1493
  direction,
1468
- limit: latest ? 1 : boundedSessionEventMcpLimit(limit),
1494
+ limit: latestClass ? 1 : boundedSessionEventMcpLimit(limit),
1469
1495
  payloadMode,
1470
1496
  includeTypes: includeTypes ?? [],
1471
1497
  excludeTypes: excludeTypes ?? [],
1472
- includeClasses: latest ? [latest] : (includeClasses ?? []),
1498
+ includeClasses: latestClass ? [latestClass] : (includeClasses ?? []),
1473
1499
  excludeClasses: excludeClasses ?? [],
1474
1500
  ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
1501
+ ...(latestClass ? { authoritativeLatest: true } : {}),
1475
1502
  maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
1476
1503
  });
1504
+ if (requestedResultMode === "compact") {
1505
+ const event = dbPage.events[0];
1506
+ return json(
1507
+ event
1508
+ ? boundSessionEventCompactResult(
1509
+ compactSessionEventResult(
1510
+ event,
1511
+ latestClass!,
1512
+ dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
1513
+ ),
1514
+ )
1515
+ : null,
1516
+ );
1517
+ }
1477
1518
  return json(
1478
1519
  boundSessionEventMcpPage({
1479
1520
  events: dbPage.events,
@@ -1581,7 +1622,8 @@ function registerWorkspaceOrchestrationTools(
1581
1622
  if (callerSessionId !== null) {
1582
1623
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1583
1624
  }
1584
- return json(await createSessionForRequest(deps, grant, grant.workspaceId, args));
1625
+ const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
1626
+ return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
1585
1627
  },
1586
1628
  );
1587
1629
  }
@@ -2542,3 +2584,15 @@ function parseMcpDate(raw: string, label: string): Date {
2542
2584
  }
2543
2585
  return date;
2544
2586
  }
2587
+
2588
+ async function withMcpEffectivePolicy(
2589
+ deps: ApiRouteDeps,
2590
+ workspaceId: string,
2591
+ session: Session,
2592
+ ): Promise<Session> {
2593
+ const [workspaceServerIds, workspaceDefaultServerIds] = await Promise.all([
2594
+ workspaceSessionToolPolicyServerIds(deps.db, workspaceId, deps.settings),
2595
+ workspaceSessionToolPolicyDefaultServerIds(deps.db, workspaceId, deps.settings),
2596
+ ]);
2597
+ return sessionWithEffectiveToolPolicy(session, workspaceServerIds, workspaceDefaultServerIds);
2598
+ }
@@ -13,11 +13,16 @@ import type {
13
13
  Rig,
14
14
  Session,
15
15
  SessionEvent,
16
+ SessionEventCompactResult,
16
17
  SessionEventPayloadMode,
17
18
  SessionEventReadDirection,
18
19
  SessionEventReadMode,
19
20
  } from "@opengeni/contracts";
20
- import { measureSessionEventJson } from "@opengeni/contracts";
21
+ import {
22
+ boundSessionEventPayload,
23
+ measureSessionEventJson,
24
+ sessionEventJsonBytes,
25
+ } from "@opengeni/contracts";
21
26
 
22
27
  export const SESSION_EVENT_MCP_MAX_BYTES = 64 * 1024;
23
28
  export const SESSION_EVENT_MCP_FIELD_MAX_CHARS = 4_000;
@@ -25,6 +30,102 @@ export const DEFAULT_SESSION_DETAIL_CHARS = 6_000;
25
30
  export const SESSION_DETAIL_MCP_MAX_BYTES = 64 * 1024;
26
31
  export const RIG_DETAIL_MCP_MAX_BYTES = 64 * 1024;
27
32
 
33
+ /**
34
+ * Keep the single-result MCP response below the same pretty-JSON envelope as
35
+ * event pages. The contracts projection bounds each value independently for
36
+ * HTTP/SDK use; this second boundary accounts for the result identity,
37
+ * failure/truncation metadata, and MCP's pretty-printing overhead.
38
+ */
39
+ export function boundSessionEventCompactResult(
40
+ result: SessionEventCompactResult,
41
+ maxBytes = SESSION_EVENT_MCP_MAX_BYTES,
42
+ ): SessionEventCompactResult {
43
+ const envelopeMaxBytes = Math.max(8 * 1024, maxBytes);
44
+
45
+ const project = (budget: number): SessionEventCompactResult => {
46
+ const noValues = budget <= 0;
47
+ const text =
48
+ noValues || result.text === null ? null : clampString(result.text, Math.max(128, budget));
49
+ const boundValue = (value: unknown): unknown =>
50
+ noValues || value === null
51
+ ? null
52
+ : boundSessionEventPayload(value, {
53
+ surface: "http_projection",
54
+ maxBytes: Math.max(1_024, budget),
55
+ });
56
+ const output = boundValue(result.output);
57
+ const resultValue = boundValue(result.result);
58
+ const checkpoint = boundValue(result.checkpoint);
59
+ const receipt = boundValue(result.receipt);
60
+ const failure =
61
+ noValues || result.failure === null
62
+ ? null
63
+ : {
64
+ error:
65
+ clampString(result.failure.error ?? "", Math.max(128, Math.floor(budget / 3))) ||
66
+ null,
67
+ code:
68
+ clampString(result.failure.code ?? "", Math.max(128, Math.floor(budget / 6))) || null,
69
+ retryable: result.failure.retryable,
70
+ recovery:
71
+ clampString(result.failure.recovery ?? "", Math.max(128, Math.floor(budget / 3))) ||
72
+ null,
73
+ };
74
+ const changed =
75
+ text !== result.text ||
76
+ output !== result.output ||
77
+ resultValue !== result.result ||
78
+ checkpoint !== result.checkpoint ||
79
+ receipt !== result.receipt ||
80
+ JSON.stringify(failure) !== JSON.stringify(result.failure);
81
+ // A compact result can inherit a source-payload boundary without any of
82
+ // its already-bounded slots changing at the MCP boundary. Keep that loss
83
+ // visible on the model-facing result, but do not manufacture a new byte
84
+ // count: the source projection owns the original/delivered accounting.
85
+ const inheritedSourceBoundary = result.truncation.fields.includes("payload");
86
+ const mcpBoundaryRecorded = changed || inheritedSourceBoundary;
87
+ const deliveredBytes = sessionEventJsonBytes({
88
+ text,
89
+ output,
90
+ result: resultValue,
91
+ failure,
92
+ checkpoint,
93
+ receipt,
94
+ });
95
+ return {
96
+ ...result,
97
+ text,
98
+ output,
99
+ result: resultValue,
100
+ failure,
101
+ checkpoint,
102
+ receipt,
103
+ truncation: {
104
+ ...result.truncation,
105
+ truncated: result.truncation.truncated || changed,
106
+ fields: mcpBoundaryRecorded
107
+ ? [...new Set([...result.truncation.fields, "mcp_envelope"])]
108
+ : result.truncation.fields,
109
+ originalBytes: changed
110
+ ? (result.truncation.originalBytes ?? result.truncation.deliveredBytes)
111
+ : result.truncation.originalBytes,
112
+ deliveredBytes,
113
+ },
114
+ };
115
+ };
116
+
117
+ // Start with a generous budget and tighten only if the full compact result
118
+ // would exceed MCP's envelope. This preserves as much result-bearing data
119
+ // as possible while guaranteeing a truthful bounded response.
120
+ for (const budget of [12_000, 8_000, 4_000, 2_000, 1_000, 0]) {
121
+ const candidate = project(budget);
122
+ if (prettyJsonBytes(candidate) <= envelopeMaxBytes) return candidate;
123
+ }
124
+ throw new RangeError(
125
+ `Session-event compact result exceeds its ${envelopeMaxBytes}-byte envelope`,
126
+ );
127
+ }
128
+
28
129
  function safeStringify(value: unknown): string {
29
130
  if (typeof value === "string") return value;
30
131
  try {