VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
MCP authoring

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']);
ModelWhat it means
sidecarOne instance per run, started by the Provisioner and torn down with the run. Gets its endpoint after its container comes up.
sharedOne long-lived instance serving many concurrent runs.
warm_poolA 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:

shared is 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:

  1. Pick a tool that carries a marker through — for a command runner, something like echo <marker> > out.txt; cat out.txt.
  2. Fire two calls concurrently, with different markers and different workspace values.
  3. Assert each response contains its own marker and not the other's.
  4. Assert each workspace directory contains only its own file.
  5. 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. Gates shared.

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.