Repository navigation
fix(api): name the bad MCP argument and log tool refusals below Error - #1238
Conversation
A tool-call filter answers a tool's own McpException and an argument the SDK could not bind before the SDK sees them, so neither logs at Error with a stack trace. A binding failure names the first argument that does not fit the tool's input schema and the shape it expects, such as "from must be a date, YYYY-MM-DD". Any other exception still logs at Error and gives the model the generic message.
Live MCP client runI ran this on an isolated stack (
The stack log for the call window has 102 events in compact JSON:
The not-found refusal as logged: {"@mt":"MCP tool {Tool} refused the call.","Tool":"cluckwork_list_daily_entries","EventId":{"Id":1856655997,"Name":"Refused"},"SourceContext":"Cluckwork.Api.Hosting.McpToolErrors", ...}
{"@mt":"\"{ToolName}\" completed. IsError = {IsError}.","ToolName":"cluckwork_list_daily_entries","IsError":true,"SourceContext":"ModelContextProtocol.Server.McpServer", ...}I did not trigger a real fault on the live stack, because no tool there has one to provoke on demand. The in-process tests cover that case with probe tools that throw The before state is the RED run of the same tests on this branch before the filter existed. All five bad-argument and refusal cases got the generic reply, and the not-found refusal logged |
Codex review of #1238 found that the schema walker disagreed with the SDK binder. It skipped JSON nulls, accepted Int32 overflow and padded numeric strings, and matched nested members case-sensitively. It also inferred after the fact where an exception came from, so a construction fault could lose its Error log. The filter now binds each argument, before invocation, into the declared parameter type with McpJsonUtilities.DefaultOptions, using the method the SDK puts first in the tool's Metadata. A failure names the parameter, or the canonical member path, and the schema only words the expected shape; an out-of-range whole number gets its type's range. Everything thrown after the check stays on the SDK fault path.
Codex review at f341340: R1 to R4 fixed in fc9ba60Codex reviewed For each parameter the input schema lists, the filter deserializes the supplied argument into the parameter's declared type. It uses
The R1 test asserts both halves in one call sequence. With a valid argument, the construction fault logs at Error and its text stays out of the reply. With a bad argument, the reply names Verification.
A live client rerun on the new head is next. I will add it as a comment. |
Live MCP client rerun at a48257cI reran on the isolated stack
The stack log for the call window has 274 events in compact JSON:
The filter's own events: {"mt":"MCP tool {Tool} refused the call: argument {Argument} is invalid.","Tool":"cluckwork_list_daily_entries","Argument":"from","level":"Information","x":null}
{"mt":"MCP tool {Tool} refused the call: argument {Argument} is invalid.","Tool":"cluckwork_list_flocks","Argument":"limit","level":"Information","x":null}
{"mt":"MCP tool {Tool} refused the call: argument {Argument} is invalid.","Tool":"cluckwork_list_flocks","Argument":"limit","level":"Information","x":null}
{"mt":"MCP tool {Tool} refused the call.","Tool":"cluckwork_list_daily_entries","Argument":null,"level":"Information","x":null}
{"mt":"MCP tool {Tool} refused the call: argument {Argument} is invalid.","Tool":"cluckwork_list_flocks","Argument":"limit","level":"Information","x":null}The last line is the 2026-07-28 overflow call. The construction-fault and body-fault cases need throwing probe tools, so the in-process tests cover them, not the live stack. |
Closes #1227
What changes for a model and for the log
A model that sends a bad argument now learns which one and what shape it needs. Before, it got
An error occurred invoking '<tool>'.and usually retried the same call.cluckwork_list_daily_entries,from: "10/11/2026"orfrom: 123An error occurred invoking 'cluckwork_list_daily_entries'.... : from must be a date, YYYY-MM-DD.cluckwork_list_flocks,limit: "ten",nullor" 1"... : limit must be a whole number.cluckwork_list_flocks,limit: 2147483648... : limit must be a whole number from -2147483648 to 2147483647.cluckwork_list_sales_orders,customerId: "Acme"... : customerId must be an id.cluckwork_record_daily_entry,grades: "invalid"... : grades must be a list.cluckwork_record_daily_entry,grades: [1]... : grades[0] must be an object.cluckwork_record_daily_entry,grades[0].quantity(orQuantity) set to"ten"ornull... : grades[0].quantity must be a whole number.cluckwork_record_daily_entrywithoutdate... : date is required.A tool's own refusal (not found, ambiguous name, missing role inside the tool) reaches the model as before. It no longer logs
"<tool>" threw an unhandled exception.at Error with a stack trace. It logs one Information line,MCP tool {Tool} refused the call., with no exception and no message text. The refusal text can carry what the model sent, such as a flock name, so it stays out of the log. A bad argument logsMCP tool {Tool} refused the call: argument {Argument} is invalid., naming only the parameter path.Every other exception still logs at Error through the SDK, and the model still gets the generic message, so no internals leak. That includes faults from the tool's construction, its body and its result.
How it works
The change is one call-tool filter,
McpToolErrors.Filterinsrc/Cluckwork.Api/Hosting/McpToolErrors.cs, registered inAddCluckworkMcp. Start reading there.McpJsonUtilities.DefaultOptions, as the SDK's binder (AIFunctionFactory, Microsoft.Extensions.AI 10.8.3) will. The method comes fromMcpServerTool.Metadata, where the SDK puts theMethodInfofirst. A missing argument with no default is "required". AJsonExceptionfrom this step is a bad argument.Quantityreports asgrades[0].quantity. The schema only words the shape:format: date,format: uuid,type: integerand so on. A whole number that still fails to bind gets its declared type's range.McpServerImpl.BuildComposedCallToolHandler) logsToolCallErrorat Error for every exception that leaves the call-tool filter pipeline. When a filter returns aCallToolResultwithIsError = true, the SDK logs only its Information"<tool>" completed. IsError = True.line. So the filter returns the result, with the same textCreateToolCallErrorResultwould write, for a bad argument and for a tool's ownMcpException. No logging filter is needed.McpProtocolExceptionpasses through, so a protocol error stays a JSON-RPC error. Any other exception from construction, the body or the result passes through to the SDK's Error path.Not changed
When a client calls a tool its role or scope does not allow, the SDK's own authorization filter rejects the call before this filter runs. It throws
McpProtocolException(InvalidRequest), and the SDK still logs that at Error. Only a client that ignorestools/listmakes that call. Lowering it would need a logger decorator around the SDK's category, which is more than this issue needs.Tests
tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolErrorTests.csuses a real MCP client, a real OAuth token, a per-host Serilog sink and probe tools that throw. It has 20 cases:limit: "2"that still reaches the tool body.InvalidOperationException,JsonException,ArgumentException) and one protocol error.Fourteen mutations each turned at least one case red. Removing the pre-invocation check turned 15 red. Widening the catch to treat
JsonExceptionorArgumentExceptionfrom the tool as a bad argument turned the construction-fault test and two body faults red. The others were catching protocol errors, not catching refusals, binding intoJsonElement, binding withJsonSerializerOptions.Default, removing the required check, case-sensitive member lookup, no path walk, no range, the date and uuid shape words, and each log level raised to Error. Binding withJsonSerializerOptions.Webpasses, because for these tools it is equivalent toDefaultOptions.Locally, all 215 tests under
Mcp.and the 386Architecturetests inCluckwork.Application.Testspass.Review
Codex reviewed
f3413409and found four defects in the first design, which inferred bad arguments from the schema after a failure. This head replaces that design with the binding check above. The reply with the before and after for each finding is #1238 (comment).Live run
The first live run, on
ee1a2bf7, is #1238 (comment). A rerun on the current head follows.