Skip to content

feat(mcp): re-route a remote MCP server to a backend - #413

Open
leo237 wants to merge 7 commits into
strands-agents:mainfrom
leo237:design/mcp-backend-reroute
Open

leo237 wants to merge 7 commits into
strands-agents:mainfrom
leo237:design/mcp-backend-reroute

Conversation

@leo237

@leo237 leo237 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

Lets a remote (type = "http") MCP server re-route to a backend, reusing the egress backend
mechanism from #311. A box that must reach an MCP server through a gateway — for example an
AgentCore gateway that SigV4-signs and injects the server credential — can now express it:

[mcp.websearch]
type         = "http"
destinations = ["gateway-quick-start-….gateway.bedrock-agentcore.us-east-1.amazonaws.com"]

[[mcp.websearch.backends]]
url              = "https://passthrough-iam-….gateway.bedrock-agentcore.us-east-1.amazonaws.com/websearch"
auth.ref         = "aws://"
auth.aws.service = "bedrock-agentcore"
auth.aws.region  = "us-east-1"

Two commits:

  1. feat(mcp): re-route a remote MCP server to a backend — accept [[mcp.<name>.backends]]
    on an http MCP entry and thread it through the existing egress backend path, so a re-routed
    MCP server reuses one config shape, one validation path, and one gateway dial.

    • McpEntry::Http carries backends: Vec<EgressBackend>; a stdio entry rejects the key
      (deny_unknown_fields).
    • The config fan-out builds the remote-MCP EgressEntry with the declared backend, so
      backend_dials, backend_credentials, and mcp_servers all key on the original
      destination host. Policy still judges net:connect/mcp:call on the original host; the
      backend appears only on the box-controlled wire. The backend is never a policy resource.
    • Every egress backend refusal applies unchanged (one backend per destination, no path/wildcard
      on a backend destination, aws:// needs explicit aws.service/aws.region, a destination
      secret beside a backend must be env://, credsd:// and plaintext-backend auth refused).
    • has_backend already chains remote_mcp, so a re-routed MCP server selects the existing
      BACKEND_RECORD_VERSION; no new record version.
  2. refactor(box): make a route's signing explicit, unify credential projection — addresses
    a feat(egress): re-route a destination to a backend #311 review comment that was deferred to this follow-up. Adds EgressRoute::signs() so the
    credential projection no longer infers "this route signs" from an absent provisioned name (a
    box-owned backend token also has no name and does not sign); collapses BackendProjection into
    Projection; extracts a shared open_vault. The two projection functions stay separate on
    purpose — a backend entry keeps its strip + box-owned routes in one vault, so it groups per
    entry while the ordinary path groups by provisioned variable.

Interface freeze

The backends key on McpEntry::Http is an additive change to the frozen box.toml surface,
spec-approved before implementation. No other frozen surface moves: RouteKind stays pub(crate),
the public RouteSpec constructors are unchanged, and the stored record (BACKEND_RECORD_VERSION),
broker protocol, CLI, and composed environment are untouched.

Not in scope

A reviewer-suggested collapse of the three RouteKind variants into one with optional
phantom/attach was deliberately declined: it would make the (no attach, no phantom) invalid
state representable, which the credentials crate's "invalid states unrepresentable" rule forbids,
and would not reduce the request-leg complexity (three distinct binding tables remain).

Related Issues

Builds on #311 (egress destination re-route) and #342 (bare aws:// default credential chain).

Type of Change

New feature

Testing

Verified end to end against a live AgentCore gateway: a proper MCP client (initialize →
notifications/initialized → tools/list → tools/call) completed a real WebSearch tool call through
the re-route, SigV4-signed with a bare aws://. Box telemetry confirmed net:connect and
mcp:call were decided on the original host, never the backend.

New tests (crates/box/src/record/config/):

  • an_http_entry_carries_a_backend, a_stdio_entry_refuses_a_backends_key

  • a_remote_mcp_backend_reroutes_through_the_egress_path (dial target + credential vault key on the
    original host)

  • a_remote_mcp_server_with_no_backend_does_not_reroute

  • a_remote_mcp_backend_stores_the_backend_record_version

  • a_remote_mcp_backend_inherits_every_egress_refusal (each egress backend refusal via the MCP path)

  • an_egress_wildcard_covering_an_mcp_backend_host_is_refused (cross-collection backend-host cover)

  • B.1: a_signed_route_places_nothing_and_still_credentials_the_workload and the backend/credsd
    projection tests still green (mutation-pinned behavior-preserving).

  • I ran the relevant suites (cargo test -p strands-box --all-features, --test site_manifest)

  • If I touched Rust, I ran cargo fmt and cargo clippy --all-targets --all-features (0 owned warnings)

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR
  • My change is focused; the B.1 refactor is a feat(egress): re-route a destination to a backend #311 review item deferred to this follow-up, bundled here by request
  • I have added tests that prove the feature works
  • I have updated the documentation accordingly (mcp.md, egress.md, config.md)
  • My changes generate no new warnings

Known limitation

policy generate-schema cannot discover a re-routed MCP server (the re-route and its backend auth apply only at run time, in the gateway; the offline command has no gateway). It refuses a [mcp.<name>] with a backend by name, so author that server's mcp:call rules by hand. Full offline backend discovery (incl. SigV4) is a follow-up.

Comment thread crates/box/src/record/config/mod.rs Resolved
Comment thread crates/box/src/record/config/mod.rs Resolved
@github-actions

Copy link
Copy Markdown

Assessment: Request Changes

At box run time, the re-route is wired correctly through the shared egress validation, dial, and credential paths. But the offline policy generate-schema path still reads [mcp.<name>] type = "http" as a direct server. Because of this, the documented per-tool policy workflow fails for the AgentCore configuration this PR targets (reproduced; see inline).

Review Categories
  • Correctness / scope: A consumer of McpEntry::Http outside RunContract (read_remote_mcp_servers) was not updated for backends.
  • Testing: The new tests cover the record and gateway-config side. None covers discovery, and the main wiring test uses any/per-field checks that would not catch an extra policy-classified host.
Assumptions and rules checked that held
  • validate_egress_and_remote runs every egress backend refusal (one backend, exact host, env://-only secret, aws:// scope, credsd:///plaintext auth) on remote_mcp entries.
  • At run time, gateway mcp_servers() and mcp:call classification key on the CONNECT (original) host; the backend Host rewrite happens after classification (l7.rs).
  • backend_dials/backend_credentials now chain remote_mcp. has_backend already did, so reusing BACKEND_RECORD_VERSION matches the stored shape.
  • stdio entries refuse backends through deny_unknown_fields. Inputs without backends behave as before (empty list, RECORD_VERSION).
  • workspace() (None, []) if signs(): aws:// and credsd:// ordinary routes still get SIGNING_PLACEHOLDERS. The new (None, []) => {} arm is unreachable from ordinary routes today, so behaviour is unchanged.
  • open_vault keeps require_every_route() on both projection paths, and hosted.rs reads the unified Projection the same way.
  • Root AGENTS.md frozen surfaces: the only move is the box.toml backends key, which is stated as approved. RouteKind and RouteSpec are unchanged.

Reusing one backend shape and one validation path for both collections keeps this change small and easy to review.

leo237 pushed a commit to leo237/box that referenced this pull request Nov 12, 2026
…whole-value

Addresses the strands-agents#413 review.

- `read_remote_mcp_servers` (the offline `policy generate-schema` path) now refuses a `[mcp.<name>]`
  that declares a backend, by name, instead of silently discovering its ORIGINAL host — which would
  read the wrong server's tools and resolve the phantom-only destination `secret` against that host.
  The re-route and its backend auth apply only at run time, in the egress gateway, which the offline
  command does not have. Documented in mcp.md#backends.
- `a_remote_mcp_backend_reroutes_through_the_egress_path` now asserts `mcp_servers()` and
  `backend_dials()` by whole value, so a regression that classified the backend host or dialed a
  wrong port/scheme is caught (the previous `any`/per-field checks were not tight enough).
- New `offline_schema_generation_refuses_a_re_routed_remote_mcp_server`.
@leo237

leo237 commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both findings addressed in 30d5bcc.

Blocking (generate-schema ignored backends) — fixed by option (b). read_remote_mcp_servers now refuses a [mcp.<name>] that declares a backend, by name, instead of silently discovering its original host. Full offline discovery would need the gateway's SigV4/env:// backend auth and path-join reproduced in the direct client with no gateway present — a separate capability — so for now a re-routed server's mcp:call rules are authored by hand. This also removes the env://-secret-against-the-original-host path you flagged. Documented in mcp.md#backends; pinned by offline_schema_generation_refuses_a_re_routed_remote_mcp_server.

Non-blocking (test tightness) — a_remote_mcp_backend_reroutes_through_the_egress_path now asserts mcp_servers() and backend_dials() by whole value, so classifying the backend host or dialing a wrong port/scheme is caught. I left an_http_entry_carries_a_backend as per-field (url + auth.reference) since it is a parse test, not a wiring test — happy to switch it to a whole-EgressBackend compare if you'd prefer.

Comment thread crates/box/src/record/config/mod.rs Resolved
@github-actions

Copy link
Copy Markdown

Assessment: Approve

30d5bcc fixes the blocking discovery issue with a named refusal, and the wiring test now pins the original-host classification and the backend dial by whole value. The one remaining point (inline) is how far the refusal reaches inside generate-schema. It doesn't block the merge.

Re-checked on 30d5bcc and held
  • A re-routed server never reaches the original host offline, and its destination secret is never resolved (reproduced).
  • A server without backends still produces https://<destination>/mcp (new test, and a live run against a stub).
  • mcp_servers() equals exactly [("api.githubcopilot.com", "github")], and backend_dials() equals the full BackendTarget (scheme, host, port 443, /mcp).
  • Keeping an_http_entry_carries_a_backend as a per-field parse test is fine. The wiring test above now covers the shape that matters.
  • The new commit changes no run-time path, record version, or frozen surface. All earlier "held" items still apply.

Thanks for the quick, well-tested follow-up.

leo237 pushed a commit to leo237/box that referenced this pull request Nov 12, 2026
…sal actionable

Addresses the non-blocking strands-agents#413 follow-up. The backend refusal aborts the whole generate-schema run
(there is no partial schema), so read the remote servers BEFORE the stdio discovery loop — the run
now fails before spawning any stdio server rather than spawning them and discarding the result. The
refusal message and mcp.md now say what to do: generate a schema from a box.toml without the
re-routed server for the others, then author its mcp:call rules by hand.
@leo237

leo237 commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the non-blocking follow-up in b73c0a6 (chose option (b)): read_remote_mcp_servers is now read before the stdio discovery loop, so a re-routed server fails the run before any stdio server is spawned (no wasted spawns). The refusal message and mcp.md#backends now tell the operator what to do — generate a schema from a box.toml without the re-routed server for the rest, then author its mcp:call rules by hand. Kept the hard refusal rather than a silent partial schema, consistent with failing clearly.

@github-actions

Copy link
Copy Markdown

Assessment: Approve

b73c0a6 resolves the last open point. The re-route refusal now happens before any stdio server is started, and the message and mcp.md#backends tell the operator how to get a schema for the rest of the box. I have no new findings.

Re-checked on b73c0a6 and held
  • Reading the remote MCP servers earlier only changes which config error is reported first. read_remote_mcp_servers does no network or process work, so nothing else changes.
  • A re-routed server stops the run before any stdio server starts (reproduced with a marker file). Without it, the same config starts the stdio server as before.
  • The refusal test's checks still match the reworded message, and the test passes.
  • No run-time path, record version, or frozen surface changed. Earlier checks that held still hold.

@leo237

leo237 commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the three additional follow-ups:

  • Reject invalid explicit backend ports (0f1c5af) — EgressBackend::target() now reads the port text explicitly: an omitted port takes the scheme default, but :65536, :not-a-port, :0, and an empty : fail at load instead of silently becoming 443. New declared_port_str + three refusal cases.
  • Credential-reference docs consistent (21c268b) — egress.md secret.inject now states it is refused beside a backend, with the destination-secret-beside-a-backend behavior (phantom-only, real variable may be unset, signed secret refused); decisions.md no longer says the AWS scope is "derived from the backend host" (it contradicted the explicit-scope requirement); the credentials README now lists all four route kinds (adds box_owned/strip_phantom).
  • Backend-prefix path limit documented (21c268b) — egress.md now explains that a backend url with a path prefix refuses %2F/%5C in the request path (the GitLab encoded-path case), with the dedicated-host-without-a-prefix workaround.

The #1 (full projection merge) and #2 (route-kind collapse) suggestions are left as-is with the reasoning on their threads.

@github-actions

Copy link
Copy Markdown

Assessment: Approve

I re-reviewed 0f1c5af and 21c268b and have no new findings. A port written explicitly in a backend url is now validated instead of silently becoming 443, and the backend docs now match what the loader and the gateway actually enforce.

Re-checked on 21c268b and held
  • Backend port cases:
    • no port → the scheme's default port;
    • :8443 and [::1]:8443 → the written port;
    • [::1] → the default port;
    • :0, :65536, :not-a-port, an empty :, and [::1]: → refused at load.
  • A user:pass@host authority is now refused at load as an invalid port. Before this change it parsed to host user on port 443. That is a better result.
  • The egress.md claims match the code:
    • secret.inject beside a backend is refused (egress.rs, a_destination_secret_inject_beside_a_backend_is_refused);
    • %2F/%5C are refused only when the backend has a path prefix (l7.rs, the !backend_prefix.is_empty() check).
  • decisions.md now says the aws:// backend auth states its service and region, which agrees with the rule that the loader requires an explicit scope.
  • The credentials README route kinds (opaque, signed_aws, box_owned, strip_phantom) match the RouteSpec constructors.
  • cargo test -p strands-box --lib backend passes (34 tests). No run-time path, record version, or frozen surface changed.

@github-actions

Copy link
Copy Markdown

Assessment: Approve

de9b417 changes only comments in run/credential.rs::workspace(). No code changed, and the new comments describe the code correctly.

Re-checked on de9b417 and held
  • The comment says no box-owned route reaches the plain (None, []) arm in workspace(). That holds: to_bindings skips any entry that has backends (egress.rs:844). A backend's credentials open only through backend_capabilities (credential.rs:104), and hosted.rs:809 passes workspace() only egress_routes().
  • The arm guarded by binding.signs() still keeps signing placeholders away from any nameless route that does not sign.
  • Everything I checked on 21c268b still applies.

siwachabhi
siwachabhi previously approved these changes Nov 14, 2026
Abhilash Panigrahi added 3 commits November 13, 2026 17:35
An operator can re-route an egress destination to a backend, but a remote
(`type = "http"`) MCP server had no way to do the same. A box that must reach an
MCP server through a gateway (for example an AgentCore gateway that signs with
SigV4 and injects the server credential) could not express that: the backend
fields existed only on `[egress.<name>]`, not on `[mcp.<name>]`.

Accept `[[mcp.<name>.backends]]` on an `http` MCP entry and thread it through the
existing egress backend path, so a re-routed MCP server reuses one config shape,
one validation path, and one gateway dial.

- `McpEntry::Http` carries `backends: Vec<EgressBackend>`; a `stdio` entry rejects
  the key as unknown (`serde(deny_unknown_fields)`).
- The config fan-out builds the remote-MCP `EgressEntry` with the declared
  backend, so `backend_dials`, `backend_credentials`, and `mcp_servers` all key
  on the original destination host. Policy still judges the original host; the
  backend is never the judged resource.
- Every egress backend refusal applies unchanged (one backend per destination,
  no path on a backend destination, an `aws://` auth needs an AWS backend host, a
  destination secret beside a backend must be `env://`).
- `has_backend` already covers `remote_mcp`, so a re-routed MCP server selects the
  existing `BACKEND_RECORD_VERSION`; no new record version.

Docs: `mcp.md` gains a Backends section with the keys and refusals; `egress.md`
and `config.md` cross-reference it.

`cargo test -p strands-box --all-features` green. New, in `record/config`:
- `an_http_entry_carries_a_backend` — the key parses onto an http entry.
- `a_stdio_entry_refuses_a_backends_key` — a stdio entry rejects it.
- `a_remote_mcp_backend_reroutes_through_the_egress_path` — the dial target and
  credential vault key on the original host (fails if the fan-out drops the
  backend).
- `a_remote_mcp_server_with_no_backend_does_not_reroute` — negative guard.
- `a_remote_mcp_backend_stores_the_backend_record_version` — version selection.
- `a_remote_mcp_backend_inherits_every_egress_refusal` — each egress refusal
  still fires for an MCP backend.

Manually verified end to end with the real binary: a box declaring an MCP backend
to an AgentCore gateway re-routed a live MCP `initialize` (SigV4 accepted, the
gateway reached GitHub's MCP endpoint), and the box's telemetry decided
`net:connect` and `mcp:call` on the original host, never the backend.
…ection

## Problem
strands-agents#311 review (r4504995822): the backend credential path duplicated the ordinary one —
`BackendProjection` mirrored `Projection`, and `backend_capabilities` restated the vault-open
boilerplate. The root cause was that `workspace` read "no provisioned name" as "this route signs",
which is false for a box-owned backend token (no name, no phantom, does not sign) — so backends
needed a separate path to avoid being handed bogus signing placeholders.

## Solution
- Add `EgressRoute::signs()` (true only for a `SignedInBoundary` placement). `workspace` now asks it
  directly, so a box-owned token is no longer mistaken for a signed route; its `(None, [])` arm
  places nothing instead of emitting `SIGNING_PLACEHOLDERS`.
- Collapse `BackendProjection` into `Projection`; `backend_capabilities` returns `Projection`.
- Extract `open_vault`, the fail-closed vault-open, so both projection paths share one home.
- Keep the two projection functions: an entry's strip + box-owned routes must stay in ONE vault, so
  backends group per entry, while the ordinary path groups by provisioned variable. Collapsing the
  groupings would merge or split credentials, so that split stays, now documented.

No interface moved: `RouteKind` stays `pub(crate)`, the public `RouteSpec` constructors are
unchanged, and the stored record and wire are untouched.

## Tests
`cargo test -p strands-box --all-features` green, including the `box_credentials` e2e suite and
`a_signed_route_places_nothing_and_still_credentials_the_workload`,
`a_backend_entry_opens_one_capability_and_provisions_only_its_strip_phantom`,
`a_shared_variable_keeps_the_ordinary_phantom_and_drops_the_backend_duplicate`.
…whole-value

Addresses the strands-agents#413 review.

- `read_remote_mcp_servers` (the offline `policy generate-schema` path) now refuses a `[mcp.<name>]`
  that declares a backend, by name, instead of silently discovering its ORIGINAL host — which would
  read the wrong server's tools and resolve the phantom-only destination `secret` against that host.
  The re-route and its backend auth apply only at run time, in the egress gateway, which the offline
  command does not have. Documented in mcp.md#backends.
- `a_remote_mcp_backend_reroutes_through_the_egress_path` now asserts `mcp_servers()` and
  `backend_dials()` by whole value, so a regression that classified the backend host or dialed a
  wrong port/scheme is caught (the previous `any`/per-field checks were not tight enough).
- New `offline_schema_generation_refuses_a_re_routed_remote_mcp_server`.
Abhilash Panigrahi added 4 commits November 13, 2026 17:35
…sal actionable

Addresses the non-blocking strands-agents#413 follow-up. The backend refusal aborts the whole generate-schema run
(there is no partial schema), so read the remote servers BEFORE the stdio discovery loop — the run
now fails before spawning any stdio server rather than spawning them and discarding the result. The
refusal message and mcp.md now say what to do: generate a schema from a box.toml without the
re-routed server for the others, then author its mcp:call rules by hand.
… to 443

A backend `url` is not range-checked by `DestinationPattern::parse` the way a destination is, so
`https://gw.example.com:65536` and `:not-a-port` silently became port 443. `target()` now reads the
port text explicitly: an omitted port takes the scheme default, an unparseable or out-of-range one
(and `:0`, and an empty `:`) fails at load. Adds `declared_port_str` (present-vs-absent, no parse)
and three refusal cases to `a_backend_url_must_be_scheme_host_port_and_optional_prefix`.
…efix path limit

- egress.md: `secret.inject` is refused beside a backend; add the destination-secret-beside-a-backend
  behavior (phantom-only, real variable may be unset, `secret.inject` refused, signed secret refused).
- egress.md: document that a backend `url` with a path prefix refuses an `%2F`/`%5C` in the request
  path (a GitLab encoded-path case), and that a dedicated backend host with no prefix is the
  workaround; note the 1..=65535 port rule on `backends.url`.
- decisions.md: a backend `aws://` signs under the service/region the auth states, not one "derived
  from the backend host" (the clause contradicted the explicit-scope requirement two lines down).
- credentials README: a route is one of four kinds, not two — add `box_owned` and `strip_phantom`.
Review found the two comments implied a box-owned backend route flows through `workspace()`. It does
not: `to_bindings()` skips a backend entry, so box-owned/strip routes open through
`backend_capabilities`. The `(None, [])` arms handle only ordinary routes (the signed case), with the
non-signing arm there for exhaustiveness. Comment-only.
@github-actions

Copy link
Copy Markdown

Assessment: Approve

cf41e19 is a rebase of the same seven commits onto the current main (d5278f2). git range-diff shows commits 2 and 4–7 unchanged. Commits 1 and 3 differ only in the context lines of docs/user/mcp.md, which #387 changed. I found nothing new.

Re-checked after the rebase and held
  • fix(shell): give the hosted Shell each route's phantom #431 (the hosted Shell gets each route's phantom): attachment.phantoms is the merged workload_environment in hosted.rs. A Shell therefore also gets the strip-phantom placeholders of a backend entry, not only the ordinary routes' phantoms.
  • fix(credentials): resolve aws://PROFILE through the SDK and bound each resolve #415 (aws:// resolved through the SDK, with each resolve bounded): a backend aws:// auth opens through the same open_vault → Vault::open path as a destination secret, so it gets the same bounded resolve. Its explicit aws_scope still sets the signing scope.
  • feat(egress): upgrade plain HTTP to HTTPS for listed hosts #411 (upgrade_http): main already lets a backend decide the upstream leg of an upgraded request (plain_upstream_leg). The PR does not change this.
  • Tests:
    • cargo test -p strands-box-egress-gateway -p strands-box-credentials passes.
    • cargo clippy --workspace --all-targets -D warnings is clean.
    • In cargo test -p strands-box --lib, 1039 tests pass. One test, hosted::tests::teardown_wakes_waiters_…, failed once under the full parallel run and passed 3 of 3 times on its own. It does not exercise any code the PR changes.
  • Everything I checked on de9b417 still applies.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants