@danielblomma/cortex-mcp 2.0.7 → 2.0.9

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.
@@ -0,0 +1,295 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ } from "node:fs";
6
+ import { hostname } from "node:os";
7
+ import { join } from "node:path";
8
+ import { loadEnterpriseConfig } from "../core/config.js";
9
+ import {
10
+ capabilityDefinitionSchema,
11
+ type CapabilityDefinition,
12
+ } from "../core/workflow/capabilities.js";
13
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
14
+ import { daemonDir } from "./paths.js";
15
+
16
+ /**
17
+ * Org-capability sync flow — daemon side.
18
+ *
19
+ * The daemon polls cortex-web /api/v1/govern/capabilities/manifest each
20
+ * tick to learn what capabilities the org has authored. It diffs against
21
+ * a local state file, fetches changed full definitions, and caches them
22
+ * locally. The pre-tool-use hook's evaluateToolCall consults the merged
23
+ * registry via loadSyncedCapabilities() with synced taking precedence
24
+ * over bundled DEFAULT_CAPABILITIES on name collisions.
25
+ *
26
+ * Three audit outcomes per tick:
27
+ * - capabilities_unchanged — manifest matches local state
28
+ * - capabilities_synced — at least one capability was added /
29
+ * changed / removed (metadata: counts)
30
+ * - capabilities_sync_failed — network / auth / parse error
31
+ */
32
+
33
+ const STATE_FILENAME = "capabilities.local.json";
34
+
35
+ type ManifestEntry = {
36
+ capability_name: string;
37
+ updated_at: string;
38
+ };
39
+
40
+ type FetchedCapability = {
41
+ capability_name: string;
42
+ description: string;
43
+ definition: CapabilityDefinition;
44
+ updated_at: string;
45
+ };
46
+
47
+ type LocalCapabilityRecord = {
48
+ capability_name: string;
49
+ updated_at: string;
50
+ definition: CapabilityDefinition;
51
+ };
52
+
53
+ type LocalCapabilitiesState = {
54
+ capabilities: Record<string, LocalCapabilityRecord>;
55
+ last_synced_at?: string;
56
+ };
57
+
58
+ export type CapabilitySyncOutcome =
59
+ | { kind: "unchanged"; count: number }
60
+ | {
61
+ kind: "synced";
62
+ added: string[];
63
+ changed: string[];
64
+ removed: string[];
65
+ }
66
+ | { kind: "failed"; error: string };
67
+
68
+ function stateFilePath(): string {
69
+ return join(daemonDir(), STATE_FILENAME);
70
+ }
71
+
72
+ function readSyncedCapabilitiesState(): LocalCapabilitiesState {
73
+ const path = stateFilePath();
74
+ if (!existsSync(path)) return { capabilities: {} };
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalCapabilitiesState;
77
+ return {
78
+ capabilities: parsed.capabilities ?? {},
79
+ last_synced_at: parsed.last_synced_at,
80
+ };
81
+ } catch {
82
+ return { capabilities: {} };
83
+ }
84
+ }
85
+
86
+ function writeSyncedCapabilitiesState(state: LocalCapabilitiesState): void {
87
+ writeFileSync(
88
+ stateFilePath(),
89
+ JSON.stringify(state, null, 2) + "\n",
90
+ "utf8",
91
+ );
92
+ }
93
+
94
+ async function fetchManifest(
95
+ baseUrl: string,
96
+ apiKey: string,
97
+ ): Promise<ManifestEntry[]> {
98
+ const url = new URL(
99
+ baseUrl.replace(/\/$/, "") + "/api/v1/govern/capabilities/manifest",
100
+ );
101
+ const res = await fetch(url, {
102
+ headers: { Authorization: `Bearer ${apiKey}` },
103
+ });
104
+ if (!res.ok) {
105
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
106
+ }
107
+ const body = (await res.json()) as { capabilities?: ManifestEntry[] };
108
+ return body.capabilities ?? [];
109
+ }
110
+
111
+ async function fetchCapability(
112
+ baseUrl: string,
113
+ apiKey: string,
114
+ capabilityName: string,
115
+ ): Promise<FetchedCapability> {
116
+ const url = new URL(
117
+ baseUrl.replace(/\/$/, "") +
118
+ "/api/v1/govern/capabilities/" +
119
+ encodeURIComponent(capabilityName),
120
+ );
121
+ const res = await fetch(url, {
122
+ headers: { Authorization: `Bearer ${apiKey}` },
123
+ });
124
+ if (!res.ok) {
125
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
126
+ }
127
+ const body = (await res.json()) as { capability?: FetchedCapability };
128
+ if (!body.capability) {
129
+ throw new Error(`Response for ${capabilityName} missing 'capability' field`);
130
+ }
131
+ return body.capability;
132
+ }
133
+
134
+ export async function runCapabilitySyncOnce(
135
+ cwd: string,
136
+ ): Promise<CapabilitySyncOutcome> {
137
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
138
+ const apiKey = config.enterprise.api_key.trim();
139
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
140
+ if (!apiKey || !baseUrl) {
141
+ const outcome: CapabilitySyncOutcome = {
142
+ kind: "failed",
143
+ error: "enterprise not configured",
144
+ };
145
+ await writeAudit(cwd, outcome);
146
+ return outcome;
147
+ }
148
+
149
+ let manifest: ManifestEntry[];
150
+ try {
151
+ manifest = await fetchManifest(baseUrl, apiKey);
152
+ } catch (err) {
153
+ const outcome: CapabilitySyncOutcome = {
154
+ kind: "failed",
155
+ error: err instanceof Error ? err.message : String(err),
156
+ };
157
+ await writeAudit(cwd, outcome);
158
+ return outcome;
159
+ }
160
+
161
+ const state = readSyncedCapabilitiesState();
162
+ const remoteByName = new Map(manifest.map((e) => [e.capability_name, e]));
163
+
164
+ const added: string[] = [];
165
+ const changed: string[] = [];
166
+ const removed: string[] = [];
167
+
168
+ for (const entry of manifest) {
169
+ const local = state.capabilities[entry.capability_name];
170
+ const isNew = !local;
171
+ const isChanged =
172
+ Boolean(local) && local.updated_at !== entry.updated_at;
173
+ if (!isNew && !isChanged) continue;
174
+
175
+ let fetched: FetchedCapability;
176
+ try {
177
+ fetched = await fetchCapability(baseUrl, apiKey, entry.capability_name);
178
+ } catch (err) {
179
+ const outcome: CapabilitySyncOutcome = {
180
+ kind: "failed",
181
+ error:
182
+ err instanceof Error
183
+ ? `fetch ${entry.capability_name}: ${err.message}`
184
+ : `fetch ${entry.capability_name}: ${String(err)}`,
185
+ };
186
+ await writeAudit(cwd, outcome);
187
+ return outcome;
188
+ }
189
+
190
+ let validated: CapabilityDefinition;
191
+ try {
192
+ validated = capabilityDefinitionSchema.parse(fetched.definition);
193
+ } catch (err) {
194
+ const outcome: CapabilitySyncOutcome = {
195
+ kind: "failed",
196
+ error:
197
+ err instanceof Error
198
+ ? `validate ${entry.capability_name}: ${err.message}`
199
+ : `validate ${entry.capability_name}: ${String(err)}`,
200
+ };
201
+ await writeAudit(cwd, outcome);
202
+ return outcome;
203
+ }
204
+
205
+ state.capabilities[entry.capability_name] = {
206
+ capability_name: entry.capability_name,
207
+ updated_at: fetched.updated_at,
208
+ definition: validated,
209
+ };
210
+ (isNew ? added : changed).push(entry.capability_name);
211
+ }
212
+
213
+ for (const name of Object.keys(state.capabilities)) {
214
+ if (remoteByName.has(name)) continue;
215
+ delete state.capabilities[name];
216
+ removed.push(name);
217
+ }
218
+
219
+ const totalChanged = added.length + changed.length + removed.length;
220
+ if (totalChanged === 0) {
221
+ const outcome: CapabilitySyncOutcome = {
222
+ kind: "unchanged",
223
+ count: manifest.length,
224
+ };
225
+ await writeAudit(cwd, outcome);
226
+ return outcome;
227
+ }
228
+
229
+ state.last_synced_at = new Date().toISOString();
230
+ writeSyncedCapabilitiesState(state);
231
+ const outcome: CapabilitySyncOutcome = {
232
+ kind: "synced",
233
+ added,
234
+ changed,
235
+ removed,
236
+ };
237
+ await writeAudit(cwd, outcome);
238
+ return outcome;
239
+ }
240
+
241
+ async function writeAudit(cwd: string, outcome: CapabilitySyncOutcome): Promise<void> {
242
+ const eventBase = {
243
+ timestamp: new Date().toISOString(),
244
+ host_id: hostname(),
245
+ };
246
+ if (outcome.kind === "unchanged") {
247
+ await writeHostAuditEvent(cwd, {
248
+ ...eventBase,
249
+ event_type: "capabilities_unchanged",
250
+ count: outcome.count,
251
+ }).catch(() => undefined);
252
+ } else if (outcome.kind === "synced") {
253
+ await writeHostAuditEvent(cwd, {
254
+ ...eventBase,
255
+ event_type: "capabilities_synced",
256
+ added: outcome.added,
257
+ changed: outcome.changed,
258
+ removed: outcome.removed,
259
+ }).catch(() => undefined);
260
+ } else {
261
+ await writeHostAuditEvent(cwd, {
262
+ ...eventBase,
263
+ event_type: "capabilities_sync_failed",
264
+ error: outcome.error,
265
+ }).catch(() => undefined);
266
+ }
267
+ }
268
+
269
+ export type CapabilitySyncTimerHandle = {
270
+ stop(): void;
271
+ };
272
+
273
+ export function startCapabilitySyncTimer(
274
+ cwd: string,
275
+ intervalMs: number,
276
+ ): CapabilitySyncTimerHandle {
277
+ const tick = () => {
278
+ void runCapabilitySyncOnce(cwd).catch((err) => {
279
+ process.stderr.write(
280
+ `[cortex-daemon] capability sync failed: ${
281
+ err instanceof Error ? err.message : String(err)
282
+ }\n`,
283
+ );
284
+ });
285
+ };
286
+
287
+ void Promise.resolve().then(tick);
288
+ const handle = setInterval(tick, intervalMs);
289
+ if (typeof handle.unref === "function") handle.unref();
290
+ return {
291
+ stop() {
292
+ clearInterval(handle);
293
+ },
294
+ };
295
+ }
@@ -29,6 +29,7 @@ import {
29
29
  import { startSyncTimer } from "./sync-checker.js";
30
30
  import { startSkillSyncTimer } from "./skill-sync-checker.js";
31
31
  import { startWorkflowSyncTimer } from "./workflow-sync-checker.js";
32
+ import { startCapabilitySyncTimer } from "./capability-sync-checker.js";
32
33
  import { startHostEventsPusher } from "./host-events-pusher.js";
33
34
  import { startEgressProxy } from "./egress-proxy.js";
34
35
  import { startHeartbeatPusher } from "./heartbeat-pusher.js";
@@ -372,6 +373,20 @@ async function main(): Promise<void> {
372
373
  startWorkflowSyncTimer(process.cwd(), workflowSyncMs);
373
374
  }
374
375
 
376
+ // Harness Phase 2: poll cortex-web for org-authored capabilities and
377
+ // cache definitions locally so evaluateToolCall can merge them over
378
+ // bundled DEFAULT_CAPABILITIES on the pre-tool-use path. Same cadence
379
+ // as the workflow sync by default; independently configurable via
380
+ // CORTEX_CAPABILITY_SYNC_MS / CORTEX_DISABLE_CAPABILITY_SYNC.
381
+ const capabilitySyncRaw = parseInt(process.env.CORTEX_CAPABILITY_SYNC_MS ?? "", 10);
382
+ const capabilitySyncMs =
383
+ Number.isFinite(capabilitySyncRaw) && capabilitySyncRaw > 0
384
+ ? capabilitySyncRaw
385
+ : workflowSyncMs;
386
+ if (process.env.CORTEX_DISABLE_CAPABILITY_SYNC !== "1") {
387
+ startCapabilitySyncTimer(process.cwd(), capabilitySyncMs);
388
+ }
389
+
375
390
  // Govern host heartbeat — fills host_enrollment on cortex-web so the
376
391
  // dashboard at /dashboard/govern actually shows this host.
377
392
  const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);
