Skip to content

Commit 0d691da

Browse files
asachs01claude
andcommitted
fix(security): eliminate cross-tenant server-ref misroute in elicitation
The "server reference" used by elicitation helpers (elicitSelection / elicitText / elicitConfirmation) was stored in a module-level `let _server` singleton in src/utils/server-ref.ts, set synchronously per request via setServerRef() and read back later via getServerRef() -- including after await gaps inside async tool handlers. In gateway (multi-tenant HTTP) mode, a fresh Server is created per request, so two concurrent tenant requests could race through the shared global and misroute an elicitation/ confirmation prompt down the wrong tenant's connection. Replaces the singleton with an AsyncLocalStorage<Server> context (runWithServerRef for per-request transports -- Node HTTP, Workers -- and bindServerRef for the single-session stdio transport), mirroring the credentialStore pattern already used in this repo for Syncro credential isolation. There is now zero module-level mutable server/transport state. Identical bug and fix pattern as halopsa-mcp#65 (the reference PR for this 6-repo remediation wave); adapted to this repo's index.ts/worker.ts control flow, which otherwise matched halopsa's shape. Adds tests/utils/server-ref.test.ts: a deterministic forced-interleave regression test (manually-resolved gate promise, not a timer) with per-tenant mock Server value assertions and negative cross-checks. Verified to fail with the predicted wrong-tenant symptom against a reinstated module-singleton implementation, and to pass against the ALS-based fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6d8da78 commit 0d691da

6 files changed

Lines changed: 293 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,55 @@
11
## [Unreleased]
22

3+
### Security
4+
5+
- **Cross-tenant elicitation/confirmation misroute (gateway mode).** The
6+
"server reference" used by elicitation helpers (`src/utils/elicitation.ts`
7+
`elicitSelection` / `elicitText` / `elicitConfirmation`) was stored in a
8+
module-level `let _server: Server | null` singleton in
9+
`src/utils/server-ref.ts`, set synchronously per request via
10+
`setServerRef(server)` (called from `createMcpServer()` in
11+
`src/mcp-server.ts`) and read back later via `getServerRef()` — including
12+
after `await` gaps inside async tool handlers (e.g. after awaiting a
13+
Syncro API call, before sending an elicitation or confirmation prompt back
14+
through "the" server).
15+
- **Impact:** in gateway (multi-tenant HTTP) mode — `AUTH_MODE=gateway`
16+
a fresh `Server` instance is created per inbound request, so two
17+
concurrent tenant requests could race through the shared global: tenant
18+
A's request sets the ref and starts awaiting async work; before A
19+
resumes, tenant B's request runs and overwrites the module-level ref
20+
with B's server/transport; when A's awaited work resolves and it reads
21+
the ref back to call `elicitInput`, it gets B's server — so A's
22+
elicitation/confirmation prompt is sent down B's connection instead of
23+
A's (or vice versa, depending on timing). Same shared-mutable-state
24+
-across-await-gaps bug class as the credential-leak fixes in
25+
liongard-mcp#58, ninjaone-mcp#71, and the identical server-ref fix in
26+
halopsa-mcp#65, but in the server/transport routing subsystem for
27+
elicitation, not credential/token caching.
28+
- **Fix:** replaced the module-level singleton with an `AsyncLocalStorage<Server>`
29+
context (`runWithServerRef` for per-request transports — Node HTTP,
30+
Workers — and `bindServerRef` for the single-session stdio transport),
31+
mirroring the existing per-request credential isolation pattern already
32+
used for Syncro credentials (`credentialStore` in
33+
`src/utils/credential-store.ts`). `getServerRef()` now reads from the
34+
ALS context instead of a shared variable, so it is correctly scoped to
35+
the request that created it and survives arbitrary `await` gaps without
36+
observing a concurrent request's server. There is no module-level
37+
mutable server/transport state left in `src/utils/server-ref.ts`. Call
38+
sites updated: `src/mcp-server.ts` (`createMcpServer` no longer calls
39+
`setServerRef`), `src/index.ts` (Node HTTP `handleMcp` wraps the
40+
connect/handleRequest chain in `runWithServerRef`; stdio calls
41+
`bindServerRef` once at startup), `src/worker.ts` (Cloudflare Workers
42+
`handleMcp` wraps the connect/handleRequest chain in `runWithServerRef`).
43+
- **Regression test:** `tests/utils/server-ref.test.ts` forces a
44+
deterministic interleave (a manually-resolved "gate" promise, not a
45+
timing stagger) where tenant A binds its server and suspends on an
46+
await gap, tenant B's entire request runs to completion in the
47+
meantime, and only then does tenant A resume and elicit. The test
48+
asserts by value which tenant's mock `elicitInput` actually received
49+
each message. Verified to fail with the exact predicted symptom
50+
(`expected 'tenant-B' to be 'tenant-A'`) against a reinstated
51+
module-singleton implementation, and to pass against the ALS-based fix.
52+
353
### Added
454

