Cut the url where the failure message is written, not where it is read - #123
Conversation
`HttpTransport#die!` composed every one of its reasons around `@url` whole and stored it as `failure_hint`. Both readers of that value put it somewhere it must not be: `send` raises it at the next write, and `abandon_pending` hands it to every waiting caller as an `RpcError` whose `safe_message` was the same string — which `McpTool` prints straight into the tool result. A tool result is not a line someone reads and discards. It goes into the model's context, is appended to `transcript.jsonl`, and travels with every `smith sessions export` of the session. #110 fixed the url in a line a human reads; this is the same url in a line that outlives the run. Filtered at composition rather than at either reader: `scrub_urls` runs in `die!`, so the ivar never holds the raw url and a reader added later is covered by construction. `safe_message` answers whether a line is a server's words; `scrub_urls` answers whether it quotes the configuration file. A message smith composed itself passes the first and needed the second. The reason survives verbatim, as do scheme, host and port. The server's own body stays apart in `failure_body` and still reaches no tool result. Two specs, both shown to fail without the change: one drives a running server through a failing call to the tool layer, one asserts the stored hint itself and the `ConnectionError` the next write raises. Closes #112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… down Review found the first version still leaking, and it was right: `scrub_urls` ends a match at whitespace, a quote or an angle bracket, so a query holding one of those was cut at that character and everything after it — the token — left standing. Reproduced end to end: mcp.json : http://127.0.0.1:8080/mcp?filter=<all>&token=LEAKQUERY tool result : ...at http://127.0.0.1:8080<all>&token=LEAKQUERY answered HTTP 500 The location was right and the technique was not: scanning a finished sentence for something url-shaped is a read-site tool, and it had been moved to the compose site without being replaced. `@url` is a `URI` and the class already refuses one without a host, so the safe form is available structurally: `@safe_url` is built once in `initialize` and is what the five `die!` callers interpolate. No message ever holds the whole url, so no regex has to find it. `scrub_urls` stays in `die!` for the other source of a url in these lines — an exception message from the HTTP client — where a filter is the only tool there is, and where the url was composed by the stdlib rather than written by hand, so the shapes that defeat it do not arise. Also from the review: `Transport#failure_hint` now carries the obligation it imposes, since a second transport overriding it the obvious way would leak again with no spec failing; and its "read once" was already untrue, there are two readers. The poll in the transport spec asserts before `not_nil!`, so a host that drops rather than refuses fails legibly. Third spec, failing without the change: a query with an angle bracket and a token, driven through a running server to the tool layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… raw url Review: the ivar's comment claimed "the only form ever written into a message", and twelve lines below it the no-host refusal writes the raw one. Scoped to what is true — after construction — with the exception named, plus where that one line goes and why it stops there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review durch einen Reviewer-Agenten, zwei Runden. Erste Runde REQUEST CHANGES mit einem reproduzierbaren Leck, zweite APPROVE WITH NITS. Was der Review gefunden hatDie erste Fassung leckte weiter. Unabhängig nachgestellt, ebenso mit Die KorrekturDer Ort war richtig, die Technik nicht: einen fertigen Satz nach etwas URL-Förmigem abzusuchen ist ein Werkzeug für die Lesestelle, und es war an die Schreibstelle verschoben worden, ohne ersetzt zu werden.
Was der Review sonst noch abgeräumt hat
Was der Review gegengeprüft und sauber gefunden hat26 URL-Formen durch Ebenfalls geprüft: alle drei SpecsDrei, jede einzeln als fallend nachgewiesen — die dritte scheitert, wenn nur
Nicht in diesem PR
Der rote macOS-Job auf einem früheren Commit war #114, der bekannte Flake in |
Closes #112
The url from
mcp.jsonreached the model's context,transcript.jsonland everysmith sessions exportof the session, through the result of a tool call that failed against a running HTTP server.The path
HttpTransport#die!composes each of its five reasons around@urlwhole — userinfo, path, query, fragment — and stores it asfailure_hint. That one value has two readers, and both put it somewhere it must not be:send(http_transport.cr:50) raises it as aConnectionErrorat the next writeClient#abandon_pending(client.cr:348) hands it to every caller still waiting asRpcError.new(0, reason, safe_message: reason)— both fields the same stringMcpTool(mcp_tool.cr:61/63/65) prints whichever arrives into the tool result. Measured before the change, with a fake server that 500s every call:Why
safe_messagedid not already cover itIt answers "are these a server's words?" — and the answer here is genuinely no, which is why the comment at
client.cr:352is right on its own terms.scrub_urlsanswers a second question: "does this quote the configuration file?" A message smith composed itself passes the first and fails the second. That was the distinction #110 established; this is the surface it had not yet reached.Where the filter went
The issue proposed
abandon_pending. It went one seam earlier, intodie!, for a reason the issue could not have known:abandon_pendingis not the only reader.sendraises the same stored string, and thatConnectionErrorreaches the samerescueinMcpTool. Filtering at composition covers both, leaves nothing raw in the ivar, and means a third reader added later is covered by construction rather than by remembering.failure_hinthas no other readers — checked — and nothing wants the unfiltered form: both uses are messages.failure_bodyis untouched. It is the server's own words, it stays apart, and it still reaches no tool result — onlysmith doctor/mcp listwithwith_server_output, which is #109's territory.What survives
The reason word for word (
answered HTTP 500,could not reach, a timeout), plus scheme, host and port. Which server it was is@handle.name, whichmcp_tool.cr:63already prints beside it. Nothing diagnostic is lost — this half had no conflict to trade against, unlike #109.Specs
Both were run with the fix commented out and both fail there:
tools/call500s, and the stringMcpToolreturns is asserted against all four secret positions. Not just the start-up path, which smith mcp list gibt die URL aus mcp.json ungekuerzt in der Fehlerzeile aus #110 already covers.failure_hintitself and theConnectionErrorthe nextsendraises. That is the reader the issue did not name.crystal spec: 1424 examples, 0 failures.crystal tool format --check: clean.🤖 Generated with Claude Code