Repository navigation
feat(protocol): add ToolSession to list and call an env's MCP tools - #152
Merged
Merged
Conversation
earakely-scale
force-pushed
the
feat/protocol-tool-client
branch
from
October 11, 2026 22:31
2397f3d to
aa6b36c
Compare
A loop of one's own (a trainer's rollout, an eval harness) can now list and call an env's MCP tools with the protocol package alone. ToolSession speaks MCP's streamable-HTTP transport on httpx, so it adds no dependency and works whichever mcp version the caller's environment pins. tool_definitions declares the tools for OpenAI Chat Completions, OpenAI Responses or Anthropic Messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chaos testing showed it: a tools/call that timed out behind a blackholed port was retransmitted by the kernel and ran on the server after the client had moved on. Say so where the error types are described. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The DELETE that ends a session was capped at a fixed 5 s, so a session opened with a 0.5 s timeout could take 5 s to close (or to fail opening) against a server that had stopped answering. Cap it at the session's timeout when that is shorter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… out On a loaded CI runner the in-process initialize took longer than the 0.3 s session timeout the deadline test opened with. Open with the default timeout and set the short one just before the slow call (and before close, in the close test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nreadable - open() on a session that is already open ends that session first, so its connection and server session aren't left behind. - A text block without string text fails validation, so call_tool raises ToolSessionError instead of result.text raising KeyError later. - Only the tests that serve FastMCP skip without mcp; the scripted-server and declaration tests run on the base dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads ROLE_HEADER now comes from tool_context instead of a second copy in types, and the gateway/protocol constant parity test is left to the loopback test that pins all three wire names. The README and docstring say what the role and the session mean to a tool now that the gateway forwards the role, and that a session belongs to the event loop that opened it. Tests: a ToolContext tool reads the role and session each ToolSession sends, in process, on a deployed items env (new items_whoami), and through a deployed gateway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A non-list tools field raised TypeError instead of ToolSessionError, and a missing or null one read as an env with no tools; MCP requires the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its event loop - mcp_url is keyword-only, so a call site names the endpoint it passes, and a later transport can sit beside it; the attribute is mcp_url too. - verify is gone: nothing passes it, and httpx reads SSL_CERT_FILE and SSL_CERT_DIR for a private CA. - A session used or closed on a loop other than the one that opened it raises a RuntimeError that says so, where its connections used to fail the first call with "Event loop is closed" and let later ones through. - MCP_PROTOCOL_VERSION is private; session.protocol_version shows the agreed version. - The docs say that only the server a session talks to reports a lost session: a gateway reconnects to a restarted child and retries the call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale
force-pushed
the
feat/protocol-tool-client
branch
from
October 12, 2026 16:04
7ec94da to
d009405
Compare
- tool_definitions is gone until a loop needs it: TRL builds tool schemas from the adapter's methods, and the conversion is a one-liner where it is needed. list_tools() returns each tool's inputSchema, the parameters a model API declares. - One timer per request: httpx has no timeouts of its own, since every request already runs under the wait_for deadline, which changing session.timeout now moves alone. - One POST path: notifications/initialized goes through _exchange, which returns after the status check for a message without an id. - The class and error docstrings keep the two rules a caller must know; the README carries the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
A training framework that runs its own loop, such as TRL's
GRPOTrainer, NeMo Gym, verifiers, verl, SkyRL or Tinker, has to list an env's tools, declare them to its model and call them. The protocol package covers an env's card, data plane and extensions, but not its tools. The only tool-calling code is core's internal_tool_namesand each agent's own, all on the MCP SDK, and installing the SDK clashes with environments that pinmcp==2.0.0(verifiers) orfastmcp>=3(OpenEnv).agentenv_protocolgainsToolSession, an MCP client for an env's streamable-HTTP endpoint, written on httpx, which is already a base dependency:ToolSession(*, mcp_url, role=None, headers=None, timeout=600.0).mcp_urlis keyword-only. A deployed env has several URLs (environment_url,gateway_url, the card's,mcp_url), and passing the wrong one positionally reads fine and fails at runtime. A later transport, such as the gateway's/step, can then sit beside it as its own keyword without MCP being the privileged positional one. None is added here.headersis there for sandbox providers'REQUEST_HEADERS, the headers a platform needs on requests to its URLs (deploy_agentpasses them to agents).timeoutis there because 600 s is a ceiling, not a deadline a training loop wants.verify: nothing passes one, and httpx readsSSL_CERT_FILEandSSL_CERT_DIRfor a private CA.mcpstays out of the protocol package, as its README promises, so the client imports wherever the package does.initializenegotiates the protocol version, thennotifications/initializedfollows.Mcp-Session-IdandMCP-Protocol-Versionare echoed on every later request.tools/listfollowsnextCursor, and redirects are followed.close()sendsDELETEand never raises when the server is already gone.json_responseandstateless_http.list_tools()returns the existingEnvironmentTool(name, description, inputSchema), the type a card'scapabilities.toolsholds. EachinputSchemais the JSON Schema a model API takes as the tool's parameters. Converting to a model API's tool format is left to the loop: TRL builds schemas from the adapter's own methods, and elsewhere it's a one-liner.ToolResultcarries MCP'scontentandisError, andtextjoins its text blocks. It leaves outstructuredContent, because servers also send that JSON as text; adding it later breaks nobody.isErrorset; it does not raise.ToolSessionErroris an MCP-level failure: a JSON-RPC error, an HTTP error status, an unreadable reply, or a session the server no longer knows. It says so when a restart has probably taken the env's state._call_child_tool), and a stateless server has no session to lose, so neither restart raises. The README and the error's docstring say so; telling that an env was reset under an episode is a job for the episode API that comes next.httpx.TransportErrormeans the connection failed.TimeoutErrormeans a request outlivedtimeout. That is one overall deadline per request, 600 s by default (the gateway'sTOOL_CALL_TIMEOUT_S). It also bounds an event stream that trickles.ToolContextthat feat(protocol): ToolContext, an opt-in injected caller for environment tools #153 hands to tools.role=sendsAgentEnv-Role, the protocol'sROLE_HEADERfromtool_context. Since feat(gateway): forward the caller's role to child servers and share one child session generation per process #145 it is the caller's identity: a gateway filterstools/listandtools/callby it and forwards it to the servers behind it, and a server reached directly reads the header itself. A tool reads it asctx.caller.role, anddefault, like no role, reads asNone.ToolSessionis its own caller session,ctx.caller.session: the session's id when the server is reached directly, or a key the gateway gives that session, stable across its calls.on_tool_callrefusal, comes back as anisErrorresult, like a tool that fails.RuntimeError("the ToolSession was opened on another event loop; ..."). Without that check, the first such call failed withRuntimeError: Event loop is closedand later ones went through. TRL calls an environment'sresetsynchronously, so its adapter will run every session on one loop thread of its own.asyncio.wait_forand catch bothasyncio.TimeoutErrorand the builtin.httpx, which was already a dependency.ToolSession,ToolResultandToolSessionError. The MCP protocol version the client asks for stays private;session.protocol_versionshows the agreed one.!.How it was tested
packages/agentenv-protocol/tests/test_tool_client.py(new, 34 tests, in process, no sockets):@toolenv served bycreate_fastmcp_appin all three SDK modes, covering:Annotatedfields;isError;TimeoutErrorat 0.3 s while the session carries on;ToolContextreading what three sessions send:alice@example.comand her own session id, while no role anddefaultboth read asNone. TheToolContextparameter stays out of the listed schema.DELETEon close;tools/list;data:;ToolSessionErrorwith its own message, including text blocks without string text;tools/listresult that can't be read (a nameless tool, ortoolsthat is not a list, null or missing), a failedinitialize, closing after the server is gone, and closing within the session's timeout whenDELETEstalls.mcp: only the 7 tests that serve FastMCP needmcp; without it the other 27 pass.tst/integration/env/envs/server_env_local_test.pygainstest_a_deployed_envs_tools_are_listed_and_called_from_a_loop_of_ones_own. On a deployed items env, it checks that:deployed.mcp_urllists its tools, with their parameter descriptions in eachinputSchema;data/get;urn:agentenv:set-errors/v1comes back asisError;items_whoamitool reads each one's role and session id;docker restart, the old session raises "no longer knows session" and a new one sees the emptied state.test_gateway_with_single_mcp_server's role section now also checks the gateway throughToolSession. Roledefaultdoesn't listlist_emails, and calling it returnsisErrorwith "is disabled for role 'default'"; roleclilists it and reads the seeded emails.test_gateway_with_agentenv_environment: through a deployed gateway, the role aToolSessionsends reaches the child'sitems_whoamias its caller. One session's key is the same across its calls, and a second session gets another. The card listsitems_whoamiwithout theToolContextparameter, so this test's andtest_renamed_registration_serves_its_registered_name's card tool lists gain it.AgentEnv-Role,agentenv.io/role,agentenv.io/session), so this PR no longer adds its own.make unit-testgives 7490 passed, 15 skipped and 1 failed. The failure,tst/unit/bundle/parse_test.py::TestFiles::test_deep_nesting[json], is pre-existing:origin/mainfails it the same way on this machine, it passes when run alone, and main's CI is green.server_env_local_test.py: 3 passed.test_gateway_with_single_mcp_server,test_gateway_with_agentenv_environmentandtest_renamed_registration_serves_its_registered_name.test_tool_client.pyon Python 3.10: 34 pass withmcp1.30.0 (the version this repo's CodeBuild job resolves); withoutmcp, 27 pass and 7 skip.make unit-testgave 7175 passed and 5 skipped;server_env_local_test.py3 passed;test_gateway_with_single_mcp_serverpassed in 3 min 40 s.Chaos testing
Chaos ran on the client before the rebase onto main. Since then the client has changed in these ways:
ROLE_HEADER;tools/listresult;mcp_url, noverify);notifications/initializedgoes through the same streamed POST as every request.The requests on the wire are the same as before. The deadline chaos measured was always the
wait_forone, which is now the only one. Chaos wasn't rerun.On an Ubuntu 24.04 VM (8 vCPU, Docker 29), with four harness processes in parallel:
MCPServerEnv.deploy(sandbox_type="local")from an@tooltest env built with this checkout's protocol package. One in four runsjson_response.Workers:
data:lines, 5 KB;tools/list, cancelling a call mid-flight, and reopening the session.Faults: on 1–3 envs every 2–10 s, each held 2–25 s:
docker pause,restart, andkillthenstart;tc netemon both ends of the link: 20–400 ms delay with jitter, up to 30% loss, duplication, reordering, corruption;ss -Kconnection resets.Runs:
ed50222..b2fa2057764ebb2397f3dConnectError/ReadError/RemoteProtocolError/TimeoutErrorEvery run:
close()never raised.TimeoutErrorat most 0.26 s late.open()stayed within three timeouts: two requests, plus a cleanupDELETEbounded the same way when the second fails. The longest was 5.6 s at a 2 s deadline.close()stayed within 5 s.What it found:
DELETEhad never ended. Any HTTP client behaves this way. The README and the docstring now say that after a failed call the env's state is unknown, and runs 2 and 3 checked state with that allowance. Of 301 adds that raised, none landed late in those runs.open()'s cleanupDELETEwas capped at a fixed 5 s.close()now waits at most the session'stimeoutwhen that is shorter, with a test.initialize, which tripped the deadline test that opened its session at 0.3 s. The test now shortens the deadline only for the call that must time out.Notes for review
ROLE_HEADERis imported fromtool_contextrather than defined a second time intypes. The docs say what a role and a session mean to a tool, and the tests check that a tool reads both. The constant parity test gave way to test(gateway): a real gateway in front of a real protocol child, over loopback #160's, which is still open.mcp_url, noverify, the event-loop check, the private protocol-version constant, and the docs on where a lost session is reported;tool_definitionsremoved until a loop needs it, one timer per request instead of httpx's pluswait_for, one POST path (no separate_notify), and docstrings cut to the two rules a caller must follow, with the README carrying the rest.Authorizationwhen one leaves the host, so the role and any extra headers go to whatever host a redirect names. A 301, 302 or 303 also turns the POST into a GET, which then waits out its deadline. The only redirect our servers send is a trailing-slash 307 on the same host, and the env is already the party being trusted.list_tools()followsnextCursorwithout a loop guard, so a server that keeps returning the same cursor keeps it paging, each page inside its own deadline.Last-Event-ID. A server that sends a request on the reply stream gets no answer and the call runs to its deadline. No AgentEnv server does that today.TransportErrorsubclass (ConnectError,ReadError,RemoteProtocolError, ...), not wrapped.DecodingErrorandTooManyRedirectsare httpx errors outsideTransportError. Neither can happen against these servers, which send no content-encoding and no redirect chains, and neither showed up in chaos, so they aren't wrapped either. Say if you'd ratherToolSessionErrorcover them.open()and the deadline:timeoutbounds each request.open()is two requests,initializeandnotifications/initialized, plus a cleanupDELETEwhen the second fails, so it can take up to three timeouts. Say if you'd rather have one deadline over all ofopen()._tool_namesand the agents' own clients could move ontoToolSession; that isn't done here. A converter to OpenAI or Anthropic tool formats should arrive with the first loop that needs one, such as PortSim's agent or verifiers.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; no new actionable issue remains.Summary
Adds
ToolSessionto list and call an environment's MCP tools without installing the MCP SDK.ToolSession.Reviews (6) · Last reviewed commit: "refactor(protocol): a smaller ToolSessio..." · Reviewed by Greptile