555
- **Interactive ticket card via MCP Apps (SEP-1865).** `syncro_tickets_get` results now render as an interactive card in MCP Apps hosts (Claude Desktop/web, and other hosts advertising the `io.modelcontextprotocol/ui` extension), instead of a wall of JSON. The card shows the ticket subject, status, problem type, resolved customer/contact names, key dates, and recent comments — and includes a working "Add comment" round-trip that calls `syncro_tickets_add_comment` with a safe internal-only (`hidden: true`) default resolved server-side. Non-App hosts are unaffected: the tool's JSON payload is unchanged apart from a new `_card` field.

src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
2424
import { createServer, IncomingMessage, ServerResponse } from "node:http";
2525
import { createMcpServer } from "./mcp-server.js";
2626
import { credentialStore } from "./utils/credential-store.js";
27+
import { runWithServerRef, bindServerRef } from "./utils/server-ref.js";
2728

2829
/**
2930
* Extract gateway credentials from HTTP request headers.
@@ -108,8 +109,14 @@ async function startHttpTransport(): Promise<void> {
108109
server.close();
109110
});
110111

111-
server.connect(transport).then(() => {
112-
transport.handleRequest(req, res);
112+
// Bind this request's server into the per-request async context
113+
// (not a module-level global) so elicitation helpers resolve
114+
// *this* server/transport even after await gaps, and never a
115+
// concurrent request's — see utils/server-ref.ts.
116+
runWithServerRef(server, () => {
117+
server.connect(transport).then(() => {
118+
transport.handleRequest(req, res);
119+
});
113120
});
114121
};
115122

@@ -162,6 +169,10 @@ async function main() {
162169
await startHttpTransport();
163170
} else {
164171
const server = createMcpServer();
172+
// stdio is single-session (one process = one caller), so there is no
173+
// concurrent tenant to isolate from — bind once for the process
174+
// lifetime rather than per-request. See utils/server-ref.ts.
175+
bindServerRef(server);
165176
const transport = new StdioServerTransport();
166177
await server.connect(transport);
167178
console.error("Syncro MCP server running on stdio");

src/mcp-server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
1919
import { getDomainHandler, getAvailableDomains } from "./domains/index.js";
2020
import { isDomainName, type DomainName } from "./utils/types.js";
2121
import { getCredentials } from "./utils/client.js";
22-
import { setServerRef } from "./utils/server-ref.js";
2322
import { registerResourceHandlers } from "./resources.js";
2423
import type { RequestCredentials } from "./utils/credential-store.js";
2524

@@ -162,6 +161,12 @@ export function resolveGatewayCredentials(
162161
* which reads from the per-request AsyncLocalStorage store (gateway mode)
163162
* or process.env (env / stdio mode). The Workers entrypoint runs each
164163
* request inside `credentialStore.run()` so isolation holds there too.
164+
*
165+
* The returned server is likewise NOT registered as "the" server anywhere
166+
* here — callers are responsible for binding it into the per-request
167+
* `server-ref` AsyncLocalStorage context (via `runWithServerRef` /
168+
* `bindServerRef`) so elicitation helpers resolve the right server even
169+
* after await gaps. See `utils/server-ref.ts` for why this matters.
165170
*/
166171
export function createMcpServer(): Server {
167172
const server = new Server(
@@ -176,7 +181,6 @@ export function createMcpServer(): Server {
176181
},
177182
}
178183
);
179-
setServerRef(server);
180184
registerResourceHandlers(server);
181185

