feat: Phase1 完整交付——Web 看板(frontend-design) + MCP server(12 工具) + CLI/导入器
- web/: phosphor 调度台风格纯静态看板,审批闸内联 accept/reject(拒绝必填意见),WS 实时 - src/api/static.ts: 手写静态服务(路径穿越防护),daemon 接入 - src/mcp/: stdio MCP server,@modelcontextprotocol/sdk 1.29.0,12 工具薄封装 REST - src/cli/: 零依赖 CLI(project/task/next/approvals/import-todo),旧 todo.json 导入器 - 实测: pangolin 18 条旧任务导入 45 条;端到端验证通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* maestro MCP server(stdio transport)。
|
||||
*
|
||||
* 给任意 Claude Code 会话提供任务读写工具:全部经 REST 调本机 maestrod,
|
||||
* 自身不碰数据库。base URL 取环境变量 MAESTRO_URL(默认 http://127.0.0.1:4517)。
|
||||
*
|
||||
* 注意:审批(accept/reject)是用户在 Web 看板上的动作,本 server 只提供
|
||||
* 只读的 get_pending_approvals,不提供 decide 类工具。
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { STATUS_LABEL, type TaskStatus } from '../model/status.js';
|
||||
|
||||
const BASE_URL = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517';
|
||||
|
||||
// ---------- REST 薄封装 ----------
|
||||
|
||||
/** 业务错误(daemon 返回 400 {error}),消息原样透传给调用方。 */
|
||||
class ApiError extends Error {}
|
||||
|
||||
async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`无法连接 maestro daemon(${BASE_URL})。请先启动 maestrod:在 maestro 目录执行 \`npm run dev\`(或设置 MAESTRO_URL 指向运行中的 daemon)。`,
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string } | null)?.error;
|
||||
throw new ApiError(msg ?? `daemon 返回 HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ---------- 工具结果辅助 ----------
|
||||
|
||||
interface ToolResult {
|
||||
content: { type: 'text'; text: string }[];
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function ok(data: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function fail(err: unknown): ToolResult {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: 'text', text: msg }], isError: true };
|
||||
}
|
||||
|
||||
/** 包一层:把 ApiError / 网络错误转成 isError 的工具结果(消息原样给调用方)。 */
|
||||
async function run(fn: () => Promise<unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
} catch (err) {
|
||||
return fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 共享 schema 片段 ----------
|
||||
|
||||
const STATUSES = Object.keys(STATUS_LABEL) as [TaskStatus, ...TaskStatus[]];
|
||||
|
||||
const complexitySchema = z
|
||||
.enum(['hard', 'medium', 'easy'])
|
||||
.describe('复杂度:hard(须分析拆解)| medium(须写方案过审)| easy(写明操作即可执行)');
|
||||
|
||||
const subtaskSchema = z.object({
|
||||
title: z.string().min(1).describe('子任务标题'),
|
||||
complexity: complexitySchema,
|
||||
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表(须全部 done 才可执行)'),
|
||||
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
|
||||
});
|
||||
|
||||
// ---------- MCP server ----------
|
||||
|
||||
const server = new McpServer({ name: 'maestro', version: '0.1.0' });
|
||||
|
||||
server.registerTool(
|
||||
'list_projects',
|
||||
{
|
||||
title: '列出项目',
|
||||
description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别等)。',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => run(() => api('GET', '/api/projects')),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_project',
|
||||
{
|
||||
title: '创建项目',
|
||||
description: '在 maestro 注册一个本地 git 项目。name 与 repoPath 必填。',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('项目名'),
|
||||
repoPath: z.string().min(1).describe('本地 git 仓库绝对路径'),
|
||||
defaultBranch: z.string().optional().describe('默认分支(默认 main)'),
|
||||
verifyCmd: z.string().optional().describe('验证命令(执行后跑,如 npm test)'),
|
||||
autonomy: z.enum(['manual', 'auto-easy', 'auto-approved']).optional()
|
||||
.describe('自治级别:manual(全手动)| auto-easy(Easy 自动执行)| auto-approved(过审即自动执行)'),
|
||||
model: z.string().optional().describe('执行 agent 使用的模型'),
|
||||
concurrency: z.number().int().min(1).optional().describe('worker 并发数(默认 1)'),
|
||||
},
|
||||
},
|
||||
async (args) => run(() => api('POST', '/api/projects', args)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_tasks',
|
||||
{
|
||||
title: '列出任务',
|
||||
description: '列出某项目的全部任务(含层级、复杂度、状态、依赖与审批历史)。',
|
||||
inputSchema: { projectId: z.string().min(1).describe('项目 id(prj_ 开头)') },
|
||||
},
|
||||
async ({ projectId }) => run(() => api('GET', `/api/projects/${encodeURIComponent(projectId)}/tasks`)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_task',
|
||||
{
|
||||
title: '查看任务详情',
|
||||
description: '取单个任务的完整详情:字段(plan/spec/operations/result)、审批历史(approvals)以及全部子任务(children)。',
|
||||
inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') },
|
||||
},
|
||||
async ({ taskId }) =>
|
||||
run(async () => {
|
||||
const tid = encodeURIComponent(taskId);
|
||||
const task = await api('GET', `/api/tasks/${tid}`);
|
||||
const children = await api('GET', `/api/tasks/${tid}/children`);
|
||||
return { task, children };
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_task',
|
||||
{
|
||||
title: '创建任务',
|
||||
description:
|
||||
'在项目下创建任务。初始状态由复杂度决定:hard→analyzing(须先分析拆解)、medium→speccing(须先写方案)、easy→ready(写明 operations 后即可执行)。',
|
||||
inputSchema: {
|
||||
projectId: z.string().min(1).describe('项目 id'),
|
||||
title: z.string().min(1).describe('任务标题'),
|
||||
complexity: complexitySchema,
|
||||
parentId: z.string().optional().describe('父任务 id(建子任务时填,最多 3-4 层)'),
|
||||
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表'),
|
||||
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
|
||||
},
|
||||
},
|
||||
async ({ projectId, ...rest }) =>
|
||||
run(() => api('POST', `/api/projects/${encodeURIComponent(projectId)}/tasks`, rest)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'decompose_task',
|
||||
{
|
||||
title: '拆解任务',
|
||||
description:
|
||||
'把一个任务批量拆解为子任务(常用于 Hard 任务 analyzing 阶段的产出)。逐个创建,返回每条的创建结果;某条失败不影响其余(结果里带 error)。拆完通常还需 write_plan 写入分析、update_status 到 plan_review 等用户审批。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('要拆解的父任务 id'),
|
||||
subtasks: z.array(subtaskSchema).min(1).describe('子任务列表,各自带复杂度'),
|
||||
},
|
||||
},
|
||||
async ({ taskId, subtasks }) =>
|
||||
run(async () => {
|
||||
const parent = await api<{ projectId: string }>('GET', `/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
const results: unknown[] = [];
|
||||
for (const st of subtasks) {
|
||||
try {
|
||||
const created = await api('POST', `/api/projects/${encodeURIComponent(parent.projectId)}/tasks`, {
|
||||
...st,
|
||||
parentId: taskId,
|
||||
});
|
||||
results.push({ ok: true, task: created });
|
||||
} catch (err) {
|
||||
results.push({ ok: false, title: st.title, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
return { parentId: taskId, created: results };
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'write_plan',
|
||||
{
|
||||
title: '写入分析拆解(plan)',
|
||||
description: 'Hard 任务的产出:写入「分析 + 拆解说明」。写完用 update_status 把任务转到 plan_review 等用户审批。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('plan 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
async ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/plan`, { plan: content })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'write_spec',
|
||||
{
|
||||
title: '写入方案(spec)',
|
||||
description: 'Medium 任务的产出:写入「具体方案 = 改动内容 + 为什么这么做」。写完用 update_status 转到 spec_review 等用户审批。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('spec 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
async ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/spec`, { spec: content })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'write_operations',
|
||||
{
|
||||
title: '写入操作记录(operations)',
|
||||
description: 'Easy 任务的产出:写清「将执行的操作」。Easy 无前置审批闸,写完即可 update_status 到 ready。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('operations 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
async ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/operations`, { operations: content })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_status',
|
||||
{
|
||||
title: '变更任务状态',
|
||||
description:
|
||||
`按状态机流转任务状态(合法流转见 daemon 守卫;非法流转会返回中文错误,原样透传)。可选值:${STATUSES.join(' | ')}。注意:plan_review/spec_review/exec_review 的 accept/reject 是用户在看板上的动作,不要用本工具绕过审批闸。`,
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
to: z.enum(STATUSES).describe('目标状态'),
|
||||
},
|
||||
},
|
||||
async ({ taskId, to }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/transition`, { to })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_next_executable',
|
||||
{
|
||||
title: '取下一个可执行任务',
|
||||
description: '取项目中下一个可执行任务(叶子、ready、依赖全部 done,按优先级排序);没有则返回 {"next": null}。',
|
||||
inputSchema: { projectId: z.string().min(1).describe('项目 id') },
|
||||
},
|
||||
async ({ projectId }) => run(() => api('GET', `/api/projects/${encodeURIComponent(projectId)}/next`)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_pending_approvals',
|
||||
{
|
||||
title: '查看待审批任务(只读)',
|
||||
description:
|
||||
'列出处于审批闸(plan_review / spec_review / exec_review)的任务,可按项目过滤。只读:accept/reject 是用户在 Web 看板上的动作,本 MCP 不提供审批工具——发现待审批项请提醒用户去看板处理。',
|
||||
inputSchema: { projectId: z.string().optional().describe('项目 id(不填则跨全部项目)') },
|
||||
},
|
||||
async ({ projectId }) =>
|
||||
run(() => api('GET', projectId ? `/api/approvals?projectId=${encodeURIComponent(projectId)}` : '/api/approvals')),
|
||||
);
|
||||
|
||||
// ---------- 启动 ----------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// stdio 模式下 stdout 是协议通道,日志只能走 stderr
|
||||
console.error(`maestro-mcp 就绪 · daemon=${BASE_URL}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('maestro-mcp 启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user