Repository navigation
feat(server): let serve() register operational routes (/healthz, /metrics) ahead of the A2A catch-all and auth #1303
Description
Summary
serve() (adcp/server/serve.py:788-832) has no routes= parameter and no built-in health or readiness endpoint. On transport="both", the dispatcher sends every HTTP path except /mcp to the A2A app (serve.py:2262-2285), and that app sits behind bearer auth (serve.py:2227). So a liveness probe or metrics scrape gets 404, or 401 once auth= is configured. The SDK expects these paths to exist: SubdomainTenantMiddleware(excluded_paths=...) documents {"/healthz", "/readyz"} as the typical exclusion (adcp/server/tenant_router.py:416-420). But nothing in serve() can serve them, except another asgi_middleware entry.
Reproduction (adcp 8.0.0)
from starlette.testclient import TestClient
from adcp.server import ADCPHandler
from adcp.server.auth import BearerTokenAuth
from adcp.server.serve import _build_mcp_and_a2a_app # the builder serve(transport="both") uses
class Seller(ADCPHandler):
async def get_products(self, params, context=None):
return {"products": []}
for auth in (None, BearerTokenAuth(validate_token=lambda token: None)):
app = _build_mcp_and_a2a_app(
Seller(), name="seller", port=3001, host="0.0.0.0", instructions=None,
test_controller=None, validation=None, auth=auth,
)
with TestClient(app) as client:
statuses = {path: client.get(path).status_code for path in ("/healthz", "/readyz", "/metrics")}
print(f"auth={'bearer' if auth else 'none':6}", statuses)Output:
auth=none {'/healthz': 404, '/readyz': 404, '/metrics': 404}
auth=bearer {'/healthz': 401, '/readyz': 401, '/metrics': 401}
Why an adopter hits this
Embedded Sales Agent mounts four non-protocol surfaces as hand-written ASGI middleware in asgi_middleware (core/main.py, _serve_kwargs):
OperationalEndpointsMiddleware(core/middleware/operational_endpoints.py, 171 LOC, mostly the health check itself) serves/health,/robots.txtand an opt-in/metrics.ManagementApiWSGIMount(core/middleware/management_api_mount.py, 35 LOC) sends four path prefixes to a WSGI management API.
Each one re-implements path matching and the http.response.start / http.response.body sequence. The order of the list is load-bearing. These entries have to come before SubdomainTenantMiddleware, or probes sent to a pod IP get 404 unknown-host. They have to come before ESA's own auth-bridge and signature-verification entries. They have to come after the metrics middleware, or health checks stop being counted. None of this is visible from serve(). A seller who adds a /healthz entry one slot too late finds out from a failing kubelet probe.
Ask
Add serve(routes=[...]) (Starlette Route / Mount objects, also on ServeConfig and build_asgi_app). The SDK would match these before the MCP/A2A dispatcher and outside bearer auth and signature verification. Sellers who want auth on a route would add it themselves. SubdomainTenantMiddleware would skip these paths without the seller repeating them in excluded_paths. asgi_middleware entries such as tracing and metrics would still wrap them.
Optional follow-up: a built-in /healthz that returns 200 once lifespan startup completes and 503 during shutdown, since the SDK owns that lifespan state.
Activity
bokelley commentedon Oct 2, 2026
Triage
Classification: Feature request
Bucket(s): server (no matching repo label found)
Status: ready-to-implement
Milestone: (omitted — no target version named)
What the experts said:
Mountpaths need prefix-aware excluded_paths (exact-match inSubdomainTenantMiddlewarewill miss sub-paths), and the routes Router must be instantiated outside_dispatch(not rebuilt per-request); also/mcpcollision detection needed andRoute | Mountrequire aTYPE_CHECKINGguard.asgi_middleware=is completely undiscoverable; recommend namingunauthenticated_routes=(notroutes=, which collides with Starlette's own constructor signature and will confuse LLM-assisted users); the same 401 problem exists ontransport="a2a"and must be covered too; ahealth_endpoint=Trueshortcut (zero Starlette imports) handles the 80% case; add a startup log warning whenauth=is set without an operational-path escape hatch.My take: Both experts converge that this is worth implementing as a clean non-breaking addition. The naming
unauthenticated_routes=is the right call — it encodes the auth-bypass contract, prevents the Starletteroutes=confusion, and is machine-readable for coding agents. Thehealth_endpoint=Trueconvenience andunauthenticated_routes=can ship in the same PR; splitting them forces boilerplate on every adopter who deploys before the follow-up.Implementation brief:
unauthenticated_routes: Sequence[Route | Mount] | None = None(notroutes=) toserve(),ServeConfig,_build_mcp_and_a2a_app, and_serve_mcp_and_a2a. Addhealth_endpoint: bool = Falseas a zero-import shortcut that registers a built-in/healthzreturning{"status": "ok"}(200) after lifespan startup.Routerfromunauthenticated_routesoutside_dispatch(not per-request); in_dispatchmatch it first, before the/mcpcheck. RaiseValueErrorat construction if any supplied path is/mcpor starts with/mcp/."both","a2a","streamable-http"), not just"both"—_wrap_a2a_with_authwraps the A2A-only transport the same way.Route | Mountare Starlette types — addif TYPE_CHECKING: from starlette.routing import Route, Mountto match the existing pattern for other optional Starlette types inserve.py:68–78.excluded_pathscoordination: Auto-injectRoutepath strings intoSubdomainTenantMiddleware.excluded_paths; restrict auto-injection toRouteobjects (notMount, sinceMountowns a subtree and the middleware does exact-match). Document in the param docstring thatMountpaths require a manualexcluded_paths=entry covering sub-paths.ServeConfig: Addunauthenticated_routesandhealth_endpointunder a new# --- Operational endpoints ---comment section (consistent with existing section pattern lines 283–328). Add a startup log whenauth=is configured withouthealth_endpoint=Trueor non-emptyunauthenticated_routes.pytestontests/server/(especiallytest_serve_both_transport.pyif present),mypy src/,ruff check ..Triaged by Claude Code. Session: https://claude.ai/code/session_019xNrDwhwTkW1YJ3bxaH4Av
Generated by Claude Code
bokelley commentedon Oct 4, 2026
Fixed by #1336 (squash dfeb5f9): serve() accepts explicit unauthenticated operational routes that run ahead of the catch-all/auth layers, with route-collision, method and tenant-prefix handling.