@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.
- package/package.json +1 -1
- package/scaffold/mcp/src/cli/stage.ts +42 -0
- package/scaffold/mcp/src/core/workflow/default-workflows.ts +35 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +7 -1
- package/scaffold/mcp/src/core/workflow/envelope.ts +26 -1
- package/scaffold/mcp/src/core/workflow/index.ts +1 -0
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +16 -0
- package/scaffold/mcp/src/core/workflow/run-lifecycle.ts +49 -2
- package/scaffold/mcp/src/core/workflow/schemas.ts +45 -0
- package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +66 -0
- package/scaffold/mcp/src/daemon/capability-sync-checker.ts +295 -0
- package/scaffold/mcp/src/daemon/main.ts +15 -0
- package/scaffold/mcp/src/enterprise/index.ts +5 -0
- package/scaffold/mcp/src/enterprise/tools/harness.ts +98 -0
- package/scaffold/mcp/src/server.ts +4 -74
- package/scaffold/mcp/tests/workflow-cli.test.mjs +41 -0
- package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +226 -0
- package/scaffold/mcp/tests/workflow-validators-override.test.mjs +272 -0
|
@@ -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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
});
|