Skip to content

VerbSpec has no slot for what a verb DOES — and verbspec-mcp drops the MCP annotations that carry it #22

Description

@bdelanghe

Writing from a consumer with several hundred verbs projected to both the CLI and MCP surfaces. The library does what it says — author once, project everywhere — so this is about a gap one level up, and it shows up the same way five times.

A verb spec declares what a verb is called and what it takes. It declares nothing about what it does to the world. So every consumer that cares about risk has to rebuild that knowledge outside the spec, keyed by verb id, and then defend the sidecar against drift with its own gates.

1. The concrete, checkable half: verbspec-mcp drops annotations the protocol already has

registerVerb builds its registerTool config from title, description, inputSchema and outputSchema. The MCP SDK (checked against 1.29.0) accepts an annotations object on exactly that config, carrying readOnlyHint, destructiveHint and idempotentHint — and it is left empty.

The effect downstream is that a destructive verb and a read-only one render identically in a client. The only signal of danger we can currently give is prose stapled onto the summary string, which is not something a client can sort, filter, or gate on. The protocol carries the risk information; the projection drops it.

2. The root cause: no facet slot on VerbSpec

actor is the only capability-shaped field, and its doc comment says it "binds to the capability/permission model". In our registry it has exactly one distinct value across every verb. A field with one distinct value carries no information — it reads as a slot that was reserved for this problem and never fit it.

Suggestion, kept deliberately unopinionated:

export type VerbSpec<I, O, C, F = Record<string, never>> = {
  // … id, summary, input, output, run …
  /** Consumer-defined facets: what this verb does to the world. Projections may read them. */
  facets?: F;
};

The package should not ship an opinion about which facets exist. Ours are "touches live systems" and "mutates state outside the repo"; another consumer will want "costs money" or "requires approval". What upstream owes is the slot, the type threading through defineVerb / Registry, and the guarantee that projections can read it. A facetReport(registry) projection would be worth having alongside, since the one thing a sidecar genuinely gives you is every verb's facets reviewable on one screen.

With that in place, verbspec-mcp can map facets to ToolAnnotations, and McpServerOptions.filter can take a facet rather than every consumer writing the same id-set predicate.

3. A generic approval wrapper, and the bug that makes it worth owning upstream

We wrap each mutating verb so its tool takes an explicit approval argument, since over MCP there is no argv and the analogous act is a human approving a visible argument.

The part that generalises is the failure mode. Write intent is a module-level singleton. Under a CLI it is reset for free by process exit after every command. A stdio server lives for the whole session, so one approved call arms every later one — including calls a human approved believing they were read-only. Intent has to be granted around a single run() and restored afterwards, throw path included.

That is a property of long-lived servers, not of any one consumer, and every verbspec-mcp user with a gated action will meet it. An approval option on McpServerOptions — which facet gates it, what the input key is called, how it is described — would let the library own the finally rather than each consumer rediscovering it.

4. Discover-then-dispatch for large registries

Registering one tool per verb does not scale. We expose a filtered subset purely for context cost: a full registry spends a client's context on tools nobody will call, and degrades tool-selection accuracy. A pair of meta-tools — list/search verbs by facet and keyword, then dispatch one by id with validated params — would keep a large registry reachable without being resident.

One caveat worth designing around rather than discovering later: a dispatcher collapses every verb behind a single tool name, and hosts that prompt per tool name would then see only the dispatcher. That would silently defeat per-tool permission rules, so it probably wants to coexist with keeping high-risk verbs resident as named tools rather than replacing them.

5. Declaration vs implementation

VerbSpec currently mixes contract (id, summary, input, output), implementation (run, deps), and CLI presentation (render, renderRaw, exitCode, warnings, positionals). Anything that wants only the contract has to import the implementations and their whole dependency graph.

0.4.0 already sketches the split without naming it: JsonSchemaVerbSpec is a projection-only verb with no run, and ProjectableVerb is the union the doc projections accept while dispatch stays Zod-only. Formalising that seam — a declaration carrying contract plus facets, and a bound verb adding run and the surface adapters — would let permission lists, docs and other generated artifacts be derived from a published declaration set with no runtime in scope.


Happy to send a PR for (1) and (2) if the direction is agreeable — they are small, backwards-compatible, and between them they retire a sidecar and two gates on our side. (3) through (5) are more design than patch, so I would rather agree the shape first than arrive with one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions