diff --git a/apps/desktop/electron/main/plugin-mcp.ts b/apps/desktop/electron/main/plugin-mcp.ts index aa5fbfd4d4..dfa93f2e66 100644 --- a/apps/desktop/electron/main/plugin-mcp.ts +++ b/apps/desktop/electron/main/plugin-mcp.ts @@ -48,7 +48,7 @@ export type JsonRpcMessage = { * the client speak the same JSON-RPC dialect over a pipe or over HTTP. */ export type McpTransport = { - send: (message: JsonRpcMessage) => Promise; + send: (message: JsonRpcMessage, timeoutMs?: number) => Promise; close: () => void; }; @@ -297,11 +297,11 @@ function createHttpTransport( const activeControllers = new Set(); return { - send: async (message) => { + send: async (message, timeoutMs = options.timeoutMs) => { if (closed) throw mcpError("UNAVAILABLE", "mcp session is closed"); const controller = new AbortController(); activeControllers.add(controller); - const timer = setTimeout(() => controller.abort(), options.timeoutMs); + const timer = setTimeout(() => controller.abort(), timeoutMs); let url = options.url; try { for (let hop = 0; ; hop += 1) { @@ -651,7 +651,7 @@ export class McpServerClient { rejectPromise(mcpError("TIMEOUT", `mcp ${method} timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer }); - void transport.send({ jsonrpc: "2.0", id, method, params }).catch((error: Error) => { + void transport.send({ jsonrpc: "2.0", id, method, params }, timeoutMs).catch((error: Error) => { const entry = this.pending.get(id); if (!entry) return; this.pending.delete(id); diff --git a/apps/desktop/test/plugin-mcp.test.mjs b/apps/desktop/test/plugin-mcp.test.mjs index 2d55608806..3082748a80 100644 --- a/apps/desktop/test/plugin-mcp.test.mjs +++ b/apps/desktop/test/plugin-mcp.test.mjs @@ -262,7 +262,7 @@ test("a slow server times out instead of hanging the load", async (t) => { }); /** Streamable-HTTP stub: JSON for the handshake, SSE for discovery. */ -async function startHttpServer(t) { +async function startHttpServer(t, { slowToolDelayMs } = {}) { const requests = []; const server = createServer((req, res) => { const chunks = []; @@ -291,7 +291,7 @@ async function startHttpServer(t) { `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, - result: { tools: [{ name: "headers" }] }, + result: { tools: [{ name: "headers" }, ...(slowToolDelayMs ? [{ name: "slow" }] : [])] }, })}\n\n`, ); return; @@ -314,6 +314,17 @@ async function startHttpServer(t) { ); return; } + if (message.params?.name === "slow" && slowToolDelayMs) { + setTimeout(() => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "finished" }] }, + })); + }, slowToolDelayMs); + return; + } res.writeHead(503).end("unavailable"); }); }); @@ -347,6 +358,22 @@ test("a remote mcp server negotiates over http and keeps its session", async (t) assert.equal(requests.at(-1).headers["mcp-session-id"], "sess-42"); }); +test("a remote MCP tool can run longer than the connection timeout", async (t) => { + const { url } = await startHttpServer(t, { slowToolDelayMs: 80 }); + const client = new McpServerClient({ + rootPath: mkdtempSync(join(tmpdir(), "pi-mcp-http-")), + server: { id: "remote", transport: "http", url }, + values: {}, + connectTimeoutMs: 20, + callTimeoutMs: 500, + }); + t.after(() => client.close()); + + assert.deepEqual((await client.connect()).map((tool) => tool.name), ["headers", "slow"]); + const result = await client.callTool("slow", {}); + assert.equal(describeMcpContent(result.content), "finished"); +}); + test("an http failure is reported as HTTP_ERROR", async (t) => { const { url } = await startHttpServer(t); const client = new McpServerClient({ diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 5ffa65dbff..c0c86e58b2 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -2769,8 +2769,8 @@ identify the platform validation still needed. #### E2E-024K: Plugin MCP server tools reach the agent - **Preconditions**: A plugin declaring one `stdio` and one non-loopback HTTP MCP server against trusted local-network stubs; `mcp.server.local` and `mcp.server.remote` granted; the HTTP host is listed in `net.domains`; a settings key holding the stub credential. -- **Steps**: 1) Enable the plugin and confirm no server process starts yet. 2) Ask the agent to call a discovered tool. 3) Inspect the stub's received environment/headers. 4) Make the stub fail a call and time one out. 5) Grow the stdio stub's catalog past the old 64-tool cap and re-discover it. 6) Disable the plugin. -- **Expected**: Servers connect lazily on first use; tools appear as `plugin_demo_*__` at `risk: "medium"` with per-call audit; the stdio child receives only the declared `env` values plus PATH/temp/locale, never host provider keys; the non-loopback HTTP endpoint is accepted only because its host is declared, and its unencrypted transport is visible in review; a redirect to an undeclared host is blocked before the second request; failures and timeouts return tool errors without crashing the plugin or the host; a catalog larger than the old 64-tool cap arrives whole, while a server that breaks a per-server guard (count, pages, cursor, traversal time) is refused instead of contributing a prefix of its catalog; disable disconnects both servers. +- **Steps**: 1) Enable the plugin and confirm no server process starts yet. 2) Ask the agent to call a discovered tool. 3) Inspect the stub's received environment/headers. 4) Make the stub fail a call, then run an HTTP tool that takes longer than the 10s handshake budget but less than the 100s call budget, then time a call out beyond its own budget. 5) Grow the stdio stub's catalog past the old 64-tool cap and re-discover it. 6) Disable the plugin. +- **Expected**: Servers connect lazily on first use; tools appear as `plugin_demo_*__` at `risk: "medium"` with per-call audit; the stdio child receives only the declared `env` values plus PATH/temp/locale, never host provider keys; the non-loopback HTTP endpoint is accepted only because its host is declared, and its unencrypted transport is visible in review; a redirect to an undeclared host is blocked before the second request; the HTTP tool can finish after the handshake budget while a call that exceeds its own budget fails; other failures and timeouts return tool errors without crashing the plugin or the host; a catalog larger than the old 64-tool cap arrives whole, while a server that breaks a per-server guard (count, pages, cursor, traversal time) is refused instead of contributing a prefix of its catalog; disable disconnects both servers. - **Specs linked**: `07-plugins/02-plugin-manifest-schema.md`, `07-plugins/04-plugin-security.md` §8.1, ADR 0038, ADR 0142, D176, D281, D452 - **Acceptance**: G (MCP bridge) + E (tools & permissions) + Security - **Status**: Unit-covered (`plugin-mcp.test.mjs` stdio + HTTP stubs); agent-facing scenario Draft diff --git a/docs/spec/07-plugins/04-plugin-security.md b/docs/spec/07-plugins/04-plugin-security.md index f9ca2221ae..ffaac8bc16 100644 --- a/docs/spec/07-plugins/04-plugin-security.md +++ b/docs/spec/07-plugins/04-plugin-security.md @@ -366,7 +366,9 @@ manifest did not name: literal secret in the manifest is a review smell, not a supported pattern (D018). - Connection budget: 10s to complete `initialize`, 100s per `tools/call`, 4MB - per stdio line. `tools/list` is followed to its last page under the per-server + per stdio line. Remote HTTP requests use the budget of the operation they + carry, so a successful handshake does not impose its 10s limit on a later + tool call. `tools/list` is followed to its last page under the per-server guards of §8.1 — 2048 tools, 100 pages, a cursor that repeats or is malformed, and 30s for the whole traversal — and a server that breaks one is refused rather than contributing a prefix of its catalog, because MCP tools reach the diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 434b3daef2..58176c5f0b 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -1386,8 +1386,8 @@ task-candidate E2E 从请求工作树运行,但使用主工作区已经准备 #### E2E-024K:插件 MCP 服务器工具到达代理 - **先决条件**:针对可信局域网存根声明一个 `stdio` 和一个非回环 HTTP MCP 服务器的插件;已授予 `mcp.server.local` 和 `mcp.server.remote`;HTTP 主机已列入 `net.domains`;保存存根凭证的设置密钥。 -- **步骤**: 1) 启用插件并确认尚未启动服务器进程。 2) 要求代理调用已发现的工具。 3) 检查存根收到的 environment/headers。 4) 使存根调用失败并超时。 5) 让 stdio 存根的目录超过旧的 64 个工具上限并重新发现。 6) 禁用插件。 -- **预期**:服务器在首次使用时延迟连接;工具在 `risk: "medium"` 上显示为 `plugin_demo_*__`,并进行每次调用审核;stdio 子级仅接收声明的 `env` 值加上 PATH/temp/locale,从不接收主机提供程序密钥;非回环 HTTP 端点只有在主机列入白名单后才会接受,未加密传输会在审查中显示;跳转到未声明主机时会在第二次请求前阻止;失败和超时会返回工具错误,而不会导致插件或主机崩溃;大于旧的 64 个工具上限的目录会完整到达,而突破某项每服务器护栏(数量、页数、游标、遍历时间)的服务器会被拒绝,而不是贡献其目录的一个前缀;禁用会断开两个服务器的连接。 +- **步骤**: 1) 启用插件并确认尚未启动服务器进程。 2) 要求代理调用已发现的工具。 3) 检查存根收到的 environment/headers。 4) 使存根调用失败,再执行耗时超过 10 秒握手预算但少于 100 秒调用预算的 HTTP 工具,最后让另一次调用超过自身预算。 5) 让 stdio 存根的目录超过旧的 64 个工具上限并重新发现。 6) 禁用插件。 +- **预期**:服务器在首次使用时延迟连接;工具在 `risk: "medium"` 上显示为 `plugin_demo_*__`,并进行每次调用审核;stdio 子级仅接收声明的 `env` 值加上 PATH/temp/locale,从不接收主机提供程序密钥;非回环 HTTP 端点只有在主机列入白名单后才会接受,未加密传输会在审查中显示;跳转到未声明主机时会在第二次请求前阻止;HTTP 工具可在握手预算之后完成,超过自身预算的调用失败;其他失败和超时会返回工具错误,而不会导致插件或主机崩溃;大于旧的 64 个工具上限的目录会完整到达,而突破某项每服务器护栏(数量、页数、游标、遍历时间)的服务器会被拒绝,而不是贡献其目录的一个前缀;禁用会断开两个服务器的连接。 - **链接规格**:`07-plugins/02-plugin-manifest-schema.md`、`07-plugins/04-plugin-security.md` §8.1、ADR 0038、ADR 0142、D176、D281、D452 - **接受**:G(MCP 桥)+ E(工具和权限)+ 安全 - **状态**:单位覆盖(`plugin-mcp.test.mjs` stdio + HTTP 存根);面向代理的场景草稿 diff --git a/docs/zh-CN/spec/07-plugins/04-plugin-security.md b/docs/zh-CN/spec/07-plugins/04-plugin-security.md index ed83393f6a..0622d7d6af 100644 --- a/docs/zh-CN/spec/07-plugins/04-plugin-security.md +++ b/docs/zh-CN/spec/07-plugins/04-plugin-security.md @@ -285,7 +285,7 @@ MCP 服务器是 `net.fetch` 旁边的第二个出口路径,因此它是声明 清单中的字面秘密是审查气味,而不是受支持的模式 (D018)。 - 连接预算:完成 `initialize` 需要 10 秒,每个 `tools/call` 需要 100 秒,每条 - stdio 线 4MB。`tools/list` 在 §8.1 的每服务器护栏下跟进到最后一页 + stdio 线 4MB。远程 HTTP 请求采用其对应操作的预算;完成握手后,后续工具调用不会继续受 10 秒握手预算限制。`tools/list` 在 §8.1 的每服务器护栏下跟进到最后一页 ——2048 个工具、100 页、重复或畸形游标、整轮遍历 30 秒——突破任一护栏的服务器会被 拒绝,而不是贡献其目录的一个前缀,因为 MCP 工具是以延迟加载的按需条目 (`ToolSearch` 之后)而非常驻列表的形式到达模型的。服务器按需连接,