Skip to content

MCP auto-exposure: single module + opt-in flag - #2059

Merged
bxyu-nvidia merged 30 commits into
mainfrom
mcp-auto-exposure
Jul 22, 2026
Merged

bxyu-nvidia merged 30 commits into
mainfrom
mcp-auto-exposure

Conversation

@adil-a

@adil-a adil-a commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Draft / RFC. Consolidates the exploration in #2002 and #2053 into one tracked module + a small framework hook, removes #1682's decorator API (@gym_tool / MCPResourcesServer) so there is exactly one user-facing MCP mechanism, then hardened by a full review pass (details below). Branched off main.

What this is

Serve a resources server's existing FastAPI tool routes over MCP with zero handler changes and one opt-in config field — no code change at all for a typed-route server:

resources_servers:
  my_server:
    entrypoint: app.py
    expose_tools_over_mcp: true     # the only addition — set per instance in the config

run_webserver installs the /mcp endpoint automatically after the app is built (and imports the MCP SDK only for servers that opt in). Handlers keep their request: Request param and request.session[...] reads exactly as written. The only in-tree code a server ever needs is for shapes the config can't express, and it is one override: mcp_tools(harvested, catchall) — return the harvested typed routes (the default), filter one out to exclude a harness-only route (e.g. /end_session), or append catch-all-backed tools (harvested + [catchall.tool(name, input_schema, description)]) for dispatcher servers. A dropped route is never advertised, never callable over MCP, never shape-checked, and unchanged over HTTP.

This PR also removes the decorator-based MCP API that #1682 added (@gym_tool, MCPResourcesServer, MCPSessionError, the token contextvar and header middleware): tools no longer need decorators or MCP-specific signatures, so the old mechanism is superseded rather than parallel. The wire contract survives (MCPServerMetadata, the X-NeMo-Gym-Session-Token header, the token salt, the reserved-name set) — claude_code_agent on main consumes it unchanged. example_mcp_weather is rewritten onto auto-exposure: a plain typed POST /get_weather route plus the yaml flag, with tests driving the MCP door (and the wrapped /seed_session + /verify) through the real engine mount, and the plain-HTTP tool route on the stock app.

How to review this (~30 min reading order)

  1. nemo_gym/mcp_auto_exposure.py module docstring — the contract in one screen.
  2. bind_route — the detector. The accept/refuse table below is what it enforces.
  3. call_direct — dispatch parity with the plain HTTP route (threadpool for sync handlers, body bytes, session cookie, response_model filtering, error-text parity).
  4. harvest_tools → install_auto_exposure — tool map, startup guards, session token, /seed_session wrap, /mcp mount.
  5. base_resources_server.py diff — the expose_tools_over_mcp config field, the two overridables (mcp_tools(harvested, catchall) — what to expose; mcp_allowed_tools_for_session(seed_body) — per-rollout narrowing), and the removal of the feat: Add Gym-owned MCP resources server support #1682 decorator API (the file drops from 315 lines on main to 150; it had grown to 356 on this branch before the removal commit).
  6. server_utils.py — the 6-line activation point (plus a 4-line session-middleware idempotency guard).
  7. tests/unit_tests/test_mcp_auto_exposure.py — 45 tests double as the spec; each guard and parity shape has a named test.

What the detector accepts vs refuses

Accepts (dispatches identically to the plain HTTP route): async and sync handlers (sync runs via run_in_threadpool, exactly like FastAPI); body: Model; body: dict / dict[str, Any]; raw-body catch-alls reading await request.json(); handlers taking both a body model and a request they read raw; str path params; response_model= filtering (decorator kwarg or return annotation).

Refuses loudly at startup (ValueError naming the route and reason): non-Gym middleware; Depends/Security; *args/**kwargs; multiple body models; union/optional body params; defaulted query params; unresolvable annotations; tool names outside ^[A-Za-z0-9_-]+$ (e.g. nested routes); tool names (from mcp_tools()) colliding with verify/seed_session/aggregate_metrics/mcp; duplicate tool names; multiple parameterized catch-all routes (auto-exposure cannot tell which backs the tools); a server that already serves /mcp (a hand-rolled MCP mount would be shadowed); a missing /seed_session or /verify. These rules apply to route-handler signatures only — inner tool functions may use **kwargs freely, and no in-tree server trips any refusal.

No silent fallback exists: a shape either dispatches provably like HTTP or the server does not start. One soft case: a dispatcher whose mcp_tools() override ignores its catch-all logs a warning (the catch-all-backed tools stay HTTP-only).

Session & token

/seed_session gains an additive "mcp" key (MCPServerMetadata — the same shape claude_code_agent on main already consumes): the /mcp URL plus a signed per-rollout token in X-NeMo-Gym-Session-Token. The token payload is always {"sid", "tools"} — the session id plus this rollout's allow-list from mcp_allowed_tools_for_session(seed_body) (null = unrestricted) — signed with URLSafeSerializer (untimed; expiry was cut as consumer-less scope). The header/salt/metadata constants live in base_resources_server — the wire contract retained from #1682's scheme after its decorator API was removed. The token is verified on every call (no cache). tools/list works tokenless and advertises the full exposed set; a token carrying an allow-list narrows both tools/list and tools/call for that session. Known limitation, deliberately out of scope: the signing secret derivation (class___config-name) is pre-existing main behavior (the session-middleware secret) — hardening it is a separate change.

Verify-time tool-name normalization (install-time, gated)

MCP-native agents record trajectory tool calls namespaced (mcp__<server>__<tool>); verifiers know bare names — found live when MCP rollouts scored 0.0 on perfect trajectories. For flag-on servers only, installation wraps the /verify route's current endpoint (whatever handler it holds — servers that re-register /verify are covered by construction): names are normalized for scoring on a deep copy, then the model's original names are restored in the echoed response (matched by call_id). Flag-off servers — every existing benchmark — keep verify byte-for-byte, so no baselines move; persisted rollout artifacts always keep the names the model actually emitted.

Diff shape (+2,136 / −631, 11 files)

Area Change
nemo_gym/mcp_auto_exposure.py new, one file, 841 lines — detector, direct dispatcher, harvest + guards, token, /seed_session + /verify wraps, /mcp mount
nemo_gym/base_resources_server.py +48/−207: adds the expose_tools_over_mcp config field, the normalize_tool_name helper, and the mcp_tools() / mcp_allowed_tools_for_session() overridables; removes @gym_tool, MCPResourcesServer, MCPSessionError, and the header middleware (their unit tests go too: test_base_resources_server.py −277)
nemo_gym/server_utils.py +10: run_webserver activates auto-exposure for opted-in servers (lazy MCP import); setup_session_middleware is now idempotent
tests/unit_tests/test_mcp_auto_exposure.py new — 45 self-contained tests (TestClient, synthetic servers, ~3 s); +37 in test_server_utils.py; docs section in fern/.../mcp-resources-server.mdx
resources_servers/ example_mcp_weather rewritten onto auto-exposure (plain typed route + yaml flag; 6 tests: the MCP door — plus the wrapped /seed_session and /verify — through the real engine mount, and the plain-HTTP tool route on the stock app). Every other server: byte-identical to main. Enablement stays per-server follow-up: pure config for typed-route servers; a small mcp_tools() override for catch-all dispatchers.

MCP engine: the official SDK's public low-level Server — no private-attr access.

Review provenance

This branch already went through a 7-reviewer + devil's-advocate review pass (59 confirmed findings) followed by a fix pass with adversarial re-verification: all 55 code findings are fixed here with tests (silently-wrong detector shapes, refuse-loudly gaps, an unbounded token cache — the token is now verified on every call with no cache, and expiry was later cut as consumer-less scope — event-loop blocking for sync handlers, provenance-preserving verify normalization), and a docstring-accuracy audit (77 reviewed, 3 corrected). The remaining follow-ups are listed under "For discussion".

Verified

  • Reproducible from this PR: pytest tests/unit_tests/test_mcp_auto_exposure.py → 45/45 (~3 s; covers every refusal, parity shape, token path, exclusion semantics, and the normalization gating/provenance — including a re-registered-/verify regression test).
  • Live e2e at this HEAD (3891dd65): workplace_assistant MCP door (claude_code_agent) — 5/5 reward: 1.0, all 8 distinct tools namespaced; HTTP door (simple_agent) — 5/5 on the second run, 4/5 on the first (the failing rollout omitted the status: "Lead" search filter — a trajectory-verified policy flake, not an engine failure; the HTTP door runs the stock route code). example_mcp_weather (claude_code_agent) — 5/5 reward: 1.0 at the removal commit 4d6d569c; the one commit since is docs plus a comment reword. Earlier demo (below): both doors 5/5 tasks reward: 1.0, ~30 s wall for 5 concurrent rollouts, MCP-door trajectories persist the namespaced names the model emitted. n=5 single runs of a stochastic policy — a smoke demonstration that both transports work end to end, not a statistical parity claim.
  • Development-time only (local, gitignored — not reproducible from this PR): a 44-check live acceptance harness exercising six representative in-tree servers (finance, workplace, aviary, newton_bench, openenv, ns_tools) against pristine origin/main handlers, covering the factory-__signature__, dict-body, and raw-body/PlainTextResponse shapes; 44/44 as of 2026-07-17 against a pre-refactor revision of the engine (development-time evidence; the harness predates the mcp_tools() API). Other in-tree servers were covered by a static route sweep, not this live harness.

Runnable e2e — both doors through gym eval run

The full 5-task workplace_assistant example dataset, driven end to end by Gym. Because resources_servers/ ships unchanged, the demo uses a small user-side entrypoint (app_mcp.py, shown in the docs added by this PR) that subclasses the stock server and overrides mcp_tools() to append the 27 catch-all-backed tools; the flag itself is plain yaml:

Compose config (workplace_2059.yaml) — MCP door via claude_code_agent (already on main; it reads the mcp key /seed_session returns and wires the Claude Code binary itself), HTTP door via simple_agent:

workplace_assistant:
  resources_servers:
    workplace_assistant:
      entrypoint: app_mcp.py   # thin subclass overriding mcp_tools(); see the docs page
      domain: agent
      expose_tools_over_mcp: true

workplace_claude:
  responses_api_agents:
    claude_code_agent:
      entrypoint: app.py
      resources_server: {type: resources_servers, name: workplace_assistant}
      model: ${anthropic_model_name}
      anthropic_api_key: ${anthropic_api_key}      # env.yaml; any Anthropic-format endpoint
      anthropic_base_url: ${anthropic_base_url}    # optional gateway; CLI appends /v1/messages

policy_model:
  responses_api_models:
    inference_provider:
      entrypoint: app.py
      base_url: ${policy_base_url}                 # any OpenAI-format endpoint
      api_key: ${policy_api_key}
      model: ${policy_model_name}

workplace_simple:
  responses_api_agents:
    simple_agent:
      entrypoint: app.py
      max_steps: 12
      resources_server: {type: resources_servers, name: workplace_assistant}
      model_server: {type: responses_api_models, name: policy_model}
gym env start --config workplace_2059.yaml

# (a) MCP door — Claude Code discovers and calls the tools over /mcp with the per-rollout token
gym eval run --no-serve --agent workplace_claude \
  --input resources_servers/workplace_assistant/data/example.jsonl \
  --output results/workplace_claude_rollouts.jsonl

# (b) HTTP door — the same tools as plain POST /<tool_name>
gym eval run --no-serve --agent workplace_simple \
  --input resources_servers/workplace_assistant/data/example.jsonl \
  --output results/workplace_simple_rollouts.jsonl

Tip: if your provider's OpenAI Responses-API emulation is incomplete (a common gateway gap on multi-turn tool exchanges), use inference_provider (chat-completions + Gym's converter) rather than openai_model.

Enablement (deliberately none in this PR)

This PR ships the engine plus the one in-tree example (example_mcp_weather) — every other server under resources_servers/ is byte-identical to main (we cannot properly test each env here, per review). Enabling a server is per-server follow-up work: set expose_tools_over_mcp: true in its instance config (typed-route servers need no code at all), plus a small mcp_tools() override for catch-all dispatcher servers — the docs added in this PR walk through workplace_assistant end to end. During development, a fleet exercise ran claude_code_agent over /mcp against 12 servers enabled this way (workplace_assistant 27 tools, math_advanced_calculations, finance_sec_search, indirect_prompt_injection with per-task narrowing, the examples, and others), all dispatching correctly; those results are development-time evidence, not shipped code.

Deliberately not exposed even as follow-up (each needs design work first): aviary (its /step//close bodies carry an env_id that can address other rollouts' environments — needs session-derived lookup), genrm_compare (its only route is a harness-side batch API), and the gymnasium family (reward accrues from /reset//step, which a model could drive to reroll its own episode). The 74 verify-only benchmarks have no tool routes to expose.

For discussion

🤖 Generated with Claude Code

…e + opt-in flag)

Consolidates the exploration in PRs #2002 and #2053 into one tracked module.
A resources server sets expose_tools_over_mcp = True and its existing FastAPI
POST /<tool> routes are served over an MCP /mcp endpoint — no decorators, no
handler changes. run_webserver calls maybe_auto_expose(server, app) after the
app is built, so exposure is automatic (no user-side install call).

nemo_gym/mcp_auto_exposure.py (new, one file): the detector (route-signature
bind + server middleware audit), direct dispatch (run the frozen handler once
via a fabricated Request whose .session is materialized directly — no second
app pass, public API only), a replay fallback for shapes/servers direct
dispatch can't prove equivalent, route harvest, signed session token, the
/seed_session wrap, and the /mcp mount. Merges the two prototype files, drops
all dead code (hybrid_call, the duplicate route walk, the route.body_field
read — schema now comes from the same signature resolution that decides
dispatch).

Framework hooks: +1 line (the opt-in flag on SimpleResourcesServer) and +3
lines (the maybe_auto_expose call in run_webserver). No tool files changed:
git diff origin/main -- resources_servers/ is empty.

Verified: acceptance suite 44/44 against pristine origin/main handlers; all
in-tree tool routes dispatch direct, zero replay, live-verified for finance,
workplace, aviary, newton_bench, openenv, and ns_tools (incl. the factory-
__signature__ and dict-body and raw-body/PlainTextResponse shapes).

Signed-off-by: Codex <adasif@nvidia.com>
Signed-off-by: Codex <codex@openai.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

codex and others added 11 commits July 16, 2026 20:56
Every in-tree tool route dispatches direct (verified across all 97 servers),
so the replay fallback had zero runtime users. For an opt-in flag the
consistent design is to fail loud: if a server installs custom middleware or
a handler uses a shape direct dispatch cannot reproduce (FastAPI DI, multiple
body models, ...), exposure now raises at startup naming the route and reason,
rather than silently degrading to a second-pass HTTP replay.

Deletes _replay, mint_session_cookie, the replay branch + caches, and the
MCPTool replay fields (mode/replay_path/raw_path/reasons). ~90 lines removed;
module 692 -> 600 lines. Verified: acceptance suite 44/44; custom-middleware
and Depends handlers refuse loudly at startup; finance/workplace/aviary/
newton/openenv still expose direct.

Signed-off-by: Codex <adasif@nvidia.com>
Signed-off-by: Codex <codex@openai.com>
Self-contained pytest (tests/unit_tests/test_mcp_auto_exposure.py, 11 tests,
~1.5s, no external deps): flag gate + /mcp mount, tools/list schema harvest,
direct dispatch with HTTP-door/MCP session sharing, dict-body and raw-body/
PlainTextResponse dispatch, allowed_tools filter/gate, error mapping
(unknown/missing-token/invalid-token/422), the refusal paths (custom
middleware, Depends), and the factory-__signature__ resolution regression.

gitignore prototypes/ — the live-uvicorn demo harness is kept locally for
manual runs but is not part of the PR (the pytest is the CI test story).

Signed-off-by: Codex <adasif@nvidia.com>
Signed-off-by: Codex <codex@openai.com>
…ignored)

The live-uvicorn acceptance harness was useful for exploration; the CI test
story is now tests/unit_tests/test_mcp_auto_exposure.py. Files remain on disk
for local manual runs but are untracked + gitignored.

Signed-off-by: Codex <adasif@nvidia.com>
Signed-off-by: Codex <codex@openai.com>
MCP-native agents (e.g. Claude Code) record trajectory tool calls as
mcp__<server>__<tool>, while verifiers compare names against their bare
tool vocabulary — so MCP-driven rollouts scored 0.0 on otherwise perfect
trajectories. The /verify route now strips this server's own MCP prefix
from function_call items before the subclass verify runs, so rollouts
score identically on both transports with no per-server changes. Found
by a live gym eval run of claude_code_agent against the auto-exposed
workplace_assistant (5/5 reward 1.0 after; trajectories identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…eserve provenance

Addresses three review findings on the verify-side normalization added in the
prior commit:

- Gate the normalization on expose_tools_over_mcp (was unconditional for every
  SimpleResourcesServer). HTTP-only benchmarks now get byte-identical verify and
  keep valid baselines; only servers that actually expose tools over MCP — the
  only ones that can receive mcp__<server>__ names — normalize. (F52, F51)
- Stop mutating the parsed verify request in place. Score against a deep copy,
  then restore the names the model emitted in the echoed response (matched by
  call_id), so persisted rollout artifacts keep the real tool names and transport
  provenance instead of laundered bare names. (F3, F35)

Verified: unit 13/13 (adds a flag-off no-op test and a provenance-preserved
assertion); base_resources_server + server_utils suites 26/26; gym-native e2e —
MCP door 5/5 reward 1.0 with persisted names still namespaced, HTTP door
unaffected (bare-name normalization is identity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Review pass (7 specialized reviewers + devil's-advocate convergence, 59
confirmed findings) followed by a fix workflow with adversarial verification.
Highlights:

Detector/dispatch correctness (silently-wrong shapes now correct or refused):
- Optional[Model] bodies bind as body params instead of dropping arguments
- dict[str, Any] accepted like dict; ambiguous unions refuse loudly
- handlers reading await request.json() alongside a body model get the real bytes
- sync (def) handlers run via run_in_threadpool, matching FastAPI (no event-loop stall)
- response_model (decorator kwarg) wins for return filtering; results are always
  dump-then-validate filtered, matching the plain HTTP route
- fabricated Request carries a synthesized session cookie
- unexpected handler exceptions log a server-side traceback and map to the same
  repr(e) text as the HTTP exception middleware

Refuse-loudly guards (were silent misconfigurations):
- dispatcher opting in without mcp_tool_inventory(): startup ValueError (was a warning)
- multiple candidate catch-alls: inventory items must name their route
- inventory names checked against RESERVED_MCP_TOOL_NAMES; tool names must match
  ^[A-Za-z0-9_-]+$ (nested routes refuse)
- app already serving /mcp (MCPResourcesServer overlap): refuse instead of shadowing
- missing /seed_session: clear error instead of StopIteration

Tokens/session:
- claims cache deleted (unbounded growth); token verified per call
- URLSafeTimedSerializer with 24h max_age; constants imported from
  base_resources_server (single source of truth); tokenless tools/list respects
  the install-time allowed_tools floor

Contracts/plumbing:
- mcp_tool_inventory() / mcp_toolless_catchall_paths declared on SimpleResourcesServer
- mint_metadata builds MCPServerMetadata; run_webserver imports the MCP module
  only for opted-in servers
- docstring accuracy audit: 77 reviewed, 3 stale corrected

Tests: 13 -> 43 (all refusals, parity shapes, token paths, provenance).
E2E re-verified: both doors 5/5 reward 1.0, MCP trajectories keep namespaced names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Remove or tighten 21 comments added during the hardening pass that restated
adjacent code, narrated the change, or argued correctness to a reviewer
(classified and unanimously approved by a cold-eyes verification pass; 76
load-bearing constraint/why comments kept). Also wraps the surviving trimmed
comments to the 119-column limit and applies ruff format. No logic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…iddleware guard

Three engine-side changes from the review follow-up queue:

- Verify-name normalization now wraps the /verify route's current endpoint at
  install time (same in-place swap as the seed_session wrap), so a flag-on
  server that strips and re-registers /verify cannot bypass it; a flag-on app
  without /verify refuses at install. Flag-off servers register plain
  self.verify — byte-identical to main.
- mcp_excluded_paths ClassVar: routes listed there are never advertised,
  callable, or shape-checked over MCP while staying untouched over HTTP;
  unknown declarations refuse at install.
- setup_session_middleware is idempotent; double-install previously stacked
  SessionMiddleware twice with no test able to catch it.

Tests 43 -> 75 across the three suites; e2e both doors re-verified 5/5
reward 1.0 (one HTTP task flaked to 0.0 on first run and re-ran 1.0 —
policy variance, 12-call trajectory).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…d-tools hook

Folds the enablement (previously planned as a stacked PR) into this branch so
everything is e2e-testable from the tree with yaml-only configs:

- gymnasium/base.py builds its app from super().setup_webserver(), gaining
  /seed_session and /verify like every other resources server (flag stays off
  for the gymnasium family; /verify moves from 404 to 422/500 if probed)
- mcp_allowed_tools_for_session(seed_body) on SimpleResourcesServer: servers
  can restrict a rollout's MCP token to the task's equipped tools; the seed
  wrap mints the claim, intersecting any install-time floor; a raising hook
  fails the seed request
- flag + mcp_tool_inventory(): workplace_assistant (shared TOOLKITS constant,
  27 tools), ns_tools (schemas captured from ToolManager at startup),
  math_advanced_calculations (_function_map), indirect_prompt_injection
  (TOOL_HANDLERS + per-task restriction via the new hook)
- flag + toolless declaration: finance_sec_search
- flag only: browsecomp_advanced_harness, circle_click, example_multi_step,
  example_session_state_mgmt, example_single_tool_call, google_search,
  litmus_agent, math_with_code, newton_bench, openenv, tavily_search
  (math_with_code and newton_bench exclude /end_session)
- aviary and genrm_compare deliberately not enabled: aviary's /step//close
  carry a cross-rollout env_id; genrm's only route is a harness-side batch API

Tests 75 -> 79 (hook coverage); ruff + py_compile clean on all touched files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Enablement is now operator-side: a server opts into MCP by setting
expose_tools_over_mcp: true in its (YAML) config, not by a class attribute.

- BaseResourcesServerConfig gains expose_tools_over_mcp: bool = False; the
  ClassVar is removed from SimpleResourcesServer. Both gates (run_webserver,
  maybe_auto_expose) read it from server.config defensively, so agents and
  model servers (which lack the field) resolve to False.
- The 9 flag-only servers revert to byte-identical origin/main — a typed-route
  server needs no code change at all to be MCP-exposable. Servers whose tools
  live behind one catch-all keep only their mcp_tool_inventory() (irreducibly
  code); math_with_code/newton_bench keep mcp_excluded_paths; finance keeps its
  toolless declaration; IPI keeps its per-task allowed-tools hook.

Proven end to end: example_single_tool_call (unchanged .py) and
workplace_assistant (inventory only) both expose over MCP and pass
claude_code_agent rollouts driven purely by a compose yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Comment thread .gitignore Resolved
Comment thread nemo_gym/server_utils.py Resolved
Comment thread nemo_gym/server_utils.py Resolved
Comment thread nemo_gym/server_utils.py Resolved
Comment thread nemo_gym/mcp_auto_exposure.py Resolved
Comment thread nemo_gym/base_resources_server.py Resolved
adil-a and others added 6 commits July 17, 2026 21:45
Per review (@bxyu-nvidia): we can't properly test the individual envs, so
this PR ships only the auto-exposure engine. Reverted all resources_servers/
changes and the .gitignore additions to origin/main; enablement is documented
for users instead of shipped. Also added the reason (server_utils.py:681) that
the MCP SDK import is gated to resources servers — agents/models never expose
tools over MCP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Per review scope discipline: the token expiry (URLSafeTimedSerializer + 24h
max_age) was added for a security review finding, not driven by any env. The
signing secret is derived from public names anyway, so a bounded lifetime on a
forgeable token buys little; hardening the secret is the real (separate) fix.
Token now uses URLSafeSerializer. The {sid, tools} payload stays — the
per-rollout tool restriction it carries is validated by indirect_prompt_injection
(a benchmark whose per-task toolset must not leak across domains over MCP's
static tools/list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…) override

Per review (@bxyu-nvidia): rather than mcp_tool_inventory() +
mcp_toolless_catchall_paths + mcp_excluded_paths — which grow core infra per
env — one overridable function returns the tools to register:

    def mcp_tools(self, harvested, catchall) -> list[MCPTool] | None:
        return harvested   # default; override to filter / add / disable

harvested = auto-harvested typed POST routes; catchall (if the server has one
parameterized catch-all) builds tools that dispatch through it. Exclusion =
filter harvested; inventory = harvested + [catchall.tool(...)]; toolless =
ignore the catchall (now a soft warning, not a hard refuse). Reserved-name,
tool-name charset, and duplicate checks stay as loud raises on the final list.

Also: Optional[Model] bodies now refuse at startup instead of carrying
speculative unwrap support — no in-tree tool uses that shape, and refusing
keeps the never-silently-wrong contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…nners, flatten installer

The module was too dense to safely modify. Readability-only, no behavior change:
- A 'How to read this file' block up top makes the two timelines explicit
  (STARTUP: harvest+bind+wrap+mount; PER-CALL: session_claims -> call_direct)
  and gives a reading order.
- Section banners relabeled by timeline + action; one-line role docstrings on
  DirectBinding / BindOutcome / MCPTool / _CatchAll.
- install_auto_exposure flattened: its nested closures (mint_metadata,
  session-token decode, effective_allowed, _to_result) lifted to module-level
  helpers taking explicit args, so the installer reads top-to-bottom.

Behaviour-equivalence verified three ways: 75 unit tests pass; a cold-eyes diff
review confirmed each lifted helper is byte-identical and receives exactly what
its closure captured; and a live claude_code_agent + workplace_assistant rollout
over MCP scored 5/5 reward 1.0 with tool-name provenance intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
…list, dead field, re-bind

Four simplifications, each adversarially verified to have no consumer or be
pure dedup before cutting (978 -> 912 lines):

- _session_cookie_header and its call_direct wiring: no tool handler in the
  repo forwards request.cookies; handlers get their session via
  scope["session"], which is untouched.
- the install-time allowed_tools floor (install_auto_exposure param,
  allowed_floor, _effective_allowed): the production path never passes it, so
  it was dead at runtime. The per-session restriction via
  mcp_allowed_tools_for_session — which a real benchmark needs — is intact and
  its tests unchanged.
- DirectBinding.defaulted_params: written, never read; Python applies query
  defaults itself. The behavior test for defaulted params stays.
- _validate_tools no longer re-calls bind_route to regenerate refusal reasons;
  they are stashed on MCPTool at harvest (route field dropped, path/reasons
  added). Same refusal messages.

Verified: 72 unit tests pass; per-cut cold-eyes equivalence review; live
claude_code_agent + workplace rollout over MCP at 5/5 reward 1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
Eleven changes from a reader-round + verification-round hunt, each
adversarially confirmed no-consumer or behavior-identical:

Structural: verify wrapper is kwargs-only (FastAPI invokes endpoints with
keywords); the duplicated find/swap route plumbing became _take_route/_swap_route;
BindOutcome dataclass replaced by a plain tuple with the tool schema derived
from the binding; _make_receive, audit_middleware, _schema_for, and BASIC_PATHS
inlined at their single call sites; _to_result collapsed; oversized comments
trimmed (reading guide untouched).

Behavioral (all consumer-checked): defaulted query params now refuse at
startup instead of silently dispatching with defaults (no in-tree handler has
one); the seed_session injected Request param uses a fixed non-colliding name;
a raising mcp_allowed_tools_for_session propagates (still fails the seed
request, fail-closed); the two union-body refusal messages merged; the session
token is always {"sid", "tools"} (tools None = unrestricted).

Verified: 71 unit tests pass; per-cluster cold-eyes equivalence review; live
both-doors workplace e2e 5/5 reward 1.0 each (MCP provenance namespaced, HTTP
bare).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
@adil-a

adil-a commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Function map of nemo_gym/mcp_auto_exposure.py (at 0f0377b0, 841 lines). Two timelines — the top box runs once at server boot, numbered in execution order; the bottom box runs on every request, numbered per flow. (The gym_tool/MCPResourcesServer removal did not change this module's function map — only the wording of the pre-existing-/mcp refusal: its error-message text and the comment above it.)

flowchart TB
    subgraph STARTUP["STARTUP — runs once, when a flag-on server boots (read 1 to 6)"]
        MAE["1. maybe_auto_expose<br/>gate: no-op unless the server config opts in<br/><i>why: exposure is an operator choice, never implicit</i>"]
        IAE["2. install_auto_exposure<br/>orchestrator: refuse if /mcp is already served, then signer, harvest, endpoint wraps, mcp mount<br/><i>why: runs after the app is fully built, so every route is present</i>"]
        HT["3. harvest_tools<br/>walk POST routes, bind each, ask server.mcp_tools for the final list<br/><i>why: tools come from routes authors already wrote</i>"]
        BR["3a. bind_route — per route<br/>detector: handler shape to a DirectBinding, or refusal reasons<br/><i>why: dispatch must be provably HTTP-equivalent or refuse at boot</i>"]
        CA["3b. _CatchAll.tool — dispatcher servers only<br/>build a named tool that dispatches through the one catch-all route<br/><i>why: dispatcher servers keep per-tool schemas in data, not routes</i>"]
        VT["3c. _validate_tools<br/>final checks: name charset, reserved names, duplicates, bindability<br/><i>why: a bad advertisement corrupts rollouts silently — fail at boot</i>"]
        WSS["4. _wrap_seed_session<br/>swap the seed_session endpoint so its response gains the mcp key<br/><i>why: hands each rollout its mcp URL plus a signed session token</i>"]
        WV["5. _wrap_verify<br/>swap the verify endpoint: normalize mcp-namespaced tool names, scoring only<br/><i>why: MCP agents record namespaced names; verifiers know bare ones</i>"]
        TR2["4+5 use: _take_route and _swap_route<br/>find-or-refuse a route, swap its endpoint in place keeping order<br/><i>why: order matters — a catch-all would otherwise shadow the swap</i>"]
        MOUNT["6. mcp mount<br/>SDK low-level Server plus StreamableHTTPSessionManager, Route + Mount pair at the front of the route table<br/><i>why: official protocol layer; front position so catch-alls cannot shadow it</i>"]
    end
    subgraph PERCALL["PER-CALL — seed flow (S1) then MCP flow (M1 to M4) per request"]
        MSM["S1. _mint_session_metadata — on each POST seed_session<br/>sign sid plus allowed tools for this rollout<br/><i>why: the mcp endpoint is stateless — the token is the rollout identity</i>"]
        LT["M1. list_tools — on tools/list<br/>advertise harvested tools, filtered by the token claim"]
        CT["M1. call_tool — on tools/call<br/>resolve the session from the token, dispatch, map failures to isError"]
        PST["M2. _parse_session_token<br/>verify the signature, yield sid and allowed tools"]
        CD["M3. call_direct<br/>run the route's own handler once via a fabricated Request with the session in scope<br/><i>why: no second app pass; handlers stay byte-identical</i>"]
        DDE["M3-fail. DirectDispatchError<br/>handler failure carrying the same text the HTTP route returns"]
        TOR["M4. _to_result<br/>shape handler output into SDK content"]
    end
    MAE --> IAE
    IAE --> HT
    HT -->|per route| BR
    HT -->|catch-all servers| CA
    CA -->|first tool: bind once, cache| BR
    HT --> VT
    IAE --> WSS
    IAE --> WV
    WSS --> TR2
    WV --> TR2
    IAE --> MOUNT
    WSS -. per seed_session .-> MSM
    MOUNT -. registers .-> LT
    MOUNT -. registers .-> CT
    LT -->|via session_claims| PST
    CT -->|via session_claims| PST
    CT --> CD
    CD -->|failure| DDE
    CT --> TOR
Loading

Data carriers not shown as nodes: BindResult (bind_route's verdict at 3a: binding + refusal reasons + body model), DirectBinding (how to invoke a handler, resolved once at 3a, read per call by call_direct at M3) and MCPTool (advertisement + binding, built at harvest, read by list_tools/call_tool).

…to-exposure

The defaulted-param refusal from the simplification pass had silently not
survived the implementing agent's session — an independent verification pass
caught the acceptance branch still in place. Re-applied: a non-DI defaulted
param now refuses at startup naming the route; the two tests flipped to
refusal assertions.

Docs: the MCP tutorial page gains an auto-exposure section — yaml-only opt-in,
the mcp_tools(harvested, catchall) override with workplace_assistant as the
worked example (user-side app_mcp.py subclass; resources_servers ships
unchanged), per-rollout restriction via mcp_allowed_tools_for_session, and a
complete run-with-Claude-Code example (Anthropic API by default, key redacted
via env.yaml placeholder).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex <adasif@nvidia.com>
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview your docs: https://nvidia-preview-mcp-auto-exposure.docs.buildwithfern.com/nemo/gym

Here are the markdown pages you've updated:

adil-a added 6 commits July 20, 2026 19:52
- defaulted handler-signature params refuse at startup (doc said they were
  accepted; contradicted the code and tests in the same commit)
- 'required' qualifier dropped from the non-body-param refusal bullet
- mcp_tools() returning None/[] exposes no tools but does not disable
  exposure: /mcp stays mounted and /seed_session still carries the mcp key
- three-ways table: the Gym-owned row's 'When' now states the real
  discriminator (MCP-first server, no HTTP routes to reuse) instead of a
  criterion equally true of auto-exposure

Signed-off-by: Codex <adasif@nvidia.com>
…CP mechanism

PR #2059 supersedes the decorator-based MCP API from #1682: tools are the
plain POST routes authors already write, exposed over MCP by the
expose_tools_over_mcp config flag, with no decorators and no signature
changes. Removed from the user-facing API:

- @gym_tool, MCPResourcesServer, MCPSessionError, the token contextvar,
  and the header middleware (base_resources_server.py, 357 -> 151 lines)
- their unit tests (test_base_resources_server.py)

Kept: the wire contract the engine reuses (MCPServerMetadata, the
X-NeMo-Gym-Session-Token header, the token salt, RESERVED_MCP_TOOL_NAMES).

example_mcp_weather is rewritten as a plain SimpleResourcesServer with a
typed POST /get_weather route and expose_tools_over_mcp: true in its yaml
- the in-tree example of the new mechanism. Its tests now cover both
doors: plain HTTP with the session cookie, and MCP tools/call through the
real engine mount (plus a missing-token rejection test).

Docs: the tutorial page now teaches auto-exposure as the Gym-owned flow
(two integration shapes, not three); the external-server section and the
workplace_assistant catch-all example are unchanged.

Signed-off-by: Codex <adasif@nvidia.com>
- DNS-rebinding note (docs + engine comment): the loopback-only allowlist
  is FastMCP auto-config; the low-level manager path Gym uses has no such
  default, and an enabled default allowlist accepts no hosts at all
- tutorial listing: verify body param was unannotated (FastAPI would read
  it as a query param if copied); the verify request model is now shown
- accepted-shapes paragraph: dict bodies are supported and now listed
- workplace app_mcp.py snippet now says the reader must create the file

Signed-off-by: Codex <adasif@nvidia.com>
The agent-wiring section now leads into the complete workplace_assistant
run (catch-all override, app_mcp.py, compose yaml, eval command); the
weather server keeps the implementation tutorial and gets a short
same-wiring run note. Verified live: the example's exact commands score
5/5 reward 1.0 with mcp__workplace_assistant__* tool names.

Signed-off-by: Codex <adasif@nvidia.com>
- the weather run now shows the concrete gym eval command (the --agent
  name was discoverable only by opening the config yaml)
- the generated-config JSON's workplace_assistant key is introduced in
  its lead-in instead of appearing before the example is named
- the weather cross-reference uses an explicit anchor link (the old
  'end of this section' pointed at its own last line)
- 'hidden MCP metadata' -> 'MCP metadata' (nothing is hidden; it is a
  plain key in the seed response)
- 'never required to be dispatchable' -> plain wording tied to the
  startup shape checks the page already introduced
- missing --- rule restored before the workplace section
- 27-tool count no longer attributed to the five toolkits alone (the
  company-directory lookup is always included)

Signed-off-by: Codex <adasif@nvidia.com>
…crets

The literal {"secret": "leak"} exists to prove undeclared fields are
stripped by response_model on the MCP dispatch path; detect-secrets'
Secret Keyword plugin flagged it. Verified with CI's exact scan command
that the full PR diff is now clean.

Signed-off-by: Codex <adasif@nvidia.com>
bxyu-nvidia
bxyu-nvidia previously approved these changes Jul 21, 2026
Reviewer UX ask: state roughly which function shapes are supported.
The refusal bullets required knowing FastAPI's parameter classification
(that a bare scalar like my_string: str is a query param); the section
now leads with a signature-by-signature table — every row verified
against bind_route — and names the query-string pattern behind the
refusals explicitly.

Signed-off-by: Codex <adasif@nvidia.com>
- the pattern sentence claimed the query string explains all five refuse
  rows; it explains exactly the two query-parameter rows (union bodies,
  multiple bodies, and Depends are unrelated) - scoped accordingly
- the old 'non-body parameter' umbrella covered Header/Cookie markers,
  *args/**kwargs, and non-str path params, which the rewrite left
  uncategorized - restored as an explicit clause (each verified to
  refuse at startup with a named reason)

Signed-off-by: Codex <adasif@nvidia.com>
@adil-a adil-a changed the title [RFC/draft] MCP auto-exposure: single module + opt-in flag MCP auto-exposure: single module + opt-in flag Jul 21, 2026
@adil-a
adil-a marked this pull request as ready for review July 21, 2026 14:54
@adil-a
adil-a enabled auto-merge (squash) July 21, 2026 14:54
Comment thread nemo_gym/base_resources_server.py Resolved
Comment thread nemo_gym/base_resources_server.py Resolved
MCP-side engine: the official SDK's public low-level ``mcp.server.lowlevel.Server`` +
``StreamableHTTPSessionManager`` — no private-attribute access.

How to read this file

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.

no need to change now, but if things grow/mature we may want to move this into the docs explicitly 🤣

Comment thread nemo_gym/mcp_auto_exposure.py Resolved
Comment thread nemo_gym/mcp_auto_exposure.py Resolved
self.detail = detail


async def call_direct(

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 think there might be a better way to handle this since it looks like we're replicating a lot of Middleware functionality.

Comment thread nemo_gym/mcp_auto_exposure.py Resolved
app.router.routes[idx] = app.router.routes.pop() # in-place swap keeps ordering vs catch-all routes


def _wrap_seed_session(app: FastAPI, mint_metadata: Callable[[Request, dict], dict]) -> None:

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 think we might be able to do this cleaner in a middleware as well

adil-a added 3 commits July 21, 2026 22:53
- the TOKEN_HEADER alias comment still justified itself by 'the two MCP
  mechanisms' — the second mechanism was removed by this PR; the real
  reason is that the header is wire contract shared with claude_code_agent
- the reading guide now mentions install_auto_exposure's first action
  (refusing a pre-existing /mcp), matching the diagram fix
- copyright year 2026 on the two files new to this PR

Signed-off-by: Codex <adasif@nvidia.com>
mcp_tools is now list["MCPTool"] -> Optional[list["MCPTool"]] via a
TYPE_CHECKING-guarded import (a runtime import would be circular and
would pull the mcp SDK into agent/model processes); seed_body is
dict[str, Any]; _validate_tools takes Optional[list[MCPTool]].
Verified at import time that base still loads without the SDK.

Signed-off-by: Codex <adasif@nvidia.com>
Replaces the (binding, reasons, body_model) 3-tuple with a frozen
dataclass so the fields are named at both call sites — in particular
body_model, whose survives-a-failed-bind role is now self-documenting.

Signed-off-by: Codex <adasif@nvidia.com>
@bxyu-nvidia
bxyu-nvidia disabled auto-merge July 22, 2026 04:22
@bxyu-nvidia
bxyu-nvidia merged commit 446b963 into main Jul 22, 2026
17 checks passed
@bxyu-nvidia
bxyu-nvidia deleted the mcp-auto-exposure branch July 22, 2026 04:23
@adil-a adil-a linked an issue Jul 22, 2026 that may be closed by this pull request
OlegSudakov pushed a commit to OlegSudakov/Gym that referenced this pull request Aug 7, 2026
> **Draft / RFC.** Consolidates the exploration in NVIDIA-NeMo#2002 and NVIDIA-NeMo#2053 into
one tracked module + a small framework hook, **removes NVIDIA-NeMo#1682's decorator
API** (`@gym_tool` / `MCPResourcesServer`) so there is exactly one
user-facing MCP mechanism, then hardened by a full review pass (details
below). Branched off `main`.

## What this is

Serve a resources server's **existing** FastAPI tool routes over MCP
with **zero handler changes** and **one opt-in config field** — no code
change at all for a typed-route server:

```yaml
resources_servers:
  my_server:
    entrypoint: app.py
    expose_tools_over_mcp: true     # the only addition — set per instance in the config
```

`run_webserver` installs the `/mcp` endpoint automatically after the app
is built (and imports the MCP SDK only for servers that opt in).
Handlers keep their `request: Request` param and `request.session[...]`
reads exactly as written. The only in-tree code a server ever needs is
for shapes the config can't express, and it is one override:
`mcp_tools(harvested, catchall)` — return the harvested typed routes
(the default), filter one out to exclude a harness-only route (e.g.
`/end_session`), or append catch-all-backed tools (`harvested +
[catchall.tool(name, input_schema, description)]`) for dispatcher
servers. A dropped route is never advertised, never callable over MCP,
never shape-checked, and unchanged over HTTP.

This PR also **removes the decorator-based MCP API** that NVIDIA-NeMo#1682 added
(`@gym_tool`, `MCPResourcesServer`, `MCPSessionError`, the token
contextvar and header middleware): tools no longer need decorators or
MCP-specific signatures, so the old mechanism is superseded rather than
parallel. The wire contract survives (`MCPServerMetadata`, the
`X-NeMo-Gym-Session-Token` header, the token salt, the reserved-name
set) — `claude_code_agent` on `main` consumes it unchanged.
`example_mcp_weather` is rewritten onto auto-exposure: a plain typed
`POST /get_weather` route plus the yaml flag, with tests driving the MCP
door (and the wrapped `/seed_session` + `/verify`) through the real
engine mount, and the plain-HTTP tool route on the stock app.

## How to review this (~30 min reading order)

1. `nemo_gym/mcp_auto_exposure.py` module docstring — the contract in
one screen.
2. `bind_route` — the detector. The accept/refuse table below is what it
enforces.
3. `call_direct` — dispatch parity with the plain HTTP route (threadpool
for sync handlers, body bytes, session cookie, `response_model`
filtering, error-text parity).
4. `harvest_tools` → `install_auto_exposure` — tool map, startup guards,
session token, `/seed_session` wrap, `/mcp` mount.
5. `base_resources_server.py` diff — the `expose_tools_over_mcp` config
field, the two overridables (`mcp_tools(harvested, catchall)` — what to
expose; `mcp_allowed_tools_for_session(seed_body)` — per-rollout
narrowing), and the removal of the NVIDIA-NeMo#1682 decorator API (the file drops
from 315 lines on `main` to 150; it had grown to 356 on this branch
before the removal commit).
6. `server_utils.py` — the 6-line activation point (plus a 4-line
session-middleware idempotency guard).
7. `tests/unit_tests/test_mcp_auto_exposure.py` — 45 tests double as the
spec; each guard and parity shape has a named test.

## What the detector accepts vs refuses

**Accepts (dispatches identically to the plain HTTP route):** async and
sync handlers (sync runs via `run_in_threadpool`, exactly like FastAPI);
`body: Model`; `body: dict` / `dict[str, Any]`; raw-body catch-alls
reading `await request.json()`; handlers taking both a body model *and*
a `request` they read raw; `str` path params; `response_model=`
filtering (decorator kwarg or return annotation).

**Refuses loudly at startup (`ValueError` naming the route and
reason):** non-Gym middleware; `Depends`/`Security`; `*args/**kwargs`;
multiple body models; union/optional body params; defaulted query
params; unresolvable annotations; tool names outside `^[A-Za-z0-9_-]+$`
(e.g. nested routes); tool names (from `mcp_tools()`) colliding with
`verify`/`seed_session`/`aggregate_metrics`/`mcp`; duplicate tool names;
multiple parameterized catch-all routes (auto-exposure cannot tell which
backs the tools); a server that already serves `/mcp` (a hand-rolled MCP
mount would be shadowed); a missing `/seed_session` or `/verify`. These
rules apply to route-handler signatures only — inner tool functions may
use `**kwargs` freely, and no in-tree server trips any refusal.

No silent fallback exists: a shape either dispatches provably like HTTP
or the server does not start. One soft case: a dispatcher whose
`mcp_tools()` override ignores its catch-all logs a warning (the
catch-all-backed tools stay HTTP-only).

## Session & token

`/seed_session` gains an additive `"mcp"` key (`MCPServerMetadata` — the
same shape `claude_code_agent` on `main` already consumes): the `/mcp`
URL plus a signed per-rollout token in `X-NeMo-Gym-Session-Token`. The
token payload is always `{"sid", "tools"}` — the session id plus this
rollout's allow-list from `mcp_allowed_tools_for_session(seed_body)`
(`null` = unrestricted) — signed with `URLSafeSerializer` (untimed;
expiry was cut as consumer-less scope). The header/salt/metadata
constants live in `base_resources_server` — the wire contract retained
from NVIDIA-NeMo#1682's scheme after its decorator API was removed. The token is
verified on every call (no cache). `tools/list` works tokenless and
advertises the full exposed set; a token carrying an allow-list narrows
both `tools/list` and `tools/call` for that session. Known limitation,
deliberately out of scope: the signing secret derivation
(`class___config-name`) is pre-existing `main` behavior (the
session-middleware secret) — hardening it is a separate change.

## Verify-time tool-name normalization (install-time, gated)

MCP-native agents record trajectory tool calls namespaced
(`mcp__<server>__<tool>`); verifiers know bare names — found live when
MCP rollouts scored 0.0 on perfect trajectories. For **flag-on servers
only**, installation wraps the `/verify` route's current endpoint
(whatever handler it holds — servers that re-register `/verify` are
covered by construction): names are normalized for scoring on a deep
copy, then the model's original names are restored in the echoed
response (matched by `call_id`). Flag-off servers — every existing
benchmark — keep verify byte-for-byte, so no baselines move; persisted
rollout artifacts always keep the names the model actually emitted.

## Diff shape (+2,136 / −631, 11 files)

| Area | Change |
|---|---|
| `nemo_gym/mcp_auto_exposure.py` | **new, one file, 841 lines** —
detector, direct dispatcher, harvest + guards, token, `/seed_session` +
`/verify` wraps, `/mcp` mount |
| `nemo_gym/base_resources_server.py` | +48/−207: adds the
`expose_tools_over_mcp` config field, the `normalize_tool_name` helper,
and the `mcp_tools()` / `mcp_allowed_tools_for_session()` overridables;
**removes** `@gym_tool`, `MCPResourcesServer`, `MCPSessionError`, and
the header middleware (their unit tests go too:
`test_base_resources_server.py` −277) |
| `nemo_gym/server_utils.py` | +10: `run_webserver` activates
auto-exposure for opted-in servers (lazy MCP import);
`setup_session_middleware` is now idempotent |
| `tests/unit_tests/test_mcp_auto_exposure.py` | **new — 45
self-contained tests** (TestClient, synthetic servers, ~3 s); +37 in
`test_server_utils.py`; docs section in
`fern/.../mcp-resources-server.mdx` |
| `resources_servers/` | `example_mcp_weather` rewritten onto
auto-exposure (plain typed route + yaml flag; 6 tests: the MCP door —
plus the wrapped `/seed_session` and `/verify` — through the real engine
mount, and the plain-HTTP tool route on the stock app). **Every other
server: byte-identical to `main`.** Enablement stays per-server
follow-up: pure config for typed-route servers; a small `mcp_tools()`
override for catch-all dispatchers. |

MCP engine: the official SDK's **public low-level** `Server` — no
private-attr access.

## Review provenance

This branch already went through a 7-reviewer + devil's-advocate review
pass (59 confirmed findings) followed by a fix pass with adversarial
re-verification: all 55 code findings are fixed here with tests
(silently-wrong detector shapes, refuse-loudly gaps, an unbounded token
cache — the token is now verified on every call with no cache, and
expiry was later cut as consumer-less scope — event-loop blocking for
sync handlers, provenance-preserving verify normalization), and a
docstring-accuracy audit (77 reviewed, 3 corrected). The remaining
follow-ups are listed under "For discussion".

## Verified

- **Reproducible from this PR:** `pytest
tests/unit_tests/test_mcp_auto_exposure.py` → **45/45** (~3 s; covers
every refusal, parity shape, token path, exclusion semantics, and the
normalization gating/provenance — including a re-registered-`/verify`
regression test).
- **Live e2e at this HEAD (`3891dd65`):** `workplace_assistant` MCP door
(`claude_code_agent`) — **5/5 `reward: 1.0`**, all 8 distinct tools
namespaced; HTTP door (`simple_agent`) — **5/5** on the second run, 4/5
on the first (the failing rollout omitted the `status: "Lead"` search
filter — a trajectory-verified policy flake, not an engine failure; the
HTTP door runs the stock route code). `example_mcp_weather`
(`claude_code_agent`) — **5/5 `reward: 1.0`** at the removal commit
`4d6d569c`; the one commit since is docs plus a comment reword. Earlier
demo (below): both doors 5/5 tasks `reward: 1.0`, ~30 s wall for 5
concurrent rollouts, MCP-door trajectories persist the namespaced names
the model emitted. n=5 single runs of a stochastic policy — a smoke
demonstration that both transports work end to end, not a statistical
parity claim.
- **Development-time only (local, gitignored — not reproducible from
this PR):** a 44-check live acceptance harness exercising six
representative in-tree servers (finance, workplace, aviary,
newton_bench, openenv, ns_tools) against pristine `origin/main`
handlers, covering the factory-`__signature__`, `dict`-body, and
raw-body/`PlainTextResponse` shapes; 44/44 as of 2026-07-17 against a
pre-refactor revision of the engine (development-time evidence; the
harness predates the `mcp_tools()` API). Other in-tree servers were
covered by a static route sweep, not this live harness.

## Runnable e2e — both doors through `gym eval run`

The full 5-task `workplace_assistant` example dataset, driven end to end
by Gym. Because `resources_servers/` ships unchanged, the demo uses a
small user-side entrypoint (`app_mcp.py`, shown in the docs added by
this PR) that subclasses the stock server and overrides `mcp_tools()` to
append the 27 catch-all-backed tools; the flag itself is plain yaml:

Compose config (`workplace_2059.yaml`) — MCP door via
`claude_code_agent` (already on `main`; it reads the `mcp` key
`/seed_session` returns and wires the Claude Code binary itself), HTTP
door via `simple_agent`:

```yaml
workplace_assistant:
  resources_servers:
    workplace_assistant:
      entrypoint: app_mcp.py   # thin subclass overriding mcp_tools(); see the docs page
      domain: agent
      expose_tools_over_mcp: true

workplace_claude:
  responses_api_agents:
    claude_code_agent:
      entrypoint: app.py
      resources_server: {type: resources_servers, name: workplace_assistant}
      model: ${anthropic_model_name}
      anthropic_api_key: ${anthropic_api_key}      # env.yaml; any Anthropic-format endpoint
      anthropic_base_url: ${anthropic_base_url}    # optional gateway; CLI appends /v1/messages

policy_model:
  responses_api_models:
    inference_provider:
      entrypoint: app.py
      base_url: ${policy_base_url}                 # any OpenAI-format endpoint
      api_key: ${policy_api_key}
      model: ${policy_model_name}

workplace_simple:
  responses_api_agents:
    simple_agent:
      entrypoint: app.py
      max_steps: 12
      resources_server: {type: resources_servers, name: workplace_assistant}
      model_server: {type: responses_api_models, name: policy_model}
```

```bash
gym env start --config workplace_2059.yaml

# (a) MCP door — Claude Code discovers and calls the tools over /mcp with the per-rollout token
gym eval run --no-serve --agent workplace_claude \
  --input resources_servers/workplace_assistant/data/example.jsonl \
  --output results/workplace_claude_rollouts.jsonl

# (b) HTTP door — the same tools as plain POST /<tool_name>
gym eval run --no-serve --agent workplace_simple \
  --input resources_servers/workplace_assistant/data/example.jsonl \
  --output results/workplace_simple_rollouts.jsonl
```

Tip: if your provider's OpenAI *Responses-API* emulation is incomplete
(a common gateway gap on multi-turn tool exchanges), use
`inference_provider` (chat-completions + Gym's converter) rather than
`openai_model`.

## Enablement (deliberately none in this PR)

This PR ships the engine plus the one in-tree example
(`example_mcp_weather`) — every other server under `resources_servers/`
is byte-identical to `main` (we cannot properly test each env here, per
review). Enabling a server is per-server follow-up work: set
`expose_tools_over_mcp: true` in its instance config (typed-route
servers need no code at all), plus a small `mcp_tools()` override for
catch-all dispatcher servers — the docs added in this PR walk through
workplace_assistant end to end. During development, a fleet exercise ran
`claude_code_agent` over `/mcp` against 12 servers enabled this way
(workplace_assistant 27 tools, math_advanced_calculations,
finance_sec_search, indirect_prompt_injection with per-task narrowing,
the examples, and others), all dispatching correctly; those results are
development-time evidence, not shipped code.

**Deliberately not exposed even as follow-up (each needs design work
first):** aviary (its `/step`/`/close` bodies carry an `env_id` that can
address other rollouts' environments — needs session-derived lookup),
genrm_compare (its only route is a harness-side batch API), and the
gymnasium family (reward accrues from `/reset`/`/step`, which a model
could drive to reroll its own episode). The 74 verify-only benchmarks
have no tool routes to expose.

## For discussion
- Flag is **opt-in (default off)** — auto-exposing every route isn't
always wanted (aviary's `/step` carries `env_id`; exposing it lets a
model address other rollouts' envs).
- Follow-ups deliberately not in this PR: session-binding aviary's
`env_id` so it can be exposed safely; an MCP-safe reward design for the
gymnasium family; and hardening the pre-existing token-signing secret
derivation shared with NVIDIA-NeMo#1682.
- **NVIDIA-NeMo#1682's decorator API is removed in this PR** rather than left as a
parallel mechanism: auto-exposure reads the routes authors already
wrote, so `@gym_tool` had no remaining role, and keeping both would have
forced every author to choose. `example_mcp_weather` (the only in-tree
consumer) is migrated here; NVIDIA-NeMo#2002's dual-registration approach is
superseded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Codex <adasif@nvidia.com>
Signed-off-by: Codex <codex@openai.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Oct 23, 2026
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.

feat: make existing resources servers MCP-compatible

3 participants