Skip to content

Commit 8d0e451

Browse files
asachs01claude
andcommitted
fix(security): close cross-tenant server-ref misroute via AsyncLocalStorage
src/utils/server-ref.ts held the current request's MCP Server instance in a module-level `let _server` singleton, set synchronously per request (setServerRef, called from server.ts's createServer()) and read back later by elicitation helpers (utils/elicitation.ts) — including after await gaps inside async tool handlers. In gateway (multi-tenant HTTP) mode, http.ts creates a fresh Server per request. Under concurrent load this let two requests race through the shared global: tenant A's request sets the ref and suspends on an await (e.g. an in-flight N-central API call); before A resumes, tenant B's request runs to completion and overwrites the module-level ref with B's server; when A's awaited work resolves and calls elicitInput via getServerRef(), it gets B's server — A's confirmation prompt is sent down B's connection instead of A's. Confirmed live-exposed: AUTH_MODE=gateway / MCP_TRANSPORT=http on rg-conduit-prod today, though not yet observed live (distinct from the confirmed-live avanan-mcp incident). Fix: replace the module-level singleton with an AsyncLocalStorage context (runWithServerRef / bindServerRef / getServerRef), same shape as the existing itglue-mcp / atera-mcp / blackpoint-mcp / avanan-mcp / datto-rmm-mcp fixes for this identical shared-scaffold bug. http.ts now wraps the entire per-request connect/handleRequest/catch chain inside runWithServerRef so the bound context survives every await gap; index.ts's stdio entry point uses bindServerRef once for the process lifetime (single-session, no concurrent tenants to isolate). server.ts no longer touches server-ref at all — callers own binding scope, not the factory. Adds src/__tests__/server-ref.test.ts (ported from itglue-mcp's identical regression test): forces the exact cross-tenant interleave deterministically via a manually-resolved gate promise (not a timing stagger), and asserts BY VALUE which tenant's mock server received the elicitation call. Verified this test genuinely catches the bug, not just passes vacuously: temporarily reinstated the old module-singleton implementation behind the same function names — the new race-detection test failed with tenant A observing tenant B's server ref, exactly the described misroute; passes again with the ALS fix restored. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6566c7d commit 8d0e451

5 files changed

Lines changed: 248 additions & 35 deletions

File tree

