Skip to content

feat(server): thread tenant_id through MCPServer handlers to managers#9

Merged
andylim-duo merged 3 commits intomainfrom
feature/multi-tenant-mcpserver-handlers
Mar 17, 2026
Merged

feat(server): thread tenant_id through MCPServer handlers to managers#9
andylim-duo merged 3 commits intomainfrom
feature/multi-tenant-mcpserver-handlers

Conversation

@andylim-duo
Copy link
Owner

Summary

Integrates tenant_id into the MCPServer server layer (iteration 4 of 6 in the multi-tenancy implementation plan), completing the plumbing from request context through to tenant-scoped storage in managers.

Context: Multi-Tenancy Implementation Plan

This is part of a 6-iteration plan to add multi-tenant support to the MCP Python SDK:

  1. Foundation — Add tenant_id to authentication tokens (done, PR feat(auth): add tenant_id field to authentication token models #2)
  2. Core Plumbing — Add tenant_id to session and request context (done, PR feat(auth): add tenant_id to session and request context #3)
  3. Managers — Implement tenant-scoped storage with composite keys (done, PR feat(managers): tenant-scoped storage for ToolManager, ResourceManager, PromptManager #5)
  4. MCPServer Integration — Thread tenant_id from handlers to managers (this PR)
  5. Client + Session Manager — Enable client to send tenant_id, partition sessions
  6. Configuration, Documentation, and Final Testing

Changes

  • src/mcp/server/mcpserver/context.py: Add tenant_id property to Context class that reads from the underlying ServerRequestContext. Also thread tenant_id through the read_resource convenience method.

  • src/mcp/server/mcpserver/server.py:

    • Update all 7 private _handle_* methods to pass ctx.tenant_id to their corresponding public methods
    • Add keyword-only tenant_id: str | None = None parameter to all public query methods (list_tools, call_tool, list_resources, read_resource, list_resource_templates, list_prompts, get_prompt)
    • Add keyword-only tenant_id parameter to mutation methods (add_tool, remove_tool, add_resource, add_prompt)
    • All new parameters default to None, preserving full backward compatibility
  • tests/server/mcpserver/test_multi_tenancy_server.py (new): 13 tests covering Context.tenant_id property, tenant-scoped operations across all public methods, and backward compatibility

Backward Compatibility

All changes are fully backward compatible. Every new tenant_id parameter defaults to None, which maps to the global scope — identical to the pre-existing single-tenant behavior. Existing tests (386 total in the MCPServer + client suites) pass without modification.

Test plan

  • 13 new tests in test_multi_tenancy_server.py all pass
  • All 354 existing MCPServer tests pass (backward compatibility)
  • All 32 client tests pass
  • pyright type checking passes with 0 errors
  • ruff formatting and linting pass

Integrate tenant_id into MCPServer server layer, completing the plumbing
from request context through to tenant-scoped storage in managers.

- Add tenant_id property to Context class, reading from request context
- Update all 7 private _handle_* methods to pass ctx.tenant_id to public methods
- Add keyword-only tenant_id parameter to all public methods (list_tools,
  call_tool, list_resources, read_resource, list_resource_templates,
  list_prompts, get_prompt, add_tool, remove_tool, add_resource, add_prompt)
- All new parameters default to None for full backward compatibility

This is iteration 4 of 6 in the multi-tenancy implementation plan.
@andylim-duo
Copy link
Owner Author

Design note: Decorators do not expose tenant_id

The @tool(), @resource(), and @prompt() decorators intentionally do not accept a tenant_id parameter. Decorators are used for static, module-level registration where no tenant context is available. Tools registered this way live in the global scope (tenant_id=None) and are only visible to unauthenticated or single-tenant requests.

For multi-tenant setups, tools/resources/prompts must be registered programmatically per tenant:

server.add_tool(my_tool, name="my_tool", tenant_id="tenant-a")
server.add_resource(my_resource, tenant_id="tenant-a")
server.add_prompt(my_prompt, tenant_id="tenant-a")

Strict isolation is enforced: globally-registered tools are not visible to tenant-scoped requests, and vice versa. There is no fallback from tenant scope to global scope.

@andylim-duo
Copy link
Owner Author

andylim-duo commented Mar 17, 2026

Note: test_list_resource_templates_with_tenant_id uses private API

The test accesses server._resource_manager.add_template(...) directly rather than going through a public MCPServer method. This is because MCPServer doesn't expose a public add_template method — the @resource() decorator handles template registration internally when it detects URI parameters.

Decision: leave as-is. Adding a public add_resource_template method was considered but rejected — it would leak an abstraction that MCPServer intentionally hides. The @resource() decorator abstracts the resource vs. template distinction; exposing template registration separately would break that design. The private API access is confined to the test and is an acceptable trade-off.

@andylim-duo
Copy link
Owner Author

andylim-duo commented Mar 17, 2026

Note: test_call_tool_with_tenant_id assertion could be stronger

Resolved in commit 0e0a73d. The test now verifies that each tenant's tool returns its own distinct result content ("result-a" vs "result-b"), including both unstructured TextContent and structured output, confirming the correct tool is dispatched per tenant scope.

@andylim-duo
Copy link
Owner Author

Note: No E2E test with Client(app)

The current tests exercise the MCPServer public API directly but don't test the full request pipeline where ctx.tenant_id is populated from the auth contextvar and flows through _handle_* methods to the managers. This would require setting up tenant_id_var in the contextvar, an authenticated session, and a full client-server round trip. This gap is expected to be addressed in iteration 6 (Configuration, Documentation, and Final Testing) with comprehensive end-to-end integration tests.

…truction

Replace plain dict with Experimental() to satisfy pyright type checking.
Verify that each tenant's tool returns its own distinct result content,
not just that results are non-None.
@andylim-duo andylim-duo self-assigned this Mar 17, 2026
@andylim-duo andylim-duo merged commit 2941534 into main Mar 17, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant