Phase 2 of the mcp dialect: project the locked tool surface as a served MCP server #339

Open
opened 2026-08-30 03:44:18 +00:00 by coilyco-ops · 1 comment
Owner

Deferred out of #336, which took the default "its own issue, filed once phase 1 lands". Phase 1 is #337.

What phase 1 built

The client half. An mcp guardfile plus a committed tool lock mount a guarded CLI whose leaves fire tools/call upstream. mcpverb.Descriptors already returns []opcore.Descriptor plus a RuntimeConfig without building a cli.Command, which is the seam a non-CLI consumer uses.

What phase 2 is

The server half: the same descriptors projected onto a served MCP surface, so umbra sits between an agent and an upstream server rather than between an operator and one.

Most of it already exists and is not new work:

  • opcore.Descriptor.Proxy with UpstreamTool{Server, Tool} and the allow/deny/post-call guards has parsed since before #336.
  • ward-mcp already drives opcore.Operation.Execute as a non-CLI consumer, so the execution path is proven.
  • The lock now supplies the upstream schema that Proxy's own doc comment says "the consumer resolves at runtime". That hand restatement is what drifts, and the lock is what ends it.

So phase 2 is mostly wiring, plus a tools/list projection from opcore.Field back to JSON Schema.

Why it was not built first

It is the shape that already half-exists, which makes it look closer than it is. Building it before the client would have meant inventing the lock and the schema mapping anyway, with no way to exercise them by hand.

Open before starting

  • Does the served surface belong in umbra at all, or in ward-mcp with umbra supplying descriptors? #336's layer reasoning pushed the aggregating proxy (serve) out of umbra deliberately. A served surface may land the same way.
  • _meta reaches the descriptor model only when the served surface has to forward it. #336 answered that it rides in the lock for phase 1 and opcore.Descriptor needs a Meta field only on the day the runtime acts on _meta.ui.resourceUri. That day is this issue, and it is the model change that answer deferred.

Consumer

coilysiren/inbox#505 wants this half for MCP Apps: a widget's callServerTool under declarative guardfile policy is the strongest fit in that design.

Deferred out of #336, which took the default "its own issue, filed once phase 1 lands". Phase 1 is #337. ## What phase 1 built The client half. An mcp guardfile plus a committed tool lock mount a guarded **CLI** whose leaves fire `tools/call` upstream. `mcpverb.Descriptors` already returns `[]opcore.Descriptor` plus a `RuntimeConfig` without building a `cli.Command`, which is the seam a non-CLI consumer uses. ## What phase 2 is The server half: the same descriptors projected onto a **served** MCP surface, so umbra sits between an agent and an upstream server rather than between an operator and one. Most of it already exists and is not new work: * `opcore.Descriptor.Proxy` with `UpstreamTool{Server, Tool}` and the allow/deny/post-call guards has parsed since before #336. * ward-mcp already drives `opcore.Operation.Execute` as a non-CLI consumer, so the execution path is proven. * The lock now supplies the upstream schema that `Proxy`'s own doc comment says "the consumer resolves at runtime". That hand restatement is what drifts, and the lock is what ends it. So phase 2 is mostly wiring, plus a `tools/list` projection from `opcore.Field` back to JSON Schema. ## Why it was not built first It is the shape that already half-exists, which makes it look closer than it is. Building it before the client would have meant inventing the lock and the schema mapping anyway, with no way to exercise them by hand. ## Open before starting * Does the served surface belong in umbra at all, or in ward-mcp with umbra supplying descriptors? #336's layer reasoning pushed the aggregating proxy (`serve`) out of umbra deliberately. A served surface may land the same way. * `_meta` reaches the descriptor model only when the served surface has to forward it. #336 answered that it rides in the lock for phase 1 and `opcore.Descriptor` needs a `Meta` field only on the day the runtime acts on `_meta.ui.resourceUri`. That day is this issue, and it is the model change that answer deferred. ## Consumer coilysiren/inbox#505 wants this half for MCP Apps: a widget's `callServerTool` under declarative guardfile policy is the strongest fit in that design.
Author
Owner

