Skip to content

fix(admin-ui): emit nested routes as <dir>/index.html so /ui/mcp/oauth/callback works - #28106

Merged
mateo-berri merged 1 commit into
litellm_rebuild_admin_ui_static_exportfrom
litellm_fix_mcp_oauth_callback
May 20, 2026
Merged

fix(admin-ui): emit nested routes as <dir>/index.html so /ui/mcp/oauth/callback works#28106
mateo-berri merged 1 commit into
litellm_rebuild_admin_ui_static_exportfrom
litellm_fix_mcp_oauth_callback

Conversation

@mateo-berri

@mateo-berri mateo-berri commented May 17, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on top of #28112 (the regenerated static export artifact). Merge that one first so this PR collapses cleanly into litellm_internal_staging.

After clicking Approve on the Linear MCP OAuth consent screen, the browser is redirected back to http://<proxy>/ui/mcp/oauth/callback?code=...&state=... and the proxy returns {"detail":"Not Found"}, so the handshake never completes.

Root cause is the packaged Next.js static export. With the default next.config.mjs, every route is emitted as <name>.html at the parent level, with a sibling directory (e.g. mcp/oauth/callback/) containing only Next.js metadata .txt files and no index.html. FastAPI's StaticFiles(html=True) mount at /ui follows Starlette's rule of serving <dir>/index.html for directory paths; it does not fall back to <path>.html for extensionless requests, so a request for /ui/mcp/oauth/callback lands on the empty callback/ directory and 404s.

docker/Dockerfile.non_root tried to compensate at image-build time by walking the export and moving *.html into <name>/index.html, but the loop uses a shell glob (for html_file in *.html) which does not recurse. The fix only touched top-level files; nested routes like mcp/oauth/callback.html were left untouched. The marker .litellm_ui_ready was still dropped, so the Python-side runtime restructure step in proxy_server.py was skipped at startup.

Linear ticket

N/A

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

CI (LiteLLM team)

  • Branch creation CI run; Link:
  • CI run for the last commit; Link:
  • Merge / cherry-pick CI run; Links:

Screenshots / Proof of Fix

Pulled the latest rc image, then ran the proxy with the rebuilt _experimental/out/ mounted into the runtime UI path that the non-root image expects:

ENV_TMP=$(mktemp) && \
sed -E 's/^([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*"?([^"]*)"?$/\1=\2/' .env > "$ENV_TMP" && \
docker run -d --name litellm-rc \
  -p 4000:4000 --env-file "$ENV_TMP" \
  -e GOOGLE_APPLICATION_CREDENTIALS=/app/unused.json \
  -v $PWD/litellm/proxy/dev_config.yaml:/app/config.yaml:ro \
  -v $PWD/litellm/proxy/dump_failure.py:/app/dump_failure.py:ro \
  -v $PWD/litellm/proxy/_experimental/out:/var/lib/litellm/ui:ro \
  -w /app \
  ghcr.io/berriai/litellm-non_root:v1.85.0-rc.2 \
  --config /app/config.yaml --detailed_debug

Static routing for the URL Linear actually redirects to, plus a handful of other UI routes for regression coverage:

$ for p in /ui/mcp/oauth/callback /ui/mcp/oauth/callback/ /ui/login /ui/models-and-endpoints /ui/ /ui/skills /ui/teams /ui/virtual-keys; do
    code=$(curl -s -o /dev/null -w "%{http_code}" -L "http://localhost:4000${p}");
    echo "$code  $p";
  done
200  /ui/mcp/oauth/callback
200  /ui/mcp/oauth/callback/
200  /ui/login
200  /ui/models-and-endpoints
200  /ui/
200  /ui/skills
200  /ui/teams
200  /ui/virtual-keys

Live LLM round-trip against a real provider through the upgraded image:

$ curl -s http://localhost:4000/v1/chat/completions \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"model":"claude","messages":[{"role":"user","content":"reply with the single word OK"}]}' \
    | jq -r '.choices[0].message.content'
OK

End-to-end browser flow to repro the original bug and confirm the fix:

  1. Go to http://localhost:4000/ui/, sign in as admin / sk-1234.
  2. Open http://localhost:4000/ui/tools/mcp-servers/ and add a Linear MCP server with OAuth enabled.
  3. Click Connect; the proxy redirects to Linear's consent screen.
  4. Click Approve. Linear posts back to http://localhost:4000/ui/mcp/oauth/callback?code=...&state=.... Before this PR that URL returned {"detail":"Not Found"}; after the PR it 307s to /ui/mcp/oauth/callback/ and the dashboard finishes the token exchange.

Type

Bug Fix

Changes

ui/litellm-dashboard/next.config.mjs now sets trailingSlash: true, so the static export emits every nested route as <dir>/index.html natively instead of <dir>.html. The matching regeneration of litellm/proxy/_experimental/out/ lives in the stacked artifact PR #28112.

docker/Dockerfile.non_root drops the broken shell-glob restructure loop. It only saw top-level *.html files and never reached mcp/oauth/callback.html, so it added no value once the export is already in the desired layout. The .litellm_ui_ready readiness marker is still written so the proxy startup path keeps skipping the redundant Python restructure step.

A regression test in tests/test_litellm/proxy/test_proxy_server.py mounts the actual bundled export through StaticFiles and asserts that /ui/mcp/oauth/callback?code=...&state=... 307s to /ui/mcp/oauth/callback/?code=...&state=... and the followed redirect returns HTML. It also asserts that no nested route in the export ships as a stray <name>.html, which is what catches future regressions of either trailingSlash being removed or someone moving back to a recursion-broken restructure step.


Note

Medium Risk
Changes the Admin UI static export layout and Docker image build steps, which could break existing UI routing if the exported artifact or proxy assumptions diverge.

Overview
Fixes Admin UI routing for extensionless nested paths by enabling trailingSlash in the Next.js static export so routes are emitted as <dir>/index.html (e.g., mcp/oauth/callback/index.html).

Simplifies the non-root Docker build by removing the non-recursive HTML “restructure” loop and only writing the .litellm_ui_ready marker after copying the prebuilt UI.

Adds a regression test that validates the bundled export contains no nested non-index.html pages and that /ui/mcp/oauth/callback?… redirects and serves HTML via StaticFiles(html=True).

Reviewed by Cursor Bugbot for commit 3cc3557. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Linear and other OAuth providers redirect the user back to
/ui/mcp/oauth/callback?code=...&state=... after the consent step. The
packaged Next.js static export only produced /ui/mcp/oauth/callback.html,
so FastAPI's StaticFiles served a 404 on the extensionless URL and the
OAuth handshake never completed.

The Dockerfile.non_root build step tried to paper over this at image-build
time with `for html_file in *.html; do ...`, but that shell glob does not
recurse, so nested routes like mcp/oauth/callback.html were left stranded
next to an empty mcp/oauth/callback/ directory containing only Next.js
metadata. The runtime restructure step in proxy_server.py was then skipped
because the .litellm_ui_ready marker had already been dropped.

Set trailingSlash: true in the dashboard's Next.js config so the export
emits every nested route as <dir>/index.html natively. The Dockerfile loop
is now a no-op for the bundled UI and has been removed; the
.litellm_ui_ready marker is still written so the proxy keeps skipping the
redundant Python restructure step at startup. Stacks on top of the static
export regeneration in the parent branch.
@mateo-berri
mateo-berri force-pushed the litellm_fix_mcp_oauth_callback branch from fa2a492 to 3cc3557 Compare May 17, 2026 06:35
@mateo-berri
mateo-berri changed the base branch from litellm_internal_staging to litellm_rebuild_admin_ui_static_export May 17, 2026 06:36
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri
mateo-berri requested a review from yuneng-berri May 17, 2026 06:38
@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the {"detail":"Not Found"} response for /ui/mcp/oauth/callback by addressing the root cause: the Next.js static export was emitting nested routes as <name>.html files rather than <name>/index.html directories, which Starlette's StaticFiles(html=True) cannot serve for extensionless paths.

  • next.config.mjs: Adds trailingSlash: true so Next.js natively emits every route as <dir>/index.html, fixing the layout at export time rather than patching it post-hoc.
  • Dockerfile.non_root: Removes the broken shell-glob restructure loop (for html_file in *.html) that only iterated top-level files and silently skipped nested routes like mcp/oauth/callback.html; the .litellm_ui_ready marker is still written to keep the proxy startup fast.
  • test_proxy_server.py: Adds an integration test that mounts the real bundled export through StaticFiles and asserts the 307→200 redirect chain works for /ui/mcp/oauth/callback, plus a file-tree scan that catches any future regression where nested routes are left as stray .html files.

Confidence Score: 5/5

