MCP Resources Server
This tutorial shows how to expose environment tools over the Model Context Protocol (MCP) so that an MCP-native agent — such as Claude Code — can discover and call them, while the Resources Server still owns verification. Tools are the plain HTTP POST routes you already write; MCP is a transport you switch on in the server’s YAML config. The pattern is: HTTP tool routes + a verify() function = a Resources Server.
Two ways to combine MCP with a Resources Server
There are two distinct integration shapes:
The rest of this page builds the Gym-owned flow, then explains the external flow at the end.
Why serve MCP from the Resources Server at all? Mounting the MCP endpoint inside the Resources Server lets a tool call be bound to the same per-rollout session as /seed_session and /verify. That is what makes “was this tool actually used in this episode?” a verifiable, isolated question. An external MCP server can’t offer that — Gym can’t observe its calls — so external-server verification has to work off the agent’s trajectory instead.
What You’ll Build
A weather environment with a single tool, get_weather(city), served both as a plain HTTP route and as an MCP tool. The agent must call the tool and then answer with exactly the sentence the tool returned. The Resources Server rewards the rollout only if the tool was called in this session and the final answer contains the returned sentence.
Episode Flow
Implementation
The server is a plain SimpleResourcesServer — no MCP imports, no decorators, no MCP-specific signatures. The tool is an ordinary typed POST route whose handler reads the rollout’s session from request.session, exactly like any other Gym route.
File (resources_servers/example_mcp_weather/app.py):
Turn it on
MCP exposure is one line in the server’s YAML config:
At startup (run_webserver), Gym:
- turns each plain
POSTroute into an MCP tool named after its path (POST /get_weather→ toolget_weather), with the input schema derived from the route’s Pydantic body model and the description from the handler’s docstring./seed_session,/verify,/aggregate_metrics, and/mcpare never tools. - mounts a Streamable-HTTP
/mcpendpoint on the same app. - wraps
/seed_sessionso its response also carries themcpmetadata (server name,/mcpURL path, and a signed per-rolloutX-NeMo-Gym-Session-Token) — which is how an MCP-native agent discovers the endpoint and its rollout-scoped credentials.
An MCP tools/call invokes the route’s own handler directly, with the rollout’s session materialized on the Request — handlers keep their request.session reads exactly as written. The route stays callable over plain HTTP too: the same tool serves both transports, and /verify scores both identically. Tool errors (HTTPException, validation failures, crashes) surface to the MCP client as tool errors with the same status and text the plain HTTP route would have returned.
Auto-exposure explicitly passes TransportSecuritySettings(enable_dns_rebinding_protection=False). When that protection is enabled it validates Host/Origin headers against a configured allowlist and rejects mismatches (the SDK’s FastMCP server auto-fills a loopback-only allowlist when bound to localhost) — an allowlist would have to enumerate every routable host agents use on multi-node / use_absolute_ip: true deployments. The endpoint is instead protected by the per-rollout session token; DNS-rebinding protection defends browsers, which never talk to this endpoint. You don’t need to set this yourself.
Which handler shapes are supported
Where direct dispatch cannot be proven equivalent to a real HTTP request, exposure fails loudly at startup, naming the route and the reason — a wrong dispatch would corrupt rollouts silently. By handler signature:
The pattern behind the two query-parameter rows: anything FastAPI would read from the query string — a bare scalar like my_string: str, with or without a default — refuses, because MCP calls carry no query string. Move such knobs into the body model, where field defaults work normally (absent fields take their Pydantic defaults). Other non-body parameter shapes — Header(...) / Cookie(...) markers, *args/**kwargs, non-str path parameters — also refuse at startup, each with its own named reason.
Beyond signatures, startup also refuses: non-Gym middleware installed on the app (direct dispatch would silently skip it); multiple parameterized catch-all routes; a tool name outside [A-Za-z0-9_-]+; and a server that already serves /mcp (a hand-rolled MCP mount conflicts with the auto-exposed one).
Wiring the agent (Claude Code)
The claude_code_agent reads the mcp metadata from /seed_session, writes a per-rollout gym_mcp_config.json, and launches Claude Code with --mcp-config. The generated config — shown here for workplace_assistant, the worked example below — looks like:
The worked example for this wiring is workplace_assistant — a real environment with 27 tools (five toolkits plus an always-included company-directory lookup). The weather server built above runs with the same wiring; see The weather server runs the same way below.
workplace_assistant with Claude Code
Its tools go through one catch-all route
Some servers serve all their tools through one parameterized route — workplace_assistant routes every call through POST /{path} and looks the tool up by name, so there are no typed routes to harvest and the per-tool schemas live in data. For these, override one method, mcp_tools(self, harvested, catchall): harvested is the auto-harvested typed-route tools, and catchall.tool(name, input_schema, description) mints a tool that dispatches through the catch-all route with the path set to name.
The shipped resources_servers/workplace_assistant stays byte-identical — the override lives in a thin user-side subclass in its own entrypoint file. Save the following as resources_servers/workplace_assistant/app_mcp.py (user-written — not shipped with the repo):
(For workplace_assistant, harvested is empty — its only non-reserved POST route is the catch-all — but keeping harvested + makes the override correct for servers that mix typed routes with a dispatcher.)
The same override handles the other tailoring cases: exclude a route by filtering it out of harvested (a route you filter out is exempt from the startup shape checks above), and expose no tools by returning None or [] — tools/list answers empty, though /mcp is still mounted and /seed_session still carries the mcp metadata. To disable exposure entirely, leave expose_tools_over_mcp off.
Per-rollout tool restriction
To narrow one rollout’s token to the tools its task actually allows, override mcp_allowed_tools_for_session(self, seed_body) — seed_body is the JSON body POSTed to /seed_session. Return the allowed tool names, or None (the default) for unrestricted:
The returned names are signed into that rollout’s session token; tools/list then advertises only those tools and tools/call rejects any other name — per rollout, with no server-wide state.
Run it
A complete, copy-pasteable run (assumes you saved app_mcp.py above). The compose config wires that subclass entrypoint to the agent:
Put your key in a repo-root env.yaml:
The agent talks to the Anthropic API by default; any Anthropic-format endpoint works by also setting the optional anthropic_base_url field. Start the servers, then collect rollouts:
A correct rollout shows Claude Code calling mcp__workplace_assistant__* tools (e.g. mcp__workplace_assistant__email_reply_email) and a reward of 1.0. /verify scores MCP and HTTP trajectories identically: when the flag is on, the verify endpoint is wrapped at startup to normalize MCP-namespaced tool-call names (mcp__workplace_assistant__email_reply_email → email_reply_email) for scoring only, while the persisted rollout keeps the names the model actually emitted.
The weather server runs the same way
The shipped config of the server built above already sets the flag and wires claude_code_agent:
A correct rollout shows Claude Code calling mcp__example_mcp_weather__get_weather and a reward of 1.0; reward-profile as in the quickstart.
To watch the MCP round-trip without a full gym env start, start the Resources Server on its own and drive /seed_session → /mcp tools/call → /verify directly (a requests.Session preserves the session cookie) — there is a copy-pasteable script in the weather server’s README. This is also the fastest way to confirm the endpoint is reachable from another host.
Pointing at an existing / external MCP server
If the MCP server already runs outside Gym, the agent talks to it directly — you do not need MCP exposure on the Resources Server. Give the agent a static mcp_config pointing at the external server, and write a plain SimpleResourcesServer.verify() that scores the agent’s trajectory:
Things to know about this flow:
- No cookie/session entanglement. Gym’s session cookie flows only between the agent server and the Resources Server (
/seed_session↔/verify). The agent-to-external-MCP connection is a separate channel with its own auth (whateverheadersyou put in the static config). They don’t interfere. - Verify off the trajectory. Gym can’t observe the external server’s calls, so
verify()must score thefunction_call/function_call_outputitems in the agent’s Responses-API output — not server-side session state. - Static + per-rollout compose. When both are present, the agent merges your static
mcp_configwith the per-rollout Gym-owned entry, so a single rollout can use external tools and Gym-owned MCP tools at once. If a static server happens to share the same name as the Gym resources server, the per-rollout Gym entry takes precedence and overwrites it.
Real-World Environment →