Statelessness and sharing
Why 'shared' requires a passed statelessness probe, what statelessness actually means here, and how to build for it.
The three concurrency models
export const zConcurrencyModel = z.enum(['sidecar', 'shared', 'warm_pool']);
export const zShareScope = z.enum(['tenant', 'global']);| Model | What it means |
|---|---|
sidecar | One instance per run, started by the Provisioner and torn down with the run. Gets its endpoint after its container comes up. |
shared | One long-lived instance serving many concurrent runs. |
warm_pool | A pool of pre-started instances. |
shareScope is only meaningful for shared. It defaults to 'tenant'; widening it to
'global' is a deliberate opt-in.
The invariant
.refine((m) => m.concurrencyModel !== 'shared' || m.statelessnessProbePassed, {
message: "declaring 'shared' requires a passed statelessness conformance probe",
path: ['concurrencyModel'],
})This is enforced in the schema, not in the code that writes the record. Verified: a
zMcpServer with concurrencyModel: 'shared' and statelessnessProbePassed: false
fails to parse, whatever its status.
The reasoning:
sharedis only sound when the MCP is genuinely stateless — when only the tool call and its response carry information. That is testable, so it is tested: two interleaved sessions must not observe each other. Within a tenant a leak is a correctness bug; if shareScope is ever widened to 'global' the same bug is a cross-tenant data leak.
An MCP that cannot pass this has not earned the right to be shared.
What "stateless" means here
Not "holds no memory". A shared execution box obviously holds files on disk. It means: only the tool call and its response carry information between the caller and the server.
Concretely, none of these may exist:
- A "current target" or "current session" set by one call and read by another.
- A module-level accumulator that later calls append to or read.
- Output written to a fixed path that the next call reads back.
- A cached credential, cookie jar, or connection tied to whoever called first.
- A single scratch directory shared by every caller.
The Kali MCP is shared and holds a great deal on disk — but every path is derived from
the workspace argument the caller passes, and every filesystem operation is guarded
against escaping it. Nothing is remembered between calls; everything is derived from
the call.
How to build for it
Take the scope as an argument. Every stateful thing a call touches should be keyed by something the caller supplied — the run id — not by server memory.
@mcp.tool()
async def run_command(command: str, timeout: int = 300, workspace: str = "") -> str:
...Derive paths; never fix them.
cwd = _workspace_dir(workspace)
env = dict(os.environ)
env["TMPDIR"] = str(cwd)
env["HOME"] = str(cwd)Setting TMPDIR and HOME per call matters more than it looks. Tools write caches and
config under $HOME by default, and two concurrent runs sharing one $HOME will read each
other's scratch.
Never let an unrecognised scope fall back to something shared and privileged. The Kali
MCP falls back to a _adhoc directory under the runs root, not to $HOME — a manual probe
should not land in another run's directory.
Provide an explicit teardown tool so a finished run's data does not linger, and make it idempotent.
Do not depend on MCP session identity. The harness client establishes a session per slot and reuses it for the whole run, so it may look like a safe place to hang state. It is not: your server may be serving several such sessions at once, and the statelessness probe runs sessions interleaved precisely because a server keeping per-connection state passes a sequential test and still leaks under concurrency.
How the probe works
The design of the statelessness probe, in the TypeScript SDK:
for (let round = 0; round < rounds; round++) {
const markerA = `alpha-${round}-aaaa`;
const markerB = `bravo-${round}-bbbb`;
// Fire both at once — this is the shape `shared` actually exposes the server to.
const [resA, resB] = await Promise.all([
tool.handler(makeArgs(markerA), ctx('session_a')),
tool.handler(makeArgs(markerB), ctx('session_b')),
]);
if (readBack(resA.output).includes(markerB)) fail('session A observed session B');
if (readBack(resB.output).includes(markerA)) fail('session B observed session A');
if (!readBack(resA.output).includes(markerA)) fail('a session did not get its own marker back');
}Three assertions, and the third is easy to overlook: a session must also receive its own marker back. A server that returns nothing to anybody trivially leaks nothing.
Testing it yourself, over the wire
Until an endpoint-based statelessness runner is extracted, the honest test is one you write against your own server. The shape:
- Pick a tool that carries a marker through — for a command runner, something like
echo <marker> > out.txt; cat out.txt. - Fire two calls concurrently, with different markers and different
workspacevalues. - Assert each response contains its own marker and not the other's.
- Assert each
workspacedirectory contains only its own file. - Repeat at least three times — a race that fires once in five rounds is still a leak.
# Two interleaved sessions against a local server, with distinct workspaces.
# Both must come back with their OWN marker and neither the other's.Registering as shared
Two flags, and they mean different things:
probe.passed— the three gates. Gates availability.statelessnessProbePassed— the interleaved-session test. Gatesshared.
A server can be available and correctly refused shared.
Register as sidecar (the default), run the interleaved test, and say how you tested it
when you ask for the widening.
maxConcurrent
The TypeScript SDK refuses a shared definition with no maxConcurrent:
if (def.concurrency.model === 'shared' && def.concurrency.maxConcurrent === undefined) {
throw new Error("a 'shared' MCP must declare maxConcurrent");
}On the registry record it is maxConcurrent: z.number().int().positive().optional(). The
engine enforces it with a Redis semaphore across replicas. Set it to a number your box can
actually sustain — a shared box that accepts more work than it can run does not fail
loudly; it just gets slow, and slow enough looks like timeouts, and timeouts look like
findings that were never there.