@@ -14,6 +14,7 @@ import { pushAuditEvents, queueAuditEvent, setAuditPushContext } from "./audit/p
14
14
  import { PolicyStore } from "../core/policy/store.js";
15
15
  import { syncFromCloud, syncFromLocal } from "./policy/sync.js";
16
16
  import { registerEnterpriseTools } from "./tools/enterprise.js";
17
+ import { registerHarnessTools } from "./tools/harness.js";
17
18
  import { pushViolations, setViolationPushContext } from "./violations/push.js";
18
19
  import { pushReviewResults, setReviewPushContext } from "./reviews/push.js";
19
20
  import { setWorkflowPushContext } from "./workflow/push.js";
@@ -319,6 +320,10 @@ export async function register(server: McpServer): Promise<void> {
319
320
  }
320
321
 
321
322
  registerEnterpriseTools(server, collector, auditWriter, config, contextDir, policyStore, version);
323
+ // Cortex Harness MCP tools (cortex.workflow.*) — only registered for
324
+ // enterprise projects, since they depend on org-authored workflows
325
+ // synced from cortex-web (also enterprise-only).
326
+ registerHarnessTools(server);
322
327
 
323
328
  // v2.0.0: globalThis.__cortexContextToolHook bridge removed.
324
329
  // Enterprise is now in-process with cortex-mcp; tool events flow via