umbra's half is in #346. This stays open for the consumer's half.

Answering the two questions this issue opened with, since both turned out to decide the scope.

"Does the served surface belong in umbra at all, or in ward-mcp with umbra supplying descriptors?" The latter. #346 ships mcpverb.ServedTools, which returns tool definitions plus the runtime that fires them, and nothing that owns a socket. Gating a request and hosting a server are different jobs, and #336 already pushed the aggregating proxy out of umbra on exactly this reasoning. Shipping a server here would reopen a decision rather than extend one.

"_meta reaches the descriptor model only when the served surface has to forward it." That day arrived, so opcore.Descriptor.Meta is in #346. Forwarded verbatim, never parsed.

What #346 turned out to be

Smaller than this issue estimated, because the estimate was right that most of it existed. Descriptor.InputSchema().JSONSchema() was built for ward-mcp and already did the projection. Three things were genuinely missing: Meta, Enum, and the assembly.

Enum was the find. The dialect folded an enum into the flag's help text, which is correct for a human reading --help and useless to an agent calling a served tool. A served schema carrying it only as prose leaves the tool callable but not correctly callable. Caught by writing the round-trip test rather than by reading the code, which is the argument for writing that test first.

What is left, and it is not umbra's

A consumer that mounts these definitions on a transport: tools/list answering from ServedTools, tools/call dispatching to Descriptor through opcore.Operation.Execute, and a session lifetime. ward-mcp already drives Execute as a non-CLI consumer, so it has the harder half.

The properties that consumer inherits rather than reimplements:

  • A served call and a CLI call refuse for identical reasons, because they are one code path.
  • The denied and the merely unnamed are both absent from tools/list.
  • Schema fidelity is tested: type, description, enum, numeric bounds, array items, nested objects, requiredness.

Reassign or move this issue to whichever repo takes that on. Nothing further is owed from here.

## umbra's half is in #346. This stays open for the consumer's half. Answering the two questions this issue opened with, since both turned out to decide the scope. **"Does the served surface belong in umbra at all, or in ward-mcp with umbra supplying descriptors?"** The latter. #346 ships `mcpverb.ServedTools`, which returns tool definitions plus the runtime that fires them, and nothing that owns a socket. Gating a request and hosting a server are different jobs, and #336 already pushed the aggregating proxy out of umbra on exactly this reasoning. Shipping a server here would reopen a decision rather than extend one. **"`_meta` reaches the descriptor model only when the served surface has to forward it."** That day arrived, so `opcore.Descriptor.Meta` is in #346. Forwarded verbatim, never parsed. ## What #346 turned out to be Smaller than this issue estimated, because the estimate was right that most of it existed. `Descriptor.InputSchema().JSONSchema()` was built for ward-mcp and already did the projection. Three things were genuinely missing: `Meta`, `Enum`, and the assembly. **`Enum` was the find.** The dialect folded an enum into the flag's help text, which is correct for a human reading `--help` and useless to an agent calling a served tool. A served schema carrying it only as prose leaves the tool callable but not correctly callable. Caught by writing the round-trip test rather than by reading the code, which is the argument for writing that test first. ## What is left, and it is not umbra's A consumer that mounts these definitions on a transport: `tools/list` answering from `ServedTools`, `tools/call` dispatching to `Descriptor` through `opcore.Operation.Execute`, and a session lifetime. ward-mcp already drives `Execute` as a non-CLI consumer, so it has the harder half. The properties that consumer inherits rather than reimplements: * A served call and a CLI call refuse for identical reasons, because they are one code path. * The denied and the merely unnamed are both absent from `tools/list`. * Schema fidelity is tested: type, description, enum, numeric bounds, array items, nested objects, requiredness. Reassign or move this issue to whichever repo takes that on. Nothing further is owed from here.
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/umbra#339
No description provided.