This PR is safe to merge — it removes dead workaround code, fixes the export layout at the source, and adds a test that will catch the same regression in the future.

All three changes are tightly scoped: a one-line Next.js config change that produces a correct export layout, removal of a shell loop that was demonstrably broken for nested paths, and a new test that directly validates the fix. No logic changes touch the request path or auth layer.

No files require special attention.

Important Files Changed

Filename Overview
ui/litellm-dashboard/next.config.mjs Adds trailingSlash: true so Next.js static export emits every route as <dir>/index.html, fixing the root cause of the 404 on nested routes like /ui/mcp/oauth/callback.
docker/Dockerfile.non_root Removes the broken shell-glob restructure loop (which only iterated top-level *.html files and missed nested routes); .litellm_ui_ready marker is still written so the proxy startup skips its Python-side restructure step.
tests/test_litellm/proxy/test_proxy_server.py Adds test_admin_ui_export_serves_nested_extensionless_routes that validates the actual bundled export has no stray <name>.html nested files, confirms callback/index.html exists, and verifies the 307→200 redirect chain via TestClient (in-process, no real network calls).

Reviews (2): Last reviewed commit: "fix(admin-ui): emit nested routes as <di..." | Re-trigger Greptile

Comment on lines +607 to +647
def test_admin_ui_export_serves_nested_extensionless_routes():
out_dir = (
Path(litellm.__file__).parent / "proxy" / "_experimental" / "out"
)
assert out_dir.is_dir(), f"missing UI export at {out_dir}"

nested_html_offenders = [
path.relative_to(out_dir).as_posix()
for path in out_dir.rglob("*.html")
if path.parent != out_dir
and path.name != "index.html"
and "_next" not in path.parts
and "litellm-asset-prefix" not in path.parts
]
assert not nested_html_offenders, (
"Nested routes must be named index.html. Offenders: "
f"{nested_html_offenders}"
)

callback_index = out_dir / "mcp" / "oauth" / "callback" / "index.html"
assert callback_index.is_file(), (
f"MCP OAuth callback page must exist at {callback_index}; "
"without it /ui/mcp/oauth/callback 404s after Linear redirects back."
)

fastapi_app = FastAPI()
fastapi_app.mount(
"/ui", StaticFiles(directory=str(out_dir), html=True), name="ui"
)
client = TestClient(fastapi_app)

redirect = client.get(
"/ui/mcp/oauth/callback?code=abc&state=xyz",
follow_redirects=False,
)
assert redirect.status_code == 307
assert redirect.headers["location"].endswith("/ui/mcp/oauth/callback/?code=abc&state=xyz")

landed = client.get("/ui/mcp/oauth/callback?code=abc&state=xyz")
assert landed.status_code == 200
assert "<html" in landed.text.lower()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Integration test depends on stacked PR artifact

test_admin_ui_export_serves_nested_extensionless_routes reads directly from the real litellm/proxy/_experimental/out/ directory rather than a tmp_path fixture. If this test is run before PR #28112 (the regenerated export artifact) is merged, it will fail at the assert out_dir.is_dir() or assert callback_index.is_file() checks with a descriptive error — but it will also fail in any CI run for this PR alone. This is intentional per the PR description, but it is worth confirming that the CI pipeline for this branch gates on #28112 being present before running the test suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's fine. We will merge the other one first

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3cc3557. Configure here.

if path.parent != out_dir
and path.name != "index.html"
and "_next" not in path.parts
and "litellm-asset-prefix" not in path.parts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test filters on absolute path parts, not relative

Low Severity

The offender-detection filter checks "_next" not in path.parts and "litellm-asset-prefix" not in path.parts, but path is an absolute Path from rglob, so path.parts includes every component of the full filesystem path — not just the portion under out_dir. If the repository happens to be checked out inside a directory named _next or litellm-asset-prefix, the filter would silently exclude all candidate files, causing the assertion to pass even when genuine offenders exist. Using path.relative_to(out_dir).parts instead of path.parts would scope the check to only the export-internal directory structure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cc3557. Configure here.

fi; \
done && \
touch .litellm_ui_ready )
touch /var/lib/litellm/ui/.litellm_ui_ready

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe all the docker files re-architect the files, not just non_root. Have you tested this with the other files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dockerfile.non_root was the only one with a build-time restructure loop. Dockerfile and docker/Dockerfile.database never restructured at build time. They ship the raw _experimental/out/ and rely on the runtime restructure in proxy_server.py::_restructure_ui_html_files