@@ -0,0 +1,98 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import {
3
+ WorkflowAdvanceInput,
4
+ WorkflowEnvelopeInput,
5
+ WorkflowStartInput,
6
+ WorkflowStatusInput,
7
+ resolveProjectRoot,
8
+ runWorkflowAdvance,
9
+ runWorkflowEnvelope,
10
+ runWorkflowStart,
11
+ runWorkflowStatus,
12
+ } from "../../core/workflow/mcp-tools.js";
13
+
14
+ /**
15
+ * Registers the cortex.workflow.* tools that drive the Cortex Harness.
16
+ * These are an enterprise-only feature: they're only registered when
17
+ * the enterprise plugin successfully loads (license + config valid).
18
+ *
19
+ * Community-mode MCP servers do not see these tools at all — the
20
+ * harness depends on org-authored workflows from cortex-web, which
21
+ * itself requires an enterprise plan.
22
+ *
23
+ * Pure runner functions live in core/workflow/mcp-tools.ts so they can
24
+ * be unit-tested without spinning up an MCP server. This module only
25
+ * wires them onto the server with the right tool names + input schemas.
26
+ */
27
+
28
+ type ToolPayload = Record<string, unknown>;
29
+
30
+ export function registerHarnessTools(server: McpServer): void {
31
+ server.registerTool(
32
+ "cortex.workflow.start",
33
+ {
34
+ description:
35
+ "Start a Cortex Harness workflow run for a task. Creates .agents/<task_id>/state.json and returns the first stage's envelope (the prompt the agent should answer). Enterprise-only.",
36
+ inputSchema: WorkflowStartInput,
37
+ },
38
+ async (input) => buildResult(
39
+ runWorkflowStart(WorkflowStartInput.parse(input ?? {}), {
40
+ cwd: resolveProjectRoot(),
41
+ }) as ToolPayload,
42
+ ),
43
+ );
44
+
45
+ server.registerTool(
46
+ "cortex.workflow.advance",
47
+ {
48
+ description:
49
+ "Complete the current stage of a workflow run by writing its artifact and advancing the run pointer. Returns the new run state plus the next stage's envelope (or null when the run is finished, blocked, or failed). Enterprise-only.",
50
+ inputSchema: WorkflowAdvanceInput,
51
+ },
52
+ async (input) => buildResult(
53
+ runWorkflowAdvance(WorkflowAdvanceInput.parse(input ?? {}), {
54
+ cwd: resolveProjectRoot(),
55
+ }) as ToolPayload,
56
+ ),
57
+ );
58
+
59
+ server.registerTool(
60
+ "cortex.workflow.status",
61
+ {
62
+ description:
63
+ "Read the current run state for a task (current stage, completed stages, outcome). Returns null state when no run exists for the given task_id. Enterprise-only.",
64
+ inputSchema: WorkflowStatusInput,
65
+ },
66
+ async (input) => buildResult(
67
+ runWorkflowStatus(WorkflowStatusInput.parse(input ?? {}), {
68
+ cwd: resolveProjectRoot(),
69
+ }) as ToolPayload,
70
+ ),
71
+ );
72
+
73
+ server.registerTool(
74
+ "cortex.workflow.envelope",
75
+ {
76
+ description:
77
+ "Compose the prompt envelope for a workflow stage without advancing the run. Defaults to the run's current_stage; pass `stage` to dry-run a different stage. Enterprise-only.",
78
+ inputSchema: WorkflowEnvelopeInput,
79
+ },
80
+ async (input) => buildResult(
81
+ runWorkflowEnvelope(WorkflowEnvelopeInput.parse(input ?? {}), {
82
+ cwd: resolveProjectRoot(),
83
+ }) as ToolPayload,
84
+ ),
85
+ );
86
+ }
87
+
88
+ function buildResult(data: ToolPayload) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text" as const,
93
+ text: JSON.stringify(data, null, 2),
94
+ },
95
+ ],
96
+ structuredContent: data,
97
+ };
98
+ }
@@ -11,17 +11,6 @@ import {
11
11
  getSessionEventHook,
12
12
  loadPlugins,
13
13
  } from "./plugin.js";
