@opengeni/core 0.8.0 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.8.0",
3
+ "version": "0.11.1",
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": {
@@ -34,15 +34,15 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
- "@opengeni/codex": "^0.2.5",
38
- "@opengeni/config": "^0.6.2",
39
- "@opengeni/contracts": "^0.15.0",
40
- "@opengeni/db": "^0.9.3",
41
- "@opengeni/documents": "^0.2.19",
42
- "@opengeni/events": "^0.3.10",
37
+ "@opengeni/codex": "^0.2.7",
38
+ "@opengeni/config": "^0.7.0",
39
+ "@opengeni/contracts": "^0.19.0",
40
+ "@opengeni/db": "^0.12.0",
41
+ "@opengeni/documents": "^0.2.30",
42
+ "@opengeni/events": "^0.3.21",
43
43
  "@opengeni/observability": "^0.3.0",
44
- "@opengeni/runtime": "^0.11.0",
45
- "@opengeni/storage": "^0.2.15",
44
+ "@opengeni/runtime": "^0.13.3",
45
+ "@opengeni/storage": "^0.2.24",
46
46
  "hono": "^4.12.18"
47
47
  },
48
48
  "engines": {
@@ -0,0 +1,126 @@
1
+ import {
2
+ NewSessionDraft,
3
+ SaveNewSessionDraftRequest,
4
+ type AccessGrant,
5
+ type NewSessionDraft as NewSessionDraftValue,
6
+ } from "@opengeni/contracts";
7
+ import {
8
+ getNewSessionDraftInTransaction,
9
+ NewSessionDraftAccessError,
10
+ saveNewSessionDraftInTransaction,
11
+ withWorkspaceSubjectRls,
12
+ } from "@opengeni/db";
13
+ import { HTTPException } from "hono/http-exception";
14
+ import type { AppDependencies } from "../dependencies";
15
+ import { settingsWithEnabledCapabilityMcpServers } from "../domain/capabilities";
16
+ import {
17
+ normalizeResources,
18
+ validateFileResources,
19
+ validateGitHubRepositorySelection,
20
+ validateToolRefs,
21
+ } from "../domain/resources";
22
+ import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "../domain/sessions";
23
+
24
+ type NewSessionDraftDependencies = Pick<AppDependencies, "settings" | "db" | "objectStorage">;
25
+
26
+ function mapNewSessionDraft(
27
+ row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
28
+ ): NewSessionDraftValue | null {
29
+ if (!row) return null;
30
+ return NewSessionDraft.parse({
31
+ revision: row.revision,
32
+ text: row.text,
33
+ resources: row.resources,
34
+ tools: row.tools,
35
+ model: row.model,
36
+ reasoningEffort: row.reasoningEffort,
37
+ options: row.sessionOptions,
38
+ updatedAt: row.updatedAt.toISOString(),
39
+ });
40
+ }
41
+
42
+ /** Read the authenticated actor's server-authoritative pre-session composer state. */
43
+ export async function getActorNewSessionDraft(
44
+ deps: Pick<NewSessionDraftDependencies, "settings" | "db">,
45
+ grant: AccessGrant,
46
+ workspaceId: string,
47
+ ): Promise<NewSessionDraftValue> {
48
+ const row = await withWorkspaceSubjectRls(deps.db, workspaceId, grant.subjectId, (scoped) =>
49
+ getNewSessionDraftInTransaction(scoped, {
50
+ workspaceId,
51
+ subjectId: grant.subjectId,
52
+ }),
53
+ );
54
+ return (
55
+ mapNewSessionDraft(row) ?? {
56
+ revision: 0,
57
+ text: "",
58
+ resources: [],
59
+ tools: [],
60
+ model: deps.settings.openaiModel,
61
+ reasoningEffort: deps.settings.openaiReasoningEffort,
62
+ options: {},
63
+ updatedAt: null,
64
+ }
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Validate and save one exact actor-private draft revision. Create-time-only
70
+ * checks (live machine target, rig/variable-set state, and permission
71
+ * delegation) intentionally remain in createSessionForRequest: a recoverable
72
+ * draft may represent incomplete options, while no invalid option can become a
73
+ * session without passing that single canonical create boundary.
74
+ */
75
+ export async function saveActorNewSessionDraft(
76
+ deps: NewSessionDraftDependencies,
77
+ grant: AccessGrant,
78
+ workspaceId: string,
79
+ rawInput: unknown,
80
+ ): Promise<NewSessionDraftValue> {
81
+ const input = SaveNewSessionDraftRequest.parse(rawInput);
82
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
83
+ deps.db,
84
+ workspaceId,
85
+ deps.settings,
86
+ );
87
+ const resources = normalizeResources(input.resources);
88
+ const tools = validateToolRefs(input.tools, runtimeSettings);
89
+ await validateGitHubRepositorySelection(deps.db, workspaceId, resources);
90
+ if (resources.some((resource) => resource.kind === "file") && !deps.objectStorage) {
91
+ throw new HTTPException(503, { message: "object storage is not configured" });
92
+ }
93
+ await validateFileResources(deps.db, workspaceId, resources);
94
+ assertConfiguredModel(deps.settings, input.model);
95
+ await assertWorkspaceModelPolicyAllows(deps.db, deps.settings, workspaceId, input.model);
96
+
97
+ try {
98
+ const saved = await withWorkspaceSubjectRls(deps.db, workspaceId, grant.subjectId, (scoped) =>
99
+ scoped.transaction((tx) =>
100
+ saveNewSessionDraftInTransaction(tx as unknown as typeof scoped, {
101
+ accountId: grant.accountId,
102
+ workspaceId,
103
+ subjectId: grant.subjectId,
104
+ expectedRevision: input.expectedRevision,
105
+ text: input.text,
106
+ resources,
107
+ tools,
108
+ model: input.model,
109
+ reasoningEffort: input.reasoningEffort,
110
+ options: input.options,
111
+ // Only managed people are removed through removeWorkspaceMember().
112
+ // API keys and delegated service actors (for example the first-party
113
+ // worker MCP principal) legitimately have no workspace_memberships
114
+ // row, so they must not be rejected by the human-removal fence.
115
+ requireWorkspaceMembership: grant.subjectId.startsWith("user:"),
116
+ }),
117
+ ),
118
+ );
119
+ return mapNewSessionDraft(saved)!;
120
+ } catch (error) {
121
+ if (error instanceof NewSessionDraftAccessError) {
122
+ throw new HTTPException(403, { message: error.message });
123
+ }
124
+ throw error;
125
+ }
126
+ }
@@ -385,6 +385,7 @@ function composerDraft(
385
385
  text: row.text,
386
386
  resources: row.resources as ComposerDraft["resources"],
387
387
  tools: row.tools as ComposerDraft["tools"],
388
+ toolsProvided: row.toolsProvided,
388
389
  model: row.model,
389
390
  reasoningEffort: row.reasoningEffort as ComposerDraft["reasoningEffort"],
390
391
  sourceTurnId: row.sourceTurnId,
@@ -648,6 +649,7 @@ export async function getHumanComposerDraft(
648
649
  text: "",
649
650
  resources: [],
650
651
  tools: [],
652
+ toolsProvided: false,
651
653
  model: session.model,
652
654
  reasoningEffort: reasoningEffortForMetadata(session.metadata, "medium"),
653
655
  sourceTurnId: null,
@@ -104,6 +104,8 @@ export type AppDependencies = {
104
104
  */
105
105
  sessionAuthorization?: SessionAuthorizationPort | null;
106
106
  managedAuth?: ManagedAuth | null;
107
+ /** Injectable Codex HTTP transport for deterministic API/provider tests. */
108
+ codexFetch?: typeof fetch;
107
109
  // The API process's OWN agent-loop-free sandbox client (constructed from
108
110
  // settings via @opengeni/runtime/sandbox). Undefined when sandboxBackend=none.
109
111
  // This is the foundation of the API-direct control plane: the API resumes
@@ -18,7 +18,7 @@ import {
18
18
  import { areGitHubRepositoriesAllowedForWorkspace, requireFile, type Database } from "@opengeni/db";
19
19
  import { HTTPException } from "hono/http-exception";
20
20
 
21
- export function validateToolRefs(tools: ToolRef[], settings: Settings): ToolRef[] {
21
+ export function validateToolRefs(tools: ToolRef[], settings: McpSettings): ToolRef[] {
22
22
  const mcpServerIds = new Set(settings.mcpServers.map((server) => server.id));
23
23
  const out: ToolRef[] = [];
24
24
  for (const tool of tools) {
@@ -75,6 +75,37 @@ export function withDefaultEnabledCapabilityMcpTools(
75
75
  return mergeToolRefs(tools, enabledCapabilityMcpToolRefs(settings, runtimeSettings));
76
76
  }
77
77
 
78
+ /** Drop stored refs that are no longer present in the current runtime registry. */
79
+ export function availableToolRefs(tools: ToolRef[], settings: McpSettings): ToolRef[] {
80
+ const available = new Set(settings.mcpServers.map((server) => server.id));
81
+ return tools.filter((tool) => available.has(tool.id));
82
+ }
83
+
84
+ /** A child or fixed-policy follow-up may narrow its allow-list, never widen it. */
85
+ export function assertToolRefsSubset(
86
+ requested: ToolRef[],
87
+ allowed: ToolRef[],
88
+ message = "requested tools exceed the session tool policy",
89
+ ): void {
90
+ const allowedIds = new Set(allowed.map((tool) => `${tool.kind}:${tool.id}`));
91
+ const widened = requested.find((tool) => !allowedIds.has(`${tool.kind}:${tool.id}`));
92
+ if (widened) {
93
+ throw new HTTPException(403, { message: `${message}: ${widened.id}` });
94
+ }
95
+ }
96
+
97
+ /** Validate runtime availability and then enforce the durable policy fence. */
98
+ export function validateToolRefsForSessionPolicy(input: {
99
+ requested: ToolRef[];
100
+ settings: McpSettings;
101
+ allowedTools: ToolRef[];
102
+ message: string;
103
+ }): ToolRef[] {
104
+ const validated = validateToolRefs(input.requested, input.settings);
105
+ assertToolRefsSubset(validated, input.allowedTools, input.message);
106
+ return validated;
107
+ }
108
+
78
109
  export function normalizeResources(resources: ResourceRef[]): ResourceRef[] {
79
110
  const mountPaths = new Map<string, string>();
80
111
  const identities = new Map<string, string>();
@@ -9,19 +9,21 @@ import type {
9
9
  import {
10
10
  createScheduledTask,
11
11
  deleteScheduledTask,
12
+ getNestedAgentDepthDeploymentPolicy,
12
13
  getRig,
13
14
  getScheduledTask,
15
+ requireWorkspace,
14
16
  updateScheduledTask,
15
17
  type Database,
16
18
  type UpdateScheduledTaskInput,
17
19
  } from "@opengeni/db";
18
20
  import { HTTPException } from "hono/http-exception";
19
- import { requirePermission } from "../access";
21
+ import { hasPermission, requirePermission } from "../access";
20
22
  import type { SessionWorkflowClient } from "../dependencies";
21
23
  import type { ObjectStorageDependency } from "../dependencies";
22
24
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
23
25
  import { validateVariableSetAttachment } from "./environments";
24
- import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "./sessions";
26
+ import { assertWorkspaceModelPolicyAllows, canonicalConfiguredModel } from "./sessions";
25
27
  import {
26
28
  normalizeResources,
27
29
  validateFileResources,
@@ -199,6 +201,7 @@ export async function validatedScheduledTaskUpdate(input: {
199
201
  settings: input.settings,
200
202
  db: input.db,
201
203
  objectStorage: input.objectStorage,
204
+ grant: input.grant,
202
205
  workspaceId: input.existing.workspaceId,
203
206
  payload: { agentConfig: input.payload.agentConfig },
204
207
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
@@ -318,6 +321,7 @@ async function validateScheduledTaskAgentConfig(input: {
318
321
  settings: Settings;
319
322
  db: Database;
320
323
  objectStorage: ObjectStorageDependency;
324
+ grant: AccessGrant;
321
325
  payload: { agentConfig: ScheduledTaskAgentConfig };
322
326
  workspaceId: string;
323
327
  toolsProvided?: boolean;
@@ -327,15 +331,10 @@ async function validateScheduledTaskAgentConfig(input: {
327
331
  // session choke points (a `scheduled_tasks:manage` holder could otherwise set
328
332
  // a model the host does not expose). An omitted model inherits the host
329
333
  // default downstream, which is always configured.
330
- assertConfiguredModel(input.settings, input.payload.agentConfig.model);
334
+ const model = canonicalConfiguredModel(input.settings, input.payload.agentConfig.model);
331
335
  // Same policy vetting as the session choke points; an omitted model flows
332
336
  // through session creation later, where the effective default is vetted.
333
- await assertWorkspaceModelPolicyAllows(
334
- input.db,
335
- input.settings,
336
- input.workspaceId,
337
- input.payload.agentConfig.model,
338
- );
337
+ await assertWorkspaceModelPolicyAllows(input.db, input.settings, input.workspaceId, model);
339
338
  const resources = normalizeResources(input.payload.agentConfig.resources ?? []);
340
339
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
341
340
  input.db,
@@ -361,8 +360,27 @@ async function validateScheduledTaskAgentConfig(input: {
361
360
  throw new HTTPException(503, { message: "object storage is not configured" });
362
361
  }
363
362
  await validateFileResources(input.db, input.workspaceId, resources);
363
+ const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
364
+ if (requestedMaxDepth !== undefined) {
365
+ const workspace = await requireWorkspace(input.db, input.workspaceId);
366
+ const workspaceMaxDepth = workspace.settings.maxNestedAgentDepth;
367
+ const deploymentPolicy = await getNestedAgentDepthDeploymentPolicy(input.db);
368
+ const inheritedMaxDepth =
369
+ typeof workspaceMaxDepth === "number"
370
+ ? workspaceMaxDepth
371
+ : deploymentPolicy.maxNestedAgentDepth;
372
+ if (
373
+ requestedMaxDepth > inheritedMaxDepth &&
374
+ !hasPermission(input.grant.permissions, "workspace:admin")
375
+ ) {
376
+ throw new HTTPException(403, {
377
+ message: `scheduled task maxNestedAgentDepth ${requestedMaxDepth} exceeds inherited limit ${inheritedMaxDepth}; workspace:admin is required to increase it`,
378
+ });
379
+ }
380
+ }
364
381
  return {
365
382
  ...input.payload.agentConfig,
383
+ ...(model === undefined || model === null ? {} : { model }),
366
384
  prompt,
367
385
  resources,
368
386
  tools,
@@ -0,0 +1,211 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import {
3
+ SESSION_EFFECTIVE_TOOL_POLICY_ID_LIMIT,
4
+ SESSION_EFFECTIVE_TOOL_POLICY_ID_MAX_LENGTH,
5
+ mergeToolRefs,
6
+ type Session,
7
+ type SessionEffectiveToolPolicy,
8
+ type SessionToolPolicy,
9
+ type ToolRef,
10
+ } from "@opengeni/contracts";
11
+ import type { Database } from "@opengeni/db";
12
+ import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
13
+ import { enabledCapabilityMcpToolRefs } from "./resources";
14
+
15
+ const MANDATORY_SESSION_MCP_SERVER_IDS = ["opengeni"] as const;
16
+ const PROJECTABLE_REGISTRY_ID = /^[A-Za-z0-9_-]+$/;
17
+
18
+ export type ResolvedSessionToolPolicy = {
19
+ toolRefs: ToolRef[];
20
+ effectivePolicy: SessionEffectiveToolPolicy;
21
+ };
22
+
23
+ export type SessionToolPolicyInput = {
24
+ toolPolicy?: SessionToolPolicy | null;
25
+ sessionTools: ToolRef[];
26
+ turnTools?: ToolRef[];
27
+ /** Undefined preserves the legacy merge path for pre-provenance callers. */
28
+ turnToolsProvided?: boolean;
29
+ availableMcpServerIds: Iterable<string>;
30
+ /** Current omitted-tools defaults, intentionally narrower than all servers. */
31
+ defaultMcpServerIds?: Iterable<string>;
32
+ };
33
+
34
+ function sortedIds(ids: Iterable<string>): string[] {
35
+ return [...new Set(ids)].sort();
36
+ }
37
+
38
+ function projectIds(ids: readonly string[]): { ids: string[]; truncated: boolean } {
39
+ const projectable = ids.filter(
40
+ (id) =>
41
+ id.length <= SESSION_EFFECTIVE_TOOL_POLICY_ID_MAX_LENGTH && PROJECTABLE_REGISTRY_ID.test(id),
42
+ );
43
+ return {
44
+ ids: projectable.slice(0, SESSION_EFFECTIVE_TOOL_POLICY_ID_LIMIT),
45
+ truncated:
46
+ projectable.length !== ids.length ||
47
+ projectable.length > SESSION_EFFECTIVE_TOOL_POLICY_ID_LIMIT,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Resolve the same ID-only policy used by API projections and worker turns.
53
+ * This function never receives endpoint URLs, credentials, schemas, or live
54
+ * probe results. `availableMcpServerIds` is the resolved runtime registry;
55
+ * `defaultMcpServerIds` is the capability-only omitted-tools set.
56
+ */
57
+ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): ResolvedSessionToolPolicy {
58
+ const policy = input.toolPolicy ?? { mode: "legacy" as const, inheritedFromSessionId: null };
59
+ const availableIds = new Set(input.availableMcpServerIds);
60
+ // Never infer omitted-tools defaults from the full runtime registry: static
61
+ // MCPs are explicit-only unless they are capability-derived defaults.
62
+ const defaultIds = new Set(input.defaultMcpServerIds ?? []);
63
+ const mandatoryIds: string[] = MANDATORY_SESSION_MCP_SERVER_IDS.filter((id) =>
64
+ availableIds.has(id),
65
+ );
66
+ const mandatoryIdSet = new Set<string>(mandatoryIds);
67
+ const selectedRefs =
68
+ input.turnToolsProvided === true
69
+ ? mergeToolRefs([], input.turnTools ?? [])
70
+ : input.turnToolsProvided === false
71
+ ? mergeToolRefs([], input.sessionTools)
72
+ : mergeToolRefs(input.sessionTools, input.turnTools ?? []);
73
+ const tracksWorkspaceDefaults =
74
+ policy.mode === "workspace_default" && input.turnToolsProvided !== true;
75
+
76
+ // Optional capability refs are a historical materialization of a
77
+ // workspace-default selection. They may outlive an installation or its
78
+ // credentials; do not hand an unavailable optional ref to runtime, where it
79
+ // would otherwise be an unknown MCP id. Strict historical refs intentionally
80
+ // remain so their fail-loud compatibility contract is preserved.
81
+ let toolRefs = selectedRefs.filter((tool) => tool.optional !== true || availableIds.has(tool.id));
82
+ if (tracksWorkspaceDefaults) {
83
+ toolRefs = mergeToolRefs(
84
+ toolRefs,
85
+ sortedIds(defaultIds)
86
+ .filter((id) => availableIds.has(id))
87
+ .map((id) => ({ kind: "mcp" as const, id, optional: true as const })),
88
+ );
89
+ }
90
+ toolRefs = mergeToolRefs(
91
+ toolRefs,
92
+ mandatoryIds.map((id) => ({ kind: "mcp" as const, id })),
93
+ );
94
+
95
+ // `effectiveIds` is the requested policy truth, including unavailable
96
+ // optional refs retained in the persisted selection. `toolRefs` above is
97
+ // the runtime-safe materialization, so projections can distinguish dropped
98
+ // history from what is actually handed to the MCP router.
99
+ const requestedEffectiveRefs = mergeToolRefs(
100
+ selectedRefs,
101
+ tracksWorkspaceDefaults
102
+ ? sortedIds(defaultIds)
103
+ .filter((id) => availableIds.has(id))
104
+ .map((id) => ({ kind: "mcp" as const, id, optional: true as const }))
105
+ : [],
106
+ );
107
+ const effectiveIds = sortedIds(
108
+ mergeToolRefs(
109
+ requestedEffectiveRefs,
110
+ mandatoryIds.map((id) => ({ kind: "mcp" as const, id })),
111
+ ).map((tool) => tool.id),
112
+ );
113
+ const configuredIds = effectiveIds.filter((id) => availableIds.has(id));
114
+ const configuredIdSet = new Set(configuredIds);
115
+ const droppedIds = effectiveIds.filter((id) => !configuredIdSet.has(id));
116
+ const deferredIds = tracksWorkspaceDefaults
117
+ ? sortedIds(
118
+ toolRefs
119
+ .filter(
120
+ (tool) =>
121
+ tool.optional === true &&
122
+ configuredIdSet.has(tool.id) &&
123
+ !mandatoryIdSet.has(tool.id),
124
+ )
125
+ .map((tool) => tool.id),
126
+ )
127
+ : [];
128
+ const selectedIds = sortedIds(
129
+ selectedRefs
130
+ .filter(
131
+ (tool) =>
132
+ !mandatoryIdSet.has(tool.id) && !(tracksWorkspaceDefaults && tool.optional === true),
133
+ )
134
+ .map((tool) => tool.id),
135
+ );
136
+ const projections = {
137
+ selected: projectIds(selectedIds),
138
+ effective: projectIds(effectiveIds),
139
+ mandatory: projectIds(sortedIds(mandatoryIds)),
140
+ deferred: projectIds(deferredIds),
141
+ configured: projectIds(configuredIds),
142
+ dropped: projectIds(droppedIds),
143
+ };
144
+
145
+ return {
146
+ toolRefs,
147
+ effectivePolicy: {
148
+ mode: policy.mode,
149
+ inheritedFromSessionId: policy.inheritedFromSessionId,
150
+ selectedIds: projections.selected.ids,
151
+ effectiveIds: projections.effective.ids,
152
+ mandatoryIds: projections.mandatory.ids,
153
+ lazyRouter: {
154
+ state: tracksWorkspaceDefaults ? "required" : "disabled",
155
+ deferredIds: projections.deferred.ids,
156
+ },
157
+ configuredIds: projections.configured.ids,
158
+ droppedIds: projections.dropped.ids,
159
+ counts: {
160
+ selected: selectedIds.length,
161
+ effective: effectiveIds.length,
162
+ mandatory: mandatoryIds.length,
163
+ deferred: deferredIds.length,
164
+ configured: configuredIds.length,
165
+ dropped: droppedIds.length,
166
+ },
167
+ idsTruncated: Object.values(projections).some((projection) => projection.truncated),
168
+ },
169
+ };
170
+ }
171
+
172
+ /** Current full runtime registry IDs, including configured static servers. */
173
+ export async function workspaceSessionToolPolicyServerIds(
174
+ db: Database,
175
+ workspaceId: string,
176
+ settings: Settings,
177
+ ): Promise<string[]> {
178
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
179
+ return sortedIds(runtimeSettings.mcpServers.map((server) => server.id));
180
+ }
181
+
182
+ /** Current omitted-tools defaults; this preserves capability-first behavior. */
183
+ export async function workspaceSessionToolPolicyDefaultServerIds(
184
+ db: Database,
185
+ workspaceId: string,
186
+ settings: Settings,
187
+ ): Promise<string[]> {
188
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
189
+ return sortedIds(enabledCapabilityMcpToolRefs(settings, runtimeSettings).map((tool) => tool.id));
190
+ }
191
+
192
+ /** Add a bounded, secret-safe effective projection to a session response. */
193
+ export function sessionWithEffectiveToolPolicy(
194
+ session: Session,
195
+ workspaceServerIds: Iterable<string>,
196
+ workspaceDefaultServerIds: Iterable<string> = [],
197
+ ): Session {
198
+ const availableIds = new Set(workspaceServerIds);
199
+ for (const server of session.mcpServers) {
200
+ availableIds.add(server.id);
201
+ }
202
+ return {
203
+ ...session,
204
+ effectiveToolPolicy: resolveSessionToolPolicy({
205
+ ...(session.toolPolicy ? { toolPolicy: session.toolPolicy } : {}),
206
+ sessionTools: session.tools,
207
+ availableMcpServerIds: availableIds,
208
+ defaultMcpServerIds: workspaceDefaultServerIds,
209
+ }).effectivePolicy,
210
+ };
211
+ }