MCP Resources Server

View as Markdown

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.

← Stateful Environment

Two ways to combine MCP with a Resources Server

There are two distinct integration shapes:

FlowWhenWhat you build
Gym-owned tools, auto-exposedThe tools and their verification live in Gym — the Resources Server serves them as plain POST routesSet expose_tools_over_mcp: true in the server’s YAML config — Gym turns the existing routes into MCP tools and mounts a Streamable-HTTP /mcp endpoint on the same app as /seed_session and /verify; typically no code change
Existing / external MCP serverThe MCP server already runs outside Gym (a third-party or shared service)Point the agent at it directly with a static mcp_config; write a plain SimpleResourcesServer.verify() that scores the resulting trajectory

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

Goal
- Learn MCP tool usage bound to a Gym session: call an MCP tool, then answer using its result.
Inputs
- seed input: expected_city (e.g., "Paris")
Flow (the MCP endpoint and /verify share one session_id)
1) Agent -> ResourcesServer POST /seed_session {"verifier_metadata": {"expected_city": "Paris"}}
- returns MCP metadata: a per-rollout X-NeMo-Gym-Session-Token bound to this session_id
2) Agent writes a per-rollout mcp_config and launches Claude Code with --mcp-config
3) Claude Code -> ResourcesServer POST /mcp (tools/call get_weather, carrying the token header)
- the call resolves the token back to session_id and runs the route's own handler
4) Agent -> ResourcesServer POST /verify {"verifier_metadata": {"expected_city": "Paris"}, "response": ...}
- reward = 1.0 iff the tool was called in this session AND the answer contains the sentence

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):

1# simplified
2from typing import Any, Optional
3
4from fastapi import FastAPI, Request
5from pydantic import BaseModel, ConfigDict, Field
6
7from nemo_gym.base_resources_server import (
8 BaseResourcesServerConfig,
9 BaseSeedSessionRequest,
10 BaseSeedSessionResponse,
11 BaseVerifyRequest,
12 BaseVerifyResponse,
13 SimpleResourcesServer,
14)
15from nemo_gym.server_utils import SESSION_ID_KEY
16
17
18def _weather_sentence(city: str) -> str:
19 return f"The weather in {city} is sunny and 72 F."
20
21
22class ExampleMCPWeatherResourcesServerConfig(BaseResourcesServerConfig):
23 pass
24
25
26class ExampleMCPWeatherSeedSessionRequest(BaseSeedSessionRequest):
27 model_config = ConfigDict(extra="allow")
28 # Task ground truth travels in verifier_metadata, e.g. {"expected_city": "Paris"}.
29 verifier_metadata: Optional[dict[str, Any]] = None
30
31
32class ExampleMCPWeatherVerifyRequest(BaseVerifyRequest):
33 model_config = ConfigDict(extra="allow")
34 verifier_metadata: Optional[dict[str, Any]] = None
35
36
37class ExampleMCPWeatherGetWeatherRequest(BaseModel):
38 city: str
39
40
41class ExampleMCPWeatherGetWeatherResponse(BaseModel):
42 weather: str
43
44
45class ExampleMCPWeatherResourcesServer(SimpleResourcesServer):
46 config: ExampleMCPWeatherResourcesServerConfig
47 session_id_to_state: dict[str, dict[str, Any]] = Field(default_factory=dict)
48
49 def setup_webserver(self) -> FastAPI:
50 app = super().setup_webserver()
51 app.post("/get_weather")(self.get_weather)
52 return app
53
54 async def seed_session(self, request: Request, body: ExampleMCPWeatherSeedSessionRequest):
55 session_id = request.session[SESSION_ID_KEY]
56 expected_city = (body.verifier_metadata or {}).get("expected_city", "Paris")
57 self.session_id_to_state[session_id] = {"expected_city": expected_city, "weather_calls": []}
58 return BaseSeedSessionResponse()
59
60 async def get_weather(
61 self, request: Request, body: ExampleMCPWeatherGetWeatherRequest
62 ) -> ExampleMCPWeatherGetWeatherResponse:
63 """Get a deterministic weather report for a city."""
64 session_id = request.session[SESSION_ID_KEY]
65 state = self.session_id_to_state.setdefault(session_id, {"weather_calls": []})
66 weather = _weather_sentence(body.city)
67 state["weather_calls"].append({"city": body.city, "weather": weather})
68 return ExampleMCPWeatherGetWeatherResponse(weather=weather)
69
70 async def verify(self, request: Request, body: ExampleMCPWeatherVerifyRequest) -> BaseVerifyResponse:
71 session_id = request.session[SESSION_ID_KEY]
72 state = self.session_id_to_state.get(session_id, {"weather_calls": []})
73 expected_city = (body.verifier_metadata or {}).get("expected_city", "Paris").casefold()
74 # reward iff the tool was called for this city in this session AND the final answer repeats it
75 tool_called = any(str(c.get("city", "")).casefold() == expected_city for c in state["weather_calls"])
76 final_text = _extract_assistant_text(body) # join the assistant message text from body.response
77 reward = float(tool_called and _weather_sentence(expected_city).casefold() in final_text.casefold())
78 return BaseVerifyResponse(**body.model_dump(), reward=reward)
79
80
81if __name__ == "__main__":
82 ExampleMCPWeatherResourcesServer.run_webserver()

Turn it on

MCP exposure is one line in the server’s YAML config:

1example_mcp_weather:
2 resources_servers:
3 example_mcp_weather:
4 entrypoint: app.py
5 expose_tools_over_mcp: true # a config field on BaseResourcesServerConfig, default false

At startup (run_webserver), Gym:

  • turns each plain POST route into an MCP tool named after its path (POST /get_weather → tool get_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 /mcp are never tools.
  • mounts a Streamable-HTTP /mcp endpoint on the same app.
  • wraps /seed_session so its response also carries the mcp metadata (server name, /mcp URL path, and a signed per-rollout X-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:

Handler signatureExposed over MCP?
async def tool(self, body: MyModel)✅ — input schema is MyModel’s JSON schema
async def tool(self, request: Request, body: MyModel)✅ — the handler’s request.session reads work unchanged
def tool(self, body: MyModel) (sync)✅ — runs in a threadpool, exactly like FastAPI
async def tool(self, body: dict)✅ — advertised with a permissive object schema
async def tool(self, request: Request) reading await request.json()✅ — permissive object schema
async def tool(self, request: Request, path: str) on POST /{path}✅ — via the catch-all mcp_tools() override below
async def tool(self, request: Request, my_string: str)❌ startup error — my_string is a FastAPI query parameter
async def tool(self, body: MyModel, limit: int = 3)❌ startup error — a defaulted scalar is a query parameter too
async def tool(self, body: MyModel | None)❌ startup error — union/optional body
async def tool(self, a: ModelA, b: ModelB)❌ startup error — multiple body models
async def tool(self, dep = Depends(...))❌ startup error — dependency injection (Depends / Security)

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:

1{
2 "mcpServers": {
3 "workplace_assistant": {
4 "type": "http",
5 "url": "http://<resources-server-host>:<port>/mcp",
6 "headers": { "X-NeMo-Gym-Session-Token": "<per-rollout-token>" }
7 }
8 }
9}

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):

1# resources_servers/workplace_assistant/app_mcp.py
2from resources_servers.workplace_assistant.app import WorkbenchResourcesServer
3from resources_servers.workplace_assistant.utils import get_tools
4
5TOOLKITS = ["email", "calendar", "analytics", "project_management", "customer_relationship_manager"]
6
7
8class MCPWorkbenchResourcesServer(WorkbenchResourcesServer):
9 def mcp_tools(self, harvested, catchall):
10 # One MCP tool per schema in data; each dispatches through POST /{path} with path = the tool name.
11 specs = get_tools(TOOLKITS)["schemas"]
12 return harvested + [catchall.tool(s["name"], s["parameters"], s.get("description")) for s in specs]
13
14
15if __name__ == "__main__":
16 MCPWorkbenchResourcesServer.run_webserver()

(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:

1def mcp_allowed_tools_for_session(self, seed_body: dict) -> list[str] | None:
2 return (seed_body.get("verifier_metadata") or {}).get("allowed_tools")

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:

1# workplace_claude.yaml
2workplace_assistant:
3 resources_servers:
4 workplace_assistant:
5 entrypoint: app_mcp.py
6 domain: agent
7 expose_tools_over_mcp: true
8
9workplace_claude:
10 responses_api_agents:
11 claude_code_agent:
12 entrypoint: app.py
13 resources_server: { type: resources_servers, name: workplace_assistant }
14 model: claude-sonnet-4-6
15 anthropic_api_key: ${anthropic_api_key}

Put your key in a repo-root env.yaml:

1anthropic_api_key: sk-ant-...

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:

$gym env start --config workplace_claude.yaml
$gym eval run --no-serve \
> --agent workplace_claude \
> --input resources_servers/workplace_assistant/data/example.jsonl \
> --output results/workplace_claude_rollouts.jsonl

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_emailemail_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:

$gym env start --config resources_servers/example_mcp_weather/configs/example_mcp_weather.yaml
$gym eval run --no-serve \
> --agent example_mcp_weather_claude_code_agent \
> --input resources_servers/example_mcp_weather/data/example.jsonl \
> --output results/weather_rollouts.jsonl

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:

1my_external_mcp_agent:
2 responses_api_agents:
3 claude_code_agent:
4 entrypoint: app.py
5 resources_server: { type: resources_servers, name: my_verifier } # a SimpleResourcesServer with verify()
6 mcp_config: /abs/path/to/external_mcp_config.json # static config passed via --mcp-config

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 (whatever headers you 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 the function_call / function_call_output items 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_config with 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 →