feat: MCP/CLI 追平 Phase 2 后端能力(score/复审/合并/额度/归档)
任务 ID:tsk_sjzC4B9ctg6W MCP(src/mcp/index.ts,重构为可测的 createTools(apiFn)): - 读:新增 get_agents(自治/并发/调度/模型/在途)、get_usage(订阅额度)、 list_archived(终态分页);get_task 补 score(自身分+链条惯性+解锁加权) 与 review(summary/verdict/securitySummary/securityVerdict)显式字段。 - 写:新增 patch_task(复用 store.patchTask 校验)、patch_project、sync_project、 decide(action + merge:通过并合并 / 仅通过;reject 必带 reason)。 - 复核全部工具 description 与状态机语义一致;entry-guard 便于 import 测试。 CLI(src/cli/index.ts)对齐子命令: - project set / task patch / decide <id> accept|reject [--no-merge] / agents / usage / archive。两端均薄封装走 REST,不重复实现。 API(src/api/server.ts):新增 GET /api/usage 单独透传订阅额度。 测试:新增 test/mcp.test.ts(zod schema 边界 + 对 mock api 的 method/path/body 断言 + score/分页/错误透传),npm test 103 全过、typecheck/build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+376
-200
@@ -5,22 +5,36 @@
|
||||
* 给任意 Claude Code 会话提供任务读写工具:全部经 REST 调本机 maestrod,
|
||||
* 自身不碰数据库。base URL 取环境变量 MAESTRO_URL(默认 http://127.0.0.1:4517)。
|
||||
*
|
||||
* 注意:审批(accept/reject)是用户在 Web 看板上的动作,本 server 只提供
|
||||
* 只读的 get_pending_approvals,不提供 decide 类工具。
|
||||
* 工具分两类:
|
||||
* 读:list_projects / list_tasks / get_task / get_next_executable / get_pending_approvals
|
||||
* / get_agents / get_usage / list_archived
|
||||
* 写:create_project / patch_project / sync_project / create_task / decompose_task
|
||||
* / write_plan / write_spec / write_operations / patch_task / update_status
|
||||
* / decide / requeue_task
|
||||
*
|
||||
* 审批裁决(decide)是把关动作:accept/reject 走 store 守卫,reject 必带 reason;
|
||||
* exec_review 的 accept 默认「通过并合并」(merge=false 则仅通过不合并)。
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { z, type ZodRawShape } from 'zod';
|
||||
import { STATUS_LABEL, type TaskStatus } from '../model/status.js';
|
||||
import { buildDependentsIndex, scoreTask } from '../model/scoring.js';
|
||||
import type { Task } from '../model/types.js';
|
||||
|
||||
const BASE_URL = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517';
|
||||
|
||||
// ---------- REST 薄封装 ----------
|
||||
|
||||
/** 业务错误(daemon 返回 400 {error}),消息原样透传给调用方。 */
|
||||
class ApiError extends Error {}
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
|
||||
/** 注入点:默认直连本机 daemon;测试可传入 mock 断言调用。 */
|
||||
export type ApiFn = <T = unknown>(method: HttpMethod, path: string, body?: unknown) => Promise<T>;
|
||||
|
||||
const defaultApi: ApiFn = async <T = unknown>(method: HttpMethod, path: string, body?: unknown): Promise<T> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
@@ -41,11 +55,11 @@ async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unk
|
||||
throw new ApiError(msg ?? `daemon 返回 HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 工具结果辅助 ----------
|
||||
|
||||
interface ToolResult {
|
||||
export interface ToolResult {
|
||||
content: { type: 'text'; text: string }[];
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
@@ -84,217 +98,379 @@ const subtaskSchema = z.object({
|
||||
priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'),
|
||||
});
|
||||
|
||||
// ---------- MCP server ----------
|
||||
/** 终态(归档区):done / cancelled */
|
||||
const TERMINAL_STATUSES: readonly TaskStatus[] = ['done', 'cancelled'];
|
||||
|
||||
const server = new McpServer({ name: 'maestro', version: '0.1.0' });
|
||||
// ---------- 工具定义(与 McpServer 解耦,便于单测) ----------
|
||||
|
||||
server.registerTool(
|
||||
'list_projects',
|
||||
{
|
||||
title: '列出项目',
|
||||
description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别等)。',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => run(() => api('GET', '/api/projects')),
|
||||
);
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
config: { title: string; description: string; inputSchema: ZodRawShape };
|
||||
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
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)'),
|
||||
/**
|
||||
* 构造全部工具定义。api 默认直连 daemon;单测可注入 mock 断言 method/path/body。
|
||||
*/
|
||||
export function createTools(api: ApiFn = defaultApi): ToolDef[] {
|
||||
return [
|
||||
// ---------- 读 ----------
|
||||
{
|
||||
name: 'list_projects',
|
||||
config: {
|
||||
title: '列出项目',
|
||||
description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别、并发数等)。',
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: () => run(() => api('GET', '/api/projects')),
|
||||
},
|
||||
},
|
||||
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().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'),
|
||||
{
|
||||
name: 'list_tasks',
|
||||
config: {
|
||||
title: '列出任务',
|
||||
description: '列出某项目的全部任务(含层级、复杂度、状态、依赖与审批历史)。',
|
||||
inputSchema: { projectId: z.string().min(1).describe('项目 id(prj_ 开头)') },
|
||||
},
|
||||
handler: ({ projectId }) =>
|
||||
run(() => api('GET', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`)),
|
||||
},
|
||||
},
|
||||
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('子任务列表,各自带复杂度'),
|
||||
{
|
||||
name: 'get_task',
|
||||
config: {
|
||||
title: '查看任务详情',
|
||||
description:
|
||||
'取单个任务的完整详情:产出字段(plan/spec/operations)、执行结果 result(branch/diffSummary/commits/prUrl)、' +
|
||||
'复审结论(result.summary + result.verdict = code review;result.securitySummary + result.securityVerdict = 安全审计;verdict/securityVerdict ∈ approve|reject|null)、' +
|
||||
'审批历史 approvals、全部子任务 children,以及调度 score(=自身分 + 链条惯性Σ已完成依赖分 + 解锁加权Σ等它解锁的 blocked 任务分;P0=3/P1=2/P2=1)。' +
|
||||
'为便于把关,summary/verdict/securitySummary/securityVerdict/score 已在返回对象的 review/score 字段中显式列出。',
|
||||
inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') },
|
||||
},
|
||||
handler: ({ taskId }) =>
|
||||
run(async () => {
|
||||
const tid = encodeURIComponent(String(taskId));
|
||||
const task = await api<Task>('GET', `/api/tasks/${tid}`);
|
||||
const children = await api<Task[]>('GET', `/api/tasks/${tid}/children`);
|
||||
// score 现算(与编排器同口径):失败不致命,置 null
|
||||
let score: number | null = null;
|
||||
try {
|
||||
const all = await api<Task[]>('GET', `/api/projects/${encodeURIComponent(task.projectId)}/tasks`);
|
||||
const byId = new Map(all.map((t) => [t.id, t]));
|
||||
score = scoreTask(task, byId, buildDependentsIndex(all));
|
||||
} catch { score = null; }
|
||||
const r = task.result;
|
||||
return {
|
||||
task,
|
||||
children,
|
||||
score,
|
||||
review: {
|
||||
summary: r?.summary ?? null,
|
||||
verdict: r?.verdict ?? null,
|
||||
securitySummary: r?.securitySummary ?? null,
|
||||
securityVerdict: r?.securityVerdict ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
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)'),
|
||||
{
|
||||
name: 'get_next_executable',
|
||||
config: {
|
||||
title: '取下一个可执行任务',
|
||||
description:
|
||||
'取项目中下一个可执行任务(叶子、ready、依赖全部 done,按 score 降序:自身分 + 链条惯性 + 解锁加权);没有则返回 {"next": null}。',
|
||||
inputSchema: { projectId: z.string().min(1).describe('项目 id') },
|
||||
},
|
||||
handler: ({ projectId }) =>
|
||||
run(() => api('GET', `/api/projects/${encodeURIComponent(String(projectId))}/next`)),
|
||||
},
|
||||
},
|
||||
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)'),
|
||||
{
|
||||
name: 'get_pending_approvals',
|
||||
config: {
|
||||
title: '查看待审批任务',
|
||||
description:
|
||||
'列出处于审批闸(plan_review / spec_review / exec_review)的任务,可按项目过滤。' +
|
||||
'裁决用 decide 工具(accept/reject,reject 必带 reason;exec_review 的 accept 默认通过并合并)。',
|
||||
inputSchema: { projectId: z.string().min(1).optional().describe('项目 id(不填则跨全部项目)') },
|
||||
},
|
||||
handler: ({ projectId }) =>
|
||||
run(() =>
|
||||
api('GET', projectId ? `/api/approvals?projectId=${encodeURIComponent(String(projectId))}` : '/api/approvals'),
|
||||
),
|
||||
},
|
||||
},
|
||||
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)'),
|
||||
{
|
||||
name: 'get_agents',
|
||||
config: {
|
||||
title: '查看各项目执行 agent 状态',
|
||||
description:
|
||||
'汇总每个项目的执行情况:自治级别 autonomy、并发上限 concurrency、调度模式 scheduling(score:自身分 + 链条惯性 + 解锁加权)、' +
|
||||
'各复杂度实际使用的模型 models、在途运行 active(含 planner/executor/reviewer/security),并附 Claude 订阅额度 usage(查询失败为 null)。',
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: () => run(() => api('GET', '/api/agents')),
|
||||
},
|
||||
},
|
||||
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('目标状态'),
|
||||
{
|
||||
name: 'get_usage',
|
||||
config: {
|
||||
title: '查看 Claude 订阅额度',
|
||||
description:
|
||||
'查询 Claude 订阅额度(执行 agent 烧的就是这个池子):session(5 小时滚动窗口)与 weekly(7 天窗口),各含 percent 已用百分比与 resetsAt 重置时刻。查询失败返回 null。',
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: () => run(() => api('GET', '/api/usage')),
|
||||
},
|
||||
},
|
||||
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')),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'requeue_task',
|
||||
{
|
||||
title: '重投 needs_attention 任务',
|
||||
description:
|
||||
'将 needs_attention 状态的任务重置重试计数并重新入队(queued),让编排器下一轮自动领取执行。只适用于 needs_attention 状态,其他状态会返回错误。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id(tsk_ 开头)'),
|
||||
{
|
||||
name: 'list_archived',
|
||||
config: {
|
||||
title: '列出归档任务(分页)',
|
||||
description:
|
||||
'分页列出某项目的归档任务(终态 done / cancelled),按更新时间倒序。默认每页 20 条、第 1 页。返回 { page, size, total, totalPages, tasks }。',
|
||||
inputSchema: {
|
||||
projectId: z.string().min(1).describe('项目 id'),
|
||||
page: z.number().int().min(1).optional().describe('页码(从 1 起,默认 1)'),
|
||||
size: z.number().int().min(1).max(100).optional().describe('每页条数(1-100,默认 20)'),
|
||||
},
|
||||
},
|
||||
handler: ({ projectId, page, size }) =>
|
||||
run(async () => {
|
||||
const tasks = await api<Task[]>('GET', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`);
|
||||
const archived = tasks
|
||||
.filter((t) => TERMINAL_STATUSES.includes(t.status))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
const pageSize = (size as number | undefined) ?? 20;
|
||||
const pageNum = (page as number | undefined) ?? 1;
|
||||
const total = archived.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = (pageNum - 1) * pageSize;
|
||||
return { page: pageNum, size: pageSize, total, totalPages, tasks: archived.slice(start, start + pageSize) };
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ taskId }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/requeue`, {})),
|
||||
);
|
||||
|
||||
// ---------- 写 ----------
|
||||
{
|
||||
name: 'create_project',
|
||||
config: {
|
||||
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)'),
|
||||
},
|
||||
},
|
||||
handler: (args) => run(() => api('POST', '/api/projects', args)),
|
||||
},
|
||||
{
|
||||
name: 'patch_project',
|
||||
config: {
|
||||
title: '更新项目配置',
|
||||
description:
|
||||
'部分更新项目配置(仅传需要改的字段,校验在 store.patchProject):' +
|
||||
'autonomy(manual|auto-easy|auto-approved)、concurrency(>=1 整数)、status(active|paused)、' +
|
||||
'verifyCmd(验证命令,传 null 清空)、model(执行模型,null 清空)、logo(URL/仓库内相对路径,null 清空)、' +
|
||||
'maxRetries(>=0 整数)、timeoutMs(>=1000 毫秒)。',
|
||||
inputSchema: {
|
||||
projectId: z.string().min(1).describe('项目 id'),
|
||||
autonomy: z.enum(['manual', 'auto-easy', 'auto-approved']).optional().describe('自治级别'),
|
||||
concurrency: z.number().int().min(1).optional().describe('worker 并发数(>=1)'),
|
||||
status: z.enum(['active', 'paused']).optional().describe('项目状态'),
|
||||
verifyCmd: z.string().nullable().optional().describe('验证命令(null 清空)'),
|
||||
model: z.string().nullable().optional().describe('执行模型(null 清空)'),
|
||||
logo: z.string().nullable().optional().describe('logo(null 清空)'),
|
||||
maxRetries: z.number().int().min(0).optional().describe('最大自动重试次数(>=0)'),
|
||||
timeoutMs: z.number().int().min(1000).optional().describe('单次执行超时毫秒(>=1000)'),
|
||||
},
|
||||
},
|
||||
handler: ({ projectId, ...patch }) =>
|
||||
run(() => api('PATCH', `/api/projects/${encodeURIComponent(String(projectId))}`, patch)),
|
||||
},
|
||||
{
|
||||
name: 'sync_project',
|
||||
config: {
|
||||
title: '同步项目 todo.json',
|
||||
description:
|
||||
'触发一次单向同步:读 <repoPath>/todo/todo.json 增量同步到 maestro(幂等可重复)。返回 { created, doneAdvanced, skipped, warnings, lastSyncAt }。',
|
||||
inputSchema: { projectId: z.string().min(1).describe('项目 id') },
|
||||
},
|
||||
handler: ({ projectId }) =>
|
||||
run(() => api('POST', `/api/projects/${encodeURIComponent(String(projectId))}/sync`)),
|
||||
},
|
||||
{
|
||||
name: 'create_task',
|
||||
config: {
|
||||
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().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'),
|
||||
},
|
||||
},
|
||||
handler: ({ projectId, ...rest }) =>
|
||||
run(() => api('POST', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`, rest)),
|
||||
},
|
||||
{
|
||||
name: 'decompose_task',
|
||||
config: {
|
||||
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('子任务列表,各自带复杂度'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, subtasks }) =>
|
||||
run(async () => {
|
||||
const parent = await api<{ projectId: string }>('GET', `/api/tasks/${encodeURIComponent(String(taskId))}`);
|
||||
const results: unknown[] = [];
|
||||
for (const st of subtasks as Array<Record<string, unknown>>) {
|
||||
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 };
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'write_plan',
|
||||
config: {
|
||||
title: '写入分析拆解(plan)',
|
||||
description: 'Hard 任务的产出:写入「分析 + 拆解说明」。写完用 update_status 把任务转到 plan_review 等用户审批。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('plan 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/plan`, { plan: content })),
|
||||
},
|
||||
{
|
||||
name: 'write_spec',
|
||||
config: {
|
||||
title: '写入方案(spec)',
|
||||
description: 'Medium 任务的产出:写入「具体方案 = 改动内容 + 为什么这么做」。写完用 update_status 转到 spec_review 等用户审批。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('spec 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/spec`, { spec: content })),
|
||||
},
|
||||
{
|
||||
name: 'write_operations',
|
||||
config: {
|
||||
title: '写入操作记录(operations)',
|
||||
description: 'Easy 任务的产出:写清「将执行的操作」。Easy 无前置审批闸,写完即可 update_status 到 ready。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
content: z.string().min(1).describe('operations 内容(Markdown)'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, content }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/operations`, { operations: content })),
|
||||
},
|
||||
{
|
||||
name: 'patch_task',
|
||||
config: {
|
||||
title: '更新任务字段',
|
||||
description:
|
||||
'部分更新任务(仅传需要改的字段,校验复用 store.patchTask):title、priority(0/1/2,P0 最高)、complexity(hard|medium|easy)。' +
|
||||
'complexity 仅在未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked)可改,改后状态重置为新复杂度的初始态。' +
|
||||
'(依赖 deps 当前不支持热改:创建时固定以保证依赖图无环。)',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
title: z.string().min(1).optional().describe('新标题'),
|
||||
priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2(P0 最高)'),
|
||||
complexity: complexitySchema.optional(),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, ...patch }) =>
|
||||
run(() => api('PATCH', `/api/tasks/${encodeURIComponent(String(taskId))}`, patch)),
|
||||
},
|
||||
{
|
||||
name: 'update_status',
|
||||
config: {
|
||||
title: '变更任务状态',
|
||||
description:
|
||||
`按状态机流转任务状态(合法流转见 daemon 守卫;非法流转会返回中文错误,原样透传)。可选值:${STATUSES.join(' | ')}。` +
|
||||
`注意:审批闸(plan_review/spec_review/exec_review)的 accept/reject 请用 decide 工具(带 reason 与合并语义),不要用本工具绕过审批闸。`,
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
to: z.enum(STATUSES).describe('目标状态'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, to }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/transition`, { to })),
|
||||
},
|
||||
{
|
||||
name: 'decide',
|
||||
config: {
|
||||
title: '审批裁决(通过 / 拒绝)',
|
||||
description:
|
||||
'对处于审批闸(plan_review / spec_review / exec_review)的任务做裁决。' +
|
||||
'action=accept 通过:plan_review→decomposed、spec_review→ready、exec_review→done。' +
|
||||
'action=reject 退回返工:plan_review→analyzing、spec_review→speccing、exec_review→ready,必带 reason(改进意见),否则报错。' +
|
||||
'merge 仅对 exec_review 的 accept 生效:true(默认)= 通过并合并(把任务分支并入默认分支,闭环;冲突则建 P0 补救任务、原任务留在闸);' +
|
||||
'false = 仅通过不合并(逃生口:目标分支被工作区占用等导致自动合并不可用时用)。',
|
||||
inputSchema: {
|
||||
taskId: z.string().min(1).describe('任务 id'),
|
||||
action: z.enum(['accept', 'reject']).describe('accept 通过 / reject 拒绝'),
|
||||
merge: z.boolean().optional().describe('仅 exec_review accept 生效:true(默认)=通过并合并;false=仅通过不合并'),
|
||||
reason: z.string().min(1).optional().describe('改进意见;reject 时必填'),
|
||||
},
|
||||
},
|
||||
handler: ({ taskId, action, merge, reason }) =>
|
||||
run(() =>
|
||||
api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/decide`, { action, merge, reason }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'requeue_task',
|
||||
config: {
|
||||
title: '重投 needs_attention 任务',
|
||||
description:
|
||||
'将 needs_attention 状态的任务重置重试计数并重新入队(queued),让编排器下一轮自动领取执行。只适用于 needs_attention 状态,其他状态会返回错误。',
|
||||
inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') },
|
||||
},
|
||||
handler: ({ taskId }) =>
|
||||
run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/requeue`, {})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 启动 ----------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = new McpServer({ name: 'maestro', version: '0.1.0' });
|
||||
for (const t of createTools()) {
|
||||
server.registerTool(t.name, t.config, t.handler as never);
|
||||
}
|
||||
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);
|
||||
});
|
||||
// 仅作为入口脚本运行时启动(被测试 import 时不启动 stdio server)
|
||||
const isEntry = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isEntry) {
|
||||
main().catch((err) => {
|
||||
console.error('maestro-mcp 启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user