flow.mjs
498 lines 19.4 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago
1 /**
2 * MCP Flow read tools — flow_list / flow_get (Phase 7A-10b).
3 *
4 * Delegates to lib/flow/flow-handlers.mjs for CLI = MCP = Hub parity.
5 *
6 * @see docs/FLOW-STORE-CONTRACT-7A-10.md §7
7 */
8
9 import { z } from 'zod';
10 import { loadConfig } from '../../lib/config.mjs';
11 import { handleFlowListRequest, handleFlowGetRequest, handleFlowProjectRequest } from '../../lib/flow/flow-handlers.mjs';
12 import { handleFlowProposeRequest } from '../../lib/flow/flow-authoring.mjs';
13 import {
14 handleFlowCaptureObserveRequest,
15 handleFlowCaptureListRequest,
16 handleFlowCaptureProposeRequest,
17 handleFlowCaptureDismissRequest,
18 } from '../../lib/flow/flow-capture.mjs';
19 import {
20 handleFlowExternalGrantMintRequest,
21 handleFlowExternalGrantRevokeRequest,
22 handleFlowExternalGrantListRequest,
23 } from '../../lib/flow/external-agent.mjs';
24 import { handleFlowRunMcpRequest } from '../../lib/flow/flow-execution.mjs';
25 import { createProposal } from '../../hub/proposals-store.mjs';
26 import { jsonResponse, jsonError } from '../create-server.mjs';
27
28 /**
29 * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
30 */
31 export function registerFlowTools(server) {
32 server.registerTool(
33 'flow_list',
34 {
35 description:
36 'List scope-visible flows (content-minimized summaries). Same JSON as Hub GET /api/v1/flows.',
37 inputSchema: {
38 scope: z.enum(['personal', 'project', 'org']).optional().describe('Narrow within authorized scopes only'),
39 tag: z.string().optional().describe('Filter by a single tag'),
40 limit: z.number().int().min(1).max(200).optional().describe('Max summaries (default 200)'),
41 vault_id: z.string().optional().describe('Vault id (default from config)'),
42 },
43 },
44 async (args) => {
45 try {
46 const config = loadConfig();
47 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
48 const cliScopes = Array.isArray(config.flow?.visible_scopes)
49 ? config.flow.visible_scopes
50 : undefined;
51 const result = handleFlowListRequest({
52 dataDir: config.data_dir,
53 vaultId,
54 cliScopes,
55 scope: args.scope,
56 tag: args.tag,
57 limit: args.limit,
58 });
59 if (!result.ok) {
60 return jsonError(result.error, result.code);
61 }
62 return jsonResponse(result.payload);
63 } catch (e) {
64 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
65 }
66 },
67 );
68
69 server.registerTool(
70 'flow_get',
71 {
72 description:
73 'Get one flow definition + ordered steps. Same JSON as Hub GET /api/v1/flows/{id}.',
74 inputSchema: {
75 flow_id: z.string().describe('Flow id (flow_<slug>)'),
76 version: z.string().optional().describe('Pin semver version; default latest visible'),
77 vault_id: z.string().optional().describe('Vault id (default from config)'),
78 },
79 },
80 async (args) => {
81 try {
82 const config = loadConfig();
83 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
84 const cliScopes = Array.isArray(config.flow?.visible_scopes)
85 ? config.flow.visible_scopes
86 : undefined;
87 const result = handleFlowGetRequest({
88 dataDir: config.data_dir,
89 vaultId,
90 flowId: args.flow_id,
91 cliScopes,
92 version: args.version,
93 });
94 if (!result.ok) {
95 return jsonError(result.error, result.code);
96 }
97 return jsonResponse(result.payload);
98 } catch (e) {
99 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
100 }
101 },
102 );
103
104 server.registerTool(
105 'flow_project',
106 {
107 description:
108 'Derive a read-only harness projection of a canonical flow. Same JSON as Hub GET /api/v1/flows/{id}/projection.',
109 inputSchema: {
110 flow_id: z.string().describe('Flow id (flow_<slug>)'),
111 harness: z
112 .enum(['cursor_rule', 'cursor_skill', 'mcp_prompt', 'cli_runbook', 'agent_bundle'])
113 .describe('Target harness format'),
114 version: z.string().optional().describe('Pin semver version; default latest visible'),
115 vault_id: z.string().optional().describe('Vault id (default from config)'),
116 },
117 },
118 async (args) => {
119 try {
120 const config = loadConfig();
121 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
122 const cliScopes = Array.isArray(config.flow?.visible_scopes)
123 ? config.flow.visible_scopes
124 : undefined;
125 const result = handleFlowProjectRequest({
126 dataDir: config.data_dir,
127 vaultId,
128 flowId: args.flow_id,
129 harness: args.harness,
130 cliScopes,
131 version: args.version,
132 });
133 if (!result.ok) {
134 return jsonError(result.error, result.code);
135 }
136 return jsonResponse(result.payload);
137 } catch (e) {
138 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
139 }
140 },
141 );
142
143 server.registerTool(
144 'flow_propose',
145 {
146 description:
147 'Propose a new or edited Flow as a reviewable proposal (review-before-write). ' +
148 'Same handler as Hub POST /api/v1/flows (+/{id}/proposals). Gated by FLOW_AUTHORING_WRITES (default off).',
149 inputSchema: {
150 flow: z.record(z.string(), z.unknown()).describe('knowtation.flow/v0 record (full)'),
151 steps: z.array(z.unknown()).describe('knowtation.flow_step/v0[] (full anatomy)'),
152 intent: z.string().describe('Required review intent (untrusted; never executed)'),
153 base_version: z.string().optional().describe('Required for an edit; omit for new'),
154 base_state_id: z.string().optional().describe('Required for an edit (flowst1_ token)'),
155 vault_id: z.string().optional().describe('Vault id (default from config)'),
156 },
157 },
158 async (args) => {
159 try {
160 const config = loadConfig();
161 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
162 const cliScopes = Array.isArray(config.flow?.visible_scopes) ? config.flow.visible_scopes : undefined;
163 const isEdit =
164 typeof args.base_version === 'string' && args.base_version.trim().length > 0;
165 const result = handleFlowProposeRequest({
166 dataDir: config.data_dir,
167 vaultId,
168 cliScopes,
169 kind: isEdit ? 'edit' : 'new',
170 flow: args.flow,
171 steps: args.steps,
172 intent: args.intent,
173 flowId: args.flow && typeof args.flow === 'object' ? args.flow.flow_id : undefined,
174 baseVersion: args.base_version,
175 baseStateId: args.base_state_id,
176 createProposal,
177 });
178 if (!result.ok) {
179 return jsonError(result.error, result.code);
180 }
181 return jsonResponse(result.payload);
182 } catch (e) {
183 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
184 }
185 },
186 );
187
188 server.registerTool(
189 'flow_import',
190 {
191 description:
192 'Import a portable Flow bundle as a scope-checked reviewable proposal. ' +
193 'Same handler as Hub POST /api/v1/flows/import. Gated by FLOW_AUTHORING_WRITES (default off).',
194 inputSchema: {
195 bundle: z
196 .object({ flow: z.record(z.string(), z.unknown()), steps: z.array(z.unknown()) })
197 .describe('Portable { flow, steps } bundle'),
198 intent: z.string().describe('Required review intent (untrusted; never executed)'),
199 external_ref: z.string().optional().describe('Lineage pointer (label only)'),
200 source_vault_hint: z.string().optional().describe('Source vault hint (label only)'),
201 vault_id: z.string().optional().describe('Vault id (default from config)'),
202 },
203 },
204 async (args) => {
205 try {
206 const config = loadConfig();
207 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
208 const cliScopes = Array.isArray(config.flow?.visible_scopes) ? config.flow.visible_scopes : undefined;
209 const result = handleFlowProposeRequest({
210 dataDir: config.data_dir,
211 vaultId,
212 cliScopes,
213 kind: 'import',
214 bundle: args.bundle,
215 intent: args.intent,
216 externalRef: args.external_ref,
217 sourceVaultHint: args.source_vault_hint,
218 createProposal,
219 });
220 if (!result.ok) {
221 return jsonError(result.error, result.code);
222 }
223 return jsonResponse(result.payload);
224 } catch (e) {
225 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
226 }
227 },
228 );
229
230 server.registerTool(
231 'flow_capture',
232 {
233 description:
234 'Flow capture flywheel — observe session signals, list candidates, propose promotion, or dismiss. ' +
235 'Detection gated by FLOW_CAPTURE_DETECTION_ENABLED; writes gated by FLOW_CAPTURE_WRITES_ENABLED (both default off).',
236 inputSchema: {
237 action: z.enum(['observe', 'list', 'propose', 'dismiss']).describe('Capture action'),
238 session_meta: z.record(z.string(), z.unknown()).optional().describe('Content-minimized session meta for observe'),
239 candidate_id: z.string().optional().describe('Candidate id for propose/dismiss'),
240 confirmed_scope: z.enum(['personal', 'project', 'org']).optional().describe('User-confirmed scope for propose'),
241 scope_widen_acknowledged: z.boolean().optional().describe('Required when confirmed_scope > scope_hint'),
242 allow_low_confidence: z.boolean().optional().describe('Allow promoting low-confidence candidates'),
243 force_new_flow: z.boolean().optional().describe('Force new Flow when dedup overlap is high'),
244 merge_into_flow_id: z.string().optional().describe('Target flow for merge proposal'),
245 include_low_confidence: z.boolean().optional().describe('Include low-confidence in observe/list'),
246 intent: z.string().optional().describe('Untrusted review intent for propose/dismiss'),
247 scope: z.enum(['personal', 'project', 'org']).optional().describe('Scope filter for list'),
248 limit: z.number().int().min(1).max(50).optional().describe('List cap'),
249 vault_id: z.string().optional().describe('Vault id (default from config)'),
250 },
251 },
252 async (args) => {
253 try {
254 const config = loadConfig();
255 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
256 const cliScopes = Array.isArray(config.flow?.visible_scopes) ? config.flow.visible_scopes : undefined;
257 const base = {
258 dataDir: config.data_dir,
259 vaultId,
260 cliScopes,
261 config,
262 };
263
264 if (args.action === 'observe') {
265 const result = handleFlowCaptureObserveRequest({
266 ...base,
267 sessionMeta: args.session_meta ?? {},
268 includeLowConfidence: args.include_low_confidence === true,
269 harness: 'mcp',
270 });
271 if (!result.ok) return jsonError(result.error, result.code);
272 return jsonResponse(result.payload);
273 }
274
275 if (args.action === 'list') {
276 const result = handleFlowCaptureListRequest({
277 ...base,
278 scope: args.scope,
279 includeLowConfidence: args.include_low_confidence === true,
280 limit: args.limit,
281 });
282 if (!result.ok) return jsonError(result.error, result.code);
283 return jsonResponse(result.payload);
284 }
285
286 if (args.action === 'propose') {
287 const result = handleFlowCaptureProposeRequest({
288 ...base,
289 candidateId: args.candidate_id,
290 confirmedScope: args.confirmed_scope,
291 scopeWidenAcknowledged: args.scope_widen_acknowledged === true,
292 allowLowConfidence: args.allow_low_confidence === true,
293 forceNewFlow: args.force_new_flow === true,
294 mergeIntoFlowId: args.merge_into_flow_id,
295 intent: args.intent,
296 createProposal,
297 });
298 if (!result.ok) return jsonError(result.error, result.code);
299 return jsonResponse(result.payload);
300 }
301
302 const result = handleFlowCaptureDismissRequest({
303 ...base,
304 candidateId: args.candidate_id,
305 intent: args.intent,
306 createProposal,
307 });
308 if (!result.ok) return jsonError(result.error, result.code);
309 return jsonResponse(result.payload);
310 } catch (e) {
311 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
312 }
313 },
314 );
315
316 server.registerTool(
317 'flow_external_grant_mint',
318 {
319 description:
320 'Mint a short-lived external-agent grant for a pinned flow version. Gated by FLOW_EXTERNAL_AGENT_ENABLED (default off).',
321 inputSchema: {
322 flow_id: z.string().describe('Flow id (flow_<slug>)'),
323 flow_version: z.string().describe('Pinned semver version'),
324 requested_tools: z.array(z.string()).min(1).describe('Tool ids ⊆ vault allowlist ∩ flow external_tool refs'),
325 ttl_seconds: z.number().int().positive().optional().describe('TTL capped by policy'),
326 actor_label: z.string().optional().describe('Untrusted label; stored hashed only'),
327 vault_id: z.string().optional().describe('Vault id (default from config)'),
328 },
329 },
330 async (args) => {
331 try {
332 const config = loadConfig();
333 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
334 const cliScopes = Array.isArray(config.flow?.visible_scopes) ? config.flow.visible_scopes : undefined;
335 const result = handleFlowExternalGrantMintRequest({
336 dataDir: config.data_dir,
337 vaultId,
338 cliScopes,
339 flowId: args.flow_id,
340 flowVersion: args.flow_version,
341 requestedTools: args.requested_tools,
342 ttlSeconds: args.ttl_seconds,
343 actorLabel: args.actor_label,
344 });
345 if (!result.ok) {
346 return jsonError(result.error, result.code);
347 }
348 return jsonResponse(result.payload);
349 } catch (e) {
350 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
351 }
352 },
353 );
354
355 server.registerTool(
356 'flow_external_grant_revoke',
357 {
358 description: 'Revoke an external-agent grant immediately. Gated by FLOW_EXTERNAL_AGENT_ENABLED.',
359 inputSchema: {
360 grant_id: z.string().describe('Grant id (fgrnt_…)'),
361 vault_id: z.string().optional().describe('Vault id (default from config)'),
362 },
363 },
364 async (args) => {
365 try {
366 const config = loadConfig();
367 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
368 const result = handleFlowExternalGrantRevokeRequest({
369 dataDir: config.data_dir,
370 vaultId,
371 grantId: args.grant_id,
372 });
373 if (!result.ok) {
374 return jsonError(result.error, result.code);
375 }
376 return jsonResponse(result.payload);
377 } catch (e) {
378 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
379 }
380 },
381 );
382
383 server.registerTool(
384 'flow_external_grant_list',
385 {
386 description: 'List external-agent grant metadata (no bearer). Gated by FLOW_EXTERNAL_AGENT_ENABLED.',
387 inputSchema: {
388 flow_id: z.string().optional().describe('Filter by flow id'),
389 vault_id: z.string().optional().describe('Vault id (default from config)'),
390 },
391 },
392 async (args) => {
393 try {
394 const config = loadConfig();
395 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
396 const result = handleFlowExternalGrantListRequest({
397 dataDir: config.data_dir,
398 vaultId,
399 flowId: args.flow_id,
400 });
401 if (!result.ok) {
402 return jsonError(result.error, result.code);
403 }
404 return jsonResponse(result.payload);
405 } catch (e) {
406 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
407 }
408 },
409 );
410
411 server.registerTool(
412 'flow_run',
413 {
414 description:
415 'Flow run lifecycle — start/advance/evidence/execute/submit. ' +
416 'Gated by FLOW_RUN_WRITES_ENABLED and FLOW_AUTOMATABLE_EXECUTION_ENABLED (default off).',
417 inputSchema: {
418 action: z
419 .enum([
420 'start',
421 'get',
422 'list',
423 'advance',
424 'evidence',
425 'execute_automatable',
426 'submit_review',
427 'consent_mint',
428 ])
429 .describe('Run action'),
430 flow_id: z.string().optional().describe('Flow id for start/list'),
431 flow_version: z.string().optional().describe('Pinned semver for start'),
432 run_id: z.string().optional().describe('Run id'),
433 step_id: z.string().optional().describe('Step id for advance/evidence/execute'),
434 to_status: z
435 .enum(['in_progress', 'blocked', 'done', 'skipped'])
436 .optional()
437 .describe('Target step status for advance'),
438 skip_reason: z
439 .enum(['policy', 'not_applicable', 'blocked_dependency'])
440 .optional()
441 .describe('Required when to_status is skipped'),
442 evidence_ref: z.string().optional().describe('Evidence pointer id/hash'),
443 pointer_kind: z
444 .enum(['proposal', 'artifact', 'hash', 'test_result'])
445 .optional()
446 .describe('Evidence pointer kind'),
447 consent_id: z.string().optional().describe('Execution consent id for execute_automatable'),
448 model_lane: z.string().optional().describe('Model lane (must be in consent)'),
449 dry_run: z.boolean().optional().describe('Validate gates only; no model call'),
450 allowed_lanes: z.array(z.string()).optional().describe('Consent mint: allowed lanes'),
451 cost_cap_units: z.number().int().positive().optional().describe('Consent mint: cost cap'),
452 ttl_seconds: z.number().int().positive().optional().describe('Consent mint: TTL'),
453 intent: z.string().optional().describe('Submit-review intent (untrusted)'),
454 task_ref: z.string().optional().describe('Optional SD-2 task link at start'),
455 external_ref: z.string().optional().describe('Optional lineage pointer at start'),
456 vault_id: z.string().optional().describe('Vault id (default from config)'),
457 },
458 },
459 async (args) => {
460 try {
461 const config = loadConfig();
462 const vaultId = args.vault_id?.trim() || config.default_vault_id || 'default';
463 const cliScopes = Array.isArray(config.flow?.visible_scopes) ? config.flow.visible_scopes : undefined;
464 const result = handleFlowRunMcpRequest({
465 dataDir: config.data_dir,
466 vaultId,
467 cliScopes,
468 action: args.action,
469 flow_id: args.flow_id,
470 flow_version: args.flow_version,
471 run_id: args.run_id,
472 step_id: args.step_id,
473 to_status: args.to_status,
474 skip_reason: args.skip_reason,
475 evidence_ref: args.evidence_ref,
476 pointer_kind: args.pointer_kind,
477 consent_id: args.consent_id,
478 model_lane: args.model_lane,
479 dry_run: args.dry_run,
480 allowed_lanes: args.allowed_lanes,
481 cost_cap_units: args.cost_cap_units,
482 ttl_seconds: args.ttl_seconds,
483 intent: args.intent,
484 task_ref: args.task_ref,
485 external_ref: args.external_ref,
486 createProposal,
487 harness: 'mcp',
488 });
489 if (!result.ok) {
490 return jsonError(result.error, result.code);
491 }
492 return jsonResponse(result.payload);
493 } catch (e) {
494 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
495 }
496 },
497 );
498 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago