Skip to content

Commit 54935d0

Browse files
committed
docs: add multi-tenancy guide, example server, and OAuth e2e tests
Add comprehensive documentation and testing for the multi-tenant MCP server feature (Iteration 6 of the multi-tenancy implementation plan). Multi-tenancy guide (docs/multi-tenancy.md): - Architecture overview with mermaid flow diagram - Static and dynamic tenant provisioning (onboarding, feature flags, plugins) - Authentication setup with TokenVerifier and identity provider configuration (Duo Security, Auth0, Okta, Microsoft Entra ID, custom JWT) - Session isolation semantics - Security considerations and backward compatibility Example server (examples/servers/simple-multi-tenant/): - Two-tenant demo (Acme analytics, Globex content) with tenant-scoped tools, resources, and prompts - Shared whoami tool demonstrating Context.tenant_id - README with usage instructions and in-memory testing guide OAuth e2e tests (test_multi_tenancy_oauth_e2e.py): - Full HTTP stack test: bearer token -> AuthContextMiddleware -> tenant_id_var -> session manager -> handler -> tenant-scoped response - StubTokenVerifier for testing without a real OAuth server - ASGI lifespan helper for httpx.ASGITransport - Tests for tenant tool isolation, tool invocation, whoami identity, and unauthenticated request rejection Also updates migration.md with multi-tenancy breaking change notes.
1 parent a0fe665 commit 54935d0

12 files changed

Lines changed: 1339 additions & 4 deletions

File tree