After this PR, with trailingSlash: true, the Next.js export already emits /index.html natively. So for non-root -> no build-time restructure needed; we still touch .litellm_ui_ready to short-circuit the Python step on the read-only fs. ForsStandard / database -> nothing to change

@mateo-berri
mateo-berri requested a review from yuneng-berri May 20, 2026 06:52
@mateo-berri
mateo-berri merged commit 7e0dced into litellm_rebuild_admin_ui_static_export May 20, 2026
116 checks passed
@mateo-berri
mateo-berri deleted the litellm_fix_mcp_oauth_callback branch May 20, 2026 18:24
mateo-berri added a commit that referenced this pull request May 26, 2026
…28112)

* chore(admin-ui): regenerate static export with trailingSlash: true

Rebuilds litellm/proxy/_experimental/out/ from ui/litellm-dashboard with
`trailingSlash: true` enabled in next.config.mjs. Next.js now emits every
route as <dir>/index.html (e.g. mcp/oauth/callback/index.html) instead of
<dir>.html with a sibling metadata-only directory, which fixes the 404 on
extensionless URLs served through FastAPI's StaticFiles(html=True) mount.

This is the build artifact half of the fix; the config change, Dockerfile
cleanup, and regression test live in the follow-up source PR that stacks
on top of this branch.

* fix(admin-ui): emit nested routes as <dir>/index.html (#28106)

Linear and other OAuth providers redirect the user back to
/ui/mcp/oauth/callback?code=...&state=... after the consent step. The
packaged Next.js static export only produced /ui/mcp/oauth/callback.html,
so FastAPI's StaticFiles served a 404 on the extensionless URL and the
OAuth handshake never completed.

The Dockerfile.non_root build step tried to paper over this at image-build
time with `for html_file in *.html; do ...`, but that shell glob does not
recurse, so nested routes like mcp/oauth/callback.html were left stranded
next to an empty mcp/oauth/callback/ directory containing only Next.js
metadata. The runtime restructure step in proxy_server.py was then skipped
because the .litellm_ui_ready marker had already been dropped.

Set trailingSlash: true in the dashboard's Next.js config so the export
emits every nested route as <dir>/index.html natively. The Dockerfile loop
is now a no-op for the bundled UI and has been removed; the
.litellm_ui_ready marker is still written so the proxy keeps skipping the
redundant Python restructure step at startup. Stacks on top of the static
export regeneration in the parent branch.

* chore: restore origin/litellm_internal_staging out files
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…erriAI#28112)

* chore(admin-ui): regenerate static export with trailingSlash: true

Rebuilds litellm/proxy/_experimental/out/ from ui/litellm-dashboard with
`trailingSlash: true` enabled in next.config.mjs. Next.js now emits every
route as <dir>/index.html (e.g. mcp/oauth/callback/index.html) instead of
<dir>.html with a sibling metadata-only directory, which fixes the 404 on
extensionless URLs served through FastAPI's StaticFiles(html=True) mount.

This is the build artifact half of the fix; the config change, Dockerfile
cleanup, and regression test live in the follow-up source PR that stacks
on top of this branch.

* fix(admin-ui): emit nested routes as <dir>/index.html (BerriAI#28106)

Linear and other OAuth providers redirect the user back to
/ui/mcp/oauth/callback?code=...&state=... after the consent step. The
packaged Next.js static export only produced /ui/mcp/oauth/callback.html,
so FastAPI's StaticFiles served a 404 on the extensionless URL and the
OAuth handshake never completed.

The Dockerfile.non_root build step tried to paper over this at image-build
time with `for html_file in *.html; do ...`, but that shell glob does not
recurse, so nested routes like mcp/oauth/callback.html were left stranded
next to an empty mcp/oauth/callback/ directory containing only Next.js
metadata. The runtime restructure step in proxy_server.py was then skipped
because the .litellm_ui_ready marker had already been dropped.

Set trailingSlash: true in the dashboard's Next.js config so the export
emits every nested route as <dir>/index.html natively. The Dockerfile loop
is now a no-op for the bundled UI and has been removed; the
.litellm_ui_ready marker is still written so the proxy keeps skipping the
redundant Python restructure step at startup. Stacks on top of the static
export regeneration in the parent branch.

* chore: restore origin/litellm_internal_staging out files
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.

2 participants