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.
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
registerVerbbuilds itsregisterToolconfig fromtitle,description,inputSchemaandoutputSchema. The MCP SDK (checked against 1.29.0) accepts anannotationsobject on exactly that config, carryingreadOnlyHint,destructiveHintandidempotentHint— 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
VerbSpecactoris 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:
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. AfacetReport(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, andMcpServerOptions.filtercan 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
approvaloption onMcpServerOptions— which facet gates it, what the input key is called, how it is described — would let the library own thefinallyrather 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
VerbSpeccurrently 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:
JsonSchemaVerbSpecis a projection-only verb with norun, andProjectableVerbis the union the doc projections accept while dispatch stays Zod-only. Formalising that seam — a declaration carrying contract plus facets, and a bound verb addingrunand 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.