From e2f816963f6a199cda29137b125b28476c855fa7 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 11 Oct 2026 02:30:30 +0800 Subject: [PATCH 1/2] fix(plugins): preserve outcomes when SDK cleanup fails --- apps/server/src/plugin-transport.test.ts | 95 +++++++++++++++++++++++- apps/server/src/plugin-transport.ts | 2 + 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/server/src/plugin-transport.test.ts b/apps/server/src/plugin-transport.test.ts index eb41d616..0e97eb8b 100644 --- a/apps/server/src/plugin-transport.test.ts +++ b/apps/server/src/plugin-transport.test.ts @@ -1,7 +1,9 @@ import { createServer } from "node:http"; import { once } from "node:events"; -import { expect, it } from "vitest"; -import { PluginTransport } from "./plugin-transport.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { expect, it, vi } from "vitest"; +import { contractPluginFixture } from "../../../scripts/contract-plugin-fixture.ts"; +import { PluginTransport, readPluginContent, withPlugin } from "./plugin-transport.js"; it("pins one explicit local endpoint, strips ambient authority, bounds bytes and terminates the session", async () => { let count = 0, mode = "normal", @@ -73,10 +75,10 @@ it("pins one explicit local endpoint, strips ambient authority, bounds bytes and } }); it("refuses a mixed public/private DNS answer before opening a socket", async () => { - const resolve = (async () => [ + const resolve = async () => [ { address: "8.8.8.8", family: 4 }, { address: "127.0.0.1", family: 4 }, - ]); + ]; const transport = new PluginTransport( "https://example.com/mcp", undefined, @@ -108,3 +110,88 @@ it("cancellation settles an unresolved resolver without sending a request later" await refused; finish?.([{ address: "8.8.8.8", family: 4 }]); }); + +it.each([ + ["tool", false], + ["resource", false], + ["tool", true], + ["resource", true], +] as const)( + "retains an observed %s result when SDK close fails (deadline expired: %s)", + async (kind, expired) => { + const fixture = await contractPluginFixture(); + const abort = new AbortController(); + const close = Client.prototype.close; + const closing = vi.spyOn(Client.prototype, "close").mockImplementation(async function ( + this: Client, + ) { + if (expired) abort.abort(new Error("Synthetic operation deadline.")); + await close.call(this); + throw new Error("Synthetic SDK cleanup failure."); + }); + try { + const result = await withPlugin( + fixture.endpoint, + fixture.token, + [fixture.endpoint], + abort.signal, + async (client) => + kind === "tool" + ? client.callTool({ name: "read_value", arguments: {} }, undefined, { + signal: abort.signal, + }) + : readPluginContent(client, "resource", "notes://public/info", {}, abort.signal), + ); + expect(result).toEqual( + kind === "tool" + ? { content: [{ type: "text", text: "42" }] } + : { + contents: [ + { uri: "notes://public/info", mimeType: "text/plain", text: "Untrusted 文档 🧪" }, + ], + }, + ); + const stats = await fetch(fixture.endpoint.replace("/mcp", "/fixture-control"), { + method: "POST", + headers: { Authorization: "Bearer " + fixture.controllerToken }, + body: "{}", + }).then((response) => response.json()); + expect(stats).toMatchObject({ + toolCalls: kind === "tool" ? 1 : 0, + resourceReads: kind === "resource" ? 1 : 0, + deletedSessions: 1, + }); + expect(closing).toHaveBeenCalledTimes(1); + } finally { + closing.mockRestore(); + await fixture.close(); + } + }, +); + +it.each(["operation failure", "caller cancellation"])( + "preserves %s when SDK close also fails", + async (mode) => { + const fixture = await contractPluginFixture(); + const abort = new AbortController(); + const original = new Error("Synthetic " + mode); + const close = Client.prototype.close; + const closing = vi.spyOn(Client.prototype, "close").mockImplementation(async function ( + this: Client, + ) { + await close.call(this); + throw new Error("Synthetic SDK cleanup failure."); + }); + if (mode === "caller cancellation") abort.abort(original); + try { + await expect( + withPlugin(fixture.endpoint, fixture.token, [fixture.endpoint], abort.signal, async () => { + throw original; + }), + ).rejects.toBe(original); + } finally { + closing.mockRestore(); + await fixture.close(); + } + }, +); diff --git a/apps/server/src/plugin-transport.ts b/apps/server/src/plugin-transport.ts index c4e74f06..84b0859a 100644 --- a/apps/server/src/plugin-transport.ts +++ b/apps/server/src/plugin-transport.ts @@ -311,6 +311,8 @@ export async function withPlugin( signal.removeEventListener("abort", abort); try { await client.close(); + } catch { + // Cleanup cannot replace an observed result or the operation's original failure. } finally { await transport.finish(); } From df2ccbaf90a696648ca8a09d3df4eedbb7b5db54 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 11 Oct 2026 02:40:51 +0800 Subject: [PATCH 2/2] fix(ci): build selected packages before checking their tests --- scripts/check-affected.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-affected.ts b/scripts/check-affected.ts index e3a62357..54642e11 100644 --- a/scripts/check-affected.ts +++ b/scripts/check-affected.ts @@ -46,8 +46,9 @@ else { `${name}: no package-local tests; consumer coverage only, not a zero-test pass.`, ); } - // Existing Turbo graph builds dependencies. Sequential phases prevent duplicate writers. - for (const task of ["typecheck", "test", "build"]) + // Tests import their own compiled entry too; ^build alone only prepares dependencies. + // Sequential phases prevent duplicate writers and make cold checkouts reproducible. + for (const task of ["build", "typecheck", "test"]) run([ "exec", "--",