@opengeni/core 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -36,15 +36,15 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "@opengeni/codex": "^0.2.0",
40
- "@opengeni/config": "^0.2.1",
41
- "@opengeni/contracts": "^0.4.0",
42
- "@opengeni/db": "^0.2.1",
43
- "@opengeni/documents": "^0.2.1",
44
- "@opengeni/events": "^0.2.1",
45
- "@opengeni/observability": "^0.2.0",
46
- "@opengeni/runtime": "^0.2.1",
47
- "@opengeni/storage": "^0.2.1",
39
+ "@opengeni/codex": "^0.2.1",
40
+ "@opengeni/config": "^0.2.3",
41
+ "@opengeni/contracts": "^0.5.0",
42
+ "@opengeni/db": "^0.3.0",
43
+ "@opengeni/documents": "^0.2.3",
44
+ "@opengeni/events": "^0.2.3",
45
+ "@opengeni/observability": "^0.2.1",
46
+ "@opengeni/runtime": "^0.2.3",
47
+ "@opengeni/storage": "^0.2.3",
48
48
  "hono": "^4.12.18"
49
49
  },
50
50
  "devDependencies": {
@@ -17,26 +17,29 @@ import { HTTPException } from "hono/http-exception";
17
17
 
18
18
  export function validateToolRefs(tools: ToolRef[], settings: Settings): ToolRef[] {
19
19
  const mcpServerIds = new Set(settings.mcpServers.map((server) => server.id));
20
- const selected = new Set<string>();
21
20
  const out: ToolRef[] = [];
22
21
  for (const tool of tools) {
23
22
  if (tool.kind !== "mcp") {
24
23
  throw new HTTPException(422, { message: `unsupported tool kind: ${(tool as { kind?: string }).kind}` });
25
24
  }
25
+ const optional = tool.optional === true;
26
26
  if (!mcpServerIds.has(tool.id)) {
27
+ if (optional) {
28
+ continue;
29
+ }
27
30
  throw new HTTPException(422, { message: `unknown MCP server id: ${tool.id}` });
28
31
  }
29
- if (selected.has(tool.id)) {
30
- continue;
31
- }
32
- selected.add(tool.id);
33
- // Normalize to a bare, STRICT ref: a client-supplied `optional` flag is
34
- // dropped so an EXPLICITLY-requested tool always fails the turn when its
35
- // server is unavailable. `optional: true` is set only server-side, at the
36
- // default-capability auto-attach seam (enabledCapabilityMcpToolRefs).
37
- out.push({ kind: "mcp", id: tool.id });
32
+ // Tool refs are tri-state for pack portability across deployments:
33
+ // - bare / optional:false is STRICT: the id must be configured here and
34
+ // runtime connection failure fails the turn.
35
+ // - optional:true + known id is preserved: runtime treats it like an
36
+ // auto-attached capability MCP and skips connect/list failures.
37
+ // - optional:true + unknown id is skipped above: the client explicitly
38
+ // opted into graceful degradation for MCPs (for example docs servers
39
+ // like context7) that only some deployments configure.
40
+ out.push(optional ? { kind: "mcp", id: tool.id, optional: true } : { kind: "mcp", id: tool.id });
38
41
  }
39
- return out;
42
+ return mergeToolRefs([], out);
40
43
  }
41
44
 
42
45
  type McpSettings = Pick<Settings, "mcpServers">;
@@ -10,6 +10,9 @@ import {
10
10
  type ResourceRef,
11
11
  type Session,
12
12
  type SessionEvent,
13
+ type SessionMcpCredentialUpdateInput,
14
+ type SessionMcpServerInput,
15
+ type SessionMcpServerMetadata,
13
16
  type SessionTurn,
14
17
  type ToolRef,
15
18
  } from "@opengeni/contracts";
@@ -19,6 +22,7 @@ import {
19
22
  createSessionGoal,
20
23
  createSessionWithIdempotencyKey,
21
24
  enqueueSessionTurn,
25
+ encryptEnvironmentValue,
22
26
  getAnySessionInGroup,
23
27
  getEnrollment,
24
28
  listDistinctEnvironmentIdsInGroup,
@@ -29,16 +33,18 @@ import {
29
33
  requireSession,
30
34
  setTemporalWorkflowId,
31
35
  updateSessionTitle as updateSessionTitleRow,
36
+ type CreateSessionMcpServerInput,
32
37
  type Database,
38
+ type UpdateSessionMcpServerCredentialsInput,
33
39
  } from "@opengeni/db";
34
40
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
35
41
  import { HTTPException } from "hono/http-exception";
36
- import { hasPermission } from "../access";
42
+ import { hasPermission, requirePermission } from "../access";
37
43
  import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
38
44
  import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
39
45
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
40
46
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
41
- import { validateEnvironmentAttachment } from "./environments";
47
+ import { requireEnvironmentEncryption, validateEnvironmentAttachment } from "./environments";
42
48
  import {
43
49
  mergeResourceRefs,
44
50
  mergeToolRefs,
@@ -49,6 +55,166 @@ import {
49
55
  withDefaultEnabledCapabilityMcpTools,
50
56
  } from "./resources";
51
57
 
58
+ const reservedSessionMcpServerIds = new Set(["opengeni", "files", "docs", "codex_apps"]);
59
+ const maxSessionMcpCredentialHeaders = 16;
60
+ const maxSessionMcpCredentialHeaderValueLength = 4096;
61
+ // RFC 9110 field-name token characters.
62
+ const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
63
+
64
+ type ValidatedSessionMcpServers = {
65
+ runtimeServers: Settings["mcpServers"];
66
+ dbServers: CreateSessionMcpServerInput[];
67
+ metadata: SessionMcpServerMetadata[];
68
+ };
69
+
70
+ function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> | undefined): Record<string, string> {
71
+ if (!headers) {
72
+ return {};
73
+ }
74
+ const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value] as const).filter(([name]) => name.length > 0);
75
+ if (entries.length > maxSessionMcpCredentialHeaders) {
76
+ throw new HTTPException(422, { message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers` });
77
+ }
78
+ const seen = new Set<string>();
79
+ for (const [name, value] of entries) {
80
+ if (!sessionMcpCredentialHeaderName.test(name)) {
81
+ throw new HTTPException(422, { message: `invalid credential header name: ${name}` });
82
+ }
83
+ const lower = name.toLowerCase();
84
+ if (seen.has(lower)) {
85
+ throw new HTTPException(422, { message: `duplicate credential header name: ${name}` });
86
+ }
87
+ seen.add(lower);
88
+ if (value.length === 0 || value.length > maxSessionMcpCredentialHeaderValueLength) {
89
+ throw new HTTPException(422, { message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters` });
90
+ }
91
+ // RFC 9110 §5.5: field values are HTAB / printable characters.
92
+ // eslint-disable-next-line no-control-regex
93
+ if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
94
+ throw new HTTPException(422, { message: `credential header ${name} contains forbidden control characters` });
95
+ }
96
+ }
97
+ return Object.fromEntries(entries);
98
+ }
99
+
100
+ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpServers"][number] {
101
+ return {
102
+ id: server.id,
103
+ ...(server.name ? { name: server.name } : {}),
104
+ url: server.url,
105
+ ...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
106
+ ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
107
+ cacheToolsList: server.cacheToolsList ?? false,
108
+ };
109
+ }
110
+
111
+ function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings["mcpServers"][number] {
112
+ return {
113
+ id: server.id,
114
+ ...(server.name ? { name: server.name } : {}),
115
+ url: server.url,
116
+ cacheToolsList: false,
117
+ };
118
+ }
119
+
120
+ function settingsWithSessionMcpServerConfigs(settings: Settings, servers: Settings["mcpServers"]): Settings {
121
+ if (servers.length === 0) {
122
+ return settings;
123
+ }
124
+ const sessionIds = new Set(servers.map((server) => server.id));
125
+ return {
126
+ ...settings,
127
+ mcpServers: [
128
+ ...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
129
+ ...servers,
130
+ ],
131
+ };
132
+ }
133
+
134
+ export function settingsWithSessionMcpServerMetadata(settings: Settings, servers: SessionMcpServerMetadata[]): Settings {
135
+ return settingsWithSessionMcpServerConfigs(settings, servers.map(mcpServerConfigFromMetadata));
136
+ }
137
+
138
+ function validateSessionMcpServersForCreate(
139
+ settings: Settings,
140
+ grant: AccessGrant,
141
+ servers: SessionMcpServerInput[],
142
+ ): ValidatedSessionMcpServers {
143
+ if (servers.length === 0) {
144
+ return { runtimeServers: [], dbServers: [], metadata: [] };
145
+ }
146
+ requirePermission(grant, "mcp_servers:attach");
147
+ const encryptionKey = requireEnvironmentEncryption(settings);
148
+ const existingIds = new Set(settings.mcpServers.map((server) => server.id));
149
+ const seenIds = new Set<string>();
150
+ const runtimeServers: Settings["mcpServers"] = [];
151
+ const dbServers: CreateSessionMcpServerInput[] = [];
152
+ const metadata: SessionMcpServerMetadata[] = [];
153
+ for (const server of servers) {
154
+ if (seenIds.has(server.id)) {
155
+ throw new HTTPException(422, { message: `duplicate session MCP server id: ${server.id}` });
156
+ }
157
+ seenIds.add(server.id);
158
+ if (reservedSessionMcpServerIds.has(server.id) || existingIds.has(server.id)) {
159
+ throw new HTTPException(422, { message: `MCP server id already exists: ${server.id}` });
160
+ }
161
+ const headers = normalizedSessionMcpCredentialHeaders(server.headers);
162
+ const headersEncrypted = Object.fromEntries(
163
+ Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
164
+ );
165
+ runtimeServers.push(mcpServerConfigFromInput(server));
166
+ dbServers.push({
167
+ id: server.id,
168
+ name: server.name ?? null,
169
+ url: server.url,
170
+ allowedTools: server.allowedTools ?? null,
171
+ timeoutMs: server.timeoutMs ?? null,
172
+ cacheToolsList: server.cacheToolsList ?? false,
173
+ headersEncrypted,
174
+ });
175
+ metadata.push({
176
+ id: server.id,
177
+ name: server.name ?? null,
178
+ url: server.url,
179
+ headerNames: Object.keys(headersEncrypted).sort(),
180
+ credentialVersion: 1,
181
+ });
182
+ }
183
+ return { runtimeServers, dbServers, metadata };
184
+ }
185
+
186
+ function validateSessionMcpCredentialUpdates(input: {
187
+ settings: Settings;
188
+ grant: AccessGrant;
189
+ session: Session;
190
+ updates: SessionMcpCredentialUpdateInput[];
191
+ }): UpdateSessionMcpServerCredentialsInput[] {
192
+ if (input.updates.length === 0) {
193
+ return [];
194
+ }
195
+ requirePermission(input.grant, "mcp_servers:attach");
196
+ const encryptionKey = requireEnvironmentEncryption(input.settings);
197
+ const knownIds = new Set(input.session.mcpServers.map((server) => server.id));
198
+ const seenIds = new Set<string>();
199
+ const encryptedUpdates = input.updates.map((update) => {
200
+ if (seenIds.has(update.id)) {
201
+ throw new HTTPException(422, { message: `duplicate session MCP credential update id: ${update.id}` });
202
+ }
203
+ seenIds.add(update.id);
204
+ if (!knownIds.has(update.id)) {
205
+ throw new HTTPException(422, { message: `unknown session MCP server id: ${update.id}` });
206
+ }
207
+ const headers = normalizedSessionMcpCredentialHeaders(update.headers);
208
+ return {
209
+ id: update.id,
210
+ headersEncrypted: Object.fromEntries(
211
+ Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
212
+ ),
213
+ };
214
+ });
215
+ return encryptedUpdates;
216
+ }
217
+
52
218
  export async function createAndStartSession(input: {
53
219
  db: Database;
54
220
  bus: EventBus;
@@ -68,6 +234,10 @@ export async function createAndStartSession(input: {
68
234
  goal?: GoalSpec | null;
69
235
  // Validated against the creating grant before this is called.
70
236
  firstPartyMcpPermissions?: Permission[] | null;
237
+ // Encrypted DB rows plus matching safe metadata for create-time per-session
238
+ // MCP servers. Metadata is the only shape emitted in events/responses.
239
+ mcpServers?: CreateSessionMcpServerInput[];
240
+ sessionMcpServers?: SessionMcpServerMetadata[];
71
241
  // The manager session spawning this worker (a worker-signed sessionId claim
72
242
  // on the creating grant); null for direct API creates and scheduled runs.
73
243
  // When set, the worker's terminal-for-now transitions wake this parent.
@@ -127,6 +297,7 @@ export async function createAndStartSession(input: {
127
297
  createIdempotencyKey: input.createIdempotencyKey,
128
298
  sandboxGroupId: input.sandboxGroupId ?? null,
129
299
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
300
+ mcpServers: input.mcpServers ?? [],
130
301
  });
131
302
  if (!created) {
132
303
  return keyed;
@@ -147,6 +318,7 @@ export async function createAndStartSession(input: {
147
318
  parentSessionId: input.parentSessionId ?? null,
148
319
  sandboxGroupId: input.sandboxGroupId ?? null,
149
320
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
321
+ mcpServers: input.mcpServers ?? [],
150
322
  });
151
323
  return await finishStartSession(input, session);
152
324
  }
@@ -171,6 +343,7 @@ async function finishStartSession(input: {
171
343
  sandboxBackend: Settings["sandboxBackend"];
172
344
  environment?: { id: string; name: string } | null;
173
345
  goal?: GoalSpec | null;
346
+ sessionMcpServers?: SessionMcpServerMetadata[];
174
347
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
175
348
  }, session: Session): Promise<Session> {
176
349
  // The goal row is durable session state; the workflow picks it up from the
@@ -197,6 +370,7 @@ async function finishStartSession(input: {
197
370
  payload: {
198
371
  status: "queued",
199
372
  ...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
373
+ ...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
200
374
  },
201
375
  },
202
376
  ...(goal ? [{
@@ -355,6 +529,7 @@ export async function postUserMessageTurn(input: {
355
529
  model?: string | null;
356
530
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
357
531
  clientEventId?: string;
532
+ mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
358
533
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
359
534
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
360
535
  const requestedModel = input.model ?? null;
@@ -362,7 +537,7 @@ export async function postUserMessageTurn(input: {
362
537
  // Reject an explicit per-message model the host does not expose; an omitted
363
538
  // model inherits the session's model downstream (always a configured id).
364
539
  assertConfiguredModel(settings, requestedModel);
365
- const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, (lockedSession) => {
540
+ const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, async (lockedSession, lockedUpdate) => {
366
541
  // Cancelled is the one terminal state: an explicit user act. A FAILED
367
542
  // session stays revivable by talking to it — conversation truth lives in
368
543
  // session_history_items, so a failed turn does not invalidate history,
@@ -373,6 +548,12 @@ export async function postUserMessageTurn(input: {
373
548
  if (lockedSession.status === "cancelled") {
374
549
  throw new HTTPException(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
375
550
  }
551
+ const mcpCredentialUpdates = input.mcpCredentialUpdates?.length
552
+ ? await lockedUpdate.updateSessionMcpServerCredentials(input.mcpCredentialUpdates)
553
+ : { servers: [], missingIds: [] };
554
+ if (mcpCredentialUpdates.missingIds.length > 0) {
555
+ throw new HTTPException(422, { message: `unknown session MCP server id: ${mcpCredentialUpdates.missingIds[0]}` });
556
+ }
376
557
  const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
377
558
  const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
378
559
  const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
@@ -386,6 +567,7 @@ export async function postUserMessageTurn(input: {
386
567
  ...(input.tools.length ? { tools: input.tools } : {}),
387
568
  ...(requestedModel ? { model: requestedModel } : {}),
388
569
  ...(requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}),
570
+ ...(mcpCredentialUpdates.servers.length ? { mcpCredentialUpdates: mcpCredentialUpdates.servers } : {}),
389
571
  },
390
572
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
391
573
  },
@@ -446,12 +628,14 @@ export async function createSessionForRequest(
446
628
  ): Promise<Session> {
447
629
  const { settings, db, bus, workflowClient, objectStorage } = deps;
448
630
  const payload = CreateSessionRequest.parse(rawPayload);
449
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
631
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
632
+ const sessionMcpServers = validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers);
633
+ const runtimeSettings = settingsWithSessionMcpServerConfigs(capabilityRuntimeSettings, sessionMcpServers.runtimeServers);
450
634
  const resources = normalizeResources(payload.resources);
451
635
  const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
452
636
  const defaultedTools = hasOwnProperty(rawPayload, "tools")
453
637
  ? requestedTools
454
- : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, runtimeSettings);
638
+ : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
455
639
  // The first-party MCP server is attached to EVERY session. It hosts the
456
640
  // session's own metadata tool (set_session_title) + goal tools, and — only
457
641
  // when the grant carries the permission — the orchestration/environment/
@@ -672,6 +856,8 @@ export async function createSessionForRequest(
672
856
  environment: environment ? { id: environment.id, name: environment.name } : null,
673
857
  goal: payload.goal ?? null,
674
858
  firstPartyMcpPermissions,
859
+ mcpServers: sessionMcpServers.dbServers,
860
+ sessionMcpServers: sessionMcpServers.metadata,
675
861
  parentSessionId,
676
862
  createIdempotencyKey: payload.idempotencyKey ?? null,
677
863
  // Create-time machine targeting (A-2a): when a target sandbox is named, the
@@ -716,19 +902,21 @@ export async function acceptSessionUserMessage(
716
902
  model?: string | null;
717
903
  reasoningEffort?: ReasoningEffort | null;
718
904
  clientEventId?: string;
905
+ mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
719
906
  },
720
907
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
721
908
  const { settings, db, bus, workflowClient, objectStorage } = deps;
722
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
723
- const requestedResources = normalizeResources(input.resources ?? []);
724
- const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
725
- const requestedTools = input.toolsProvided
726
- ? validatedTools
727
- : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, runtimeSettings);
909
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
728
910
  // Hoisted above requireLimit so the codex-billed predicate can resolve the
729
911
  // turn's effective model (a follow-up turn inherits the session's model). A
730
912
  // pure read with no side effects.
731
913
  const existingSession = await requireSession(db, workspaceId, sessionId);
914
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(capabilityRuntimeSettings, existingSession.mcpServers);
915
+ const requestedResources = normalizeResources(input.resources ?? []);
916
+ const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
917
+ const requestedTools = input.toolsProvided
918
+ ? validatedTools
919
+ : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, capabilityRuntimeSettings);
732
920
  await requireLimit(deps, {
733
921
  accountId: grant.accountId,
734
922
  workspaceId,
@@ -741,6 +929,12 @@ export async function acceptSessionUserMessage(
741
929
  }
742
930
  await validateFileResources(db, workspaceId, requestedResources);
743
931
  await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
932
+ const mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
933
+ settings,
934
+ grant,
935
+ session: existingSession,
936
+ updates: input.mcpCredentialUpdates ?? [],
937
+ });
744
938
  const { accepted, turn } = await postUserMessageTurn({
745
939
  db,
746
940
  bus,
@@ -754,6 +948,7 @@ export async function acceptSessionUserMessage(
754
948
  tools: requestedTools,
755
949
  model: input.model ?? null,
756
950
  reasoningEffort: input.reasoningEffort ?? null,
951
+ mcpCredentialUpdates,
757
952
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
758
953
  });
759
954
  await recordWorkspaceUsage(deps, {