Summary
mcptool never inspects CallToolResult.IsError. A tool call that the MCP server refuses comes back to the agent as err == nil, so the agent concludes it succeeded.
This is a correctness bug in the data path, not an observability nit: an agent can be told "you do not have permission to do that" and go on to report that it did it.
Where
tool/mcptool/mcp.go:391
func (w *mcpWrapper) Call(ctx context.Context, args string) (any, error) {
result, err := w.session.CallTool(ctx, &mcp.CallToolParams{...})
if err != nil {
return nil, fmt.Errorf("MCP tool call failed: %w", err) // TRANSPORT errors only
}
contents := mcpCallToolResultToAgentContent(result)
if len(contents) == 0 {
return nil, nil
}
return contents, nil // <-- result.IsError is never read
}
The MCP specification carries tool execution failure in the result (CallToolResult.isError), deliberately separate from protocol/transport errors, so that the model can see and react to the failure. mcptool checks only the transport error, so isError: true is silently downgraded to success.
How I hit it
An agent with an MCP toolkit whose OAuth client had read-only scope called a write tool. The server refused:
{"content":[{"type":"text","text":"{\"error\":\"insufficient_scope\",\"message\":\"Operation put_page requires 'write' scope\",\"your_scopes\":[\"read\"]}"}],"isError":true}
ft.Call(...) returned err = nil. The agent reported that it had published. Nothing was written. The failure was invisible to every layer above the wrapper.
Minimal reproduction against any MCP server that can refuse a call:
all, _ := mcptool.ListTools(ctx, sess)
ft := all[i].(tool.FuncTool)
res, err := ft.Call(ctx, `{...}`) // server responds isError: true
fmt.Println(err) // <nil> <-- the bug
fmt.Println(res) // [{"content":[...],"isError":true}]
Suggested fix
Surface the in-band failure. Two reasonable shapes:
- Return an error when
result.IsError is true, wrapping the text content as the message. Simple, and matches what every caller already assumes err == nil means.
- Keep returning the content (so the model can see the failure text and self-correct, which is the point of
isError being in-band) but also return a sentinel error so programmatic callers, middleware and tracing can tell success from failure.
I lean towards (2) with a typed error (e.g. ToolCallError{Content: ...}), because it preserves the model-facing behaviour the MCP spec intends while making the failure legible to code. But (1) is strictly better than today, and I am happy to implement whichever you prefer.
Happy to open a PR.
Why it matters beyond one agent
Anything that reasons about tool outcomes inherits the lie: retry logic sees success and does not retry; approval gates see success and report a write that never happened; OpenTelemetry spans (#issue for tool spans filed separately) come out green. A refused call that reports success is the worst possible failure mode, because it launders the failure into evidence of success.
Summary
mcptoolnever inspectsCallToolResult.IsError. A tool call that the MCP server refuses comes back to the agent aserr == nil, so the agent concludes it succeeded.This is a correctness bug in the data path, not an observability nit: an agent can be told "you do not have permission to do that" and go on to report that it did it.
Where
tool/mcptool/mcp.go:391The MCP specification carries tool execution failure in the result (
CallToolResult.isError), deliberately separate from protocol/transport errors, so that the model can see and react to the failure.mcptoolchecks only the transport error, soisError: trueis silently downgraded to success.How I hit it
An agent with an MCP toolkit whose OAuth client had read-only scope called a write tool. The server refused:
{"content":[{"type":"text","text":"{\"error\":\"insufficient_scope\",\"message\":\"Operation put_page requires 'write' scope\",\"your_scopes\":[\"read\"]}"}],"isError":true}ft.Call(...)returnederr = nil. The agent reported that it had published. Nothing was written. The failure was invisible to every layer above the wrapper.Minimal reproduction against any MCP server that can refuse a call:
Suggested fix
Surface the in-band failure. Two reasonable shapes:
result.IsErroris true, wrapping the text content as the message. Simple, and matches what every caller already assumeserr == nilmeans.isErrorbeing in-band) but also return a sentinel error so programmatic callers, middleware and tracing can tell success from failure.I lean towards (2) with a typed error (e.g.
ToolCallError{Content: ...}), because it preserves the model-facing behaviour the MCP spec intends while making the failure legible to code. But (1) is strictly better than today, and I am happy to implement whichever you prefer.Happy to open a PR.
Why it matters beyond one agent
Anything that reasons about tool outcomes inherits the lie: retry logic sees success and does not retry; approval gates see success and report a write that never happened; OpenTelemetry spans (#issue for tool spans filed separately) come out green. A refused call that reports success is the worst possible failure mode, because it launders the failure into evidence of success.