Intentionally-omitted verbs should surface as explicit stubs, not silent absence #54

Closed
opened 2026-08-13 00:05:42 +00:00 by coilyco-ops · 1 comment
Member

Problem

When a verb is deliberately excluded from a generated tool surface, the agent sees nothing at all. Absence is indistinguishable between:

  1. deliberately withheld by policy,
  2. not yet implemented,
  3. not offered by the upstream API,
  4. present but not matched by the agent's tool search.

An agent cannot tell these apart, so it guesses — and the guesses are wrong in both directions.

Concrete evidence from one session

Working the forgejo MCP through a 23-issue filing batch, I got it wrong twice in opposite directions:

Inferred a restriction that was real but misattributed. Edit-comment is absent. I concluded "this MCP has no edit-comment verb", treated the two comments I had posted as immutable, and worked around it by posting a third superseding comment — noise that a normal edit would have avoided. The omission turns out to be deliberate, which is a fine decision, but nothing in the surface said so, so the workaround was built on a guess rather than on the actual reason.

Inferred a restriction that did not exist. create_issue has no labels parameter, so I twice told the user labelling was impossible and left 24 issues untagged. add_issue-label and set_issue-label were exposed the whole time. Absence of the parameter got over-generalised into absence of the capability.

Both errors have the same root: an agent reasoning from a hole in the tool list, with nothing in the surface to reason against.

Requested

Emit a stub for deliberately-omitted verbs. It appears in discovery, carries the reason in its description, and returns a structured refusal rather than doing anything. Sketch:

forgejo edit_issue-comment
  description: "NOT AVAILABLE — comment edits are intentionally withheld on this
                surface (audit trail integrity). Post a new comment instead.
                See <policy ref>."
  → { "error": "verb_withheld", "reason": "...", "alternative": "comment_issue" }

The important parts:

  • Discoverable. It shows up in the tool list, so the agent stops inferring from silence.
  • States the reason. "Withheld by policy" reads very differently from "upstream has no such endpoint", and the agent's next move should differ too.
  • Names the alternative where one exists. Half the wasted effort above was hunting for a workaround that was already sitting in the tool list.

Scope question for you

Whether this belongs as a first-class guardfile construct (a withheld / deny node that generates the stub) or as convention — an ordinary tool entry whose handler always refuses — is your call. The guardfile version is better if the intent is auditable policy; the convention version is cheaper and needs no generator change.

Either way it wants a schema-level marker so a client can distinguish stubs from live tools without parsing prose in the description.

  • #39 (advertise MCP tool safety and structured output metadata) — same surface, same idea of making tool properties machine-legible.
  • #53 — the labels half of the second failure above.
## Problem When a verb is deliberately excluded from a generated tool surface, the agent sees nothing at all. Absence is indistinguishable between: 1. deliberately withheld by policy, 2. not yet implemented, 3. not offered by the upstream API, 4. present but not matched by the agent's tool search. An agent cannot tell these apart, so it guesses — and the guesses are wrong in both directions. ## Concrete evidence from one session Working the forgejo MCP through a 23-issue filing batch, I got it wrong twice in opposite directions: **Inferred a restriction that was real but misattributed.** Edit-comment is absent. I concluded "this MCP has no edit-comment verb", treated the two comments I had posted as immutable, and worked around it by posting a third superseding comment — noise that a normal edit would have avoided. The omission turns out to be deliberate, which is a fine decision, but nothing in the surface said so, so the workaround was built on a guess rather than on the actual reason. **Inferred a restriction that did not exist.** `create_issue` has no `labels` parameter, so I twice told the user labelling was impossible and left 24 issues untagged. `add_issue-label` and `set_issue-label` were exposed the whole time. Absence of the *parameter* got over-generalised into absence of the *capability*. Both errors have the same root: an agent reasoning from a hole in the tool list, with nothing in the surface to reason against. ## Requested Emit a stub for deliberately-omitted verbs. It appears in discovery, carries the reason in its description, and returns a structured refusal rather than doing anything. Sketch: ``` forgejo edit_issue-comment description: "NOT AVAILABLE — comment edits are intentionally withheld on this surface (audit trail integrity). Post a new comment instead. See <policy ref>." → { "error": "verb_withheld", "reason": "...", "alternative": "comment_issue" } ``` The important parts: - **Discoverable.** It shows up in the tool list, so the agent stops inferring from silence. - **States the reason.** "Withheld by policy" reads very differently from "upstream has no such endpoint", and the agent's next move should differ too. - **Names the alternative** where one exists. Half the wasted effort above was hunting for a workaround that was already sitting in the tool list. ## Scope question for you Whether this belongs as a first-class guardfile construct (a `withheld` / `deny` node that generates the stub) or as convention — an ordinary tool entry whose handler always refuses — is your call. The guardfile version is better if the intent is auditable policy; the convention version is cheaper and needs no generator change. Either way it wants a schema-level marker so a client can distinguish stubs from live tools without parsing prose in the description. ## Related - https://forgejo.coilysiren.me/coilyco-flight-deck/mcp-beaver/issues/39 (advertise MCP tool safety and structured output metadata) — same surface, same idea of making tool properties machine-legible. - https://forgejo.coilysiren.me/coilyco-flight-deck/mcp-beaver/issues/53 — the `labels` half of the second failure above.
Author
Member

