Repository navigation
Conversation
…rver
The proxy authenticates the caller and enforces the ACL, then talks to every
backend with one credential resolved at startup. That is the right model when
the backend's data has no per-row owner — Sentry, Slack, Databricks: the shared
token IS the service account, and the proxy decides who may reach it.
It stops being the right model when the backend's data is owned per user: an
internal app catalogue, a notes service, anything that records an author. There
a shared credential makes every write land under the same name, and it does not
fail loudly — it records the wrong owner, silently.
New per-server option on HTTP backends:
"hub": {
"url": "http://hub.internal/mcp",
"headers": { "Authorization": "Bearer ${HUB_SERVICE_TOKEN}" },
"forward_identity": { "header": "X-MCP-Subject" }
}
The subject travels per request, from the identity the proxy already resolved in
dispatch. `roles_header` is optional and off by default — most backends only
need to know who, and sending roles a backend does not read is avoidable
exposure.
Scoped deliberately:
- HTTP backends only. A stdio/CLI backend is a process the proxy owns, and there
is no per-request channel to carry a header into a long-lived stdin pipe.
- Opt-in per server, never on by default: a backend that never asked for
identity must not start receiving one on upgrade.
- The forwarded header WINS over a backend's own `x-mcp-header` annotation with
the same name, and the argument-derived one is dropped rather than appended.
Otherwise a tool could declare an argument mapped to `X-MCP-Subject` and let
the caller pick their own subject — privilege escalation, tested.
- A subject or role containing a control character REFUSES the call instead of
falling back to the shared credential: falling back is the silent-wrong-owner
outcome this feature exists to remove.
- Header names are validated with the same `is_tchar` predicate the
`x-mcp-header` path already uses (now `pub(crate)`) — two copies of a
header-injection check is how one of them drifts.
The safety condition the proxy cannot verify is documented on the type, in the
config reference and in the code: the backend must not be reachable by anyone
who could set the header themselves, or the header is a complete authentication
bypass. That is why this is opt-in.
Tests: 8 unit tests on the header builder (no config, stdio backend, unknown
server, default and custom header names, roles, CRLF in subject, control char in
roles, four malformed header names) and 2 client tests (shadowing refused,
additive behaviour unchanged). Deny cases outnumber allow cases, per
CONTRIBUTING's checklist for trusted-header changes. Full suite: 822 passing.
Four findings from review, all of them real. Two were mine to own.
**1. Displaced doc comments took `#[allow]` with them — this is what broke
CI.** Inserting `identity_headers` between `resolve_tool_call`'s doc block and
the function moved BOTH the doc and `#[allow(clippy::result_large_err)]` onto
the new function. `resolve_tool_call` lost its allow, so `cargo clippy --
-D warnings` failed on it.
The same mistake sits in client.rs and review did not catch it: the MRTR doc
block belonged to `call_tool_raw` and ended up on `call_tool_raw_with`, leaving
`call_tool_raw` undocumented. Both restored.
This also corrects the PR's claim that the two `result_large_err` errors
pre-existed on main. Only ONE does — main's CI has been red since 2026-08-26
on `dispatch.rs` `client_or_connect`. The `proxy.rs` one was mine. Verified
against main's own CI log, not a local run: the local run used `--all-targets`
without `-D warnings`, so it printed them as warnings and I read both as
inherited.
**2. `roles_header` equal to `header` is now refused.** Two same-named headers
with unrelated values leave the choice to the receiver, which could read the
roles value as the subject. Refused for the same reason as a control character.
Checked in `identity_headers` and not at load time because the existing config
validation only warns at boot, and a warning would let the ambiguous call out.
**3. The shadowing threat was overstated, and the test was vacuous.**
`x-mcp-header` annotations always emit `Mcp-Param-{Name}`, so a tool annotating
`X-MCP-Subject` produces `Mcp-Param-X-MCP-Subject` and cannot claim the
identity header by name — the prefix isolates it with no help from the filter.
There is no privilege escalation through that path, and the doc, the docs page
and the PR body all claimed there was.
`forwarded_identity_cannot_be_shadowed_by_x_mcp_header` asserted only that the
forwarded subject was the value under `x-mcp-subject`, which was true for that
structural reason — it passed with the filter deleted. Replaced by two tests:
one documenting the prefix guarantee (and saying plainly that it does not
exercise the filter), one exercising the only reachable collision, which is an
operator configuring `header` under the `Mcp-Param-` prefix.
**4. Doc said `forwardIdentity`; the field is `forward_identity`.** Fixed.
Also translated the Portuguese comments and assertion messages this branch
introduced — the rest of the codebase is in English and mixing the two is
noise for a reader.
Verified in a rust:1-slim container:
cargo fmt --all --check clean
cargo clippy -- -D warnings 1 error, the pre-existing dispatch.rs one
cargo test 825 passed; 0 failed
The three new tests were mutation-checked — removing the collision filter or
disabling the roles_header check fails exactly them, and nothing else. The
prefix-guarantee test keeps passing under those mutations, which is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refusal path returned early and skipped finish_audit, so a call the ACL allowed and the proxy then stopped left no audit entry, no metric and no result envelope. Identity also rode only on tools/call, which let a backend that owns data per user answer resources/read as the service account. Refusals now leave as Err and go out through the same funnel as every other response. read_resource_raw and get_prompt_raw take extra headers the way call_tool_raw does, so all three backend methods carry the subject. Two more things get refused for the reason roles_header equal to header already was: an unauthenticated caller, whose subject is the placeholder anonymous, and a static headers entry sharing a name with a forwarded one, which the transport appends rather than replaces. mcp update stops erasing forward_identity and tool_acl on the way through. The seam itself had no test. identity_headers and the header merge were each covered alone, so passing an empty sliceite green. The new e2e tests assert on what reached the backend instead. cargo fmt --all --check clean, cargo test 839 passed. The one clippy result_large_err error on dispatch.rs cl-existing one, unchanged. Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Resolve transport-auth header collisions, reject empty subjects, and distinguish authenticated users named “anonymous.”
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Updates caller-identity forwarding across all proxied backend methods, with auditing, configuration preservation, tests, and documentation.
Changes:
- Forwards identity on tools, resources, and prompts.
- Validates anonymous, empty, and colliding identity headers.
- Preserves forwarding settings during updates.
- Expands end-to-end tests and documentation.
File summaries
| File | Description |
|---|---|
src/server_auth/mod.rs |
Adds anonymous-identity handling. |
src/serve/proxy.rs |
Builds and validates forwarded headers. |
src/serve/e2e_tests.rs |
Adds forwarding and refusal tests. |
src/serve/dispatch.rs |
Applies identity headers and audits refusals. |
src/output.rs |
Updates HTTP configuration fixtures. |
src/manager.rs |
Preserves forwarding and ACL settings. |
src/config.rs |
Defines forwarding configuration. |
src/client.rs |
Supports per-request extra headers. |
docs/reference/config-file.md |
Documents forwarding configuration. |
docs/reference/cli.md |
Documents preserved update fields. |
docs/guides/proxy-mode.md |
Links identity-forwarding guidance. |
docs/guides/authentication.md |
Explains backend identity forwarding. |
Review details
Suppressed comments (1)
src/serve/proxy.rs:1088
- An empty subject is accepted here and then emitted as an empty identity header.
BearerTokenaccepts empty legacy/extended subjects, and an empty header is indistinguishable from no caller identity to many backends; withforward_identityenabled this should refuse the call rather than assert an ambiguous identity. Includevalue.is_empty()in this validation (and keep the same refusal path).
fn valid_header_value(value: &str, what: &str) -> std::result::Result<String, String> {
if value.bytes().any(|b| b < 0x20 || b == 0x7f) {
return Err(format!("caller {what} contains a control character"));
}
Ok(value.to_string())
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
main has been red on this lint since dcd75dc and the branch inherits it, so CI here failed at the clippy step and skipped cargo test entirely. The 839 tests on this branch had never actually run in CI. Same allow the module already carries on resolve_tool_call, for the same reason: JsonRpcResponse is the error type across dispatch by design, because a failure here is the reply the caller relays. Boxing it is the other answer and it changes the error type on every dispatch path at once, which does not belong in this branch. The hunk is byte for byte identical to the one in PR #111, so whichever of the two lands first the other still merges clean. Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Resolve transport-managed header collisions and avoid misclassifying authenticated identities with the subject “anonymous.”
Review details
Suppressed comments (2)
src/serve/proxy.rs:885
- This collision check covers only configured static headers, but the HTTP transport can append a saved or refreshed bearer
Authorizationheader after these per-request headers when no static Authorization is configured (src/transport/http.rs:156-177). Thusforward_identity.header: "Authorization"can emit two Authorization values, and the retry path guarantees the duplicate after a 401; the backend still decides which identity wins. Reject transport-managed header names as well, or define a replacement/merge rule in the transport so forwarded identity cannot be duplicated.
let static_headers = entry.static_headers();
let subject_name = valid_header_name(&cfg.header)?;
reject_static_collision(static_headers, &subject_name, "header")?;
src/server_auth/mod.rs:53
is_anonymous()treats every identity whose subject is the literal"anonymous"as unauthenticated. A valid bearer, forwarded, or OAuth identity can legitimately use that subject, andAuthIdentity::new("anonymous", ...)is otherwise a named identity; forwarding is then refused even though authentication succeeded. Track authentication provenance explicitly (or reserve/reject this subject at provider validation) instead of inferring it from the user-controlled subject string.
pub fn is_anonymous(&self) -> bool {
self.subject == ANONYMOUS_SUBJECT
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Lite
Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Discovery requests do not forward caller identity, and transport-owned headers are not fully rejected.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
src/config.rs:92
- This says forwarding applies to every backend-reaching method, but discovery still calls
list_tools,list_resources, andlist_promptsfromdiscover_pending_backendswithout anyextra_headers. For an opted-in backend, those requests still use only the service credential, so per-user discovery/authorization either fails or can cache metadata from the wrong identity. Please either thread the caller identity through discovery and make its cache identity-aware, or narrow this contract and document that discovery/list requests are intentionally not forwarded.
/// The identity travels on every method that reaches the backend —
/// `tools/call`, `resources/read`, `prompts/get` — because a read of per-user
/// data needs to know whose data it is as much as a write does.
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
Replaced 'mcp-session-id' with 'content-type' and 'accept' in TRANSPORT_OWNED_HEADERS. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Add Mcp-Session-Id to transport-owned headers to prevent a collision with the transport session header.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Reject roles containing the comma delimiter and document the transport-owned Content-Type and Accept headers.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
docs/reference/config-file.md:122
- The documented transport-owned header list is incomplete: the implementation also rejects
Content-TypeandAcceptinTRANSPORT_OWNED_HEADERS(src/serve/proxy.rs:1112-1121). An operator following this list can configure either name and receive a refusal that the reference does not predict; please include both headers here (or narrow the implementation if they are intended to be allowed).
- Neither header may be one the HTTP transport writes itself: `Authorization`,
`Mcp-Session-Id`, `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `traceparent`,
`tracestate`. `Authorization` is the one worth naming. The transport re-adds a saved
OAuth token whenever the config map has no usable one, and the 401 retry path removes
the config entry before doing so, so a forwarded header of that name would travel
beside a bearer token that the `headers` check below never sees.
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
| out.push(( | ||
| roles_name, | ||
| valid_header_value(&identity.roles.join(","), "roles")?, | ||
| )); |
Review fixes on top of #110. Two blockers, four corrections, two nits, and the test that was missing on the seam between the two halves of the feature.
Extends #110 (
luisaniceto-infra/mcp:feat/forward-caller-identity).Part of #109.
This targets the feature branch, not
main. It does not close #109 on its own, #110 does that when it lands. Nothing here is a new feature, it is the review of #110 applied as code instead of left as a comment thread.Changes
The refusal skipped the audit funnel.
dispatch_requestbuilds one response andfinish_auditstamps every one of them, which is why the other three early returns in that function all go through it. The identity refusal returned directly instead, so a call the ACL had already allowed, stopped by the proxy, produced no audit entry, no OTel metric and no 2026-07-28 result envelope. It now leaves asErrand flows into the samematch resolvedas everything else. The audit attribution moved above the check too, otherwise the entry came back without naming the tool.A static
headersentry of the same name was not checked.build_requestapplies the identity headers first and the server's configuredheadersafter, andreqwest's builder callsHeaderMap::append, notinsert(reqwest-0.12.28/src/async_impl/request.rs:223). Soheaders: {"X-MCP-Subject": "svc"}together withforward_identity.header: "X-MCP-Subject"sent two lines of the same field, and the backend picked the winner. Go'sHeader.Gettakes the first, several proxies take the last. #110 already refusesroles_header == headeron the argument that a duplicate is resolved by the receiver and that decision is not ours. Same rule, and this is the collision an operator actually hits when migrating off a hardcoded subject header.Identity only rode on
tools/call.resources/readandprompts/getreach the backend too. A resource read is where per-user data comes back, so a per-user backend was answering every caller as the service account while the docs promised "per request". Rather than growing*_withtwins,request_raw,read_resource_rawandget_prompt_rawnow takeextra_headersthe waycall_tool_raw_withdoes. The twins would have been dead code, since this crate is binary only.An unauthenticated caller was forwarded as
anonymous. With noserverAuth.providers,build_auth_providerreturnsNoAuthand every subject is the literalanonymous, which is the documented default for stdio and local dev. The backend, which trusts this header by contract, was recording that as a person. Now refused, withANONYMOUS_SUBJECTandAuthIdentity::is_anonymous()so it is recognizable rather than a loose string. Servers that did not opt intoforward_identityare untouched and still work unauthenticated.mcp updateerased the field.merge_entrypreserves by allowlist, and neitherforward_identitynortool_aclwas on it, somcp update <server>silently handed back a weaker config.tool_aclhas been losing ACL overrides this way since before #110, so that half is a pre-existing bug, not a regression from this feature.Nits. Two error strings said
forwardIdentity, and the config key isforward_identity. Thex_mcp_headermodule went back to private, withpub(crate) use x_mcp_header::is_tcharexporting only the predicateserve::proxyactually needs instead ofHeaderParamandheaders_forriding along.Dedup pass.
call_tool_rawwas a copy ofcall_tool_raw_withwith theMcp-Param-*extraction repeated, now one line. The refusal message lived in three places and is now inidentity_refusal, for the same reason #110 promotedis_tcharto shared: two copies is how one of them drifts.Tests
The seam had no test.
identity_headersand the header merge were each well covered alone, so passing&[]from dispatch kept all 825 green. The new e2e tests assert on what reached the stub backend, on all three methods. Checked by sabotage: swapping&identity_headersfor&[]turns them red immediately.Also covered: the refusal appears in the audit log with tool and server, none of the refusals let the call reach the backend, and a config written before #110 loads unchanged and forwards nothing.
Docs
forward_identityonly existed in the config reference. The authentication guide is the one page about caller identity and said nothing about what the proxy does with it on the way out, so it has a section now, with a cross-reference from proxy mode.cli.mdlists the fieldsmcp updatepreserves.Not fixed here
forward_identityinside a stdio server still deserializes without complaint and becomes a no-op. The enum isuntaggedwith nodeny_unknown_fieldsanywhere in the repo, and warning about it needs the raw JSON at boot, which does not survive deserialization. Left as it was.Follow-ups worth their own issue
No issue exists for either of these today.
mcp updatepreserving by allowlist means any new user-settable field is erased until someone remembers to add it. Inverting it to a denylist is the real fix, and it is independent of this feature.main's CI has been red oncargo clippy -- -D warningsforresult_large_errindispatch.rsclient_or_connectsincedcd75dc. Not from this branch, and the check stays red until it is handled. BoxingJsonRpcResponseor an#[allow]like the one onresolve_tool_callare both on the table.Checklist