Fix router prefix ignored when set via include_router (#204) - #329
Fix router prefix ignored when set via include_router (#204)#329wakqasahmed wants to merge 2 commits into
Conversation
wakqasahmed
left a comment
There was a problem hiding this comment.
Cold-start review — request changes
Verified locally against a fresh clone of main + this PR head. The core idea (recover the include_router(prefix=...) prefix from the app's route table) is sound and the happy path works, and I confirmed the new test genuinely fails on the parent commit e5cad13 with exactly the issue's symptom (/mcp at app root) and passes on this head — so it is a real regression test, not a tautology.
However there are two blocking correctness problems and several smaller ones. Details are in the inline comments; summary:
Blocking
-
The fix is a silent no-op on FastAPI >= 0.137.0. As of 0.137.0
app.include_router()no longer flattens sub-routes intoapp.routes; it appends a single_IncludedRouterobject with no.path/.endpoint._resolve_router_include_prefix()therefore finds nothing and returns"", and the bug is back. This project declaresfastapi>=0.100.0, so this is inside the supported range. The new test also fails on this PR head under 0.141.1 — it only passes becauseuv.lockpins fastapi 0.115.11, so CI will never catch it. Bisected: 0.136.0 flat, 0.137.0_IncludedRouter. -
The issue's literal repro is still not fixed. In #204
other_router = APIRouter()has zero routes — it is used purely as a mount point. With no routes there is nothing to match against, so the method returns""andmcp.mount(other_router)still lands at the app root. The new test only passes because it adds a/pingroute the issue never had. The empty-router path is handled gracefully (no crash — I verified), but it is silent, and it is the exact case the reporter filed.
Non-blocking but worth fixing
- Endpoint-identity matching picks the wrong prefix when the same endpoint function is registered on more than one router (a legitimate FastAPI pattern) — first match in
app.routeswins. Reproduced. - Same for a router included twice under different prefixes — first inclusion wins, with no way for the caller to select the intended one and no warning.
- Resolution happens once at mount time and is never re-evaluated, so
mount_http()beforeinclude_router()still mounts at the root (and then leaks a second MCP route at the later prefix). logger.info(f"MCP HTTP/SSE server listening at {mount_path}")still logs/mcp, not the resolved/other/route/mcp— now actively misleading.
Scope check (passes): the PR does what it claims and no more — the mount_path semantics from the issue's 2nd/3rd examples (explicitly called "not the expected usage" by the reporter) are untouched. Good restraint. Also verified there is no double-counting when a router has both a constructor prefix and an include_router prefix (/outer/inner/mcp — correct), and that a router route at / resolves correctly.
| continue | ||
| for app_route in self.fastapi.routes: | ||
| app_path = getattr(app_route, "path", "") | ||
| if getattr(app_route, "endpoint", None) is router_endpoint and app_path.endswith(router_path): |
There was a problem hiding this comment.
Blocking (1/2): this whole method returns "" on FastAPI >= 0.137.0.
Since FastAPI 0.137.0, app.include_router() no longer copies the sub-routes into app.routes — it appends one _IncludedRouter object that has neither .path nor .endpoint. Both getattr(app_route, "endpoint", None) and getattr(app_route, "path", "") come back None/"", nothing matches, and the method falls through to return "" — i.e. the pre-fix behaviour.
Reproduced on a clean checkout of this PR head:
fastapi 0.135.0 -> [('APIRoute', '/x/p')]
fastapi 0.136.0 -> [('APIRoute', '/x/p')]
fastapi 0.137.0 -> [('_IncludedRouter', None)]
fastapi 0.141.1 -> [('_IncludedRouter', None)]
and the new test on this branch:
$ pip install fastapi==0.141.1
$ pytest tests/test_basic_functionality.py -q
FAILED test_mount_router_with_include_router_prefix -
assert '/other/route/mcp' in [..., '/error', None, None]
$ pip install fastapi==0.115.11 # the uv.lock pin
4 passed
pyproject.toml declares fastapi>=0.100.0, so 0.137+ is in the supported range; CI passes only because uv.lock pins 0.115.11. Please either walk _IncludedRouter (its nested router/prefix are reachable off the object) or, better, resolve the prefix from self.fastapi.routes after a app.router.routes-flattening step that handles both shapes — and add a test matrix entry (or at least a min/max fastapi tox/CI job) so this can't silently rot again.
| app_path = getattr(app_route, "path", "") | ||
| if getattr(app_route, "endpoint", None) is router_endpoint and app_path.endswith(router_path): | ||
| return app_path[: -len(router_path)] | ||
| return "" |
There was a problem hiding this comment.
Blocking (2/2): the issue's actual repro has an empty router, and it is still broken.
In #204 the reporter's router is:
other_router = APIRouter() # zero routes
app.include_router(other_router, prefix="/other/route")
mcp.mount(other_router)There are no routes to match, so this loop never executes and we return "". I verified the empty-router case does not crash (good — the graceful fallback works), but the outcome is that the MCP endpoint still lands at /mcp on the app root, which is precisely the bug being closed:
--- empty router used purely as a mount point ---
routes: ['/root', '/mcp'] # expected '/empty/mcp'
The new test passes only because it adds a /ping route that the issue never had. A router with no routes of its own that exists purely as a mount point is a normal pattern and is the literal filed case, so Fixes #204 currently over-claims.
Two things needed here:
- handle the zero-route case (on modern FastAPI the
_IncludedRouterobject carries the router identity and prefix, which solves this and finding (1) together); - if it genuinely can't be resolved,
logger.warning(...)rather than silently returning""— a silent fallback to the buggy behaviour is the worst outcome for a user hitting this.
Also a small robustness nit while you're here: the fallback is documented as "the router hasn't been included into the app yet", but it also fires for empty routers, for Mount-only routers, and (per finding 1) for every modern FastAPI — the docstring should not imply a single cause.
| if not router_path or router_endpoint is None: | ||
| continue | ||
| for app_route in self.fastapi.routes: | ||
| app_path = getattr(app_route, "path", "") |
There was a problem hiding this comment.
Wrong prefix when the same endpoint function is reused across routers. Registering one handler on two routers is legitimate FastAPI; endpoint is router_endpoint cannot tell the two registrations apart, and the first hit in self.fastapi.routes wins. Reproduced on this branch:
async def ping(): ...
ra = APIRouter(); ra.add_api_route("/ping", ping, methods=["GET"])
rb = APIRouter(); rb.add_api_route("/ping", ping, methods=["GET"])
app.include_router(ra, prefix="/alpha")
app.include_router(rb, prefix="/beta")
mcp._resolve_router_include_prefix(rb) # -> '/alpha' (expected '/beta')
mcp.mount_http(rb)
# app paths: ['/alpha/ping', '/beta/ping', '/alpha/ping', '/alpha/mcp']So rb gets its routes re-registered under /alpha and the MCP endpoint lands at /alpha/mcp. Silently mounting a user's MCP server under an unrelated router's prefix is worse than the original bug.
Cheap hardening that fixes this without changing the approach: don't return on the first hit. Collect the candidate prefix implied by every route in router.routes and only accept a prefix p for which all of the router's routes have a counterpart at p + route.path with the same endpoint and matching methods; if zero or more than one candidate survives, warn and fall back. In the example above only /beta satisfies all-routes agreement.
(The and app_path.endswith(router_path) guard doesn't help here — both candidates end with /ping. I checked the guard does correctly reject unrelated suffixes like /xping.)
|
|
||
| assert isinstance(router, (FastAPI, APIRouter)), f"Invalid router type: {type(router)}" | ||
|
|
||
| include_prefix = self._resolve_router_include_prefix(router) if isinstance(router, APIRouter) else "" |
There was a problem hiding this comment.
Ambiguity when a router is included more than once, and no re-evaluation after mount time.
Multiple inclusions — unusual but legal, and the method silently picks whichever inclusion appears first in app.routes:
app.include_router(rc, prefix="/one")
app.include_router(rc, prefix="/two")
mcp._resolve_router_include_prefix(rc) # -> '/one'
# after mount_http(rc): ['/one/x', '/two/x', '/one/x', '/one/mcp']There is no correct answer here from mount_http()'s signature alone — but "first wins, silently" is the wrong default. Either logger.warning on multiple distinct candidate prefixes, or let the caller disambiguate explicitly.
Ordering — this is computed once, here, at mount time and never revisited. Mounting before including still mounts at the root, and then the later include_router re-registers the MCP route a second time:
mcp.mount_http(rf) # rf not yet included
app.include_router(rf, prefix="/late")
# app paths: ['/z', '/mcp', '/late/z', '/late/mcp']Two MCP endpoints, one of them at the root. Worth at least documenting the ordering requirement in the mount_http/mount_sse docstrings ("the router must already be included into the app"), and ideally warning when the router isn't found in the app at all.
| self.fastapi.include_router(router) | ||
| self.fastapi.include_router(router, prefix=include_prefix) | ||
|
|
||
| logger.info(f"MCP HTTP server listening at {mount_path}") |
There was a problem hiding this comment.
Nit, but now actively misleading: this logs MCP HTTP server listening at /mcp even when the endpoint was actually registered at /other/route/mcp. Since the point of the PR is that the effective path differs from mount_path, please log the resolved path (include_prefix + router.prefix + mount_path). Same at the mount_sse equivalent below.
| # TODO: Find a better way to do this. | ||
| if isinstance(router, APIRouter): | ||
| self.fastapi.include_router(router) | ||
| self.fastapi.include_router(router, prefix=include_prefix) |
There was a problem hiding this comment.
Confirmed correct — no double-counting when a router has both a constructor prefix and an include_router prefix, because router.routes paths already contain router.prefix, so the diff yields only the include-time prefix and include_router(..., prefix=include_prefix) re-applies router.prefix itself:
rd = APIRouter(prefix="/inner"); app.include_router(rd, prefix="/outer")
resolved='/outer', router.prefix='/inner' -> '/outer/inner/mcp' # correctAlso verified mount_sse's include_prefix + router.prefix + mount_path produces the right messages_path (/sse-pfx/sse/messages/).
One pre-existing wart this change makes more visible: re-including the router now lands its own routes on exactly the same paths as the original inclusion, so every route is registered twice at the same path and app.openapi() emits UserWarning: Duplicate Operation ID .... Before this PR the duplicates landed at different (root) paths, so it was equally duplicated but less obvious. Not introduced here, and the # HACK ... TODO: Find a better way to do this comment already flags it — but if you're touching this line anyway, re-including only the newly added MCP route instead of the whole router would kill both the duplication and most of the edge cases above.
| `app.include_router(router, prefix=...)`, the MCP server should still be mounted | ||
| at the router's effective path, not at the app root. | ||
| """ | ||
| other_router = APIRouter() # No prefix on the constructor |
There was a problem hiding this comment.
This is the crux of the scope gap: the issue's other_router = APIRouter() has no routes at all, and the fix depends entirely on there being at least one route to diff against. Adding /ping here makes the test pass but moves it off the reported case.
Please keep this test and add one with a genuinely empty router asserting /empty/mcp (it currently yields /mcp), plus a case where the same endpoint function is shared by two routers. Those two are where the implementation actually breaks.
| mounted_paths = [getattr(route, "path", None) for route in simple_fastapi_app.routes] | ||
|
|
||
| assert "/other/route/mcp" in mounted_paths, f"Expected mount path not found in: {mounted_paths}" | ||
| assert "/mcp" not in mounted_paths, "MCP endpoint should not be mounted at the app root" |
There was a problem hiding this comment.
Good assertion — I verified this test is a real regression test, not a tautology. On the parent commit e5cad13 with only this test file applied:
FAILED test_mount_router_with_include_router_prefix -
assert '/other/route/mcp' in ['/openapi.json', ..., '/items/{item_id}', '/error', None, None]
3 passed, 1 failed
and 4 passed on this head (with the lockfile's fastapi 0.115.11). So the claim in the PR description holds.
One caveat: because it asserts on the pinned fastapi, it gives false confidence — see the fastapi >= 0.137 comment on server.py. Consider asserting via app.openapi()["paths"] or a TestClient request to /other/route/mcp instead of walking app.routes, which would be resilient to FastAPI's internal route-table representation changing (and would have caught the 0.137 breakage).
…adata-org#204) Address blocking review findings on PR tadata-org#329: - FastAPI >= 0.137 stopped flattening include_router()'d sub-routes into app.routes (it appends an opaque _IncludedRouter object instead), which made the previous path-diffing resolver silently return "" and regress to the original bug. Now resolve the prefix from FastAPI's own _IncludedRouter.original_router / include_context.prefix metadata when available, which is exact (object identity, not endpoint-function identity) and works even when the router has no routes of its own. - Falls back to the previous path-diffing approach on fastapi < 0.137, but hardened: requires every route on the router to agree on the same candidate prefix (matching endpoint identity and HTTP methods) before accepting it, and warns + falls back to the safe empty prefix on disagreement, instead of silently mounting under an unrelated router's prefix when two routers share the same endpoint function. - Warn (and fall back to the safe empty prefix) when a router is included under more than one distinct prefix, instead of silently picking the first one. - mount_http/mount_sse now log the actually-resolved mount path instead of the pre-resolution relative mount_path. - Document that mount_http/mount_sse must be called after the router has already been include_router()'d -- resolution happens once at mount time and is not re-evaluated later. Tested against fastapi 0.115.11 (this repo's uv.lock pin) and 0.141.1 (current latest as of writing) -- full suite green on both. Fixes tadata-org#204 fully on fastapi >= 0.137 (including the issue's literal zero-route repro). On fastapi < 0.137 the zero-route case remains unresolvable (no data in app.routes to recover the prefix from) and is left as a documented, safe fallback to the app root rather than a crash or a silently wrong prefix -- see the docstrings on _resolve_include_prefix_by_diff and the new test_mount_empty_router_with_include_router_prefix test.
|
Thanks for the thorough review — all three concerns confirmed and addressed in the new commit. What changed:
Also addressed:
Left as documented, not silently glossed over:
Verified the full suite green against both PR description updated to reflect the actual final scope. |
Fixes #204
Root cause
When an
APIRouteris created without aprefixon the constructor and is insteadgiven one at inclusion time via
app.include_router(router, prefix="/x"), the prefix isonly reflected on
self.fastapi's route table — the router object's own.prefixattribute stays empty (
"").mount_http()/mount_sse()usedrouter.prefixdirectlyto compute where the MCP endpoint should live and how to re-include the router, so the
MCP endpoint ended up mounted at the app root instead of under the router's actual path.
The issue's second and third examples (passing an explicit
mount_path="/other/route"/"/other/route/mcp") are, per the issue text itself, not the intended usage of themount_pathparameter — that parameter controls where the MCP endpoint sits relative tothe router, not the router's own external prefix — so this PR does not change that
behavior.
Update after review
A cold-start review caught that the original version of this fix (matching routes by
endpoint is router_endpointidentity and diffing paths againstapp.routes) had twoblocking correctness problems, both confirmed empirically:
app.include_router()nolonger flattens sub-routes into
app.routes— it appends one opaque_IncludedRouterobject with no
.path/.endpoint. The old resolver found nothing and silentlyreturned
"", restoring the original bug. This repo declaresfastapi>=0.100.0, sothis was within the supported range, and the original test only passed because
uv.lockpins fastapi 0.115.11 — it would not have caught the regression once thelockfile is bumped.
still broken — no routes meant nothing to diff, so the resolver returned
""andthe MCP endpoint still landed at
/mcp. The original test only passed because itadded a
/pingroute the issue's repro never had.other router's prefix — silently mounting the MCP endpoint under the wrong prefix,
which is worse than the original bug.
What changed
_resolve_router_include_prefix()now tries an exact, identity-based resolution first:on FastAPI >= 0.137,
app.routescontains one_IncludedRouterperinclude_router()call, carrying
.original_router(the exact router instance) and.include_context.prefix(the prefix it was included under). Matching onoriginal_router is routeris exact — it works even with zero routes, and cannot beconfused by two routers sharing an endpoint function.
metadata doesn't exist), but hardened: it now requires every route on the router to
agree on the same candidate prefix (matching endpoint identity and HTTP methods)
before accepting it. On disagreement (or zero matches), it warns and falls back to the
safe empty prefix instead of guessing.
include_router()-ed more than once under differentprefixes: warns and falls back to the safe empty prefix rather than silently picking
the first inclusion.
mount_http()/mount_sse()now log the actually-resolved mount path instead of thepre-resolution relative
mount_path.mount_http()/mount_sse()must be called after the router hasalready been
include_router()-ed — resolution happens once at mount time and is notre-evaluated if routes/routers change afterwards.
Known, documented limitations (not fixed in this PR)
resolved — there's no metadata and nothing to diff against, so it safely falls back to
the app root rather than guessing.
test_mount_empty_router_with_include_router_prefixdocuments this explicitly and branches on the fastapi version.
# HACK ... TODOinmount_http/mount_sse)re-registers its routes at the same paths, which can produce duplicate-operation-ID
warnings from FastAPI's OpenAPI generation. Pre-existing, not introduced by this PR.
include_router()still landsat the app root (now documented in the docstrings, not silently fixed).
Tests
Added to
tests/test_basic_functionality.py:test_mount_router_with_include_router_prefix(existing test, now asserts via a realrequest through
TestClientinstead of walkingapp.routes, since FastAPI's internalroute-table shape is not a stable contract across versions).
test_mount_empty_router_with_include_router_prefix— the issue's literal zero-routerepro; asserts full resolution on fastapi >= 0.137, and the documented safe fallback on
fastapi < 0.137.
test_resolve_prefix_with_endpoint_shared_across_routers— two routers registering thesame endpoint function; asserts the resolver never returns the other router's prefix.
test_resolve_prefix_with_router_included_at_multiple_prefixes— a routerinclude_router()-ed under two different prefixes; asserts the safe empty-prefixfallback rather than "first one wins".
Verified against both
fastapi==0.115.11(this repo'suv.lockpin) andfastapi==0.141.1(current latest as of writing) — full test suite green on both:Also ran
ruff check .andmypy .with the exact versions pinned inuv.lock(ruff 0.9.10, mypy 1.15.0) — clean aside from one pre-existing, unrelated
mypyerror infastapi_mcp/types.pythat reproduces identically on unmodifiedmain.tests/test_http_real_transport.pyhas a handful ofRuntimeError: Event loop is closedteardown errors; this reproduces identically on unmodified
mainin this sandbox and isunrelated to this change.
Honest scope assessment
Fixes #204is now true across the fastapi version range this repo declares(
fastapi>=0.100.0), including the issue's literal zero-route repro, on fastapi >=0.137. On fastapi < 0.137, the zero-route case remains a documented limitation (falls
back to the app root, same as before this PR existed) rather than a silent wrong-prefix
mount — there is no recoverable information in
app.routeson those versions to dobetter, short of tracking
include_router()calls ourselves at a higher level, whichfelt out of scope for this fix.