Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions environments/compact/compact/program.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["openai", "mcp>=1.24.0,<2"]
# dependencies = ["openai", "mcp==2.0.0", "httpx2"]
# ///
"""The compacting harness's program: a context-rewrite loop (every turn branches).

Expand Down Expand Up @@ -70,9 +70,9 @@ async def chat(


async def connect_mcp(stack: AsyncExitStack, config: dict) -> tuple[list[dict], dict]:
"""Connect to each configured MCP server (a streamable-HTTP `url`); return
(tool schemas, dispatch mapping `<server>_<tool>` -> (session, raw tool name))."""
from mcp import ClientSession
"""Connect to each MCP server and negotiate the newest mutually supported protocol."""
import httpx2
from mcp import Client
from mcp.client.streamable_http import (
create_mcp_http_client,
streamable_http_client,
Expand All @@ -82,32 +82,32 @@ async def connect_mcp(stack: AsyncExitStack, config: dict) -> tuple[list[dict],
dispatch: dict[str, tuple] = {}
for name, spec in config.get("mcpServers", {}).items():
http_client = await stack.enter_async_context(
create_mcp_http_client(headers=spec.get("headers") or None)
)
read, write, *_ = await stack.enter_async_context(
streamable_http_client(spec["url"], http_client=http_client)
create_mcp_http_client(
headers=spec.get("headers") or None,
timeout=httpx2.Timeout(30.0, read=300.0),
)
)
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
for tool in (await session.list_tools()).tools:
transport = streamable_http_client(spec["url"], http_client=http_client)
client = await stack.enter_async_context(Client(transport))
for tool in (await client.list_tools()).tools:
full = f"{name}_{tool.name}"
tool_schemas.append(
{
"type": "function",
"function": {
"name": full,
"description": tool.description or "",
"parameters": tool.inputSchema,
"parameters": tool.input_schema,
},
}
)
dispatch[full] = (session, tool.name)
dispatch[full] = (client, tool.name)
return tool_schemas, dispatch


async def call_mcp(dispatch: dict, name: str, arguments: dict) -> str:
session, raw = dispatch[name]
result = await session.call_tool(raw, arguments)
client, raw = dispatch[name]
result = await client.call_tool(raw, arguments)
texts = [b.text for b in result.content if getattr(b, "type", None) == "text"]
return "\n".join(texts) if texts else str(result.content)

Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ dependencies = [
"datasets>=3.3.0,<6.0.0",
"numpy>=2.1.0",
"math-verify>=0.8.0",
"mcp>=1.24.0,<2",
"openai>=2.9.0",
"openai-agents>=0.8.2",
"openai-agents>=0.20.0",
"prime-tunnel>=0.1.8",
"prime-sandboxes>=0.2.39",
"pydantic>=2.12.3",
Expand All @@ -53,6 +52,7 @@ dependencies = [
"tomli-w>=1.0.0",
"renderers>=0.1.10",
"typing_extensions>=4.12.2",
"mcp==2.0.0",
]

[dependency-groups]
Expand Down Expand Up @@ -122,7 +122,6 @@ openenv = [
]
nemo-gym = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is now superfluous, no?

# NeMo Gym currently publishes Python 3.12+ packages; keep core verifiers installable on 3.11.
"nemo-gym==0.4.0; python_version >= '3.12'",
]
[tool.uv]
preview = true
Expand Down
295 changes: 77 additions & 218 deletions uv.lock

Large diffs are not rendered by default.

31 changes: 14 additions & 17 deletions verifiers/legacy/envs/experimental/mcp_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, cast

from mcp import ClientSession, StdioServerParameters
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.types import TextContent, Tool as MCPTool

Expand All @@ -27,7 +27,7 @@ class MCPServerConnection:
def __init__(self, config: MCPServerConfig, logger: logging.Logger):
self.config = config
self.logger = logger
self.session: Optional[ClientSession] = None
self.client: Optional[Client] = None
self.tools: Dict[str, MCPTool] = {}

self._connection_task: Optional[asyncio.Task] = None
Expand All @@ -54,36 +54,33 @@ async def _get_connection(self):
env=self.config.env,
)

async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
self.session = session
async with Client(stdio_client(server_params)) as client:
self.client = client

await session.initialize()
tools_response = await client.list_tools()

tools_response = await session.list_tools()
for tool in tools_response.tools:
self.tools[tool.name] = tool

for tool in tools_response.tools:
self.tools[tool.name] = tool
self._ready.set()

self._ready.set()

while True:
await asyncio.sleep(1)
while True:
await asyncio.sleep(1)

except asyncio.CancelledError:
raise
except Exception as e:
self._error = e
self._ready.set()
finally:
self.session = None
self.client = None
self.tools = {}

async def call_tool(self, tool_name: str, arguments: dict) -> str:
assert self.session is not None, f"Server '{self.config.name}' not connected"
assert self.client is not None, f"Server '{self.config.name}' not connected"
assert self.loop is not None, "Connection loop not initialized"
fut = asyncio.run_coroutine_threadsafe(
self.session.call_tool(tool_name, arguments=arguments), self.loop
self.client.call_tool(tool_name, arguments=arguments), self.loop
)
result = await asyncio.wrap_future(fut)

Expand Down Expand Up @@ -130,7 +127,7 @@ def to_tool_def(self) -> Tool:
"""Convert the MCP tool metadata directly to vf.Tool."""
parameters = cast(
dict[str, object],
self.tool.inputSchema or {"type": "object", "properties": {}},
self.tool.input_schema or {"type": "object", "properties": {}},
)
return Tool(
name=self.__name__,
Expand Down
44 changes: 21 additions & 23 deletions verifiers/v1/harnesses/bash/program.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["openai", "mcp>=1.24.0,<2", "httpx", "tenacity"]
# dependencies = ["openai", "mcp==2.0.0", "httpx", "httpx2", "tenacity"]
# ///
"""Secrets arrive through argv so local tool subprocesses do not inherit them."""

Expand Down Expand Up @@ -187,12 +187,14 @@ async def chat(


@asynccontextmanager
async def mcp_session(spec: dict):
"""One fresh streamable-HTTP session to an MCP server, opened and closed within the caller's
task so AnyIO cancellation scopes stay correctly nested. A teardown failure after the body
completed is swallowed — the result is already in hand, and closing noise must not fail (or
replay) an already-answered call."""
from mcp import ClientSession
async def mcp_client(spec: dict):
"""One fresh MCP client, negotiating the newest protocol and falling back for old servers.

