The bug that lived in plain sight
Last week, a developer opened a discussion on our repo with a simple question: "Can you add multi-user support to the MCP server like the REST API has?"
We thought we already had it. We were wrong.
The REST API had multi-user isolation baked in from day one — pass user_id in the request body, memories get scoped per end-user. The Python and JavaScript SDKs inherited it. But the MCP server — which is how Claude Desktop, Cursor, Windsurf, and a growing list of AI-native IDEs talk to memory — was half-wired. Some tools respected user_id. Fourteen did not.
This post walks through why multi-tenancy matters for MCP, the specific design we used to fix it without breaking backward compatibility, and the exact code change so you can do the same in your own MCP server.
Why MCP is single-user by default
The Model Context Protocol was designed for a personal AI assistant on your laptop. The mental model is simple: one user, one server, one scope of memory. That's fine for filesystem or github MCP servers — they operate on resources you personally own.
But memory is different. The moment you ship an MCP server that wraps a SaaS backend, every request arrives with the same credential (your API key), and the server has no way to know which human the query is about.
Concretely: imagine you run a customer-support platform. Your AI agent — running through Claude Managed Agents, Claude Desktop, or a Cursor workflow — handles tickets for thousands of end-users. If your memory MCP server stores everything under the API key owner's scope, you end up with one giant bucket of facts where Alice's allergies, Bob's deployment history, and Carol's billing preferences are mixed together. Search for "Alice" and you might get results from another customer who mentioned her name in passing.
That's not a memory layer. That's a leak.
The two-tier model: tenant + end-user
The fix — already widely used in B2B SaaS but not yet baked into MCP conventions — is a two-tier identity model:
- Tenant (from the API key): "Who is paying for this?"
- End-user (from the request): "Which of this tenant's users is this about?"
MCP doesn't define how to carry the second tier. The transport authenticates once via a bearer token, and every tool call inherits that scope. To add end-user identity, you have to pass it inside the tool arguments.
Here's the pattern we settled on:
// Every tool accepts an optional user_id argument.
// Without it: use the API key owner's default scope.
// With it: scope the operation to that end-user.
{
"name": "remember",
"arguments": {
"conversation": [
{"role": "user", "content": "Alice prefers dark mode"}
],
"user_id": "alice"
}
}
If user_id is absent, the tool falls back to the default scope — identical behavior to before we shipped this. Zero breakage for existing clients. Explicit opt-in for multi-tenant use.
The code change, exactly
Here's what the fix looks like in the MCP server. Before:
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "remember":
result = mem.add(arguments["conversation"], user_id=user_id)
# ^^^^^^^^^^^^^^^^^^ hardcoded to server default
return [TextContent(type="text", text=format(result))]
After:
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if "user_id" in arguments:
print(f"[mcp] user_id override: tool={{name}} "
f"sub_uid={{arguments['user_id']}}", file=sys.stderr)
if name == "remember":
uid = arguments.get("user_id", user_id) # fallback to default
result = mem.add(arguments["conversation"], user_id=uid)
return [TextContent(type="text", text=format(result))]
Three line changes per tool. One log line at the top to give you a clean audit trail when end-users are explicitly scoped — invaluable when debugging a customer report of "my users see each other's data."
You also need to declare user_id in each tool's inputSchema so the MCP client can advertise it:
Tool(
name="remember",
description="Save knowledge from a conversation.",
inputSchema={
"type": "object",
"properties": {
"conversation": {"type": "array", "items": {...}},
"user_id": {
"type": "string",
"description": "Optional user ID override"
}
},
"required": ["conversation"],
},
)
That's the entire design. Fourteen tools at Mengram needed this change. We shipped it as v2.23.0 and deployed to production the same day.
Alternatives we considered
1. A separate API key per tenant
"Just make each customer use a different API key." This works but only solves tier 1. The end-user tier is still flat. Also creates a rotation nightmare if one customer has 50,000 end-users, and every key rotation means touching every MCP client config.
2. HTTP header for user_id
Technically possible on the streamable HTTP transport, but MCP clients (Claude Desktop, Cursor, etc.) don't let you add per-call headers. Tool arguments are the only channel the MCP spec guarantees across stdio, SSE, and streamable HTTP.
3. One MCP server process per end-user
Spawn-on-login works for a dozen users. Breaks past 100. Memory cost is linear, and you lose the ability to have a single persistent connection pool to your backend.
4. Encode user_id into the API key
"Use apikey-alice, apikey-bob." Nope — tenants don't want to provision a separate key per end-user. Plus the key becomes a security-sensitive identifier instead of an authentication secret.
The two-tier model with user_id in tool arguments is the only approach that scales, stays backward-compatible, and works across every MCP transport.
What you get with multi-tenant MCP
Once every tool accepts user_id, you can build things that were impossible with a flat namespace:
- Per-user cognitive profiles — Alice's system prompt is not Bob's.
- Scoped search —
search("allergies", user_id="alice")only returns Alice's data, even if Bob once mentioned her name. - Per-user procedures — Alice's deployment workflow evolves independently from Bob's. See procedural memory.
- Per-user triggers — contradictions and reminders fire only for the right person.
- Compliant data deletion — when a user leaves your platform, delete just their scope. GDPR Article 17 becomes a one-line operation instead of a database surgery.
A note on security
The two-tier model puts the burden of passing the correctuser_id on the API caller. If your agent code accidentally mixes up user_id values between requests, you've leaked data. This is the same trust model as every multi-tenant SaaS, but it's worth stating explicitly.
Mitigations we recommend:
- Derive
user_idfrom your session/auth layer at the agent level, not from LLM output. The LLM should never choose which user's data to query. - Log every
user_idoverride at your MCP server (see theprintline above) so you have an audit trail. - Enforce a server-side allowlist of valid
user_idvalues per API key if your threat model is strict.
Try it
Mengram's cloud MCP server now exposes multi-user scoping on every tool. Point your MCP client at the streamable HTTP endpoint, include user_id in your tool calls, and memories stay isolated per end-user:
Endpoint: https://mengram.io/mcp
Auth: Authorization: Bearer <MENGRAM_API_KEY>
Discovery: https://mengram.io/.well-known/mcp
Full reference: MCP server docs. The original discussion thread that kicked this off: discussions/30. Shipped in v2.23.0.
If you're building an MCP server yourself and want to add multi-tenancy, or if you hit gotchas we missed, open a discussion on the repo. The more MCP servers adopt this pattern, the easier it becomes for everyone downstream to build multi-user agents.