// df-mcp E2E 实测脚本:spawn 真实二进制,按行喂 JSON-RPC,断言修复项 + 全链路。 // 用法: node df-mcp-e2e.mjs [--db <临时库>] import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const exe = process.argv[2]; if (!exe) { console.error('用法: node df-mcp-e2e.mjs '); process.exit(1); } const dbArg = process.argv[4] ?? join(mkdtempSync(join(tmpdir(), 'dfmcp-')), 'test.db'); let pass = 0, fail = 0; const failures = []; function assert(cond, name, extra) { if (cond) { pass++; console.log(` ✅ ${name}`); } else { fail++; failures.push(name); console.log(` ❌ ${name}${extra ? ` — ${extra}` : ''}`); } } function session(dbPath, readOnly = false) { const args = ['mcp-server', '--db', dbPath]; if (readOnly) args.push('--read-only'); const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'] }); const rl = createInterface({ input: child.stdout }); const pending = []; const waiters = []; let buf = ''; rl.on('line', (line) => { const resolved = waiters.shift(); if (resolved) resolved(line); }); child.stderr.on('data', (d) => process.stderr.write(` [server] ${d}`)); let idSeq = 0; return { async call(req) { const payload = { jsonrpc: '2.0', id: ++idSeq, ...req }; const p = new Promise((res) => waiters.push(res)); child.stdin.write(JSON.stringify(payload) + '\n'); const line = await p; return JSON.parse(line); }, async raw(line) { const p = new Promise((res) => waiters.push(res)); child.stdin.write(line + '\n'); const out = await p; return JSON.parse(out); }, close() { child.stdin.end(); }, }; } // 从 CallToolResult 取文本 function txt(res) { try { return res.result.content[0].text; } catch { return JSON.stringify(res); } } function json(res) { return JSON.parse(txt(res)); } async function main() { console.log(`\n═══ df-mcp E2E 实测 exe=${exe} db=${dbArg} ═══`); // ── 实例 A:读写模式 ────────────────────────────── const s = session(dbArg, false); console.log('\n【1】握手与工具清单'); const init = await s.call({ method: 'initialize', params: {} }); assert(init.result.serverInfo.name === 'devflow-mcp', 'initialize 返回 serverInfo.name', init.result?.serverInfo?.name); assert(init.result.capabilities.tools.listChanged === false, 'capabilities.tools 声明'); const tl = await s.call({ method: 'tools/list', params: {} }); const names = tl.result.tools.map(t => t.name); const low = ['list_projects','get_project','list_tasks','list_ideas','evaluate_idea','list_trash']; const med = ['create_project','update_project','bind_directory','create_task','update_task','advance_task','create_idea','update_idea','score_idea','restore_project']; const high = ['delete_project','delete_task','delete_idea','run_workflow']; assert(low.every(n => names.includes(n)), 'Low 工具全部可见'); assert(med.every(n => names.includes(n)), 'Medium 工具全部可见'); assert(!high.some(n => names.includes(n)), 'High 工具默认不可见'); assert(names.length === low.length + med.length, `工具数 = ${low.length + med.length}`, `实际 ${names.length}`); console.log('\n【2】修复项:parse error 带 id:null(修复#1)'); const perr = await s.raw('{"jsonrpc":"2.0","id":999,"method":"bogus", BAD JSON'); assert(perr.error && perr.error.code === -32700, '非法 JSON → PARSE_ERROR(-32700)'); assert(perr.id === null, 'parse error 响应 id 必须为 null', `实际 ${JSON.stringify(perr.id)}`); console.log('\n【3】修复项:tools/call 缺 name 明确报错(修复#7)'); const noname = await s.call({ method: 'tools/call', params: { arguments: {} } }); assert(noname.error && noname.error.code === -32601, '缺 name → METHOD_NOT_FOUND'); assert(noname.error.message.includes('name'), '错误消息含 name 提示', noname.error.message); assert(!noname.error.message.includes('未知工具: '), '不是空名「未知工具: 」', noname.error.message); console.log('\n【4】未知工具'); const unk = await s.call({ method: 'tools/call', params: { name: 'nope', arguments: {} } }); assert(txt(unk).includes('未知工具'), '未知工具报错', txt(unk)); console.log('\n【5】修复项:update_project 非法状态不再静默降级(修复#3)'); const cp = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目', description: '实测' } } }); assert(!cp.result.isError, 'create_project 成功', txt(cp)); const proj = json(cp); const pid = proj.id; const badSt = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, status: 'bogus_state' } } }); assert(badSt.result.isError === true, 'update_project 非法 status 报错'); assert(txt(badSt).includes('非法状态值'), '错误信息含"非法状态值"', txt(badSt)); console.log('\n【6】update_project 部分更新保留原值'); const upd = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, name: 'E2E项目改' } } }); assert(!upd.result.isError, '部分更新成功'); const updJ = json(upd); assert(updJ.project.name === 'E2E项目改' && updJ.project.status === 'planning', 'name 更新 + status 保留 planning', `status=${updJ.project.status}`); console.log('\n【7】create_task 带 parent_id(外部新增能力)'); const cproj = await s.call({ method: 'tools/call', params: { name: 'list_projects', arguments: {} } }); const listJ = json(cproj); const parentT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '父任务', description: '' } } }); const parentId = json(parentT).id; const childT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '子任务', description: '', parent_id: parentId } } }); assert(!childT.result.isError, '创建子任务成功', txt(childT)); const grandchild = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '孙任务', description: '', parent_id: json(childT).id } } }); assert(grandchild.result.isError === true && txt(grandchild).includes('父任务不能是子任务'), '1 级嵌套限制:孙任务被拒', txt(grandchild)); const badP = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: 'x', description: '', parent_id: 'ghost' } } }); assert(badP.result.isError === true && txt(badP).includes('父任务不存在'), '父任务不存在被拒', txt(badP)); console.log('\n【8】advance_task 状态机'); const adv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'in_progress' } } }); assert(!adv.result.isError && json(adv).task.status === 'in_progress', 'todo→in_progress 成功'); const badAdv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'done' } } }); assert(badAdv.result.isError === true, 'todo→done 非法跳态被拒', txt(badAdv)); console.log('\n【9】idea 闭环:evaluate(Low只读) → score(Medium写库)'); const ci = await s.call({ method: 'tools/call', params: { name: 'create_idea', arguments: { title: '核心功能', description: '需要立即重构关键模块以解除阻塞' } } }); const iid = json(ci).id; const ev = await s.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } }); assert(!ev.result.isError && json(ev).scores.overall >= 0, 'evaluate_idea 返回分数'); const evJ = json(ev); assert(evJ.idea.scores == null, 'evaluate_idea 不写库(scores 仍 null)'); const sc = await s.call({ method: 'tools/call', params: { name: 'score_idea', arguments: { id: iid } } }); const scJ = json(sc); assert(!sc.result.isError && scJ.idea.scores != null, 'score_idea 写库成功'); console.log('\n【10】bind_directory:冲突/穿越/不存在路径'); const bd1 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid, path: 'C:/Temp/nonexist_dir' } } }); assert(!bd1.result.isError, '绑定不存在目录成功(规范化为绝对路径)'); const bdConflict = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目2', description: '' } } }); const pid2 = json(bdConflict).id; const bd2 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'c:/temp/nonexist_dir' } } }); assert(bd2.result.isError === true && txt(bd2).includes('路径已被项目'), '路径冲突检测(大小写不敏感)', txt(bd2)); const bdTraverse = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'C:/temp/../Windows' } } }); assert(bdTraverse.result.isError === true && txt(bdTraverse).includes('..'), '.. 段拒绝', txt(bdTraverse)); console.log('\n【11】回收站 list_trash / restore_project'); const lt = await s.call({ method: 'tools/call', params: { name: 'list_trash', arguments: {} } }); assert(!lt.result.isError, 'list_trash 成功'); const rest = await s.call({ method: 'tools/call', params: { name: 'restore_project', arguments: { id: 'ghost' } } }); assert(rest.result.isError === true, '恢复不存在的项目报错', txt(rest)); console.log('\n【12】跨实体校验'); const cross = await s.call({ method: 'tools/call', params: { name: 'update_task', arguments: { id: iid, title: 'x' } } }); assert(cross.result.isError === true && txt(cross).includes('属于 idea'), 'update_task 传 idea id → 跨实体报错', txt(cross)); console.log('\n【13】High 工具即便绕过 tools/list 也被拒'); const del = await s.call({ method: 'tools/call', params: { name: 'delete_project', arguments: { id: pid } } }); assert(del.result.isError === true && txt(del).includes('DevFlow'), 'delete_project 默认拒绝', txt(del)); s.close(); // ── 实例 B:read-only 模式 ───────────────────────── console.log('\n════ read-only 模式 ════'); const ro = session(dbArg, true); const roList = await ro.call({ method: 'tools/list', params: {} }); const roNames = roList.result.tools.map(t => t.name); assert(roNames.length === low.length, `read-only 仅 Low(${low.length} 个)`, `实际 ${roNames.length}`); assert(roNames.every(n => low.includes(n)), 'read-only 只含 Low 工具'); const roCall = await ro.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'x', description: '' } } }); assert(roCall.result.isError === true && txt(roCall).includes('只读模式'), 'read-only 拒绝 Medium', txt(roCall)); const roEval = await ro.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } }); assert(!roEval.result.isError, 'read-only 放行 evaluate_idea(Low)'); ro.close(); try { await new Promise(r => setTimeout(r, 300)); rmSync(join(dbArg, '..'), { recursive: true, force: true }); } catch {} console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`); if (failures.length) { console.log('失败项:'); failures.forEach(f => console.log(` - ${f}`)); process.exit(1); } console.log('✅ 全部通过'); } main().catch(e => { console.error('脚本异常:', e); process.exit(1); });