14
- import {
15
- WorkflowStartInput,
16
- WorkflowAdvanceInput,
17
- WorkflowStatusInput,
18
- WorkflowEnvelopeInput,
19
- resolveProjectRoot,
20
- runWorkflowAdvance,
21
- runWorkflowEnvelope,
22
- runWorkflowStart,
23
- runWorkflowStatus,
24
- } from "./core/workflow/mcp-tools.js";
25
14
 
26
15
  type ToolPayload = Record<string, unknown>;
27
16
 
@@ -334,69 +323,10 @@ function registerTools(server: McpServer): void {
334
323
  })
335
324
  );
336
325
 
337
- server.registerTool(
338
- "cortex.workflow.start",
339
- {
340
- description:
341
- "Start a Cortex Harness workflow run for a task. Creates .agents/<task_id>/state.json and returns the first stage's envelope (the prompt the agent should answer).",
342
- inputSchema: WorkflowStartInput,
343
- },
344
- async (input) => executeInstrumentedTool(
345
- "cortex.workflow.start",
346
- input,
347
- async () => runWorkflowStart(WorkflowStartInput.parse(input ?? {}), {
348
- cwd: resolveProjectRoot(),
349
- }) as ToolPayload,
350
- ),
351
- );
352
-
353
- server.registerTool(
354
- "cortex.workflow.advance",
355
- {
356
- description:
357
- "Complete the current stage of a workflow run by writing its artifact and advancing the run pointer. Returns the new run state plus the next stage's envelope (or null when the run is finished, blocked, or failed).",
358
- inputSchema: WorkflowAdvanceInput,
359
- },
360
- async (input) => executeInstrumentedTool(
361
- "cortex.workflow.advance",
362
- input,
363
- async () => runWorkflowAdvance(WorkflowAdvanceInput.parse(input ?? {}), {
364
- cwd: resolveProjectRoot(),
365
- }) as ToolPayload,
366
- ),
367
- );
368
-
369
- server.registerTool(
370
- "cortex.workflow.status",
371
- {
372
- description:
373
- "Read the current run state for a task (current stage, completed stages, outcome). Returns null state when no run exists for the given task_id.",
374
- inputSchema: WorkflowStatusInput,
375
- },
376
- async (input) => executeInstrumentedTool(
377
- "cortex.workflow.status",
378
- input,
379
- async () => runWorkflowStatus(WorkflowStatusInput.parse(input ?? {}), {
380
- cwd: resolveProjectRoot(),
381
- }) as ToolPayload,
382
- ),
383
- );
384
-
385
- server.registerTool(
386
- "cortex.workflow.envelope",
387
- {
388
- description:
389
- "Compose the prompt envelope for a workflow stage without advancing the run. Defaults to the run's current_stage; pass `stage` to dry-run a different stage.",
390
- inputSchema: WorkflowEnvelopeInput,
391
- },
392
- async (input) => executeInstrumentedTool(
393
- "cortex.workflow.envelope",
394
- input,
395
- async () => runWorkflowEnvelope(WorkflowEnvelopeInput.parse(input ?? {}), {
396
- cwd: resolveProjectRoot(),
397
- }) as ToolPayload,
398
- ),
399
- );
326
+ // Note: cortex.workflow.* tools (the Cortex Harness) are enterprise-only
327
+ // and registered by enterprise/index.ts::register() once the license has
328
+ // verified. They intentionally do not appear here so community-mode MCP
329
+ // servers do not surface them at all.
400
330
  }
