@danielblomma/cortex-mcp 2.0.6 → 2.0.8

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,301 @@
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 { workflowDefinitionSchema, type WorkflowDefinition } from "../core/workflow/schemas.js";
10
+ import { writeHostAuditEvent } from "./ungoverned-scanner.js";
11
+ import { daemonDir } from "./paths.js";
12
+
13
+ /**
14
+ * Org-workflow sync flow — daemon side.
15
+ *
16
+ * The daemon polls cortex-web /api/v1/govern/workflows/manifest each tick
17
+ * to learn what workflows the org has authored. It diffs against a local
18
+ * state file, fetches changed full definitions, and caches them locally.
19
+ * cortex.workflow.start (and the cortex stage CLI) read the cache via
20
+ * loadSyncedWorkflows() and merge with bundled DEFAULT_WORKFLOWS, with
21
+ * org definitions taking precedence on workflow_id collisions.
22
+ *
23
+ * Three audit outcomes per tick:
24
+ * - workflows_unchanged — manifest matches local state
25
+ * - workflows_synced — at least one workflow was added / changed /
26
+ * removed (metadata: counts)
27
+ * - workflows_sync_failed — network / auth / parse error
28
+ *
29
+ * Unlike skills, there is no on-disk artifact to write per workflow —
30
+ * the cached JSON is the only product. No "restart Claude Code"
31
+ * notification is needed because workflow lookup happens at run-start.
32
+ */
33
+
34
+ const STATE_FILENAME = "workflows.local.json";
35
+
36
+ type ManifestEntry = {
37
+ workflow_id: string;
38
+ version: number;
39
+ updated_at: string;
40
+ };
41
+
42
+ type FetchedWorkflow = {
43
+ workflow_id: string;
44
+ description: string;
45
+ version: number;
46
+ definition: WorkflowDefinition;
47
+ updated_at: string;
48
+ };
49
+
50
+ type LocalWorkflowRecord = {
51
+ workflow_id: string;
52
+ version: number;
53
+ updated_at: string;
54
+ definition: WorkflowDefinition;
55
+ };
56
+
57
+ type LocalWorkflowsState = {
58
+ workflows: Record<string, LocalWorkflowRecord>;
59
+ last_synced_at?: string;
60
+ };
61
+
62
+ export type WorkflowSyncOutcome =
63
+ | { kind: "unchanged"; count: number }
64
+ | {
65
+ kind: "synced";
66
+ added: string[];
67
+ changed: string[];
68
+ removed: string[];
69
+ }
70
+ | { kind: "failed"; error: string };
71
+
72
+ function stateFilePath(): string {
73
+ return join(daemonDir(), STATE_FILENAME);
74
+ }
75
+
76
+ export function readSyncedWorkflowsState(): LocalWorkflowsState {
77
+ const path = stateFilePath();
78
+ if (!existsSync(path)) return { workflows: {} };
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as LocalWorkflowsState;
81
+ return {
82
+ workflows: parsed.workflows ?? {},
83
+ last_synced_at: parsed.last_synced_at,
84
+ };
85
+ } catch {
86
+ return { workflows: {} };
87
+ }
88
+ }
89
+
90
+ function writeSyncedWorkflowsState(state: LocalWorkflowsState): void {
91
+ writeFileSync(
92
+ stateFilePath(),
93
+ JSON.stringify(state, null, 2) + "\n",
94
+ "utf8",
95
+ );
96
+ }
97
+
98
+ async function fetchManifest(
99
+ baseUrl: string,
100
+ apiKey: string,
101
+ ): Promise<ManifestEntry[]> {
102
+ const url = new URL(
103
+ baseUrl.replace(/\/$/, "") + "/api/v1/govern/workflows/manifest",
104
+ );
105
+ const res = await fetch(url, {
106
+ headers: { Authorization: `Bearer ${apiKey}` },
107
+ });
108
+ if (!res.ok) {
109
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
110
+ }
111
+ const body = (await res.json()) as { workflows?: ManifestEntry[] };
112
+ return body.workflows ?? [];
113
+ }
114
+
115
+ async function fetchWorkflow(
116
+ baseUrl: string,
117
+ apiKey: string,
118
+ workflowId: string,
119
+ ): Promise<FetchedWorkflow> {
120
+ const url = new URL(
121
+ baseUrl.replace(/\/$/, "") +
122
+ "/api/v1/govern/workflows/" +
123
+ encodeURIComponent(workflowId),
124
+ );
125
+ const res = await fetch(url, {
126
+ headers: { Authorization: `Bearer ${apiKey}` },
127
+ });
128
+ if (!res.ok) {
129
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
130
+ }
131
+ const body = (await res.json()) as { workflow?: FetchedWorkflow };
132
+ if (!body.workflow) {
133
+ throw new Error(`Response for ${workflowId} missing 'workflow' field`);
134
+ }
135
+ return body.workflow;
136
+ }
137
+
138
+ export async function runWorkflowSyncOnce(
139
+ cwd: string,
140
+ ): Promise<WorkflowSyncOutcome> {
141
+ const config = loadEnterpriseConfig(join(cwd, ".context"));
142
+ const apiKey = config.enterprise.api_key.trim();
143
+ const baseUrl = (config.enterprise.base_url || config.enterprise.endpoint).trim();
144
+ if (!apiKey || !baseUrl) {
145
+ const outcome: WorkflowSyncOutcome = {
146
+ kind: "failed",
147
+ error: "enterprise not configured",
148
+ };
149
+ await writeAudit(cwd, outcome);
150
+ return outcome;
151
+ }
152
+
153
+ let manifest: ManifestEntry[];
154
+ try {
155
+ manifest = await fetchManifest(baseUrl, apiKey);
156
+ } catch (err) {
157
+ const outcome: WorkflowSyncOutcome = {
158
+ kind: "failed",
159
+ error: err instanceof Error ? err.message : String(err),
160
+ };
161
+ await writeAudit(cwd, outcome);
162
+ return outcome;
163
+ }
164
+
165
+ const state = readSyncedWorkflowsState();
166
+ const remoteByName = new Map(manifest.map((e) => [e.workflow_id, e]));
167
+
168
+ const added: string[] = [];
169
+ const changed: string[] = [];
170
+ const removed: string[] = [];
171
+
172
+ for (const entry of manifest) {
173
+ const local = state.workflows[entry.workflow_id];
174
+ const isNew = !local;
175
+ const isChanged =
176
+ Boolean(local) &&
177
+ (local.updated_at !== entry.updated_at || local.version !== entry.version);
178
+ if (!isNew && !isChanged) continue;
179
+
180
+ let fetched: FetchedWorkflow;
181
+ try {
182
+ fetched = await fetchWorkflow(baseUrl, apiKey, entry.workflow_id);
183
+ } catch (err) {
184
+ const outcome: WorkflowSyncOutcome = {
185
+ kind: "failed",
186
+ error:
187
+ err instanceof Error
188
+ ? `fetch ${entry.workflow_id}: ${err.message}`
189
+ : `fetch ${entry.workflow_id}: ${String(err)}`,
190
+ };
191
+ await writeAudit(cwd, outcome);
192
+ return outcome;
193
+ }
194
+
195
+ let validated: WorkflowDefinition;
196
+ try {
197
+ validated = workflowDefinitionSchema.parse(fetched.definition);
198
+ } catch (err) {
199
+ const outcome: WorkflowSyncOutcome = {
200
+ kind: "failed",
201
+ error:
202
+ err instanceof Error
203
+ ? `validate ${entry.workflow_id}: ${err.message}`
204
+ : `validate ${entry.workflow_id}: ${String(err)}`,
205
+ };
206
+ await writeAudit(cwd, outcome);
207
+ return outcome;
208
+ }
209
+
210
+ state.workflows[entry.workflow_id] = {
211
+ workflow_id: entry.workflow_id,
212
+ version: fetched.version,
213
+ updated_at: fetched.updated_at,
214
+ definition: validated,
215
+ };
216
+ (isNew ? added : changed).push(entry.workflow_id);
217
+ }
218
+
219
+ for (const name of Object.keys(state.workflows)) {
220
+ if (remoteByName.has(name)) continue;
221
+ delete state.workflows[name];
222
+ removed.push(name);
223
+ }
224
+
225
+ const totalChanged = added.length + changed.length + removed.length;
226
+ if (totalChanged === 0) {
227
+ const outcome: WorkflowSyncOutcome = {
228
+ kind: "unchanged",
229
+ count: manifest.length,
230
+ };
231
+ await writeAudit(cwd, outcome);
232
+ return outcome;
233
+ }
234
+
235
+ state.last_synced_at = new Date().toISOString();
236
+ writeSyncedWorkflowsState(state);
237
+ const outcome: WorkflowSyncOutcome = {
238
+ kind: "synced",
239
+ added,
240
+ changed,
241
+ removed,
242
+ };
243
+ await writeAudit(cwd, outcome);
244
+ return outcome;
245
+ }
246
+
247
+ async function writeAudit(cwd: string, outcome: WorkflowSyncOutcome): Promise<void> {
248
+ const eventBase = {
249
+ timestamp: new Date().toISOString(),
250
+ host_id: hostname(),
251
+ };
252
+ if (outcome.kind === "unchanged") {
253
+ await writeHostAuditEvent(cwd, {
254
+ ...eventBase,
255
+ event_type: "workflows_unchanged",
256
+ count: outcome.count,
257
+ }).catch(() => undefined);
258
+ } else if (outcome.kind === "synced") {
259
+ await writeHostAuditEvent(cwd, {
260
+ ...eventBase,
261
+ event_type: "workflows_synced",
262
+ added: outcome.added,
263
+ changed: outcome.changed,
264
+ removed: outcome.removed,
265
+ }).catch(() => undefined);
266
+ } else {
267
+ await writeHostAuditEvent(cwd, {
268
+ ...eventBase,
269
+ event_type: "workflows_sync_failed",
270
+ error: outcome.error,
271
+ }).catch(() => undefined);
272
+ }
273
+ }
274
+
275
+ export type WorkflowSyncTimerHandle = {
276
+ stop(): void;
277
+ };
278
+
279
+ export function startWorkflowSyncTimer(
280
+ cwd: string,
281
+ intervalMs: number,
282
+ ): WorkflowSyncTimerHandle {
283
+ const tick = () => {
284
+ void runWorkflowSyncOnce(cwd).catch((err) => {
285
+ process.stderr.write(
286
+ `[cortex-daemon] workflow sync failed: ${
287
+ err instanceof Error ? err.message : String(err)
288
+ }\n`,
289
+ );
290
+ });
291
+ };
292
+
293
+ void Promise.resolve().then(tick);
294
+ const handle = setInterval(tick, intervalMs);
295
+ if (typeof handle.unref === "function") handle.unref();
296
+ return {
297
+ stop() {
298
+ clearInterval(handle);
299
+ },
300
+ };
301
+ }
@@ -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
+ });