Landed in 4632484 as a guardfile construct rather than a convention, taking the scope question the issue left open.

withhold "edit_issue-comment" {
    reason "Comment edits are withheld here for audit-trail integrity."
    alternative "comment_issue"
}

Chose the guardfile version because the intent is auditable policy and belongs beside the grants it explains. It rides beside wrap like the other siblings, so opcore's frozen grammar and the umbra pin are untouched.

All three of the important parts:

  • Discoverable - appears in tools/list and in lint, so absence stops being the only signal.
  • States the reason - the description leads with NOT AVAILABLE, then the authored reason, then the alternative, then a plain statement that calling it always fails and reaches no upstream. reason is required: a stub that does not say why just restates the absence, louder.
  • Names the alternative - and a named alternative the spec does not mint is a build error, since sending an agent after a tool that does not exist is the second failure in your report.

The schema-level marker you asked for. _meta carries coilyco.io/withheld: true, plus coilyco.io/withheld/alternative when one is stated, so a client separates stubs from live tools without parsing prose. Calling one returns an error result with structured content:

{"error": "verb_withheld", "reason": "...", "alternative": "comment_issue"}

Error rather than a success carrying a refusal, so a client that only checks isError cannot read it as having worked.

One guard worth stating. withhold naming a tool the spec does mint is a build error. A stub shadowing a live grant would advertise a working capability as refused - worse than the silence, because it manufactures exactly the wrong inference rather than no inference.

lint --methods prints WITHHELD for stubs rather than the bare - the info tool gets, since both resolve no HTTP method for completely different reasons.

Deny-by-absence is intact: a stub grants nothing, holds no credential, and reaches no upstream. It converts silence into a statement.

Your second failure - create_issue having no labels - is #53, and the runtime side of that turns out to need no change. Details there.

Landed in 4632484 as a guardfile construct rather than a convention, taking the scope question the issue left open. ```kdl withhold "edit_issue-comment" { reason "Comment edits are withheld here for audit-trail integrity." alternative "comment_issue" } ``` Chose the guardfile version because the intent is auditable policy and belongs beside the grants it explains. It rides beside `wrap` like the other siblings, so opcore's frozen grammar and the umbra pin are untouched. All three of the important parts: * **Discoverable** - appears in `tools/list` and in `lint`, so absence stops being the only signal. * **States the reason** - the description leads with `NOT AVAILABLE`, then the authored reason, then the alternative, then a plain statement that calling it always fails and reaches no upstream. `reason` is required: a stub that does not say why just restates the absence, louder. * **Names the alternative** - and a named alternative the spec does not mint is a build error, since sending an agent after a tool that does not exist is the second failure in your report. **The schema-level marker you asked for.** `_meta` carries `coilyco.io/withheld: true`, plus `coilyco.io/withheld/alternative` when one is stated, so a client separates stubs from live tools without parsing prose. Calling one returns an error result with structured content: ```json {"error": "verb_withheld", "reason": "...", "alternative": "comment_issue"} ``` Error rather than a success carrying a refusal, so a client that only checks `isError` cannot read it as having worked. **One guard worth stating.** `withhold` naming a tool the spec *does* mint is a build error. A stub shadowing a live grant would advertise a working capability as refused - worse than the silence, because it manufactures exactly the wrong inference rather than no inference. `lint --methods` prints `WITHHELD` for stubs rather than the bare `-` the info tool gets, since both resolve no HTTP method for completely different reasons. Deny-by-absence is intact: a stub grants nothing, holds no credential, and reaches no upstream. It converts silence into a statement. Your second failure - `create_issue` having no `labels` - is #53, and the runtime side of that turns out to need no change. Details there.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/mcp-beaver#54
No description provided.