401
331
 
402
332
  let shutdownCalled = false;
@@ -9,6 +9,15 @@ import { runStageCommand } from "../dist/cli/stage.js";
9
9
  function makeWorkspace() {
10
10
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-"));
11
11
  process.env.CORTEX_PROJECT_ROOT = dir;
12
+ // cortex stage is enterprise-only; satisfy the gate by writing a
13
+ // minimal enterprise.yml. isEnterpriseProject only requires a
14
+ // non-empty enterprise.api_key field.
15
+ fs.mkdirSync(path.join(dir, ".context"), { recursive: true });
16
+ fs.writeFileSync(
17
+ path.join(dir, ".context", "enterprise.yml"),
18
+ "enterprise:\n api_key: test-key-for-cli-tests\n",
19
+ "utf8",
20
+ );
12
21
  return dir;
13
22
  }
14
23
 
@@ -289,5 +298,37 @@ test("stage help: prints help text and returns without throwing", async () => {
289
298
  });
290
299
 
291
300
  test("stage <unknown>: throws with help text", async () => {
301
+ makeWorkspace();
292
302
  await assert.rejects(runStageCommand(["frobnicate"]), /Unknown stage subcommand/);
293
303
  });
304
+
305
+ test("stage start: blocked in community mode (no enterprise.yml)", async () => {
306
+ // Bypass the helper that auto-writes enterprise.yml.
307
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-community-"));
308
+ process.env.CORTEX_PROJECT_ROOT = dir;
309
+ try {
310
+ await assert.rejects(
311
+ runStageCommand([
312
+ "start",
313
+ "--task-id",
314
+ "task-1",
315
+ "--description",
316
+ "x",
317
+ ]),
318
+ /Cortex Harness — an enterprise-only feature/,
319
+ );
320
+ } finally {
321
+ delete process.env.CORTEX_PROJECT_ROOT;
322
+ }
323
+ });
324
+
325
+ test("stage help: still prints in community mode (so users discover the feature)", async () => {
326
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-stage-cli-community-help-"));
327
+ process.env.CORTEX_PROJECT_ROOT = dir;
328
+ try {
329
+ const { captured } = await captureStdout(() => runStageCommand(["help"]));
330
+ assert.match(captured, /Usage:/);
331
+ } finally {
332
+ delete process.env.CORTEX_PROJECT_ROOT;
333
+ }
334
+ });