Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/desktop/electron/main/plugin-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
send: (message: JsonRpcMessage, timeoutMs?: number) => Promise<void>;
close: () => void;
};

Expand Down Expand Up @@ -297,11 +297,11 @@ function createHttpTransport(
const activeControllers = new Set<AbortController>();

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) {
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 29 additions & 2 deletions apps/desktop/test/plugin-mcp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand All @@ -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");
});
});
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_*_<serverId>_<tool>` 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_*_<serverId>_<tool>` 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
Expand Down
4 changes: 3 additions & 1 deletion docs/spec/07-plugins/04-plugin-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_*_<serverId>_<tool>`,并进行每次调用审核;stdio 子级仅接收声明的 `env` 值加上 PATH/temp/locale,从不接收主机提供程序密钥;非回环 HTTP 端点只有在主机列入白名单后才会接受,未加密传输会在审查中显示;跳转到未声明主机时会在第二次请求前阻止;失败和超时会返回工具错误,而不会导致插件或主机崩溃;大于旧的 64 个工具上限的目录会完整到达,而突破某项每服务器护栏(数量、页数、游标、遍历时间)的服务器会被拒绝,而不是贡献其目录的一个前缀;禁用会断开两个服务器的连接。
- **步骤**: 1) 启用插件并确认尚未启动服务器进程。 2) 要求代理调用已发现的工具。 3) 检查存根收到的 environment/headers。 4) 使存根调用失败,再执行耗时超过 10 秒握手预算但少于 100 秒调用预算的 HTTP 工具,最后让另一次调用超过自身预算。 5) 让 stdio 存根的目录超过旧的 64 个工具上限并重新发现。 6) 禁用插件。
- **预期**:服务器在首次使用时延迟连接;工具在 `risk: "medium"` 上显示为 `plugin_demo_*_<serverId>_<tool>`,并进行每次调用审核;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 存根);面向代理的场景草稿
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/spec/07-plugins/04-plugin-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 之后)而非常驻列表的形式到达模型的。服务器按需连接,
Expand Down
Loading