Skip to content

feat(middleware): auto-synthesize allowed_hosts from SubdomainTenantMiddleware router#527

Closed
bokelley wants to merge 2 commits intomainfrom
claude/issue-518-subdomain-allowlist-synthesis
Closed

feat(middleware): auto-synthesize allowed_hosts from SubdomainTenantMiddleware router#527
bokelley wants to merge 2 commits intomainfrom
claude/issue-518-subdomain-allowlist-synthesis

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 4, 2026

Closes #518

Summary

InMemorySubdomainTenantRouter strips ports at lookup time (acme.localhost:3001acme.localhost via _normalize_host), but the FastMCP allowed_hosts allowlist previously required adopters to register both acme.localhost and acme.localhost:* explicitly. This created an asymmetry: the router was port-agnostic at lookup, but the allowlist was port-strict unless the adopter maintained a separate _allowed_hosts() helper (as multi_platform_seller did).

This PR closes that gap. serve() now calls _synthesize_allowed_hosts() after _prepend_debug_endpoint, which scans asgi_middleware for SubdomainTenantMiddleware entries (including subclasses) and, when the router exposes hosts(), auto-produces bare + :* pairs for each registered host. Existing explicit allowed_hosts pass-through is unchanged and deduplicated against synthesized entries.

Before:

serve(
    router,
    asgi_middleware=[(SubdomainTenantMiddleware, {"router": subdomain_router})],
    allowed_hosts=["tenant-a.localhost", "tenant-a.localhost:*",
                   "tenant-b.localhost", "tenant-b.localhost:*"],
)

After:

serve(
    router,
    asgi_middleware=[(SubdomainTenantMiddleware, {"router": subdomain_router})],
    # allowed_hosts synthesized automatically from the router's host list
)

Changes

  • InMemorySubdomainTenantRouter.hosts() — new method returning normalized registered host names; the synthesis hook
  • _synthesize_allowed_hosts() in serve.py — pure helper that scans asgi_middleware and builds the merged allowlist; uses issubclass (with TypeError guard) so subclasses of SubdomainTenantMiddleware also trigger synthesis
  • Startup UserWarning when a SubdomainTenantMiddleware router lacks hosts() — silent 421 was a confusing failure mode for SQL-backed router adopters graduating from InMemorySubdomainTenantRouter
  • Comment at _serve_a2a call site noting allowed_hosts is MCP-only
  • SubdomainTenantRouter Protocol comment documenting the optional hosts() convention
  • multi_platform_seller example simplified: _allowed_hosts() helper and allowed_hosts= kwarg removed

What-tested

  • pytest tests/test_subdomain_tenant_router.py tests/test_serve_transport_security.py — 27/27 passed (9 new tests: hosts() normalization/empty, synthesis happy path, merge/dedup, no-middleware noop, warning for custom router without hosts(), subclass match, callable-factory skip)
  • ruff check src/adcp/server/serve.py src/adcp/server/tenant_router.py — clean
  • mypy src/adcp/server/serve.py src/adcp/server/tenant_router.py — no new errors (all pre-existing import-not-found for third-party stubs)

Nit from pre-PR review (not fixed — out of scope): O(n) deduplication in _synthesize_allowed_hosts is idiomatic for single-digit tenant counts; dict.fromkeys would be cleaner but not worth the churn.

Pre-PR review:

  • code-reviewer: approved — no blockers; issubclass, warning, and A2A comment issues addressed in fixup commit
  • security-reviewer: approved — :* synthesis does not expand attack surface (FastMCP's _validate_host uses anchored prefix match; evil.acme.localhost:3001 does not match acme.localhost:*); synthesis is a startup-time snapshot documented in docstring; SQL-backed router warning added

Triage-managed PR. This bot does not currently iterate on
review comments or PR conversation threads (only on the source
issue). To unblock:

  • Push fixup commits directly: gh pr checkout <num>
    fix → push.
  • Or re-trigger: comment /triage execute on the source
    issue.

See adcp#3121
for context.

Session: https://claude.ai/code/session_0198Bk2S7eJCQtvUALXrQXHj


Generated by Claude Code

claude added 2 commits May 4, 2026 01:11
…iddleware router

Fixes the asymmetry where InMemorySubdomainTenantRouter strips ports
at lookup (via _normalize_host) but the FastMCP allowed_hosts allowlist
required both bare and :* variants to be registered explicitly.

serve() now calls _synthesize_allowed_hosts() after _prepend_debug_endpoint,
which scans asgi_middleware for SubdomainTenantMiddleware entries and, when
the router exposes hosts(), auto-produces bare+:* pairs for each registered
host.  Adopters need only the router's host list — no separate _allowed_hosts()
helper.  Existing explicit allowed_hosts pass-through is unchanged.

Closes #518

https://claude.ai/code/session_0198Bk2S7eJCQtvUALXrQXHj
- _synthesize_allowed_hosts: use issubclass (with TypeError guard for
  non-class callables) instead of identity check so subclasses of
  SubdomainTenantMiddleware also trigger synthesis
- Emit a UserWarning at serve() startup when a SubdomainTenantMiddleware
  router lacks hosts() — silent skip + 421 was a confusing failure mode
- Add comment at _serve_a2a call site noting allowed_hosts is MCP-only
- Document startup-time snapshot semantics in _synthesize_allowed_hosts
  docstring; note hosts() convention in SubdomainTenantRouter Protocol
- Tests: warning assertion for missing hosts(), subclass synthesis coverage

https://claude.ai/code/session_0198Bk2S7eJCQtvUALXrQXHj
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Superseded by #537 (merged) which already auto-synthesizes :* host variants for bare allowed_hosts.

@bokelley bokelley closed this May 4, 2026
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Acknowledged — #537 covers this. No further action on this PR.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

decisioning: SubdomainTenantMiddleware allowlist should auto-synthesize :* host variants

2 participants