src/__tests__/server-ref.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Regression test: cross-tenant "server reference" misrouting.
3+
*
4+
* Historically the server reference used by elicitation helpers
5+
* (`utils/elicitation.ts`) was stored in a module-level `let _server`
6+
* singleton in `utils/server-ref.ts` (`setServerRef` / `getServerRef`),
7+
* set synchronously per request and read back later — including after
8+
* `await` gaps inside async tool handlers (e.g. after awaiting an N-central
9+
* API call, before sending an elicitation/confirmation prompt back through
10+
* "the" server).
11+
*
12+
* In gateway (multi-tenant HTTP) mode a fresh `Server` is created per
13+
* request, so two concurrent requests raced through that shared global:
14+
* tenant A's request sets the ref and starts awaiting async work; before A
15+
* resumes, tenant B's request runs to completion and overwrites the
16+
* module-level ref with B's server/transport; when A's awaited work
17+
* resolves and it reads the ref back to call `elicitInput`, it gets B's
18+
* server — so A's confirmation prompt is sent down B's connection instead
19+
* of A's (or vice versa, depending on timing).
20+
*
21+
* The fix replaces the module-level singleton with an AsyncLocalStorage
22+
* context (`runWithServerRef` / `bindServerRef` / `getServerRef`), scoped
23+
* per request and correctly restored across await gaps.
24+
*
25+
* This test forces the exact interleave deterministically — via a
26+
* manually-resolved "gate" promise, not a timing-based stagger — and
27+
* asserts, BY VALUE, which tenant's mock server actually received the
28+
* elicitation call. Ported from itglue-mcp's identical regression test
29+
* (same bug, same fix pattern, same shared server-ref.ts template).
30+
*/
31+
import { describe, it, expect, vi } from "vitest";
32+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
33+
import { runWithServerRef, bindServerRef, getServerRef } from "../utils/server-ref.js";
34+
import { elicitConfirmation } from "../utils/elicitation.js";
35+
36+
/** A deferred promise the test can resolve on demand, for a deterministic forced interleave. */
37+
function createDeferred<T = void>() {
38+
let resolve!: (value: T) => void;
39+
const promise = new Promise<T>((res) => {
40+
resolve = res;
41+
});
42+
return { promise, resolve };
43+
}
44+
45+
type FakeServer = Server & { tenantId: string; elicitInput: ReturnType<typeof vi.fn> };
46+
47+
/** Minimal fake MCP Server whose elicitInput is a per-instance spy (per-tenant mock). */
48+
function createFakeServer(tenantId: string): FakeServer {
49+
const elicitInput = vi.fn().mockImplementation(async () => ({
50+
action: "accept" as const,
51+
content: { confirm: true },
52+
}));
53+
return { tenantId, elicitInput } as unknown as FakeServer;
54+
}
55+
56+
describe("server-ref cross-tenant isolation", () => {
57+
it("getServerRef returns null outside of any bound context", () => {
58+
expect(getServerRef()).toBeNull();
59+
});
60+
61+
it("getServerRef resolves the server bound by runWithServerRef within its scope", async () => {
62+
const server = createFakeServer("tenant-X");
63+
await runWithServerRef(server, async () => {
64+
expect(getServerRef()).toBe(server);
65+
});
66+
});
67+
68+
it(
69+
"routes each tenant's elicitation through its OWN server, even when a " +
70+
"second tenant's request runs to completion while the first is still " +
71+
"in flight (forced deterministic interleave, not a timing stagger)",
72+
async () => {
73+
const serverA = createFakeServer("tenant-A");
74+
const serverB = createFakeServer("tenant-B");
75+
const gate = createDeferred<void>();
76+
77+
// Tenant A: binds its server, then suspends on an await gap
78+
// (simulating an in-flight N-central API call inside a tool handler)
79+
// BEFORE sending its elicitation/confirmation prompt.
80+
const tenantA = runWithServerRef(serverA, async () => {
81+
await gate.promise; // the exact await gap the original bug lost the ref across
82+
expect((getServerRef() as FakeServer | null)?.tenantId).toBe("tenant-A"); // must still be A's server after resuming
83+
return elicitConfirmation("Confirm tenant A's sensitive action?");
84+
});
85+
86+
// Force the interleave: tenant B's ENTIRE request — bind, elicit,
87+
// resolve — runs to completion while tenant A is still suspended
88+
// above, exactly like a second concurrent HTTP request racing in.
89+
const tenantB = runWithServerRef(serverB, async () => {
90+
return elicitConfirmation("Confirm tenant B's sensitive action?");
91+
});
92+
await tenantB;
93+
94+
// Only now let tenant A resume.
95+
gate.resolve();
96+
const resultA = await tenantA;
97+
expect(resultA).toBe(true);
98+
99+
// --- Per-tenant VALUE assertions -----------------------------------
100+
// Each tenant's prompt must have gone out through THAT tenant's mock
101+
// server specifically, not the other tenant's.
102+
expect(serverA.elicitInput).toHaveBeenCalledTimes(1);
103+
expect(serverA.elicitInput).toHaveBeenCalledWith(
104+
expect.objectContaining({
105+
message: "Confirm tenant A's sensitive action?",
106+
})
107+
);
108+
109+
expect(serverB.elicitInput).toHaveBeenCalledTimes(1);
110+
expect(serverB.elicitInput).toHaveBeenCalledWith(
111+
expect.objectContaining({
112+
message: "Confirm tenant B's sensitive action?",
113+
})
114+
);
115+
116+
// Explicit negative checks: A's message must never have reached B's
117+
// transport, and B's must never have reached A's.
118+
for (const call of serverA.elicitInput.mock.calls) {
119+
expect(call[0].message).not.toBe(
120+
"Confirm tenant B's sensitive action?"
121+
);
122+
}
123+
for (const call of serverB.elicitInput.mock.calls) {
124+
expect(call[0].message).not.toBe(
125+
"Confirm tenant A's sensitive action?"
126+
);
127+
}
128+
}
129+
);
130+
131+
it("bindServerRef binds for the remainder of the current async execution (stdio single-session mode)", async () => {
132+
const server = createFakeServer("tenant-X");
133+
bindServerRef(server);
134+
// Simulate work continuing across an await gap in the same "session".
135+
await Promise.resolve();
136+
expect(getServerRef()).toBe(server);
137+
});
138+
});

src/http.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createServer as createHttpServer } from 'node:http';
1414
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
1515
import { createServer } from './server.js';
1616
import { getCredentials, runWithCredentials } from './utils/client.js';
17+
import { runWithServerRef } from './utils/server-ref.js';
1718
import { logger } from './utils/logger.js';
1819

1920
function headerValue(value: string | string[] | undefined): string | undefined {
@@ -65,36 +66,46 @@ function startHttpServer(): void {
6566
}
6667

6768
const handle = async () => {
68-
try {
69-
// Stateless: fresh Server + Transport per request.
70-
const server = createServer();
71-
const transport = new StreamableHTTPServerTransport({
72-
sessionIdGenerator: undefined,
73-
enableJsonResponse: true,
74-
});
69+
// Stateless: fresh Server + Transport per request.
70+
const server = createServer();
7571

76-
res.on('close', () => {
77-
transport.close();
78-
server.close();
79-
});
72+
// Bind this request's server into the per-request async context (not
73+
// a module-level global) so elicitation helpers resolve *this*
74+
// server/transport even after await gaps, and never a concurrent
75+
// request's — see utils/server-ref.ts. The whole connect/handleRequest/
76+
// catch chain must stay inside this callback so the bound context
77+
// survives every await gap between here and any later getServerRef()
78+
// call.
79+
await runWithServerRef(server, async () => {
80+
try {
81+
const transport = new StreamableHTTPServerTransport({
82+
sessionIdGenerator: undefined,
83+
enableJsonResponse: true,
84+
});
8085

81-
await server.connect(transport);
82-
await transport.handleRequest(req, res);
83-
} catch (error) {
84-
logger.error('MCP transport error', {
85-
error: error instanceof Error ? error.message : String(error),
86-
});
87-
if (!res.headersSent) {
88-
res.writeHead(500, { 'Content-Type': 'application/json' });
89-
res.end(
90-
JSON.stringify({
91-
jsonrpc: '2.0',
92-
error: { code: -32603, message: 'Internal error' },
93-
id: null,
94-
})
95-
);
86+
res.on('close', () => {
87+
transport.close();
88+
server.close();
89+
});
90+
91+
await server.connect(transport);
92+
await transport.handleRequest(req, res);
93+
} catch (error) {
94+
logger.error('MCP transport error', {
95+
error: error instanceof Error ? error.message : String(error),
96+
});
97+
if (!res.headersSent) {
98+
res.writeHead(500, { 'Content-Type': 'application/json' });
99+
res.end(
100+
JSON.stringify({
101+
jsonrpc: '2.0',
102+
error: { code: -32603, message: 'Internal error' },
103+
id: null,
104+
})
105+
);
106+
}
96107
}
97-
}
108+
});
98109
};
99110

100111
// Gateway mode: scope this request's credentials to an AsyncLocalStorage

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
*/
66
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
77
import { createServer } from './server.js';
8+
import { bindServerRef } from './utils/server-ref.js';
89
import { logger } from './utils/logger.js';
910

1011
const server = createServer();
12+
// stdio is single-session (one process = one caller), so there is no
13+
// concurrent tenant to isolate from — bind once for the process lifetime
14+
// rather than per-request. See utils/server-ref.ts.
15+
bindServerRef(server);
1116
const transport = new StdioServerTransport();
1217
await server.connect(transport);
1318
logger.info('N-central MCP server started (stdio)');

src/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
} from '@modelcontextprotocol/sdk/types.js';
66
import { getNavigationTools, handleNavigate, handleStatus } from './domains/navigation.js';
77
import { getAllDomainTools, getHandlerForTool } from './domains/index.js';
8-
import { setServerRef } from './utils/server-ref.js';
98
import { logger } from './utils/logger.js';
109
import type { DomainName } from './utils/types.js';
1110

@@ -15,7 +14,10 @@ export function createServer(): Server {
1514
{ capabilities: { tools: {} } }
1615
);
1716

18-
setServerRef(server);
17+
// Caller (index.ts / http.ts) is responsible for binding this server into
18+
// the per-request async context via runWithServerRef/bindServerRef — see
19+
// utils/server-ref.ts. Doing it here would set a module-level ref, the
20+
// exact cross-tenant bug this file used to have.
1921

2022
// Expose ALL tools flat, always: the informational helpers plus every
2123
// domain's tools. This matches the deployed WYRE fleet and lets one-shot

src/utils/server-ref.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,72 @@
11
/**
2-
* Shared MCP Server reference for elicitation support.
3-
* Avoids circular imports by decoupling the server instance from domain handlers.
2+
* Per-request MCP Server reference for elicitation support.
3+
*
4+
* Avoids circular imports by decoupling the server instance from domain
5+
* handlers, without leaking that reference across concurrent requests.
6+
*
7+
* SECURITY (cross-tenant misroute): this used to be a module-level
8+
* `let _server` singleton, set synchronously per request via `setServerRef`
9+
* and read back later via `getServerRef` — including after `await` gaps
10+
* inside async tool handlers (e.g. after awaiting an N-central API call,
11+
* before sending an elicitation/confirmation prompt back through "the"
12+
* server).
13+
*
14+
* In gateway (multi-tenant HTTP) mode a fresh `Server` is created per
15+
* request, so two concurrent requests race through that shared global:
16+
* tenant A's request sets the ref and starts awaiting async work; before A
17+
* resumes, tenant B's request runs and overwrites the module-level ref with
18+
* B's server/transport; when A's awaited work resolves and it reads the ref
19+
* back to call `elicitInput`, it gets B's server and A's confirmation prompt
20+
* is sent down B's connection instead of A's.
21+
*
22+
* Fixed by binding the server reference to an AsyncLocalStorage context
23+
* instead of a shared mutable variable. ALS scopes the value to the async
24+
* call graph it was entered from, and correctly restores it after arbitrary
25+
* `await` gaps, so concurrent requests can never observe each other's
26+
* server.
27+
*
28+
* There is intentionally no module-level mutable server/transport state in
29+
* this file.
430
*/
31+
import { AsyncLocalStorage } from 'node:async_hooks';
532
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
633

7-
let _server: Server | null = null;
34+
/**
35+
* Per-request server store.
36+
*/
37+
const serverRefStore = new AsyncLocalStorage<Server>();
38+
39+
/**
40+
* Run a callback with `server` bound to the async context for the duration
41+
* of that callback — including anything it `await`s or schedules (promise
42+
* chains, timers, etc). Use this for transports that create a fresh
43+
* `Server` per inbound request (HTTP, Workers), one call per request, so
44+
* concurrent requests never observe each other's server reference.
45+
*/
46+
export function runWithServerRef<T>(server: Server, fn: () => T): T {
47+
return serverRefStore.run(server, fn);
48+
}
849

9-
export function setServerRef(server: Server): void {
10-
_server = server;
50+
/**
51+
* Bind `server` for the remainder of the current synchronous execution and
52+
* all following async work, without requiring a wrapping callback.
53+
*
54+
* Only safe for single-session transports (stdio) where exactly one
55+
* `Server` instance lives for the whole process and there are no
56+
* concurrent tenants to isolate from each other. Do NOT use this for
57+
* per-request transports (HTTP / Workers) — use `runWithServerRef` there,
58+
* since `enterWith` has no natural "scope end" and would leak across
59+
* requests just like the old module-level singleton.
60+
*/
61+
export function bindServerRef(server: Server): void {
62+
serverRefStore.enterWith(server);
1163
}
1264

65+
/**
66+
* Get the server bound to the current request's async context, or `null`
67+
* if none is bound (e.g. called outside of a request/session, before a
68+
* server ref has been established).
69+
*/
1370
export function getServerRef(): Server | null {
14-
return _server;
71+
return serverRefStore.getStore() ?? null;
1572
}

0 commit comments

Comments
 (0)