182186
/**

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 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 a Syncro API call, before
11+
* sending an elicitation/confirmation prompt back through "the" server).
12+
*
13+
* In gateway (multi-tenant HTTP) mode a fresh `Server` is created per
14+
* request, so two concurrent requests race through that shared global:
15+
* tenant A's request sets the ref and starts awaiting async work; before A
16+
* resumes, tenant B's request runs and overwrites the module-level ref with
17+
* B's server/transport; when A's awaited work resolves and it reads the ref
18+
* back to call `elicitInput`, it gets B's server and A's confirmation prompt
19+
* is sent down B's connection instead of A's.
20+
*
21+
* Fixed by binding the server reference to an AsyncLocalStorage context
22+
* instead of a shared mutable variable. ALS scopes the value to the async
23+
* call graph it was entered from, and correctly restores it after arbitrary
24+
* `await` gaps, so concurrent requests can never observe each other's
25+
* server — mirroring the existing per-request credential isolation in
26+
* `utils/credential-store.ts` (`credentialStore`).
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
}

src/worker.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
credentialStore,
3131
type RequestCredentials,
3232
} from "./utils/credential-store.js";
33+
import { runWithServerRef } from "./utils/server-ref.js";
3334

3435
export interface Env {
3536
SYNCRO_API_KEY?: string;
@@ -65,23 +66,30 @@ function withCors(res: Response): Response {
6566

6667
/**
6768
* Run the MCP request through a fresh server + Web Standard transport.
68-
* Stateless: a new server/transport pair is created per request.
69+
* Stateless: a new server/transport pair is created per request. The server
70+
* is likewise bound to the per-request async context (not a module-level
71+
* global) so elicitation helpers resolve *this* request's server even
72+
* after await gaps, and never a concurrent request's — see
73+
* utils/server-ref.ts.
6974
*/
7075
async function handleMcp(request: Request): Promise<Response> {
7176
const server = createMcpServer();
7277
const transport = new WebStandardStreamableHTTPServerTransport({
7378
sessionIdGenerator: undefined,
7479
enableJsonResponse: true,
7580
});
76-
await server.connect(transport);
7781

78-
try {
79-
const response = await transport.handleRequest(request);
80-
return withCors(response);
81-
} finally {
82-
await transport.close();
83-
await server.close();
84-
}
82+
return runWithServerRef(server, async () => {
83+
await server.connect(transport);
84+
85+
try {
86+
const response = await transport.handleRequest(request);
87+
return withCors(response);
88+
} finally {
89+
await transport.close();
90+
await server.close();
91+
}
92+
});
8593
}
8694