It is opened and closed within the caller's task so AnyIO cancellation scopes stay correctly
nested. Closing noise after a completed body cannot fail or replay an answered call.
"""
import httpx2
from mcp import Client
from mcp.client.streamable_http import (
create_mcp_http_client,
streamable_http_client,
Expand All @@ -203,22 +205,18 @@ async def mcp_session(spec: dict):
http_client = await stack.enter_async_context(
create_mcp_http_client(
headers=spec.get("headers") or None,
timeout=httpx.Timeout(spec.get("timeout", MCP_TIMEOUT), connect=5.0),
timeout=httpx2.Timeout(spec.get("timeout", MCP_TIMEOUT), connect=5.0),
)
)
read, write, *_ = await stack.enter_async_context(
streamable_http_client(spec["url"], http_client=http_client)
)
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
yield session
transport = streamable_http_client(spec["url"], http_client=http_client)
yield await stack.enter_async_context(Client(transport))
finally:
with suppress(Exception):
await stack.aclose()


async def with_retry(call):
"""Run one session-scoped operation, retrying transient failures with backoff. A call whose
"""Run one client operation, retrying transient failures with backoff. A call whose
response was lost may be replayed — MCP has no idempotency key, so tools should tolerate
at-least-once delivery (a tool that fails reports through its result, not an exception)."""
async for attempt in AsyncRetrying(
Expand All @@ -235,16 +233,16 @@ async def connect_mcp(
) -> tuple[list[dict], dict, dict]:
"""Enumerate each configured MCP server's tools (a streamable-HTTP `url`); return (tool schemas,
dispatch mapping `<server>_<tool>` -> (server name, raw tool name), servers mapping name -> spec).
No session is held — a stateless-HTTP server is reconnected per call."""
No protocol session is held; a fresh client handles each call."""
tool_schemas: list[dict] = []
dispatch: dict[str, tuple] = {}
servers: dict[str, dict] = {}
for name, spec in config.get("mcpServers", {}).items():
servers[name] = spec

async def list_tools(spec: dict = spec):
async with mcp_session(spec) as session:
return (await session.list_tools()).tools
async with mcp_client(spec) as client:
return (await client.list_tools()).tools

for tool in await with_retry(list_tools):
# A server named "" (TOOL_PREFIX = None) advertises its tools bare.
Expand All @@ -259,7 +257,7 @@ async def list_tools(spec: dict = spec):
"function": {
"name": full,
"description": tool.description or "",
"parameters": tool.inputSchema,
"parameters": tool.input_schema,
},
}
)
Expand All @@ -273,7 +271,7 @@ def mcp_content_to_chat_content(blocks) -> str | list[dict]:
if block.type == "text":
parts.append({"type": "text", "text": block.text})
elif block.type == "image":
url = f"data:{block.mimeType};base64,{block.data}"
url = f"data:{block.mime_type};base64,{block.data}"
parts.append({"type": "image_url", "image_url": {"url": url}})
else:
parts.append({"type": "text", "text": str(block)})
Expand All @@ -287,13 +285,13 @@ def mcp_content_to_chat_content(blocks) -> str | list[dict]:
async def call_mcp(
servers: dict, dispatch: dict, name: str, arguments: dict
) -> str | list[dict]:
"""Call a tool on a fresh session per attempt — see `with_retry` for the replay semantics.
"""Call a tool with a fresh client per attempt — see `with_retry` for replay semantics.
The result is converted outside the retry so a conversion failure fails once."""
server_name, raw = dispatch[name]

async def call():
async with mcp_session(servers[server_name]) as session:
return await session.call_tool(raw, arguments)
async with mcp_client(servers[server_name]) as client:
return await client.call_tool(raw, arguments)

result = await with_retry(call)
return mcp_content_to_chat_content(result.content)
Expand Down
Loading