diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 5e8be2ed..ed4d9fcc 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -476,9 +476,23 @@ export class MiftahServer { async close(): Promise { this.profileTransitionSession += 1; this.profileTransitionConfirmations = new WeakMap(); - await this.unsubscribeResourceSubscriptions(() => true); - await this.server.close(); - await this.upstreams.close(); + let closeFailure: { readonly error: unknown } | undefined; + try { + await this.unsubscribeResourceSubscriptions(() => true); + } catch (error) { + closeFailure = { error }; + } + try { + await this.server.close(); + } catch (error) { + closeFailure ??= { error }; + } + try { + await this.upstreams.close(); + } catch (error) { + closeFailure ??= { error }; + } + if (closeFailure !== undefined) throw closeFailure.error; await this.auditTrail.writeLifecycle({ operation: "wrapper/shutdown", name: this.config.name, diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index 3952ac0e..fe6f22e7 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -176,6 +176,58 @@ class DropInitializedNotificationTransport implements Transport { } } +class RejectingCloseTransport implements Transport { + constructor( + private readonly delegate: Transport, + private readonly closeError: Error + ) {} + + get onclose(): Transport["onclose"] { + return this.delegate.onclose; + } + + set onclose(handler: Transport["onclose"]) { + this.delegate.onclose = handler; + } + + get onerror(): Transport["onerror"] { + return this.delegate.onerror; + } + + set onerror(handler: Transport["onerror"]) { + this.delegate.onerror = handler; + } + + get onmessage(): Transport["onmessage"] { + return this.delegate.onmessage; + } + + set onmessage(handler: Transport["onmessage"]) { + this.delegate.onmessage = handler; + } + + get sessionId(): string | undefined { + return this.delegate.sessionId; + } + + get setProtocolVersion(): Transport["setProtocolVersion"] { + return this.delegate.setProtocolVersion; + } + + async start(): Promise { + await this.delegate.start(); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + await this.delegate.send(message, options); + } + + async close(): Promise { + await this.delegate.close(); + throw this.closeError; + } +} + function deferred(): { readonly promise: Promise; resolve(): void } { let resolvePromise: (() => void) | undefined; const promise = new Promise((resolve) => { @@ -216,6 +268,38 @@ interface ProfileManagementHost { }; } +describe("Miftah server lifecycle", () => { + it("stops upstream processes when downstream transport close rejects", async () => { + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { work: { env: { TEST_ACCOUNT_NAME: "work" } } }, + audit: { enabled: false } + }); + const upstreams = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer(config, new ProfileManager(config), upstreams); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "rejecting-close-client", version: "1.0.0" }); + const closeError = new Error("simulated downstream close failure"); + + try { + await Promise.all([ + wrapper.connect(new RejectingCloseTransport(serverTransport, closeError)), + client.connect(clientTransport) + ]); + await client.listTools(); + expect(upstreams.listHealth()).toMatchObject([{ profile: "work", processState: "running" }]); + + await expect(wrapper.close()).rejects.toBe(closeError); + expect(upstreams.listHealth()).toMatchObject([{ profile: "work", processState: "stopped", pid: null }]); + } finally { + await Promise.allSettled([client.close(), wrapper.close(), upstreams.close()]); + } + }); +}); + describe("Miftah MCP wrapper", () => { it("uses explicitly trusted tool annotations and records risk provenance", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-risk-annotations-"));