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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ dependencies = [
"tomli-w>=1.0.0",
"renderers[multimodal]>=0.1.13.dev17",
"typing_extensions>=4.12.2",
"mcp==2.0.0",
"mcp==2.2.0",
]

[dependency-groups]
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/fixtures/tool_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""tool-error (v1, MCP tool): a tool's exception text reaches the model.

Task tools raise to tell the model what went wrong (an unknown id, an invalid argument),
so the exception's message must come back as the tool result. The tool always raises
with a token the prompt never reveals, so the reward is 1.0 only if the error text
reached the trace.
"""

import verifiers.v1 as vf
from verifiers.v1.types import content_text

ERROR_TOKEN = "err-5c2" # only the raised message carries this


class OrdersToolset(vf.Toolset[vf.ToolsetConfig]):
TOOL_PREFIX = "orders"

@vf.tool
def lookup(self, order_id: str) -> str:
"""Look up an order by its id."""
raise ValueError(f"Order {order_id} not found [{ERROR_TOKEN}]")


class ToolErrorTaskConfig(vf.TaskConfig):
tools: vf.ToolsetConfig = vf.ToolsetConfig()


class ToolErrorTask(vf.Task[vf.TaskData, vf.State, ToolErrorTaskConfig]):
@classmethod
def toolsets(cls, config: ToolErrorTaskConfig) -> list[vf.Toolset]:
return [OrdersToolset(config.tools)]

@vf.reward(weight=1.0)
async def error_reported(self, trace: vf.Trace) -> float:
results = (content_text(m.content) for m in trace.tool_messages)
return float(any(ERROR_TOKEN in r for r in results))


class ToolErrorConfig(vf.TasksetConfig):
task: ToolErrorTaskConfig = ToolErrorTaskConfig()


class ToolErrorTaskset(vf.Taskset[ToolErrorTask, ToolErrorConfig]):
def load(self) -> list[ToolErrorTask]:
return [
ToolErrorTask(
vf.TaskData(
idx=0,
prompt=(
"Call the `lookup` tool from the `orders` MCP server with the order "
'id "A-17", then reply with exactly what it returns inside '
"<answer></answer> tags."
),
),
self.config.task,
)
]


__all__ = ["ToolErrorTaskset"]


if __name__ == "__main__":
OrdersToolset.run()
13 changes: 12 additions & 1 deletion tests/integration/test_multi_turn_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
never reveals (echo-tool), so reward 1.0 proves the tool ran. Every MCP harness reaches
it colocated across the harness x runtime grid; the chat loop reaches it across its
placements (colocated in the harness's box, in a Compose service beside it, or its own
runtime) on the runtime x runtime grid."""
runtime) on the runtime x runtime grid. A tool that raises (tool-error) reports its message
to the model; that is server-side, so the chat loop covers it."""

import pytest
from grid import assert_one_branch, grid, placements
Expand Down Expand Up @@ -64,6 +65,16 @@ async def test_tool_placement(run, harness_runtime, tool_runtime):
assert "message" in echo_tool.parameters.get("properties", {})


async def test_tool_error(run):
"""A tool's exception message comes back to the model, not just that the call failed."""
(episode,) = await run("tool-error", max_turns=4)
(trace,) = episode.traces
assert trace.ok, [error.message for error in trace.errors]
assert trace.reward == 1.0, [
message.model_dump_json(exclude_none=True) for message in trace.tool_messages
]


@pytest.mark.parametrize(
"placement",
[
Expand Down
25 changes: 13 additions & 12 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions verifiers/v1/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,19 @@ async def wrapper(*args, **kwargs):
token = _call_state.set(state)
try:
before = self._state_adapter.dump_json(state) if sync_state else None
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
try:
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
except Exception as e:
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import MCPError

if isinstance(e, (ToolError, MCPError)):
raise
# MCPServer withholds an unexpected exception's text from the client, but
# tools raise to tell the model what went wrong.
raise ToolError(str(e)) from e
if before is not None:
await self._push_state(before)
return result
Expand Down
Loading