Skip to content

Commit e1ec049

Browse files
Waishnavclaude
andcommitted
feat(workflow): register MCP run/status/cancel tools
Gate on config.subagents. run_workflow spawns the same detached worker as CLI; status long-polls journal events; cancel requests cooperative stop. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 349b52c commit e1ec049

2 files changed

Lines changed: 322 additions & 0 deletions

File tree

src/server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { formatPathForPrompt } from "./skills.js";
4747
import { createWorkspaceStore } from "./workspace-store.js";
4848
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
4949
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
50+
import { registerWorkflowTools } from "./workflow-tools.js";
5051
import {
5152
formatLocalAgentProviderAvailabilitySummary,
5253
getLocalAgentProviderAvailabilitySnapshot,
@@ -1594,6 +1595,10 @@ function createMcpServer(
15941595
registerCodexProcessTools(server, config, workspaces, processSessions);
15951596
}
15961597

1598+
if (config.subagents) {
1599+
registerWorkflowTools(server, config, workspaces);
1600+
}
1601+
15971602
return server;
15981603
}
15991604

src/workflow-tools.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { fileURLToPath } from "node:url";
2+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
4+
import * as z from "zod/v4";
5+
import type { ServerConfig } from "./config.js";
6+
import type { WorkspaceRegistry } from "./workspaces.js";
7+
import {
8+
persistWorkflowScript,
9+
resolveNamedWorkflowScript,
10+
readWorkflowScriptFile,
11+
} from "./workflow-files.js";
12+
import { parseWorkflowScript } from "./workflow-script.js";
13+
import { createWorkflowStore } from "./workflow-store.js";
14+
import {
15+
WORKFLOW_MCP_YIELD_MS,
16+
WORKFLOW_LIMITS,
17+
type AgentProvidersConfig,
18+
type WorkflowEventRecord,
19+
type WorkflowRunRecord,
20+
} from "./workflow-types.js";
21+
import { resolveWorkspaceHead } from "./workflow-worktrees.js";
22+
import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js";
23+
import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js";
24+
import {
25+
isLocalAgentProvider,
26+
LOCAL_AGENT_PROVIDERS,
27+
type LocalAgentProvider,
28+
} from "./local-agent-profiles.js";
29+
30+
const WORKFLOW_API_CHEATSHEET = `
31+
Workflow scripts (JS only):
32+
export const meta = { name, description, phases?, defaultProvider?, concurrency? }
33+
agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' })
34+
parallel(thunks) → Array<T|null> // barrier; throw → null
35+
pipeline(items, ...stages) // no cross-item barrier
36+
phase(title); log(msg); args; budget (stub)
37+
workflow(name | { scriptPath }, args?) // nest depth 1
38+
Bans: Date.now(), Math.random(), new Date() without args.
39+
No writeMode — teach RO vs write in prompts; isolation contains writes.
40+
`.trim();
41+
42+
export function registerWorkflowTools(
43+
server: McpServer,
44+
config: ServerConfig,
45+
workspaces: WorkspaceRegistry,
46+
): void {
47+
if (!config.subagents) return;
48+
49+
registerAppTool(
50+
server,
51+
"run_workflow",
52+
{
53+
title: "Run workflow",
54+
description:
55+
`Start a DevSpace Dynamic Workflow in an open workspace. Prefer named scripts or short inline scripts. ` +
56+
`Poll with workflow_status until terminal. Cancel with workflow_cancel. ${WORKFLOW_API_CHEATSHEET}`,
57+
inputSchema: {
58+
workspaceId: z.string().describe("Workspace id from open_workspace."),
59+
script: z
60+
.string()
61+
.optional()
62+
.describe("Inline workflow script source (export const meta = …)."),
63+
name: z.string().optional().describe("Named workflow under .devspace/workflows/<name>.js"),
64+
resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."),
65+
args: z.unknown().optional().describe("Args object/array passed to script as `args`."),
66+
yieldTimeMs: z
67+
.number()
68+
.int()
69+
.min(0)
70+
.max(WORKFLOW_MCP_YIELD_MS)
71+
.optional()
72+
.describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`),
73+
},
74+
annotations: { readOnlyHint: false },
75+
_meta: {},
76+
},
77+
async ({ workspaceId, script, name, resumeFromRunId, args, yieldTimeMs }) => {
78+
const workspace = workspaces.getWorkspace(workspaceId);
79+
const store = createWorkflowStore(config);
80+
try {
81+
const provided = [script, name, resumeFromRunId].filter((v) => v !== undefined);
82+
if (provided.length !== 1) {
83+
throw new Error("Provide exactly one of script, name, or resumeFromRunId");
84+
}
85+
86+
let source: string;
87+
let scriptHash: string;
88+
let nameHint: string;
89+
let priorRunId: string | undefined;
90+
let priorScriptPath: string | undefined;
91+
let runSource: "inline" | "named" | "resume" = "inline";
92+
93+
if (resumeFromRunId) {
94+
const prior = store.getRun(resumeFromRunId);
95+
if (!prior) throw new Error(`Unknown run: ${resumeFromRunId}`);
96+
priorRunId = prior.id;
97+
priorScriptPath = prior.scriptPath;
98+
const resolved = await readWorkflowScriptFile(prior.scriptPath);
99+
source = resolved.source;
100+
scriptHash = prior.scriptHash;
101+
nameHint = prior.name;
102+
runSource = "resume";
103+
if (args === undefined && prior.argsJson && prior.argsJson !== "null") {
104+
try {
105+
args = JSON.parse(prior.argsJson);
106+
} catch {
107+
// keep undefined
108+
}
109+
}
110+
} else if (name) {
111+
const resolved = await resolveNamedWorkflowScript({
112+
name,
113+
workspaceRoot: workspace.root,
114+
stateDir: config.stateDir,
115+
});
116+
source = resolved.source;
117+
scriptHash = resolved.scriptHash;
118+
nameHint = resolved.nameHint;
119+
runSource = "named";
120+
} else {
121+
source = script!;
122+
const parsed = parseWorkflowScript(source);
123+
scriptHash = parsed.scriptHash;
124+
nameHint = parsed.meta.name;
125+
runSource = "inline";
126+
}
127+
128+
const parsed = parseWorkflowScript(source);
129+
const baseSha = await resolveWorkspaceHead(workspace.root);
130+
const run = store.createRun({
131+
name: parsed.meta.name || nameHint,
132+
source: runSource,
133+
scriptPath: priorScriptPath ?? "pending",
134+
scriptHash,
135+
workspaceRoot: workspace.root,
136+
workspaceId,
137+
argsJson: JSON.stringify(args ?? null),
138+
resumedFromRunId: priorRunId,
139+
baseSha,
140+
});
141+
142+
const persisted =
143+
priorScriptPath ??
144+
(await persistWorkflowScript({
145+
stateDir: config.stateDir,
146+
runId: run.id,
147+
source,
148+
preferredName: parsed.meta.name || nameHint,
149+
}));
150+
if (!priorScriptPath) store.setScriptPath(run.id, persisted);
151+
152+
const cliEntry = fileURLToPath(
153+
import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"),
154+
);
155+
spawnWorkflowWorkerFromCli(run.id, cliEntry);
156+
157+
const yieldMs = yieldTimeMs ?? 2_000;
158+
const page = await yieldEvents(store, run.id, 0, yieldMs);
159+
return toolResult(page);
160+
} finally {
161+
store.close();
162+
}
163+
},
164+
);
165+
166+
registerAppTool(
167+
server,
168+
"workflow_status",
169+
{
170+
title: "Workflow status",
171+
description: "Drain events for a workflow run; optional long-poll yield.",
172+
inputSchema: {
173+
runId: z.string(),
174+
sinceSeq: z.number().int().min(0).optional(),
175+
yieldTimeMs: z
176+
.number()
177+
.int()
178+
.min(0)
179+
.max(WORKFLOW_MCP_YIELD_MS)
180+
.optional()
181+
.describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`),
182+
},
183+
annotations: { readOnlyHint: true },
184+
_meta: {},
185+
},
186+
async ({ runId, sinceSeq, yieldTimeMs }) => {
187+
const store = createWorkflowStore(config);
188+
try {
189+
if (!store.getRun(runId)) throw new Error(`Unknown workflow run: ${runId}`);
190+
const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0);
191+
return toolResult(page);
192+
} finally {
193+
store.close();
194+
}
195+
},
196+
);
197+
198+
registerAppTool(
199+
server,
200+
"workflow_cancel",
201+
{
202+
title: "Cancel workflow",
203+
description: "Request cooperative cancel of a running workflow.",
204+
inputSchema: {
205+
runId: z.string(),
206+
},
207+
annotations: { readOnlyHint: false },
208+
_meta: {},
209+
},
210+
async ({ runId }) => {
211+
const store = createWorkflowStore(config);
212+
try {
213+
const run = store.requestCancel(runId);
214+
if (run.pid && (run.status === "running" || run.status === "starting")) {
215+
try {
216+
process.kill(run.pid, "SIGTERM");
217+
} catch {
218+
// already gone
219+
}
220+
}
221+
const latest = store.getRun(runId)!;
222+
return {
223+
content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }],
224+
structuredContent: { runId, status: latest.status },
225+
};
226+
} finally {
227+
store.close();
228+
}
229+
},
230+
);
231+
232+
}
233+
234+
async function yieldEvents(
235+
store: ReturnType<typeof createWorkflowStore>,
236+
runId: string,
237+
sinceSeq: number,
238+
yieldMs: number,
239+
): Promise<{
240+
run: WorkflowRunRecord;
241+
events: WorkflowEventRecord[];
242+
nextSeq: number;
243+
terminal: boolean;
244+
}> {
245+
const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS);
246+
let cursor = sinceSeq;
247+
let events: WorkflowEventRecord[] = [];
248+
let terminal = false;
249+
let run = store.getRun(runId)!;
250+
251+
for (;;) {
252+
const page = store.drainEvents(runId, cursor, WORKFLOW_LIMITS.eventDrainDefault);
253+
events = events.concat(page.events);
254+
cursor = page.nextSeq;
255+
terminal = page.terminal;
256+
run = page.run;
257+
if (terminal || Date.now() >= deadline) break;
258+
await sleep(250);
259+
}
260+
261+
return { run, events, nextSeq: cursor, terminal };
262+
}
263+
264+
function toolResult(page: {
265+
run: WorkflowRunRecord;
266+
events: WorkflowEventRecord[];
267+
nextSeq: number;
268+
terminal: boolean;
269+
}) {
270+
const payload = {
271+
runId: page.run.id,
272+
status: page.run.status,
273+
events: page.events.map((e) => ({
274+
seq: e.seq,
275+
type: e.type,
276+
phase: e.phase,
277+
label: e.label,
278+
dataJson: e.dataJson,
279+
})),
280+
nextSeq: page.nextSeq,
281+
result: page.run.resultJson ? safeJson(page.run.resultJson) : undefined,
282+
error: page.run.error,
283+
errorKind: page.run.errorKind,
284+
};
285+
return {
286+
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
287+
structuredContent: payload,
288+
};
289+
}
290+
291+
function safeJson(text: string): unknown {
292+
try {
293+
return JSON.parse(text);
294+
} catch {
295+
return text;
296+
}
297+
}
298+
299+
function sleep(ms: number): Promise<void> {
300+
return new Promise((r) => setTimeout(r, ms));
301+
}
302+
303+
/** Resolve enabled ∩ live providers for workflows. */
304+
export function resolveWorkflowEnabledProviders(
305+
agentProviders: AgentProvidersConfig | undefined,
306+
): LocalAgentProvider[] {
307+
const snapshot = getLocalAgentProviderAvailabilitySnapshot();
308+
const live = new Set(
309+
snapshot.filter((row) => row.available).map((row) => row.name),
310+
);
311+
if (!agentProviders) {
312+
return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id));
313+
}
314+
return agentProviders.enabled.filter(
315+
(id): id is LocalAgentProvider => isLocalAgentProvider(id) && live.has(id),
316+
);
317+
}

0 commit comments

Comments
 (0)