You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
No structured way to signal a tool's refusal, distinct from success or a schema-validation error #282
Filing this from real friction hit building three apps for the WebMCP Challenge (one of them, Consequence, is built specifically around this issue).
The situation
execute() returns { content: [{ type: "text", text }] } regardless of whether the tool succeeded, was refused on purpose, or failed. There's no structured field distinguishing "I did the thing" from "I'm declining to do the thing, on purpose, and here's why."
We needed this because some of our tools enforce a real permission boundary: an agent is allowed to fill most fields on a form, but a few (attestations, signatures) can only be set by a human. When an agent calls our answer_question tool on one of those fields, the tool needs to refuse, clearly and on purpose, not silently no-op and not throw a generic error.
What we did instead
We return a normal-shaped success result whose text is a plain-language refusal message ("Refused: this field requires the human's own action"), and rely on the calling agent to read and respect that text. It works, but it's a convention we invented, not something the spec gives any structure to. An agent framework has no principled way to tell "the tool succeeded and this is the answer" apart from "the tool declined and this is why" without parsing prose.
Why this seems worth a spec-level answer
A refusal is a different kind of outcome than a normal result, and calling code (or a UI showing tool-call history, which several of our apps do) benefits from being able to render it differently without string-sniffing.
It's different from a schema-validation failure (which the runtime already rejects before execute even runs) — this is a tool voluntarily declining a well-formed, schema-valid call, because of business logic the schema can't express.
Multiple entries in the awesome-webmcp list implement permission/authorization patterns where this would apply (ours included), so it's probably a recurring need, not a one-off.
Happy to be pointed at existing discussion if this has already been considered and rejected — didn't find one searching open issues.
One distinction in the current spec may help narrow this issue: a WebMCP execute callback is Promise<any>; it is not required to return an MCP { content: [...] } envelope (IDL). The normative imperative execution algorithm already treats fulfillment and rejection differently: fulfillment is JSON-serialized as a successful result, while a thrown exception/rejected promise takes the failure path (steps). So authors do not have to encode every refusal as a success-shaped text response.
The remaining gap is narrower, but real: the rejection reason r is discarded (apart from an optional console warning), and executeTool() maps the failure to a generic UnknownError (completion steps). Consequently, an in-page caller cannot distinguish a deliberate business-rule refusal from an operational exception, nor receive a structured refusal reason.
That suggests the design question is specifically whether and how WebMCP should preserve a typed refusal category/reason across the execution boundary, rather than whether success and failure can be distinguished at all. This also appears adjacent to #85 (execution lifecycle), #86 (result content model), and the authorization outcomes discussed in #165. A normative shape still needs group agreement, so I don't think a code change is safe yet.
We hit exactly this building a review tool on WebMCP, and ended up with two layers, both returned as ordinary tool results rather than thrown errors:
Schema failures: the page validates input itself (the browser does not, Who owns the validation layer? #92) and returns { ok:false, error:{ code:"INVALID_INPUT", problems:[…], inputSchema }, retrySafe:true }.
Deliberate refusals: OUT_OF_SCOPE (the person limited where the agent may write; carries the allowed ids), STALE_READ (the person changed the slide after the agent read it; carries changedSince), NOT_APPLICABLE (the slide type has no such field; carries the fields it does have). Each carries what the agent needs to recover, plus retrySafe.
The distinction that mattered for us is that a refusal is not a fault. The call was well-formed and the tool worked correctly by declining, and the agent's right next move differs in the two cases: fix the arguments, or go ask the person. Throwing conflates them, and the agent cannot tell from an exception whether retrying is even meaningful, hence retrySafe as a separate field rather than something inferred from the code.
What we would want from the spec is a conventional envelope (ok, error.code, retrySafe) that hosts could surface uniformly. Today every site invents its own, so an agent has to learn each site's dialect before it can tell "you may not" from "that broke".
@mlmrx thanks, that's a clarifying distinction I'd missed, execute() not being required to return the MCP envelope means a refusal can already be a rejection rather than a success-shaped text blob. I'll probably rework Consequence's answer_question to throw on a human_only field instead of returning a plain-language refusal, since that's clearly closer to what the spec already supports.
But @minjikim89's comment is the part that makes me think this needs more than an implementation fix on our end. We ended up in almost the same place independently: not throwing, but returning an ordinary result with a structured outcome, because a refusal and an exception felt like different things to us too. Our version is cruder (a PermissionClass per field, and a tool that returns a plain refusal string when it hits a human_only one), but the shape rhymes with the ok/error.code/retrySafe envelope they built. Neither of us knew the other existed until this thread.
That's two unrelated apps solving "the call was valid and the tool worked correctly by declining" the same rough way, without the spec saying anything about it. If it's really just us, fine, that's a one-off pattern not worth standardizing. But I'd bet it's not, anything with a permission boundary, a stale-data check, or a "this doesn't apply here" case is going to reinvent some version of this the moment it needs an agent to tell the difference between "try again differently" and "go ask a human."
I don't think the fix is picking one of our two shapes and blessing it. It's narrower than that: the rejection reason already exists at the point execute() rejects, and executeTool() throwing it away and replacing it with a generic UnknownError is the actual loss. Even just preserving whatever the tool rejected with, structured or not, and handing it back instead of discarding it, would let every one of us stop inventing our own success-shaped workaround.
Happy to write this up as a proper proposal if that's useful, referencing both Consequence and redline as real, independent motivating cases, rather than leaving it as "seems like it'd be nice."
Hi @AshrafAhmed9 — yes, please write it up, and I would be glad to have Redline cited as one of the cases. Two unrelated apps landing on the same shape without knowing about each other is the strongest argument this thread has, and it is better made by someone other than either of us.
One caution before you rework answer_question, though. Throwing on a human_only field is probably the right end state, but today it would cost you information you currently have. As @mlmrx's reading of the completion steps shows, the caller's promise is rejected with a bare UnknownErrorDOMException and the rejection reason is not carried, so an agent receiving your throw would learn less than it does from your current plain refusal string. The spec change has to land before the implementation change is an improvement rather than a regression.
That also means your narrowing is the right one, and I would widen it slightly. The same boundary discards three different things a tool already knows:
Three issues, one shape: the tool knows something and the boundary replaces it with a generic error. The spec already flags this four times in these steps with the same note, Issue: Support more granular errors than "UnknownError", based on each failure case. A proposal framed as preserve what the tool produced across the boundary would close all three, which may be easier to land than three separate arguments for granularity.
Happy to supply whatever helps. The error shapes and what an agent does differently with each code are written up in docs/pattern.md, and I can pull the specific call sites if that is useful for the write-up.
Good catch on the throw regression, that would've made Consequence worse, not better, until the spec side moves. Leaving answer_question as-is for now.
Wrote up the unified version at #308, framed around your point that this is one boundary problem wearing three costumes, not three separate granularity asks. Cited both Consequence and Redline as the motivating cases, and left the actual envelope shape as an open question rather than picking one, since I think that's a bigger discussion than the core claim (don't discard what the tool already produced).
@mlmrx tagging you too since the rejection-reason framing in #308 builds directly on your read of the completion steps.
FWIW, the Prompt API is exploring essentially this same distinction as part of its tool-calling work: webmachinelearning/prompt-api#159.
Their proposed LanguageModelToolResponse explicitly leaves open whether tool responses should carry a structured success/error signal, and it' be useful for us to align on as much of this as possible. /cc @michaelwasserman.
(Separately, I'm tracking platform-level execution failures in #323, since they're conceptually different from a tool successfully executing but reporting an application-level failure).
Fair point. I overextended the unifying framing on #308: my three layers are a product-side evidence model, not a claim that refusal, caller abort, and unregistration share one WebMCP mechanism. For #282, I agree the narrower question is whether a deliberate refusal reason can survive the boundary distinctly from an operational failure. The Prompt API tool-call/response discussion looks useful as cross-surface context, but not as a reason to bundle the WebMCP cases. I maintain Hronaut, a paid local Browser/MCP workspace; AI-assisted.
Activity
mlmrx commentedon Sep 2, 2026
One distinction in the current spec may help narrow this issue: a WebMCP
executecallback isPromise<any>; it is not required to return an MCP{ content: [...] }envelope (IDL). The normative imperative execution algorithm already treats fulfillment and rejection differently: fulfillment is JSON-serialized as a successful result, while a thrown exception/rejected promise takes the failure path (steps). So authors do not have to encode every refusal as a success-shaped text response.The remaining gap is narrower, but real: the rejection reason
ris discarded (apart from an optional console warning), andexecuteTool()maps the failure to a genericUnknownError(completion steps). Consequently, an in-page caller cannot distinguish a deliberate business-rule refusal from an operational exception, nor receive a structured refusal reason.That suggests the design question is specifically whether and how WebMCP should preserve a typed refusal category/reason across the execution boundary, rather than whether success and failure can be distinguished at all. This also appears adjacent to #85 (execution lifecycle), #86 (result content model), and the authorization outcomes discussed in #165. A normative shape still needs group agreement, so I don't think a code change is safe yet.
minjikim89 commentedon Sep 6, 2026
We hit exactly this building a review tool on WebMCP, and ended up with two layers, both returned as ordinary tool results rather than thrown errors:
{ ok:false, error:{ code:"INVALID_INPUT", problems:[…], inputSchema }, retrySafe:true }.OUT_OF_SCOPE(the person limited where the agent may write; carries the allowed ids),STALE_READ(the person changed the slide after the agent read it; carrieschangedSince),NOT_APPLICABLE(the slide type has no such field; carries the fields it does have). Each carries what the agent needs to recover, plusretrySafe.The distinction that mattered for us is that a refusal is not a fault. The call was well-formed and the tool worked correctly by declining, and the agent's right next move differs in the two cases: fix the arguments, or go ask the person. Throwing conflates them, and the agent cannot tell from an exception whether retrying is even meaningful, hence
retrySafeas a separate field rather than something inferred from the code.Live: https://minjikim89.github.io/redline/ · shapes documented in https://github.com/minjikim89/redline/blob/main/docs/pattern.md
What we would want from the spec is a conventional envelope (
ok,error.code,retrySafe) that hosts could surface uniformly. Today every site invents its own, so an agent has to learn each site's dialect before it can tell "you may not" from "that broke".AshrafAhmed9 commentedon Sep 10, 2026
@mlmrx thanks, that's a clarifying distinction I'd missed,
execute()not being required to return the MCP envelope means a refusal can already be a rejection rather than a success-shaped text blob. I'll probably rework Consequence'sanswer_questionto throw on ahuman_onlyfield instead of returning a plain-language refusal, since that's clearly closer to what the spec already supports.But @minjikim89's comment is the part that makes me think this needs more than an implementation fix on our end. We ended up in almost the same place independently: not throwing, but returning an ordinary result with a structured outcome, because a refusal and an exception felt like different things to us too. Our version is cruder (a
PermissionClassper field, and a tool that returns a plain refusal string when it hits ahuman_onlyone), but the shape rhymes with theok/error.code/retrySafeenvelope they built. Neither of us knew the other existed until this thread.That's two unrelated apps solving "the call was valid and the tool worked correctly by declining" the same rough way, without the spec saying anything about it. If it's really just us, fine, that's a one-off pattern not worth standardizing. But I'd bet it's not, anything with a permission boundary, a stale-data check, or a "this doesn't apply here" case is going to reinvent some version of this the moment it needs an agent to tell the difference between "try again differently" and "go ask a human."
I don't think the fix is picking one of our two shapes and blessing it. It's narrower than that: the rejection reason already exists at the point
execute()rejects, andexecuteTool()throwing it away and replacing it with a genericUnknownErroris the actual loss. Even just preserving whatever the tool rejected with, structured or not, and handing it back instead of discarding it, would let every one of us stop inventing our own success-shaped workaround.Happy to write this up as a proper proposal if that's useful, referencing both Consequence and redline as real, independent motivating cases, rather than leaving it as "seems like it'd be nice."
minjikim89 commentedon Sep 11, 2026
Hi @AshrafAhmed9 — yes, please write it up, and I would be glad to have Redline cited as one of the cases. Two unrelated apps landing on the same shape without knowing about each other is the strongest argument this thread has, and it is better made by someone other than either of us.
One caution before you rework
answer_question, though. Throwing on ahuman_onlyfield is probably the right end state, but today it would cost you information you currently have. As @mlmrx's reading of the completion steps shows, the caller's promise is rejected with a bareUnknownErrorDOMExceptionand the rejection reason is not carried, so an agent receiving your throw would learn less than it does from your current plain refusal string. The spec change has to land before the implementation change is an improvement rather than a regression.That also means your narrowing is the right one, and I would widen it slightly. The same boundary discards three different things a tool already knows:
UnknownError.{ applied, remaining }payload the tool finished building is discarded.UnknownErroreven though the side effects are on screen.Three issues, one shape: the tool knows something and the boundary replaces it with a generic error. The spec already flags this four times in these steps with the same note,
Issue: Support more granular errors than "UnknownError", based on each failure case.A proposal framed as preserve what the tool produced across the boundary would close all three, which may be easier to land than three separate arguments for granularity.Happy to supply whatever helps. The error shapes and what an agent does differently with each code are written up in docs/pattern.md, and I can pull the specific call sites if that is useful for the write-up.
AshrafAhmed9 commentedon Sep 11, 2026
Good catch on the throw regression, that would've made Consequence worse, not better, until the spec side moves. Leaving
answer_questionas-is for now.Wrote up the unified version at #308, framed around your point that this is one boundary problem wearing three costumes, not three separate granularity asks. Cited both Consequence and Redline as the motivating cases, and left the actual envelope shape as an open question rather than picking one, since I think that's a bigger discussion than the core claim (don't discard what the tool already produced).
@mlmrx tagging you too since the rejection-reason framing in #308 builds directly on your read of the completion steps.
domfarolino commentedon Sep 26, 2026
FWIW, the Prompt API is exploring essentially this same distinction as part of its tool-calling work: webmachinelearning/prompt-api#159.
Their proposed
LanguageModelToolResponseexplicitly leaves open whether tool responses should carry a structured success/error signal, and it' be useful for us to align on as much of this as possible. /cc @michaelwasserman.(Separately, I'm tracking platform-level execution failures in #323, since they're conceptually different from a tool successfully executing but reporting an application-level failure).
Hronom commentedon Sep 26, 2026
Fair point. I overextended the unifying framing on #308: my three layers are a product-side evidence model, not a claim that refusal, caller abort, and unregistration share one WebMCP mechanism. For #282, I agree the narrower question is whether a deliberate refusal reason can survive the boundary distinctly from an operational failure. The Prompt API tool-call/response discussion looks useful as cross-surface context, but not as a reason to bundle the WebMCP cases. I maintain Hronaut, a paid local Browser/MCP workspace; AI-assisted.