fix(mcp): a tool's refusal reaches the agent with its reason (#4794)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 27s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 27s
SDK 2.x passes on only a ToolError's text. Any other exception becomes
UnexpectedToolError("Error executing tool X") and its message stays on the
server. ValueError is how every Scribe tool refuses — what was refused, why,
what to do instead — so every refusal reached the agent bare, and it could
only retry blind. Seen live: tune_retrieval(actor="operator") and
judge_menu(verdicts=[]) both answered "Error executing tool …" and nothing
else.
StrictArgsMCPServer.call_tool re-raises an UnexpectedToolError caused by a
ValueError as a ToolError carrying the message, in the SDK's own
"Error executing tool X: <reason>" shape. Other exceptions are crashes and
stay masked. The stale comment claiming the SDK returns ValueError text is
corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import difflib
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from quart import Quart
|
||||
|
||||
@@ -282,9 +282,10 @@ class StrictArgsMCPServer(MCPServer):
|
||||
declared cannot have been meant to be dropped.
|
||||
"""
|
||||
|
||||
# ToolError, not ValueError: the SDK returns either one's text to the
|
||||
# caller as an error result, but logs anything that is not a ToolError as
|
||||
# an unexpected crash. A misnamed argument is the caller's mistake.
|
||||
# ToolError, not ValueError: since SDK 2.x only a ToolError's text reaches
|
||||
# the caller. Anything else is wrapped as UnexpectedToolError("Error
|
||||
# executing tool X") and its message stays on the server. A misnamed
|
||||
# argument is the caller's mistake, and the caller needs to read why.
|
||||
async def call_tool(self, name, arguments, context=None):
|
||||
try:
|
||||
tool = self._tool_manager.get_tool(name)
|
||||
@@ -304,7 +305,17 @@ class StrictArgsMCPServer(MCPServer):
|
||||
f"It accepts: {', '.join(sorted(declared))}. Nothing was "
|
||||
"created or changed — retry with the declared names."
|
||||
)
|
||||
return await super().call_tool(name, arguments, context)
|
||||
try:
|
||||
return await super().call_tool(name, arguments, context)
|
||||
except UnexpectedToolError as exc:
|
||||
# ValueError is how every Scribe tool refuses (#4794): what was
|
||||
# refused, why, and what to do instead, written for the agent.
|
||||
# SDK 2.x masks it like a crash, so the agent got a failure with
|
||||
# no reason and could only retry blind. Other exceptions ARE
|
||||
# crashes; their text can carry internals and stays masked.
|
||||
if isinstance(exc.__cause__, ValueError):
|
||||
raise ToolError(f"{exc}: {exc.__cause__}") from exc.__cause__
|
||||
raise
|
||||
|
||||
|
||||
def build_mcp_server() -> MCPServer:
|
||||
|
||||
Reference in New Issue
Block a user