From 36793a4ea5771b2156ac4e7e61c45950fcee7a1f Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 10:39:43 +0000 Subject: [PATCH] fix(tooling): emit NDJSON MCP responses for agentctl and jev Cursor IDE loads pi-subagents (NDJSON) but not agentctl/jev which replied with Content-Length framing. Align send() and mark type=stdio in mcp.json. --- .cursor/mcp.json | 2 + .../2026-09-24-pi-executor-boundaries.md | 6 ++ .doc/audit/ISSUES.md | 2 +- scripts/mcp/README.md | 8 ++- scripts/mcp/agentctl-mcp.mjs | 5 +- scripts/mcp/jev-mcp.mjs | 4 +- scripts/tests/test_mcp_ndjson_framing.py | 70 +++++++++++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 scripts/tests/test_mcp_ndjson_framing.py diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 5c0bb961..d6fba1c0 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,10 +1,12 @@ { "mcpServers": { "agentctl": { + "type": "stdio", "command": "node", "args": ["scripts/mcp/agentctl-mcp.mjs"] }, "jev": { + "type": "stdio", "command": "node", "args": ["scripts/mcp/jev-mcp.mjs"] }, diff --git a/.doc/audit/2026-09-24-pi-executor-boundaries.md b/.doc/audit/2026-09-24-pi-executor-boundaries.md index b5ab8ad7..1f3c73b2 100644 --- a/.doc/audit/2026-09-24-pi-executor-boundaries.md +++ b/.doc/audit/2026-09-24-pi-executor-boundaries.md @@ -37,3 +37,9 @@ issue_id:ISSUE-20260924-cursor-cli-mcp-path;issue_state:deferred;severit 本机 Cursor CLI 2026.09.18 在项目 MCP args 中未展开 workspaceFolder 变量,原样传给 Node。pi-subagents 新入口已改工作区相对路径并实测通过。 同类只读检查:agent mcp list 也显示既有 agentctl / jev Connection failed,它们仍使用相同变量写法;codegraph ready。未进一步修改/验收这两个既有服务,也未断言 IDE 同样失败。官方支持声明与本机观测须分开。 此残差不阻塞 PI MCP(其内部直接调用主仓治理 CLI),不构成本轮产品发布结论;后续由工程维护在 Cursor CLI 升级或这两项 MCP 接入验收时检查路径/stdio 协议,形成独立修复与实际客户端证据。仅保留问题,不宣称已修复。 + + +## cursor-cli-mcp-path 续:NDJSON 回包 + +2026-09-24 IDE:`pi-subagents`/`codegraph` ready;`agentctl`/`jev` 不在 MCP 缓存。 +根因补充:后两者 `send()` 使用 `Content-Length` 帧,而可加载的 `pi-subagents` 使用换行分隔 JSON-RPC。已改为 NDJSON 回包并补 `"type":"stdio"`;仍接受 Content-Length 请求。单元:`scripts/tests/test_mcp_ndjson_framing.py`。IDE 重载后需再核 `list-tools`。 diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index c9d0db5d..2c6ef7d8 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -15,5 +15,5 @@ | ISSUE-20260924-zombie-credentials | auth / user_credentials / LEFT JOIN / CreateUser | 空哈希静默建号导致永久无法登录 | high | resolved | Cursor | 2026-09-24;三环境只读盘点;机制修复不含存量清理 | [主记录](2026-09-24-admin-password-reset-issues.md#issue-20260924-zombie-credentials) | 待补 LESSON | | ISSUE-20260924-cursor-test-rule-drift | Cursor / test-reports / 固定模型 / 规则副本 | Cursor 测试规则与验证契约存在固定模型及范围冲突 | medium | verifying | Cursor | 2026-09-24;规则文本已缩为验证契约指针;客户端实际读取行为待实机 | [主记录](2026-09-24-subagent-workflow-research.md#4-issue-20260924-cursor-test-rule-drift) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | | ISSUE-20260924-pi-executor-boundaries | PI executor / 路径规范化 / MCP / 子进程 / 幂等 | 未发布初版只读边界与执行可靠性缺陷 | high | resolved | Cursor/Codex | 2026-09-24;173 工程回归与真实 Cursor CLI/MCP/PI | [主记录](2026-09-24-pi-executor-boundaries.md) | [工具路径与输入隔离](../ops/lessons/2026-09-24-pi-tool-boundaries.md) | -| ISSUE-20260924-cursor-cli-mcp-path | Cursor CLI / MCP / workspaceFolder | 既有 agentctl/jev 项目 MCP 入口在本机 CLI 连接失败 | medium | verifying | Cursor | 2026-09-24;mcp.json 已改相对路径;IDE/CLI list 证据待补 | [主记录](2026-09-24-pi-executor-boundaries.md#cursor-cli-mcp-path) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | +| ISSUE-20260924-cursor-cli-mcp-path | Cursor CLI / MCP / workspaceFolder / NDJSON | agentctl/jev MCP 加载失败(路径 + Content-Length 回包) | medium | verifying | Cursor | 2026-09-24;相对路径+NDJSON 已修;待 IDE 重载后 list-tools 实机 | [主记录](2026-09-24-pi-executor-boundaries.md#cursor-cli-mcp-path) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | | ISSUE-20260924-cursor-hook-event-contract | Cursor / hooks / beforeMCPExecution / probe | hook 事件 stdin 字段名与 matcher 官方未文档化 | medium | verifying | Cursor | 2026-09-24;IDE postToolUse tool_name=`MCP:pi_subagent_roles` 已采样;beforeMCP 全量 stdin 仍缺 | [主记录](2026-09-24-cursor-hook-event-contract.md) | [LESSON-20260924-cursor-hook-advisory-gates](../ops/lessons/2026-09-24-cursor-hook-advisory-gates.md) | diff --git a/scripts/mcp/README.md b/scripts/mcp/README.md index 00bc431e..a06e55a0 100644 --- a/scripts/mcp/README.md +++ b/scripts/mcp/README.md @@ -42,9 +42,13 @@ The executor resolves `agentctl` helpers from the git main worktree scripts dire Scoped reads: `scripts/pi-subagent-readonly.ts` — builtin `path` only; `@`/`~` denied; directory walks must contain only allowlisted regular files. Approved tests use a separate snapshot; model evidence stays pristine. -### Cursor CLI compatibility (pi-subagents) +### Cursor CLI / IDE compatibility -Real Cursor CLI `2026.09.18` (and `2026.09.23` verified here) does **not** expand `${workspaceFolder}` in MCP `args`. The `pi-subagents` entry therefore uses workspace-relative `args: ["scripts/mcp/pi-subagents-mcp.mjs"]` with `type: "stdio"` / `command: "node"`. Absolute `${workspaceFolder}/…` paths fail with `Cannot find module '…/${workspaceFolder}/scripts/mcp/pi-subagents-mcp.mjs'`. After `agent mcp enable pi-subagents`, `agent mcp list-tools pi-subagents` must report exactly three tools: `pi_subagent_roles`, `pi_subagent_run`, `pi_subagent_result`. Older `agentctl` / `jev` entries may still use `${workspaceFolder}` until separately corrected. +Real Cursor CLI `2026.09.18+` does **not** expand `${workspaceFolder}` in MCP `args`. All Node MCP entries use workspace-relative `args: ["scripts/mcp/….mjs"]` with `"type": "stdio"`. + +**Response framing:** Cursor stdio MCP expects **newline-delimited JSON-RPC** responses (same as `pi-subagents-mcp.mjs`). `agentctl-mcp.mjs` / `jev-mcp.mjs` historically replied with `Content-Length` framing, which prevented IDE loading even after path fixes; they now emit NDJSON while still accepting Content-Length *requests* on stdin. + +After reload + enable: `agent mcp list-tools agentctl` / `jev` / `pi-subagents` should list tools. Older Connection-failed notes that only cited `${workspaceFolder}` are incomplete without the framing check. ### Limitations (v1) diff --git a/scripts/mcp/agentctl-mcp.mjs b/scripts/mcp/agentctl-mcp.mjs index e1ea8d80..70c75c49 100755 --- a/scripts/mcp/agentctl-mcp.mjs +++ b/scripts/mcp/agentctl-mcp.mjs @@ -4,7 +4,6 @@ * lease_run passes command as argv after `--` — no extra shell. */ import { spawnSync } from 'node:child_process'; -import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; @@ -339,8 +338,10 @@ function callTool(name, args = {}) { } function send(msg) { + // Cursor IDE/CLI stdio MCP expects newline-delimited JSON-RPC (same as pi-subagents). + // Still accept Content-Length framed *requests* on stdin for compatibility. const body = JSON.stringify(msg); - process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`); + process.stdout.write(body + '\n'); } function handle(msg) { diff --git a/scripts/mcp/jev-mcp.mjs b/scripts/mcp/jev-mcp.mjs index 3ce7faf8..3339072b 100755 --- a/scripts/mcp/jev-mcp.mjs +++ b/scripts/mcp/jev-mcp.mjs @@ -165,8 +165,10 @@ async function callTool(name, args = {}) { } function send(msg) { + // Cursor IDE/CLI stdio MCP expects newline-delimited JSON-RPC (same as pi-subagents). + // Still accept Content-Length framed *requests* on stdin for compatibility. const body = JSON.stringify(msg); - process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`); + process.stdout.write(body + '\n'); } function handle(msg) { diff --git a/scripts/tests/test_mcp_ndjson_framing.py b/scripts/tests/test_mcp_ndjson_framing.py new file mode 100644 index 00000000..90b0f369 --- /dev/null +++ b/scripts/tests/test_mcp_ndjson_framing.py @@ -0,0 +1,70 @@ +"""Stdio MCP framing: Cursor expects NDJSON responses (pi-subagents style).""" +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +MCP = REPO / "scripts" / "mcp" + + +class McpNdjsonFramingTest(unittest.TestCase): + def _handshake(self, script: Path) -> dict: + """Send NDJSON initialize; expect a single NDJSON JSON-RPC response line.""" + req = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "unittest", "version": "0"}, + }, + } + r = subprocess.run( + ["node", str(script)], + input=json.dumps(req) + "\n", + capture_output=True, + text=True, + cwd=str(REPO), + timeout=10, + check=False, + ) + out = (r.stdout or "").strip() + # Must not use Content-Length response framing for Cursor IDE. + self.assertFalse( + out.lower().startswith("content-length:"), + f"{script.name} still uses Content-Length response: {out[:120]!r}", + ) + lines = [ln for ln in out.splitlines() if ln.strip()] + self.assertTrue(lines, f"{script.name} produced no stdout: stderr={r.stderr!r}") + msg = json.loads(lines[0]) + self.assertEqual(msg.get("jsonrpc"), "2.0") + self.assertEqual(msg.get("id"), 1) + self.assertIn("result", msg) + return msg + + def test_agentctl_initialize_ndjson(self): + msg = self._handshake(MCP / "agentctl-mcp.mjs") + self.assertEqual(msg["result"]["serverInfo"]["name"], "agentctl") + + def test_jev_initialize_ndjson(self): + msg = self._handshake(MCP / "jev-mcp.mjs") + self.assertEqual(msg["result"]["serverInfo"]["name"], "jev") + + def test_pi_subagents_initialize_ndjson(self): + msg = self._handshake(MCP / "pi-subagents-mcp.mjs") + self.assertEqual(msg["result"]["serverInfo"]["name"], "pi-subagents") + + def test_mcp_json_stdio_type_for_all_node_servers(self): + cfg = json.loads((REPO / ".cursor" / "mcp.json").read_text(encoding="utf-8")) + for name in ("agentctl", "jev", "pi-subagents"): + entry = cfg["mcpServers"][name] + self.assertEqual(entry.get("type"), "stdio", name) + self.assertTrue(any(a.startswith("scripts/") for a in entry["args"]), name) + + +if __name__ == "__main__": + unittest.main()