agentic-community / agentic-community/mcp-gateway-registry
feat: route virtual-server backend calls through the egress hop so egress-brokered servers can be aggregated
- Dominant language
- Python
- Stars
- 911
- Forks
- 234
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 62
Description
## Summary
Today a **virtual MCP server** cannot aggregate **egress-brokered** backing servers:
tool calls dispatched to a backing server via the virtual router bypass the egress
vend/inject hop **and** strip the `Authorization` header, so no per-user PAT (or 3LO
token) is ever presented to the backend. Any backing server that relies on egress auth
receives token-less requests and fails.
Request: make the virtual router's backend hop go **through** the per-server egress path
(`/mcp-proxy//`) — keyed on the *resolved* backend server path — so a
virtual server can front egress-brokered backends while still injecting the correct
per-user credential per backend.
Observed on **v1.28.0**.
## Current behavior
Routing itself is backend-aware — that part works:
- `docker/lua/virtual_router.lua` resolves an aliased `tools/call` to its
`backend_server_path` from the virtual server's `tool_mappings`, then dispatches via an
internal subrequest: `ngx.location.capture("/_vs_backend")`.
But the internal backend locations generated by
`_generate_virtual_backend_locations()` in `registry/core/nginx_service.py` proxy
**directly** to the backend's MCP endpoint and deliberately clear auth:
```nginx
location /_vs_backend {
internal;
# SECURITY: proxies directly to a registrant-controlled backend;
# clearing Authorization AND Cookie prevents leaking the caller's
# gateway bearer token / registry session cookie.
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_pass http:///mcp;
}
```
This path does **not** traverse the `/mcp-proxy//` hop that vends the
vaulted per-user credential and injects it (the hop that, for a directly-registered
server, produces `mcp_proxy: pat server=/ …` in the auth-server log). So:
- No egress token is vended or injected for the backend.
- The `Authorization` header is blanked, so the backend gets **nothing**.
## Impact
- **Virtual servers and egress auth (`pat` / `oauth_user`) are mutually exclusive.** Any
attempt to bundle egress-brokered backends behind a virtual server yields token-less
backend requests → auth failures / empty tool results.
- Concrete example: splitting a self-hosted Atlassian MCP into `mcp-jira` and
`mcp-confluence` (each egress-brokered with its own per-user PAT) and aggregating them
into one virtual server is not possible — the router knows which backend a tool targets
but never injects that backend's credential.
## Observed (v1.28.0)
Real trace from calling a virtual server (`/virtual/`) that fronts a
single egress-`pat` backend — a **stateless** SonarQube MCP server registered at
`/mcp-sonar`. Registry log, identifiers redacted:
```
# router resolves the backend and looks up its session — L2 miss, on every attempt:
GET /api/internal/sessions/backend/vs-:/_vs_backend_mcp_sonar?user_id= 404 Not Found
GET /api/internal/sessions/backend/vs-:/_vs_backend_mcp_sonar?user_id= 404 Not Found
GET /api/internal/sessions/backend/vs-:/_vs_backend_mcp_sonar?user_id= 404 Not Found
# MCP OAuth discovery (benign: per-resource 404 -> bare-metadata 200 fallback):
GET /.well-known/oauth-protected-resource/registry/virtual//mcp 404 Not Found
GET /.well-known/oauth-protected-resource 200 OK
# a client session is created, but a BACKEND session never is:
POST /api/internal/sessions/client 201 Created
GET /api/internal/sessions/client/vs-?...&virtual_server_path=/virtual/ 200 OK
```
**The egress hop is never invoked.** The entire trace contains **no**
`POST /api/internal/egress-token` (the vend) and **no** `POST /mcp-proxy/mcp-sonar/`
(the inject hop) — both of which appear on every *direct* (non-virtual) call to the same
server. The backend is reached only via `/_vs_backend_mcp_sonar`, the direct-proxy
internal location that clears `Authorization`. So no per-user PAT is ever presented and
the call never completes — the bypass described above, confirmed at runtime.
### Related gap: the backend-session model assumes stateful backends
The repeated `/_internal/sessions/backend/... 404` loop is `_get_backend_session()` in
`virtual_router.lua` never establishing a backend session. It expects to capture an
`Mcp-Session-Id` from the backend's `initialize` response and cache it — but a
**stateless** Streamable-HTTP backend (e.g. the SonarQube MCP server) returns no
`Mcp-Session-Id`, so there is nothing to cache and the lookup misses on every request.
(Here it is compounded by the missing egress credential, which makes the backend
`initialize` fail regardless.) Virtual servers should also tolerate stateless
(session-less) backends.
## Proposed solution
When a virtual server's backing server has an egress mode configured, route its backend
subrequest **through the same egress vend/inject hop** used for a directly-registered
server, keyed on the **resolved backend `server_path`** (so the vend selects the right
per-user credential per backend), instead of proxying directly and clearing
`Authorization`.
Sketch:
- In `_generate_virtual_backend_locations()`, for a backend whose registration has an
egress mode set, emit a `/_vs_backend` block that forwards through
`/mcp-proxy//` (or otherwise invokes the egress vend) rather than a
bare `proxy_pass` to the MCP endpoint.
- Ensure the resolved backend `server_path` (and the caller's verified identity /
`X-User`) reach the egress hop so the vend keys correctly — the router already knows the
`backend_server_path`, so the mapping is available.
- Backends **without** an egress mode keep today's direct-proxy behavior.
## Security considerations (must preserve)
The existing header-clearing exists to avoid leaking the caller's **gateway** bearer
token / session cookie to a registrant-controlled backend. The fix must keep that
property: **do not forward the caller's gateway token**; instead inject only the
per-user credential the egress vend returns for that specific backend. The result is the
same trust posture as the direct per-server path — the backend sees its own brokered
credential and nothing else.
## Acceptance criteria
- [ ] A virtual server whose backing servers have egress mode `pat` (and/or
`oauth_user`) injects the correct per-user credential **per backend** on
`tools/call` routed through the virtual server.
- [ ] The vend is keyed on the **resolved backend** `server_path`, so distinct backends
(e.g. `mcp-jira` vs `mcp-confluence`) each get their own credential.
- [ ] The caller's gateway bearer token / session cookie is still **not** forwarded to
the backend.
- [ ] Backends without an egress mode retain the current direct-proxy behavior.
- [ ] `tools/list` and `initialize` via the virtual server continue to work for both
egress and non-egress backends.
- [ ] Virtual servers work with **stateless** Streamable-HTTP backends that return no
`Mcp-Session-Id` (no backend-session lookup loop).
Contributor guide
Assessment
This issue has not been assessed yet.