docs/migration.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,21 @@ app = server.streamable_http_app(
879879

880880
The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`.
881881

882+
### Multi-tenancy support
883+
884+
The SDK now supports multi-tenant deployments where a single server instance serves multiple isolated tenants. Tenant identity flows from authentication tokens through sessions, request context, and into all handler invocations.
885+
886+
Key additions:
887+
888+
- `AccessToken.tenant_id` — carries tenant identity in OAuth tokens
889+
- `Context.tenant_id` — available in tool, resource, and prompt handlers
890+
- `server.add_tool(fn, tenant_id="...")`, `server.add_resource(r, tenant_id="...")`, `server.add_prompt(p, tenant_id="...")` — register tenant-scoped tools, resources, and prompts
891+
- `StreamableHTTPSessionManager` — validates tenant identity on every request and prevents cross-tenant session access
892+
893+
All APIs default to `tenant_id=None`, preserving backward compatibility for single-tenant servers.
894+
895+
See the [Multi-Tenancy Guide](multi-tenancy.md) for details.
896+
882897
## Need Help?
883898

884899
If you encounter issues during migration:

docs/multi-tenancy.md

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Multi-Tenancy Guide
2+
3+
This guide explains how to build MCP servers that safely isolate multiple tenants sharing a single server instance. Multi-tenancy ensures that tools, resources, prompts, and sessions belonging to one tenant are invisible and inaccessible to others.
4+
5+
> For a complete working example, see [`examples/servers/simple-multi-tenant/`](../examples/servers/simple-multi-tenant/).
6+
7+
## Overview
8+
9+
In a multi-tenant deployment, a single MCP server process serves requests from multiple organizations, teams, or users (tenants). Without proper isolation, Tenant A could list or invoke Tenant B's tools, read their resources, or hijack their sessions.
10+
11+
The MCP Python SDK provides built-in tenant isolation across all layers:
12+
13+
- **Authentication tokens** carry a `tenant_id` field
14+
- **Sessions** are bound to a single tenant on first authenticated request
15+
- **Request context** propagates `tenant_id` to every handler
16+
- **Managers** (tools, resources, prompts) use tenant-scoped storage
17+
- **Session manager** validates tenant identity on every request
18+
19+
## How It Works
20+
21+
### Tenant Identification Flow
22+
23+
```mermaid
24+
flowchart TD
25+
A["HTTP Request"] --> B["AuthContextMiddleware"]
26+
B -->|"extracts tenant_id from AccessToken<br/>sets tenant_id_var (contextvar)"| C["StreamableHTTPSessionManager"]
27+
C -->|"binds new sessions to the current tenant<br/>rejects cross-tenant session access (HTTP 404)"| D["Low-level Server<br/>(_handle_request / _handle_notification)"]
28+
D -->|"reads tenant_id_var<br/>sets session.tenant_id (set-once)<br/>populates ServerRequestContext.tenant_id"| E["MCPServer handlers<br/>(_handle_list_tools, _handle_call_tool, etc.)"]
29+
E -->|"passes ctx.tenant_id to managers"| F["ToolManager / ResourceManager / PromptManager"]
30+
F -->|"looks up (tenant_id, name) in nested dict<br/>returns only the requesting tenant's entries"| G["Response"]
31+
```
32+
33+
### Key Components
34+
35+
| Component | File | Role |
36+
|---|---|---|
37+
| `AccessToken.tenant_id` | `server/auth/provider.py` | Carries tenant identity in OAuth tokens |
38+
| `tenant_id_var` | `shared/_context.py` | Transport-agnostic contextvar for tenant propagation |
39+
| `AuthContextMiddleware` | `server/auth/middleware/auth_context.py` | Extracts tenant from auth and sets contextvar |
40+
| `ServerSession.tenant_id` | `server/session.py` | Binds session to tenant (set-once semantics) |
41+
| `ServerRequestContext.tenant_id` | `shared/_context.py` | Per-request tenant context for handlers |
42+
| `Context.tenant_id` | `server/mcpserver/context.py` | High-level property for tool/resource/prompt handlers |
43+
| `ToolManager` | `server/mcpserver/tools/tool_manager.py` | Tenant-scoped tool storage |
44+
| `ResourceManager` | `server/mcpserver/resources/resource_manager.py` | Tenant-scoped resource storage |
45+
| `PromptManager` | `server/mcpserver/prompts/manager.py` | Tenant-scoped prompt storage |
46+
| `StreamableHTTPSessionManager` | `server/streamable_http_manager.py` | Validates tenant on session access |
47+
48+
## Usage
49+
50+
### Simple Registering Tenant-Scoped Tools, Resources, and Prompts
51+
52+
Use the `tenant_id` parameter when adding tools, resources, or prompts:
53+
54+
```python
55+
from mcp.server.mcpserver import MCPServer
56+
57+
server = MCPServer("my-server")
58+
59+
# Register a tool for a specific tenant
60+
def analyze_data(query: str) -> str:
61+
return f"Results for: {query}"
62+
63+
server.add_tool(analyze_data, tenant_id="acme-corp")
64+
65+
# Register a resource for a specific tenant
66+
from mcp.server.mcpserver.resources.types import FunctionResource
67+
68+
server.add_resource(
69+
FunctionResource(uri="data://config", name="config", fn=lambda: "tenant config"),
70+
tenant_id="acme-corp",
71+
)
72+
73+
# Register a prompt for a specific tenant
74+
from mcp.server.mcpserver.prompts.base import Prompt
75+
76+
async def onboarding_prompt() -> str:
77+
return "Welcome to Acme Corp!"
78+
79+
server.add_prompt(
80+
Prompt.from_function(onboarding_prompt, name="onboarding"),
81+
tenant_id="acme-corp",
82+
)
83+
```
84+
85+
The same name can be registered under different tenants without conflict:
86+
87+
```python
88+
server.add_tool(acme_tool, name="analyze", tenant_id="acme-corp")
89+
server.add_tool(globex_tool, name="analyze", tenant_id="globex-inc")
90+
```
91+
92+
### Dynamic Tenant Provisioning
93+
94+
Multi-tenancy enables MCP servers to operate as SaaS platforms where tenants are
95+
provisioned and deprovisioned at runtime. Tools, resources, and prompts can be
96+
added or removed dynamically — for example, when a tenant signs up, changes
97+
their subscription tier, or installs a plugin.
98+
99+
#### Tenant Onboarding and Offboarding
100+
101+
Register a tenant's capabilities when they sign up and remove them when they leave:
102+
103+
```python
104+
def onboard_tenant(server: MCPServer, tenant_id: str, plan: str) -> None:
105+
"""Provision tools for a new tenant based on their plan."""
106+
107+
# Base tools available to all tenants
108+
server.add_tool(search_docs, tenant_id=tenant_id)
109+
server.add_tool(get_status, tenant_id=tenant_id)
110+
111+
# Premium tools gated by plan
112+
if plan in ("pro", "enterprise"):
113+
server.add_tool(run_analytics, tenant_id=tenant_id)
114+
server.add_tool(export_data, tenant_id=tenant_id)
115+
116+
117+
def offboard_tenant(server: MCPServer, tenant_id: str) -> None:
118+
"""Remove all tools when a tenant is deprovisioned."""
119+
server.remove_tool("search_docs", tenant_id=tenant_id)
120+
server.remove_tool("get_status", tenant_id=tenant_id)
121+
server.remove_tool("run_analytics", tenant_id=tenant_id)
122+
server.remove_tool("export_data", tenant_id=tenant_id)
123+
```
124+
125+
#### Plugin Systems
126+
127+
Let tenants install or uninstall integrations that map to MCP tools:
128+
129+
```python
130+
def install_plugin(server: MCPServer, tenant_id: str, plugin: str) -> None:
131+
"""Install a plugin's tools for a specific tenant."""
132+
plugin_tools = load_plugin_tools(plugin) # Your plugin registry
133+
for tool_fn in plugin_tools:
134+
server.add_tool(tool_fn, tenant_id=tenant_id)
135+
136+
137+
def uninstall_plugin(server: MCPServer, tenant_id: str, plugin: str) -> None:
138+
"""Remove a plugin's tools for a specific tenant."""
139+
plugin_tool_names = get_plugin_tool_names(plugin)
140+
for name in plugin_tool_names:
141+
server.remove_tool(name, tenant_id=tenant_id)
142+
```
143+
144+
All dynamic changes take effect immediately — the next `list_tools` request from that tenant will reflect the updated set. Other tenants are unaffected.
145+
146+
### Accessing Tenant ID in Handlers
147+
148+
Inside tool, resource, or prompt handlers, access the current tenant through `Context.tenant_id`:
149+
150+
```python
151+
from mcp.server.mcpserver.context import Context
152+
153+
@server.tool()
154+
async def get_data(ctx: Context) -> str:
155+
tenant = ctx.tenant_id # e.g., "acme-corp" or None
156+
return f"Data for tenant: {tenant}"
157+
```
158+
159+
### Setting Up Authentication with Tenant ID
160+
161+
The `tenant_id` field on `AccessToken` is populated by your token verifier or OAuth provider. The `AuthContextMiddleware` automatically extracts `tenant_id` from the authenticated user's access token and sets the `tenant_id_var` contextvar for downstream use.
162+
163+
Implement the `TokenVerifier` protocol to bridge your external identity provider with the MCP auth stack. Your `verify_token` method decodes or introspects the bearer token and returns an `AccessToken` with `tenant_id` populated.
164+
165+
**Configuring your identity provider to include tenant identity in tokens:**
166+
167+
Most identity providers allow you to add custom claims to access tokens. The claim name varies by provider, but common conventions include `org_id`, `tenant_id`, or a namespaced claim like `https://myapp.com/tenant_id`. Here are some examples:
168+
169+
- **Duo Security**: Define a [custom user attribute](https://duo.com/docs/user-attributes) (e.g., `tenant_id`) and assign it to users via Duo Directory sync or the Admin Panel. Include this attribute as a claim in the access token issued by Duo as your IdP.
170+
- **Auth0**: Use [Organizations](https://auth0.com/docs/manage-users/organizations) to model tenants. When a user authenticates through an organization, Auth0 automatically includes an `org_id` claim in the access token. Alternatively, use an [Action](https://auth0.com/docs/customize/actions) on the "Machine to Machine" or "Login" flow to add a custom claim based on app metadata or connection context.
171+
- **Okta**: Add a [custom claim](https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/) to your authorization server. Map the claim value from the user's profile (e.g., `user.profile.orgId`) or from a group membership.
172+
- **Microsoft Entra ID (Azure AD)**: Use the `tid` (tenant ID) claim that is included by default in tokens, or configure [optional claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims) to add organization-specific attributes.
173+
- **Custom JWT issuer**: Include a `tenant_id` (or equivalent) claim in the JWT payload when minting tokens. For example: `{"sub": "user-123", "tenant_id": "acme-corp", "scope": "read write"}`.
174+
175+
Once your provider includes the tenant claim, extract it in your `TokenVerifier`:
176+
177+
```python
178+
from mcp.server.auth.provider import AccessToken, TokenVerifier
179+
180+
181+
class JWTTokenVerifier(TokenVerifier):
182+
"""Verify JWTs and extract tenant_id from claims."""
183+
184+
async def verify_token(self, token: str) -> AccessToken | None:
185+
# Decode and validate the JWT (e.g., using PyJWT or authlib)
186+
claims = decode_and_verify_jwt(token)
187+
if claims is None:
188+
return None
189+
190+
return AccessToken(
191+
token=token,
192+
client_id=claims["sub"],
193+
scopes=claims.get("scope", "").split(),
194+
expires_at=claims.get("exp"),
195+
tenant_id=claims["org_id"], # Extract tenant from your JWT claims
196+
)
197+
```
198+
199+
Then pass the verifier when creating your `MCPServer`:
200+
201+
```python
202+
from mcp.server.auth.settings import AuthSettings
203+
from mcp.server.mcpserver.server import MCPServer
204+
205+
server = MCPServer(
206+
"my-server",
207+
token_verifier=JWTTokenVerifier(),
208+
auth=AuthSettings(
209+
issuer_url="https://auth.example.com",
210+
resource_server_url="https://mcp.example.com",
211+
required_scopes=["read"],
212+
),
213+
)
214+
```
215+
216+
Once the `AccessToken` reaches the middleware stack, the flow is automatic: `BearerAuthBackend` validates the token → `AuthContextMiddleware` extracts `tenant_id``tenant_id_var` contextvar is set → all downstream handlers and managers receive the correct tenant scope.
217+
218+
### Session Isolation
219+
220+
Sessions are automatically bound to their tenant on first authenticated request (set-once semantics). The `StreamableHTTPSessionManager` enforces this:
221+
222+
- New sessions record the creating tenant's ID
223+
- Subsequent requests must come from the same tenant
224+
- Cross-tenant session access returns HTTP 404
225+
- Session tenant binding cannot be changed after initial assignment
226+
227+
## Backward Compatibility
228+
229+
All tenant-scoped APIs default to `tenant_id=None`, preserving single-tenant behavior:
230+
231+
```python
232+
# These all work exactly as before — no tenant scoping
233+
server.add_tool(my_tool)
234+
server.add_resource(my_resource)
235+
await server.list_tools() # Returns tools in global (None) scope
236+
```
237+
238+
Tools registered without a `tenant_id` live in the global scope and are only visible when no tenant context is active (i.e., `tenant_id_var` is not set or is `None`).
239+
240+
## Architecture Notes
241+
242+
### Storage Model
243+
244+
Managers use a nested dictionary `{tenant_id: {name: item}}` for O(1) lookups per tenant. When the last item in a tenant scope is removed, the scope dictionary is cleaned up automatically.
245+
246+
### Set-Once Session Binding
247+
248+
`ServerSession.tenant_id` uses set-once semantics: once a session is bound to a tenant (on the first request with a non-None tenant_id), it cannot be changed. This prevents session fixation attacks where a session created by one tenant could be reused by another.
249+
250+
### Security Considerations
251+
252+
- **Cross-tenant tool invocation**: A tenant can only call tools registered under their own tenant_id. Attempting to call a tool from another tenant's scope raises a `ToolError`.
253+
- **Resource access**: Resources are tenant-scoped. Reading a resource registered under a different tenant raises a `ResourceError`.
254+
- **Session hijacking**: The session manager validates the requesting tenant against the session's bound tenant on every request. Mismatches return HTTP 404 with an opaque "Session not found" error (no tenant information is leaked).
255+
- **Log levels**: Tenant mismatch events are logged at WARNING level (session ID only). Sensitive tenant identifiers are logged at DEBUG level only.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Multi-Tenant MCP Server Example
2+
3+
Demonstrates tenant-scoped tools, resources, and prompts using the MCP Python SDK's multi-tenancy support.
4+
5+
## What it shows
6+
7+
- **Acme** (analytics company) has `run_query` and `generate_report` tools, a `database-schema` resource, and an `analyst` prompt
8+
- **Globex** (content company) has `publish_article` and `check_seo` tools, a `style-guide` resource, and an `editor` prompt
9+
- Each tenant sees only their own tools, resources, and prompts — Acme cannot see Globex's tools and vice versa
10+
- A `whoami` tool is registered under both tenants and reports the current tenant identity from `Context.tenant_id`
11+
12+
## Running
13+
14+
Start the server on the default or custom port:
15+
16+
```bash
17+
uv run mcp-simple-multi-tenant --port 3000
18+
```
19+
20+
The server starts a StreamableHTTP endpoint at `http://127.0.0.1:3000/mcp`.
21+
22+
## What each tenant sees
23+
24+
**Acme** (analytics):
25+
- Tools: `run_query`, `generate_report`, `whoami`
26+
- Resources: `data://schema` (database schema)
27+
- Prompts: `analyst` (data analyst system prompt)
28+
29+
**Globex** (content):
30+
- Tools: `publish_article`, `check_seo`, `whoami`
31+
- Resources: `content://style-guide` (editorial style guide)
32+
- Prompts: `editor` (content editor system prompt)
33+
34+
**No tenant** (unauthenticated): sees nothing — all items are tenant-scoped.
35+
36+
## Example: programmatic client
37+
38+
You can verify tenant isolation using the MCP client with in-memory transport:
39+
40+
```python
41+
import asyncio
42+
43+
from mcp.client.session import ClientSession
44+
from mcp.shared._context import tenant_id_var
45+
from mcp.shared.memory import create_client_server_memory_streams
46+
47+
from mcp_simple_multi_tenant.server import create_server
48+
49+
50+
async def main():
51+
server = create_server()
52+
actual = server._lowlevel_server
53+
54+
async with create_client_server_memory_streams() as (client_streams, server_streams):
55+
client_read, client_write = client_streams
56+
server_read, server_write = server_streams
57+
58+
import anyio
59+
60+
async with anyio.create_task_group() as tg:
61+
# Set tenant context for the server side
62+
async def run_server():
63+
token = tenant_id_var.set("acme")
64+
try:
65+
await actual.run(
66+
server_read,
67+
server_write,
68+
actual.create_initialization_options(),
69+
)
70+
finally:
71+
tenant_id_var.reset(token)
72+
73+
tg.start_soon(run_server)
74+
75+
async with ClientSession(client_read, client_write) as session:
76+
await session.initialize()
77+
78+
# Acme sees only analytics tools
79+
tools = await session.list_tools()
80+
print(f"Tools: {[t.name for t in tools.tools]}")
81+
# → ['run_query', 'generate_report', 'whoami']
82+
83+
result = await session.call_tool(
84+
"run_query", {"sql": "SELECT * FROM users"}
85+
)
86+
print(f"Result: {result.content[0].text}")
87+
# → Query result for: SELECT * FROM users (3 rows returned)
88+
89+
tg.cancel_scope.cancel()
90+
91+
92+
asyncio.run(main())
93+
```
94+
95+
## How tenant identity works
96+
97+
In a production deployment, `tenant_id` is extracted from the OAuth `AccessToken` by the `AuthContextMiddleware` and propagated through the request context automatically — no manual `tenant_id_var.set()` is needed. The in-memory example above sets it manually to simulate what the middleware does.
98+
99+
See the [Multi-Tenancy Guide](../../../docs/multi-tenancy.md) for the full architecture.

examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from .server import main
4+
5+
sys.exit(main()) # type: ignore[call-arg]

0 commit comments

Comments
 (0)