8795
export default {

tests/utils/server-ref.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 a Syncro
9+
* API call, before sending an elicitation/confirmation prompt back
10+
* through "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. (Verified by temporarily reinstating a module-singleton
29+
* implementation behind the same function names: this test fails with
30+
* tenant A's prompt observed on tenant B's mock `elicitInput`, and passes
31+
* again once the ALS-based fix is restored — see the PR description.)
32+
*/
33+
import { describe, it, expect, vi } from "vitest";
34+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
35+
import {
36+
runWithServerRef,
37+
bindServerRef,
38+
getServerRef,
39+
} from "../../src/utils/server-ref.js";
40+
import { elicitConfirmation } from "../../src/utils/elicitation.js";
41+
42+
/** A deferred promise the test can resolve on demand, for a deterministic forced interleave. */
43+
function createDeferred<T = void>() {
44+
let resolve!: (value: T) => void;
45+
const promise = new Promise<T>((res) => {
46+
resolve = res;
47+
});
48+
return { promise, resolve };
49+
}
50+
51+
type FakeServer = Server & { tenantId: string; elicitInput: ReturnType<typeof vi.fn> };
52+
53+
/** Minimal fake MCP Server whose elicitInput is a per-instance spy (per-tenant mock). */
54+
function createFakeServer(tenantId: string): FakeServer {
55+
const elicitInput = vi.fn().mockImplementation(async () => ({
56+
action: "accept" as const,
57+
content: { confirm: true },
58+
}));
59+
return { tenantId, elicitInput } as unknown as FakeServer;
60+
}
61+
62+
describe("server-ref cross-tenant isolation", () => {
63+
it("getServerRef returns null outside of any bound context", () => {
64+
expect(getServerRef()).toBeNull();
65+
});
66+
67+
it("getServerRef resolves the server bound by runWithServerRef within its scope", async () => {
68+
const server = createFakeServer("tenant-X");
69+
await runWithServerRef(server, async () => {
70+
expect(getServerRef()).toBe(server);
71+
});
72+
});
73+
74+
it(
75+
"routes each tenant's elicitation through its OWN server, even when a " +
76+
"second tenant's request runs to completion while the first is still " +
77+
"in flight (forced deterministic interleave, not a timing stagger)",
78+
async () => {
79+
const serverA = createFakeServer("tenant-A");
80+
const serverB = createFakeServer("tenant-B");
81+
const gate = createDeferred<void>();
82+
83+
// Tenant A: binds its server, then suspends on an await gap
84+
// (simulating an in-flight Syncro API call inside a tool handler)
85+
// BEFORE sending its elicitation/confirmation prompt.
86+
const tenantA = runWithServerRef(serverA, async () => {
87+
await gate.promise; // the exact await gap the original bug lost the ref across
88+
expect((getServerRef() as FakeServer | null)?.tenantId).toBe("tenant-A"); // must still be A's server after resuming
89+
return elicitConfirmation("Confirm tenant A's sensitive action?");
90+
});
91+
92+
// Force the interleave: tenant B's ENTIRE request — bind, elicit,
93+
// resolve — runs to completion while tenant A is still suspended
94+
// above, exactly like a second concurrent HTTP request racing in.
95+
const tenantB = runWithServerRef(serverB, async () => {
96+
return elicitConfirmation("Confirm tenant B's sensitive action?");
97+
});
98+
await tenantB;
99+
100+
// Only now let tenant A resume.
101+
gate.resolve();
102+
const resultA = await tenantA;
103+
expect(resultA).toBe(true);
104+
105+
// --- Per-tenant VALUE assertions -----------------------------------
106+
// Each tenant's prompt must have gone out through THAT tenant's mock
107+
// server specifically, not the other tenant's.
108+
expect(serverA.elicitInput).toHaveBeenCalledTimes(1);
109+
expect(serverA.elicitInput).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
message: "Confirm tenant A's sensitive action?",
112+
})
113+
);
114+
115+
expect(serverB.elicitInput).toHaveBeenCalledTimes(1);
116+
expect(serverB.elicitInput).toHaveBeenCalledWith(
117+
expect.objectContaining({
118+
message: "Confirm tenant B's sensitive action?",
119+
})
120+
);
121+
122+
// Explicit negative checks: A's message must never have reached B's
123+
// transport, and B's must never have reached A's.
124+
for (const call of serverA.elicitInput.mock.calls) {
125+
expect(call[0].message).not.toBe(
126+
"Confirm tenant B's sensitive action?"
127+
);
128+
}
129+
for (const call of serverB.elicitInput.mock.calls) {
130+
expect(call[0].message).not.toBe(
131+
"Confirm tenant A's sensitive action?"
132+
);
133+
}
134+
}
135+
);
136+
137+
it("bindServerRef binds for the remainder of the current async execution (stdio single-session mode)", async () => {
138+
const server = createFakeServer("tenant-X");
139+
bindServerRef(server);
140+
// Simulate work continuing across an await gap in the same "session".
141+
await Promise.resolve();
142+
expect(getServerRef()).toBe(server);
143+
});
144+
});

0 commit comments

Comments
 (0)