Skip to content

RemoteHttp MCP client has no per-run/per-call context propagation — blocks tenant-scoped downstream MCP servers #296

Description

@GQAdonis

Summary

McpServerEntry::RemoteHttp (a remote MCP server UAR connects out to) has no mechanism to carry a
per-invocation, per-run credential or context to the server it connects to. The connection is
established once — at initial binding, or lazily on reconnect after a transport loss — and reused for
every subsequent tool call against that server, for the lifetime of the process (or until the next
reconnect). This blocks a real downstream design: a BFF in front of UAR that needs UAR's outbound
/mcp calls to carry the identity of whichever human/tenant started the specific run that triggered the
tool call, so the MCP server on the other end can enforce per-tenant data isolation without trusting an
unverified, caller-supplied argument.

Where this was found

Planning d-server (a downstream consumer project, go-mark/GoMark) — specifically gd03 (an MCP tool
server exposing gomark_domain operations to UAR) and gd04 (gofast_server's BFF in front of UAR).
The design needed UAR, when it calls out to gd03's /mcp endpoint on behalf of a running agent, to
forward some proof of which tenant/human started that run — so gd03's tool handlers could scope their
queries correctly without re-implementing session verification independently (which would duplicate
gofast_server's own auth, and still requires some way to get the verified identity across the wire).

Two designs were tried and both failed against this repository's real code, read directly rather than
assumed:

  1. A run-scoped bearer token, minted fresh per run, forwarded by UAR as an HTTP header on each
    outbound /mcp call.
    This is impossible with the current schema — see "Root cause" below.
  2. A static, long-lived service credential (proving "this call is from our own UAR instance") plus a
    caller-supplied tenant_id argument on each tool call, trusted by the tool handler.
    This is
    buildable against UAR as-is, but it forces the consuming application (gofast_server) to violate
    its own security posture: a blocking constraint in that project reads "Axum derives tenant (team) and
    roles from a server-verified session. No handler accepts a caller-selected tenant or trusts unverified
    token claims" — and a static-credential-plus-trusted-argument design is exactly the caller-selected-
    tenant pattern that rule exists to forbid. An independent adversarial review pass (cross-model, judge
    ≠ producer) correctly flagged this as a real security regression, not a style nitpick.

Neither design is acceptable. The root cause is upstream, in this repository, not something the
downstream consumer can design around.

Root cause (exact, current source)

src/mcp/config.rs:41-57 — McpServerEntry::RemoteHttp has exactly two fields:

RemoteHttp {
    url: String,
    #[serde(default)]
    env: HashMap<String, String>,
},

env is not used to set outbound HTTP headers. It is only used to expand ${VAR} placeholders that
appear inside the url string itself — confirmed at the call site:

src/mcp/registry.rs:633-641:

McpServerEntry::RemoteHttp { url, env } => {
    let env = expand_env_map(env);
    let endpoint = resolve_remote_http_url(name, url, &env)?;
    ().serve(StreamableHttpClientTransport::from_uri(
        endpoint.to_string(),
    ))
    .await
    .with_context(|| format!("failed to connect remote MCP server '{name}'"))
}

There is no field, anywhere in McpServerEntry, for static or dynamic outbound headers. The connection
itself — StreamableHttpClientTransport::from_uri(endpoint) — is a single, long-lived transport handed
to .serve(), not something re-established or re-parameterized per tool call.

connect_server is called from exactly one call site (src/mcp/registry.rs:2246, inside a
reconnect-after-transport-loss path). Its own neighboring comment makes the current design explicit and
intentional, not an oversight this issue is second-guessing: "A child cannot re-read auth/env/config
after transport loss ... Re-establish the transport for future calls."
Config (and therefore any
credential embedded in it) is read once per connection lifetime — at initial bind, or at reconnect — and
is then fixed for every tool call made over that connection until the next reconnect. There is currently
no code path, anywhere in src/mcp/, that re-evaluates McpServerEntry, re-resolves ${VAR} placeholders,
or attaches anything at all to an individual outbound tool-call request beyond what the transport already
carries from bind time.

Conclusion, stated precisely: UAR's RemoteHttp MCP client has no mechanism — none — to carry a
value that varies per agent run or per tool call to a remote MCP server. Everything it can send is fixed
at connection-establishment time and identical for every call made over that connection's lifetime.

Why this matters (the actual downstream requirement)

A BFF in front of UAR (or any application embedding UAR as a library/service and fronting it with its
own auth) has a real, human-verified identity (tenant, role, user) available at the moment it starts an
agent run. UAR itself, once it starts calling tools — including remote MCP servers it doesn't own — has
no way to carry that identity forward to those tool calls, because:

  1. UAR's own inbound API today has no per-caller auth on top of the run-start call itself in the
    relevant configuration (confirmed separately, in this same downstream project's gc04 change: UAR's
    API surface relies on compose-network isolation, not per-request identity verification, in its
    current deployment shape).
  2. Even if UAR's inbound API did verify the caller, nothing in src/mcp/ today propagates that
    verified identity onward into the RemoteHttp transport's outbound calls.

The result: any remote MCP server UAR calls on an application's behalf can prove "this request came from
some UAR instance holding my static credential," but never "this request is acting on behalf of tenant
X, verified by the application that started this run." Any application wanting real per-tenant
authorization at the MCP-tool layer is currently forced to choose between (a) trusting an unverified
argument (a real security regression), or (b) reimplementing its own, entirely separate, out-of-band
identity-verification path at the tool server that has nothing to do with the run UAR is actually
executing — which is fragile and easy to get wrong independently.

Proposed fix

The shape that fits UAR's existing architecture with the least disruption, without asserting this is the
only possible design:

  1. Extend McpServerEntry::RemoteHttp with an optional static headers: HashMap<String, String>
    field
    , expanded through the same expand_env_map/${VAR} mechanism url already uses. This alone
    closes the simplest half of the gap (a static service credential can be sent as a real header
    instead of smuggled into the URL as a query parameter, which is what today's schema forces and which
    leaks into logs/referrers far more readily than a header does). Low risk, additive, no behavior change
    for existing configs that don't set it.

  2. The harder, real half — per-run/per-call context propagation — needs a new concept, since nothing
    in the current binding/connection model has a notion of "the current run" at the point a RemoteHttp
    transport is established or reused. Two directions worth scoping, in increasing order of invasiveness:

    • (a) A per-run context object, threaded from wherever a run starts (RunContext or similar,
      UAR already has NativeExecutionContext for its own native tools — src/uar/runtime/native_skill.rs
      — which is a real precedent for "context threaded into a tool call") extended to also reach
      connect_server/the RemoteHttp call path, with an application-supplied callback or field
      (e.g., run_context.external_headers()) merged into the outbound request's headers per call,
      not baked into the long-lived transport at bind time. This likely requires moving from "one
      long-lived transport reused for every call" to either a lighter-weight per-call header injection at
      the StreamableHttpClientTransport layer (if the underlying rmcp crate's HTTP client supports
      per-request header overrides — needs checking against rmcp's own API) or reconnecting per run
      (likely too expensive for high-frequency tool calls).
    • (b) A registration-time, per-agent (not per-run) scoped credential — coarser-grained than (a)
      but far less invasive: when an agent is registered (as in this downstream project's gc04), the
      RemoteHttp entry's credential could be scoped to that agent's own identity rather than a single
      global static secret shared by every agent and every run. This does not solve per-run/per-tenant
      scoping, but it at least narrows the blast radius of the single-static-credential design from "any
      agent, any run, any tenant" to "this specific agent." Worth doing even if (a) is deferred, since it's
      a real improvement in isolation with much less design risk.

Recommend scoping (1) and (2b) as a near-term, low-risk change, and (2a) as its own, carefully-designed
follow-up — it changes the shape of connect_server's call graph and needs its own investigation into
whether rmcp's StreamableHttpClientTransport (or whatever transport crate UAR uses) supports
per-request header overrides at all before committing to a design.

What is NOT being claimed

  • This is not a claim that UAR's current design is wrong for its primary use case (a human/CLI-driven
    agent session with tools UAR itself governs). It is a real gap specifically for the "application fronts
    UAR with its own verified auth and needs that identity to reach a remote, application-owned MCP
    server" use case, which may not have been a design target originally.
  • This is not a request to weaken UAR's own Cedar-based tool governance — that governance decides
    Permit/Deny/RequireApproval for tools UAR itself knows about, and is orthogonal to this issue. This
    issue is about identity propagating to an external MCP server UAR calls, not about UAR's own
    authorization decision for calling it.
  • No code in this issue has been written or proposed as a patch — this is an analysis and a design
    sketch for the owner to evaluate, scope, and prioritize.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions