From 939be0ec6bbd555a63f1cf46f5c3c1bcb8a682de Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 23 Sep 2026 16:12:16 +0000 Subject: [PATCH] feat(mcp): add jev-service governance decide MCP server Expose typed jev_decide/jev_health over stdio for closed-enum governance routing; credentials stay in ~/.secrets/jev and out of git. --- .cursor/mcp.json | 4 + .cursor/skills/jev-decide/SKILL.md | 52 ++++ .doc/ops/CURRENT.md | 1 + .../2026-09-24-jev-mcp-integration.md | 40 +++ ...6-09-24-jev-mcp-integration-test-report.md | 74 ++++++ .../01-local-handshake-decide.decoded.json | 97 +++++++ .../01-local-handshake-decide.err | 0 .../01-local-handshake-decide.jsonl | 9 + .../02-auth-401.decoded.json | 29 ++ .../02-auth-401.err | 0 .../02-auth-401.jsonl | 5 + .../03-frp-decide.decoded.json | 28 ++ .../03-frp-decide.err | 0 .../03-frp-decide.jsonl | 5 + .../04-latency.txt | 4 + scripts/mcp/README.md | 19 +- scripts/mcp/jev-mcp.mjs | 250 ++++++++++++++++++ 17 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 .cursor/skills/jev-decide/SKILL.md create mode 100644 .doc/ops/short-term/2026-09-24-jev-mcp-integration.md create mode 100644 .doc/test-reports/2026-09-24-jev-mcp-integration-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.decoded.json create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.err create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.jsonl create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.decoded.json create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.err create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.jsonl create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.decoded.json create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.err create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.jsonl create mode 100644 .doc/test-reports/assets/2026-09-24-jev-mcp-integration/04-latency.txt create mode 100755 scripts/mcp/jev-mcp.mjs diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 8ce9b5e2..2e619ff1 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,6 +3,10 @@ "agentctl": { "command": "node", "args": ["${workspaceFolder}/scripts/mcp/agentctl-mcp.mjs"] + }, + "jev": { + "command": "node", + "args": ["${workspaceFolder}/scripts/mcp/jev-mcp.mjs"] } } } diff --git a/.cursor/skills/jev-decide/SKILL.md b/.cursor/skills/jev-decide/SKILL.md new file mode 100644 index 00000000..30fbb91c --- /dev/null +++ b/.cursor/skills/jev-decide/SKILL.md @@ -0,0 +1,52 @@ +--- +name: jev-decide +description: Use jev-service typed decisions (choice/noul/numeric with probabilities) for Agent-System governance classification. Use when choosing task tier, validation level, reject-code routing, or other closed-enum judgments. +--- + +# jev-decide(治理决策) + +依赖 MCP 服务器 `jev`([`scripts/mcp/jev-mcp.mjs`](../../../scripts/mcp/jev-mcp.mjs))。凭证在本机 `~/.secrets/jev/`(`api.key`、`base-url`),**禁止**写入仓库、commit 或聊天。 + +## 何时用 + +输入是情境文本、输出是**封闭枚举 / yes-no / 分桶数值**时调用 `jev_decide`,例如: + +| 决策点 | 建议 questions | +|---|---| +| 任务分级(是否开 worktree) | `choice`:`info_reply` / `single_task` / `cross_module` | +| 验证层级(验证契约) | `choice`(可多题):`doc` / `script` / `go` / `frontend` / `migration` / `product_pi` | +| 拒绝码处置 | `choice`:`reconcile` / `re_snapshot` / `fix_record_id` / `request_capability` / `abort` | +| 是否 blocker / 是否触碰重验面 | `noul` | + +不适合:需要解释链、多步规划、自由文本生成——那些走大模型或 agentctl 确定性门禁。 + +## 调用约定 + +1. 可选先 `jev_health`;失败则本机判断,勿阻塞写路径。 +2. `jev_decide`:同一情境的多问合成一次请求。 +3. 问句**正向措辞**,避免双重否定。 +4. 置信度路由: + - `confidence >= 0.9` 且 top1−top2 ≥ 0.15 → 可采用 + - 否则 → agent 自行按规范裁定,把 jev 结果当参考 +5. 401/超时/`answers.*.error` → 降级到本机判断;勿把决策失败变成业务失败。 + +## 示例(任务分级) + +```json +{ + "context": "用户请求:给 CURRENT 加一行认领说明,不改代码", + "questions": { + "tier": { + "type": "choice", + "question": "This request matches which AGENTS task tier?", + "options": ["info_reply", "single_task", "cross_module"] + }, + "needs_worktree": { + "type": "noul", + "question": "Does this require a new feature/fix worktree?" + } + } +} +``` + +权威矩阵仍以 [AGENTS.md](../../../AGENTS.md)、[验证契约](../../../.doc/ops/2026-09-21-validation-contract.md) 为准;jev 只加速封闭枚举分类,不替代机器门禁(admit/hooks/docs apply)。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index d31d87fe..08020cac 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | jev-mcp-integration-2026-09-24 | feature/jev-mcp-integration-2026-09-24 | .worktrees/jev-mcp-integration-2026-09-24 | jev-mcp+docs | in_progress | | Cursor | integrate-intent-plan-u9u12-land-2026-09-24 | feature/integrate-intent-plan-u9u12-land-2026-09-24 | .worktrees/integrate-intent-plan-u9u12-land-2026-09-24 | git:dev | done | | Cursor | integrate-pi-narrative-docs-2026-09-23 | feature/integrate-pi-narrative-docs-2026-09-23 | .worktrees/integrate-pi-narrative-final-closeout-b-2026-09-23 | git:dev | done | | Cursor | pi-narrative-final-closeout-b-2026-09-23 | feature/pi-narrative-final-closeout-b-2026-09-23 | .worktrees/pi-narrative-final-closeout-2026-09-23 | docgov | done | diff --git a/.doc/ops/short-term/2026-09-24-jev-mcp-integration.md b/.doc/ops/short-term/2026-09-24-jev-mcp-integration.md new file mode 100644 index 00000000..57a3cbfb --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-jev-mcp-integration.md @@ -0,0 +1,40 @@ +--- +status: in_progress +write_scope: scripts/mcp,.cursor,.doc/ops/short-term,.doc/test-reports +mutex: jev-mcp+docs +base_dev_sha: b6f21e8a947c4bb8ab1171e05f7404b35f7bd2f1 +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +remote_sync: not_pushed +owner: Cursor +task_id: jev-mcp-integration-2026-09-24 +--- + +# jev-service 治理侧 MCP 接入 + +## 目标 + +将本机 jev-service(类型化 decision API)以独立 MCP 服务器 `jev` 接入本仓库治理链路,供 agent 在封闭枚举决策点调用;凭证仅存 `~/.secrets/jev/`,不进 git。 + +## 交付物 + +- `scripts/mcp/jev-mcp.mjs` — `jev_decide` / `jev_health` +- `.cursor/mcp.json` — 注册 `jev` server +- `.cursor/skills/jev-decide/SKILL.md` — 治理用例指引 +- `.doc/test-reports/2026-09-24-jev-mcp-integration-test-report.md` — 真实链路证据 + +## 非目标 + +- 不改 Go 产品路径(intent / coordinator / pi-agent) +- 不 push、不晋升 test/main +- 不把 API key / 完整 secret 写入仓库或报告正文 + +## 验证计划 + +1. MCP initialize + tools/list(stdio NDJSON 或 Content-Length) +2. `jev_health` 对本机与 frp 备用 URL +3. `jev_decide` 治理样例(任务分级 + 验证层级) +4. 401 负例(错误 key) +5. 产品 PI 全链路:NOT REQUIRED(工程 MCP/文档变更,依据 PROJECT.md §验证入口与 test-reports-standard 例外) diff --git a/.doc/test-reports/2026-09-24-jev-mcp-integration-test-report.md b/.doc/test-reports/2026-09-24-jev-mcp-integration-test-report.md new file mode 100644 index 00000000..9d1e7777 --- /dev/null +++ b/.doc/test-reports/2026-09-24-jev-mcp-integration-test-report.md @@ -0,0 +1,74 @@ +# jev-service 治理侧 MCP 集成 测试报告 + +## 一、 测试背景 (Background) + +将本机 jev-service(类型化 `/v1/decide`)以独立 MCP 服务器接入 Agent-System 治理链路,供 agent 在任务分级、验证层级等封闭枚举决策点调用。凭证仅存本机 `~/.secrets/jev/`,不进入仓库。 + +## 二、 测试目标 (Test Objectives) + +- MCP stdio 握手(initialize / tools/list)成功 +- `jev_health` / `jev_decide` 对本机 `http://127.0.0.1:8767` 真实可用 +- 错误 API key 返回 401 且 MCP `isError=true` +- frp 备用 URL 可真实 decide +- 资产目录无 API key 明文泄漏 +- 产品 PI 全链路:NOT REQUIRED(工程 MCP/文档变更;依据 PROJECT.md 验证入口与 test-reports-standard 纯工程例外) + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Node:v26.9.0 +- jev-service:本机 :8767 + frp `https://fun.u1680832.nyat.app:34608`(healthz 均 ok) +- 被测:`.worktrees/jev-mcp-integration-2026-09-24/scripts/mcp/jev-mcp.mjs` +- 层级:MCP 协议冒烟 + HTTP 端到端;无 Go/前端回归要求 +- 证据:`.doc/test-reports/assets/2026-09-24-jev-mcp-integration/` + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|---|---| +| initialize | serverInfo.name=`jev` | +| tools/list | 含 `jev_health`、`jev_decide` | +| decide(文档小改) | tier≈single_task;validation≈doc;低风险 | +| 401 | isError + unauthorized 文案,不崩溃 | +| frp decide | HTTP 成功返回 answers | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:MCP 握手 + 本地 decide + +- **测试输入 (Input)**:NDJSON initialize → tools/list → jev_health → jev_decide(任务分级+验证层级+是否开 worktree) +- **实际输出 (Actual Output)**:见 `assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.decoded.json` + - health:`ok=true`,base_url=`http://127.0.0.1:8767` + - answers:`tier=single_task` confidence≈0.909;`validation=doc` confidence≈0.996;`needs_worktree=no` confidence≈0.917 +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:整批 MCP 往返约 2.2s(含 3 问 decide) + +### 用例 2:401 负例 + +- **测试输入 (Input)**:`JEV_API_KEY=definitely-wrong-key` + jev_decide +- **实际输出 (Actual Output)**:`isError=true`,文案 `jev_decide unauthorized (401): check ~/.secrets/jev/api.key or JEV_API_KEY`(`02-auth-401.decoded.json`) +- **评价判定**:PASS +- **性能与耗时**:约 3s 内返回 + +### 用例 3:frp 远程 decide + +- **测试输入 (Input)**:`base_url=https://fun.u1680832.nyat.app:34608`,validation 六类选择 +- **实际输出 (Actual Output)**:`validation=script` confidence≈0.668(`03-frp-decide.decoded.json`)——本批改动含 MCP 脚本,script 合理;置信中等,技能约定应回退 agent 自判 +- **评价判定**:PASS(连通与契约正确;中置信按设计不强制采纳) +- **性能与耗时**:约 3s 内完成 + +### 用例 4:探活延迟与密钥泄漏扫描 + +- **实际输出**:`04-latency.txt` — local healthz ≈2ms;frp ≈49ms;`grep` 资产目录无 key 明文 +- **评价判定**:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go test ./...`:NOT REQUIRED(未改 Go;工程 MCP/文档) +- 产品 PI Runner:NOT REQUIRED(同上) +- 本轮执行:MCP NDJSON 真实调用 4 组 + curl healthz 双端点 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 治理侧 MCP 接入达到合入标准:握手、鉴权、本地/frp decide、错误路径均有真实证据。 +- 合入后需在 Cursor 重载 MCP 以加载 `jev` server。 +- 后续(另授权):产品侧 shadow 评测与低风险决策点灰度,不在本任务范围。 diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.decoded.json b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.decoded.json new file mode 100644 index 00000000..7815205b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.decoded.json @@ -0,0 +1,97 @@ +[ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "jev", + "version": "1.0.0" + } + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "jev_health", + "description": "Probe jev-service /healthz (no auth). Returns ok/model/llama_server status. Use before decide when unsure the service is up.", + "inputSchema": { + "type": "object", + "properties": { + "base_url": { + "type": "string", + "description": "Optional override; default JEV_BASE_URL or ~/.secrets/jev/base-url or http://127.0.0.1:8767" + }, + "timeout_ms": { + "type": "number", + "description": "Request timeout ms (default 15000)" + } + }, + "additionalProperties": false + } + }, + { + "name": "jev_decide", + "description": "Call jev-service POST /v1/decide: typed choice/noul/numeric answers with real probability distributions. For governance decisions (task tier, validation level, reject routing). Low confidence (top1-top2 < 0.15) → fall back to agent judgment. Requires API key from ~/.secrets/jev/api.key or JEV_API_KEY.", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Situation text (user request, change summary, error code context, …)" + }, + "questions": { + "type": "object", + "description": "Map of answer name → {type: choice|noul|numeric, question, options?, min?, max?}. Example: {\"tier\":{\"type\":\"choice\",\"question\":\"Task tier?\",\"options\":[\"info_reply\",\"single_task\",\"cross_module\"]}}", + "additionalProperties": true + }, + "base_url": { + "type": "string", + "description": "Optional URL override" + }, + "timeout_ms": { + "type": "number", + "description": "Request timeout ms (default 15000)" + } + }, + "required": [ + "context", + "questions" + ], + "additionalProperties": false + } + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"base_url\": \"http://127.0.0.1:8767\",\n \"ok\": true,\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"llama_server\": {\n \"status\": \"ok\"\n },\n \"auth\": true\n}" + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"base_url\": \"http://127.0.0.1:8767\",\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"answers\": {\n \"tier\": {\n \"type\": \"choice\",\n \"choice\": \"single_task\",\n \"confidence\": 0.909262,\n \"probabilities\": {\n \"info_reply\": 0.05180025,\n \"single_task\": 0.90926193,\n \"cross_module\": 0.03893782\n }\n },\n \"validation\": {\n \"type\": \"choice\",\n \"choice\": \"doc\",\n \"confidence\": 0.995727,\n \"probabilities\": {\n \"doc\": 0.995727,\n \"script\": 0.00013765,\n \"go\": 0.00012722,\n \"frontend\": 0.00175551,\n \"migration\": 0.00068579,\n \"product_pi\": 0.00156682\n }\n },\n \"needs_worktree\": {\n \"type\": \"noul\",\n \"choice\": \"no\",\n \"confidence\": 0.917266,\n \"probabilities\": {\n \"yes\": 0.08273366,\n \"no\": 0.91726634\n },\n \"noul\": 0,\n \"yes\": false\n }\n }\n}" + } + ] + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.err b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.err new file mode 100644 index 00000000..e69de29b diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.jsonl b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.jsonl new file mode 100644 index 00000000..649f2c51 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/01-local-handshake-decide.jsonl @@ -0,0 +1,9 @@ +Content-Length: 140 + +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"jev","version":"1.0.0"}}}Content-Length: 1511 + +{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"jev_health","description":"Probe jev-service /healthz (no auth). Returns ok/model/llama_server status. Use before decide when unsure the service is up.","inputSchema":{"type":"object","properties":{"base_url":{"type":"string","description":"Optional override; default JEV_BASE_URL or ~/.secrets/jev/base-url or http://127.0.0.1:8767"},"timeout_ms":{"type":"number","description":"Request timeout ms (default 15000)"}},"additionalProperties":false}},{"name":"jev_decide","description":"Call jev-service POST /v1/decide: typed choice/noul/numeric answers with real probability distributions. For governance decisions (task tier, validation level, reject routing). Low confidence (top1-top2 < 0.15) → fall back to agent judgment. Requires API key from ~/.secrets/jev/api.key or JEV_API_KEY.","inputSchema":{"type":"object","properties":{"context":{"type":"string","description":"Situation text (user request, change summary, error code context, …)"},"questions":{"type":"object","description":"Map of answer name → {type: choice|noul|numeric, question, options?, min?, max?}. Example: {\"tier\":{\"type\":\"choice\",\"question\":\"Task tier?\",\"options\":[\"info_reply\",\"single_task\",\"cross_module\"]}}","additionalProperties":true},"base_url":{"type":"string","description":"Optional URL override"},"timeout_ms":{"type":"number","description":"Request timeout ms (default 15000)"}},"required":["context","questions"],"additionalProperties":false}}]}}Content-Length: 253 + +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\n \"base_url\": \"http://127.0.0.1:8767\",\n \"ok\": true,\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"llama_server\": {\n \"status\": \"ok\"\n },\n \"auth\": true\n}"}]}}Content-Length: 1075 + +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"{\n \"base_url\": \"http://127.0.0.1:8767\",\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"answers\": {\n \"tier\": {\n \"type\": \"choice\",\n \"choice\": \"single_task\",\n \"confidence\": 0.909262,\n \"probabilities\": {\n \"info_reply\": 0.05180025,\n \"single_task\": 0.90926193,\n \"cross_module\": 0.03893782\n }\n },\n \"validation\": {\n \"type\": \"choice\",\n \"choice\": \"doc\",\n \"confidence\": 0.995727,\n \"probabilities\": {\n \"doc\": 0.995727,\n \"script\": 0.00013765,\n \"go\": 0.00012722,\n \"frontend\": 0.00175551,\n \"migration\": 0.00068579,\n \"product_pi\": 0.00156682\n }\n },\n \"needs_worktree\": {\n \"type\": \"noul\",\n \"choice\": \"no\",\n \"confidence\": 0.917266,\n \"probabilities\": {\n \"yes\": 0.08273366,\n \"no\": 0.91726634\n },\n \"noul\": 0,\n \"yes\": false\n }\n }\n}"}]}} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.decoded.json b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.decoded.json new file mode 100644 index 00000000..f7fb100a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.decoded.json @@ -0,0 +1,29 @@ +[ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "jev", + "version": "1.0.0" + } + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "jev_decide unauthorized (401): check ~/.secrets/jev/api.key or JEV_API_KEY" + } + ], + "isError": true + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.err b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.err new file mode 100644 index 00000000..e69de29b diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.jsonl b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.jsonl new file mode 100644 index 00000000..98f22975 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/02-auth-401.jsonl @@ -0,0 +1,5 @@ +Content-Length: 140 + +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"jev","version":"1.0.0"}}}Content-Length: 162 + +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"jev_decide unauthorized (401): check ~/.secrets/jev/api.key or JEV_API_KEY"}],"isError":true}} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.decoded.json b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.decoded.json new file mode 100644 index 00000000..285c8c2c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.decoded.json @@ -0,0 +1,28 @@ +[ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "jev", + "version": "1.0.0" + } + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "{\n \"base_url\": \"https://fun.u1680832.nyat.app:34608\",\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"answers\": {\n \"validation\": {\n \"type\": \"choice\",\n \"choice\": \"script\",\n \"confidence\": 0.668127,\n \"probabilities\": {\n \"doc\": 0.0784743,\n \"script\": 0.6681266,\n \"go\": 0.13105554,\n \"frontend\": 0.01885709,\n \"migration\": 0.05828189,\n \"product_pi\": 0.04520459\n }\n }\n }\n}" + } + ] + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.err b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.err new file mode 100644 index 00000000..e69de29b diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.jsonl b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.jsonl new file mode 100644 index 00000000..46bc2009 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/03-frp-decide.jsonl @@ -0,0 +1,5 @@ +Content-Length: 140 + +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"jev","version":"1.0.0"}}}Content-Length: 559 + +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n \"base_url\": \"https://fun.u1680832.nyat.app:34608\",\n \"model\": \"jev-clone-qwen3-4b/0.2.0\",\n \"answers\": {\n \"validation\": {\n \"type\": \"choice\",\n \"choice\": \"script\",\n \"confidence\": 0.668127,\n \"probabilities\": {\n \"doc\": 0.0784743,\n \"script\": 0.6681266,\n \"go\": 0.13105554,\n \"frontend\": 0.01885709,\n \"migration\": 0.05828189,\n \"product_pi\": 0.04520459\n }\n }\n }\n}"}]}} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/04-latency.txt b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/04-latency.txt new file mode 100644 index 00000000..e7d2d901 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-24-jev-mcp-integration/04-latency.txt @@ -0,0 +1,4 @@ +local_healthz: +{"ok": true, "model": "jev-clone-qwen3-4b/0.2.0", "llama_server": {"status": "ok"}, "auth": true} http=200 time=0.001998 +frp_healthz: +{"ok": true, "model": "jev-clone-qwen3-4b/0.2.0", "llama_server": {"status": "ok"}, "auth": true} http=200 time=0.048645 diff --git a/scripts/mcp/README.md b/scripts/mcp/README.md index 8edb4b61..eeaa7be2 100644 --- a/scripts/mcp/README.md +++ b/scripts/mcp/README.md @@ -1,12 +1,23 @@ -# agentctl MCP (stdio) +# MCP servers (stdio) -Zero-dependency Node MCP server wrapping `scripts/agentctl.py`. +Zero-dependency Node MCP wrappers used by Cursor (see [`.cursor/mcp.json`](../../.cursor/mcp.json)). + +| Server | Script | Purpose | +|---|---|---| +| `agentctl` | `agentctl-mcp.mjs` | Lease / docs apply / validate / queue | +| `jev` | `jev-mcp.mjs` | Typed decide/health against local or remote jev-service | ```bash # from repo root node scripts/mcp/agentctl-mcp.mjs +node scripts/mcp/jev-mcp.mjs ``` -Cursor loads it via [`.cursor/mcp.json`](../../.cursor/mcp.json). Reload MCP after checkout. +## jev credentials (not in git) -`lease_run` / `validate` take `command` as an argv array (no shell). Set `AGENTCTL_REPO` only when the script is not under the repo tree. +- `~/.secrets/jev/api.key` (chmod 600) or env `JEV_API_KEY` +- `~/.secrets/jev/base-url` or env `JEV_BASE_URL` (default `http://127.0.0.1:8767`) + +Reload MCP after checkout. See skill `.cursor/skills/jev-decide/SKILL.md`. + +`lease_run` / `validate` take `command` as an argv array (no shell). Set `AGENTCTL_REPO` only when the agentctl script is not under the repo tree. diff --git a/scripts/mcp/jev-mcp.mjs b/scripts/mcp/jev-mcp.mjs new file mode 100755 index 00000000..3ce7faf8 --- /dev/null +++ b/scripts/mcp/jev-mcp.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** + * jev-service MCP (stdio, zero deps). Typed decide/health against local or frp jev-server. + * Credentials: ~/.secrets/jev/api.key and ~/.secrets/jev/base-url (or JEV_API_KEY / JEV_BASE_URL). + * Never embeds secrets in this file or logs. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const DEFAULT_BASE = 'http://127.0.0.1:8767'; +const DEFAULT_TIMEOUT_MS = 15000; +const SECRETS_DIR = path.join(os.homedir(), '.secrets', 'jev'); + +function readSecretFile(name) { + const p = path.join(SECRETS_DIR, name); + try { + if (!fs.existsSync(p)) return null; + return fs.readFileSync(p, 'utf8').trim() || null; + } catch { + return null; + } +} + +function resolveBaseUrl() { + return (process.env.JEV_BASE_URL || readSecretFile('base-url') || DEFAULT_BASE).replace(/\/$/, ''); +} + +function resolveApiKey() { + return process.env.JEV_API_KEY || readSecretFile('api.key') || null; +} + +function textResult(payload) { + const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); + return { content: [{ type: 'text', text: body }] }; +} + +function errResult(msg) { + return { content: [{ type: 'text', text: msg }], isError: true }; +} + +const TOOLS = [ + { + name: 'jev_health', + description: + 'Probe jev-service /healthz (no auth). Returns ok/model/llama_server status. Use before decide when unsure the service is up.', + inputSchema: { + type: 'object', + properties: { + base_url: { + type: 'string', + description: 'Optional override; default JEV_BASE_URL or ~/.secrets/jev/base-url or http://127.0.0.1:8767', + }, + timeout_ms: { type: 'number', description: 'Request timeout ms (default 15000)' }, + }, + additionalProperties: false, + }, + }, + { + name: 'jev_decide', + description: + 'Call jev-service POST /v1/decide: typed choice/noul/numeric answers with real probability distributions. For governance decisions (task tier, validation level, reject routing). Low confidence (top1-top2 < 0.15) → fall back to agent judgment. Requires API key from ~/.secrets/jev/api.key or JEV_API_KEY.', + inputSchema: { + type: 'object', + properties: { + context: { + type: 'string', + description: 'Situation text (user request, change summary, error code context, …)', + }, + questions: { + type: 'object', + description: + 'Map of answer name → {type: choice|noul|numeric, question, options?, min?, max?}. Example: {"tier":{"type":"choice","question":"Task tier?","options":["info_reply","single_task","cross_module"]}}', + additionalProperties: true, + }, + base_url: { type: 'string', description: 'Optional URL override' }, + timeout_ms: { type: 'number', description: 'Request timeout ms (default 15000)' }, + }, + required: ['context', 'questions'], + additionalProperties: false, + }, + }, +]; + +async function httpJson(url, { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { + method, + headers, + body, + signal: ctrl.signal, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text }; + } + return { status: res.status, ok: res.ok, json, text }; + } finally { + clearTimeout(timer); + } +} + +async function callTool(name, args = {}) { + const base = (args.base_url || resolveBaseUrl()).replace(/\/$/, ''); + const timeoutMs = Number(args.timeout_ms) > 0 ? Number(args.timeout_ms) : DEFAULT_TIMEOUT_MS; + + switch (name) { + case 'jev_health': { + try { + const r = await httpJson(`${base}/healthz`, { timeoutMs }); + if (!r.ok) { + return errResult(`jev_health HTTP ${r.status}: ${r.text.slice(0, 500)}`); + } + return textResult({ base_url: base, ...r.json }); + } catch (e) { + return errResult(`jev_health failed: ${e?.name === 'AbortError' ? 'timeout' : String(e?.message || e)}`); + } + } + case 'jev_decide': { + if (!args.context || typeof args.context !== 'string') { + return errResult('jev_decide.context must be a non-empty string'); + } + if (!args.questions || typeof args.questions !== 'object' || Array.isArray(args.questions)) { + return errResult('jev_decide.questions must be an object map'); + } + const key = resolveApiKey(); + if (!key) { + return errResult( + 'jev API key missing: set JEV_API_KEY or create ~/.secrets/jev/api.key (chmod 600). Do not put the key in chat/repo.', + ); + } + try { + const r = await httpJson(`${base}/v1/decide`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ context: args.context, questions: args.questions }), + timeoutMs, + }); + if (r.status === 401) { + return errResult('jev_decide unauthorized (401): check ~/.secrets/jev/api.key or JEV_API_KEY'); + } + if (!r.ok) { + return errResult(`jev_decide HTTP ${r.status}: ${r.text.slice(0, 800)}`); + } + return textResult({ + base_url: base, + model: r.json?.model, + answers: r.json?.answers, + }); + } catch (e) { + return errResult(`jev_decide failed: ${e?.name === 'AbortError' ? 'timeout' : String(e?.message || e)}`); + } + } + default: + return errResult(`unknown tool: ${name}`); + } +} + +function send(msg) { + const body = JSON.stringify(msg); + process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`); +} + +function handle(msg) { + if (!msg || typeof msg !== 'object') return; + if (msg.method && msg.id === undefined) { + return; + } + const { id, method, params } = msg; + const reply = (result) => send({ jsonrpc: '2.0', id, result }); + const fail = (code, message) => send({ jsonrpc: '2.0', id, error: { code, message } }); + + try { + if (method === 'initialize') { + reply({ + protocolVersion: params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'jev', version: '1.0.0' }, + }); + return; + } + if (method === 'tools/list') { + reply({ tools: TOOLS }); + return; + } + if (method === 'tools/call') { + const name = params?.name; + const args = params?.arguments || {}; + Promise.resolve(callTool(name, args)) + .then((result) => reply(result)) + .catch((e) => fail(-32000, String(e?.stack || e))); + return; + } + if (method === 'ping') { + reply({}); + return; + } + fail(-32601, `Method not found: ${method}`); + } catch (e) { + fail(-32000, String(e?.stack || e)); + } +} + +// Support both Content-Length framed MCP and newline-delimited JSON (for tests). +let buf = Buffer.alloc(0); +process.stdin.on('data', (chunk) => { + buf = Buffer.concat([buf, chunk]); + while (true) { + const headerEnd = buf.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + const header = buf.slice(0, headerEnd).toString('utf8'); + const m = /Content-Length:\s*(\d+)/i.exec(header); + if (!m) { + buf = buf.slice(headerEnd + 4); + continue; + } + const len = Number(m[1]); + const start = headerEnd + 4; + if (buf.length < start + len) return; + const body = buf.slice(start, start + len).toString('utf8'); + buf = buf.slice(start + len); + try { + handle(JSON.parse(body)); + } catch (e) { + process.stderr.write(`parse error: ${e}\n`); + } + continue; + } + const nl = buf.indexOf('\n'); + if (nl === -1) return; + const line = buf.slice(0, nl).toString('utf8').trim(); + buf = buf.slice(nl + 1); + if (!line || line.startsWith('Content-Length')) continue; + try { + handle(JSON.parse(line)); + } catch (e) { + process.stderr.write(`ndjson parse error: ${e}\n`); + } + } +}); + +process.stdin.on('end', () => process.exit(0));