specgen learns MCP: a third transport dialect, with a locked tool surface #337

Merged
coilysiren merged 5 commits from mcp-dialect into main 2026-08-30 03:50:07 +00:00
Owner

Implements coilyco-flight-deck/umbra#336: specgen learns MCP as a third transport dialect.

Landed as a pull request rather than straight to main (the repo's merge-remote-main lane) because the direct push to main was refused by the harness permission layer, not by policy. The branch is the only thing carrying the work until this merges.

What this is

MCP is a third transport at a seam that already existed, not a new subsystem. codegen.Transport was spec | exec; it is now spec | exec | mcp. sniffTransport gains a third answer, member a third guardfile, and the generated main.go a mountMCP arm.

The design and its reasoning are on the issue. What changed while building it is at the bottom.

Why this beats wrapping an MCP client

Every guard umbra has sits above transport. opcore.Operation.Execute grows a third branch, next to the sql grant and the raw response: an mcp leaf leaves before the HTTP floor and rejoins at checkResponse. So restrict, fail-when, the destructive marking, the audit row, and the exit-code taxonomy all bind to a tools/call unchanged, because none of them ever touched HTTP.

mcporter call has no policy, no audit row, no postcondition, and no deny-by-absence.

The lock is the headline

specgen lock connects, runs tools/list, prunes to the granted tools, and freezes them through the same gzip encoder the spec dialect uses. specgen skew prunes live upstream the same way and diffs, exit 3 on drift.

Nothing else locks MCP tool schemas. A client can print today's schema; only a lock can say it moved. _meta is part of the compared value, so an MCP Apps widget address that silently repoints is drift rather than a swap nobody sees. That closes the gate coilysiren/inbox#505 named.

Commits

  • feat(mcpclient) - the client, in pkg/ because it expresses no permission. A stdio spawn passes pkg/policy's argv gate: it does not get a weaker check for arriving through the request surface.
  • feat(mcpverb) - the dialect, the guards, and the Execute branch.
  • feat(specgen) - the driver, lock, and skew.
  • docs(mcpverb) - docs/mcpverb.md, the trifecta updates, and a runnable example.
  • style(mcpverb) - held to the repo's 2-line comment cap.

Two decisions that changed against the issue

  1. No third top-level surface. The issue recommended mcp/, on the reasoning that http/mcpverb would have to reach into cli/execverb to spawn a stdio child. Reading pkg/policy showed the spawn needs core, not cli/, so the cross-surface import never arises and umbra stays two surfaces. docs/architecture.md states the wrinkle rather than leaving it to be found.
  2. Guard selectors are checked against the locked schema, not the inline grammar's fixed vocabulary. The lock knows the tool's real arguments, so a selector naming no argument fails the build instead of compiling into a rule that matches nothing and reads like a guard that passed.

Verification

go build, go vet, go test ./..., golangci-lint run ./..., and pre-commit run --all-files all pass.

Tests run against a real MCP server over HTTP rather than a mock, so the protocol, flag binding, guards, and drift detection are exercised end to end. TestLockAndSkew_MCPMember locks a live server, asserts a clean skew, moves the server's schema mid-test, and asserts ErrSkew.

One unrelated fix carried along

_typos.toml learns ser8. That hook fails on main today, before this branch: typos reads the ser inside the real hostname as a misspelt set. Fixed here because this change has to pass the same gate.

Not in scope, per the issue

The keep-alive daemon (waiting on a cold-start measurement), the OAuth browser flow, editor config import, serve, and emit-ts. brew uninstall mcporter is not the end of this phase.

Implements coilyco-flight-deck/umbra#336: specgen learns MCP as a third transport dialect. Landed as a pull request rather than straight to `main` (the repo's `merge-remote-main` lane) because the direct push to `main` was refused by the harness permission layer, not by policy. The branch is the only thing carrying the work until this merges. ## What this is **MCP is a third transport at a seam that already existed**, not a new subsystem. `codegen.Transport` was `spec | exec`; it is now `spec | exec | mcp`. `sniffTransport` gains a third answer, `member` a third guardfile, and the generated `main.go` a `mountMCP` arm. The design and its reasoning are on the issue. What changed while building it is at the bottom. ## Why this beats wrapping an MCP client Every guard umbra has sits above transport. `opcore.Operation.Execute` grows a third branch, next to the `sql` grant and the raw response: an mcp leaf leaves before the HTTP floor and rejoins at `checkResponse`. So `restrict`, `fail-when`, the destructive marking, the audit row, and the exit-code taxonomy all bind to a `tools/call` unchanged, because none of them ever touched HTTP. `mcporter call` has no policy, no audit row, no postcondition, and no deny-by-absence. ## The lock is the headline `specgen lock` connects, runs `tools/list`, prunes to the granted tools, and freezes them through the same gzip encoder the spec dialect uses. `specgen skew` prunes live upstream the same way and diffs, exit 3 on drift. Nothing else locks MCP tool schemas. A client can print today's schema; only a lock can say it moved. `_meta` is part of the compared value, so an MCP Apps widget address that silently repoints is drift rather than a swap nobody sees. That closes the gate coilysiren/inbox#505 named. ## Commits * `feat(mcpclient)` - the client, in `pkg/` because it expresses no permission. A stdio spawn passes `pkg/policy`'s argv gate: it does not get a weaker check for arriving through the request surface. * `feat(mcpverb)` - the dialect, the guards, and the `Execute` branch. * `feat(specgen)` - the driver, `lock`, and `skew`. * `docs(mcpverb)` - `docs/mcpverb.md`, the trifecta updates, and a runnable example. * `style(mcpverb)` - held to the repo's 2-line comment cap. ## Two decisions that changed against the issue 1. **No third top-level surface.** The issue recommended `mcp/`, on the reasoning that `http/mcpverb` would have to reach into `cli/execverb` to spawn a stdio child. Reading `pkg/policy` showed the spawn needs core, not `cli/`, so the cross-surface import never arises and umbra stays two surfaces. `docs/architecture.md` states the wrinkle rather than leaving it to be found. 2. **Guard selectors are checked against the locked schema**, not the inline grammar's fixed vocabulary. The lock knows the tool's real arguments, so a selector naming no argument fails the build instead of compiling into a rule that matches nothing and reads like a guard that passed. ## Verification `go build`, `go vet`, `go test ./...`, `golangci-lint run ./...`, and `pre-commit run --all-files` all pass. Tests run against a real MCP server over HTTP rather than a mock, so the protocol, flag binding, guards, and drift detection are exercised end to end. `TestLockAndSkew_MCPMember` locks a live server, asserts a clean skew, moves the server's schema mid-test, and asserts `ErrSkew`. ## One unrelated fix carried along `_typos.toml` learns `ser8`. That hook fails on `main` today, before this branch: typos reads the `ser` inside the real hostname as a misspelt `set`. Fixed here because this change has to pass the same gate. ## Not in scope, per the issue The keep-alive daemon (waiting on a cold-start measurement), the OAuth browser flow, editor config import, `serve`, and `emit-ts`. `brew uninstall mcporter` is not the end of this phase.
pkg/mcpclient is one declared upstream server, a session over it, and the
three calls the dialect needs: tools/list, tools/call, resources/read.

It sits in pkg/ rather than a guarded surface because it expresses no
permission, which also keeps the arrow downward-only: both http/ and any
later consumer reach it without a cross-surface import.

A stdio transport starts a subprocess, so its command and argv go through
pkg/policy's shell-metacharacter check. A spawn does not get a weaker gate
for arriving through the request surface.

Tool is umbra's own type rather than the SDK's, so the lock format does not
move when the SDK does, and _meta rides through verbatim. MCP Apps addresses
its widget at _meta.ui, and a lock that dropped unknown keys would lose it
silently. The test proves the round-trip against a real session.

Refs coilyco-flight-deck/umbra#336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
One `can call <tool>` grant becomes one guarded leaf that fires tools/call
against the declared upstream. Flags come from the tool's locked input
schema, so the surface is the contract rather than a hand restatement.

opcore.Operation.Execute grows a third branch, next to the sql grant and the
raw response: an mcp leaf leaves before the HTTP floor and rejoins at
checkResponse, so `fail-when`, `restrict`, the audit row, and the exit-code
taxonomy all bind unchanged. That is the whole reason this beats a wrapper
around a client: every guard already sits above transport.

Deny is absence. A denied tool returns no leaf rather than a leaf that
refuses, matching the descriptor projection: a tool that exists still costs
context and still invites the call.

Guard selectors are checked against the locked schema instead of the inline
grammar's fixed vocabulary. The lock knows the tool's real arguments, so a
misspelled selector fails the build rather than compiling into a rule that
matches nothing and reads like a guard that passed.

Tests run against a real MCP server over HTTP rather than a mock, so the
protocol, the flag binding, and the guards are all exercised end to end.

Refs coilyco-flight-deck/umbra#336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
sniffTransport gains a third answer, member a third guardfile, and the
generated main.go a mountMCP arm. The `mcp` in `wrap ward mcp forgejo` is a
positional argument rather than a child, so an mcp-named spec member still
sniffs as spec; there is a test for exactly that.

`lock` connects, lists, prunes to the granted tools, and freezes them through
the same gzip encoder the spec dialect uses. `skew` prunes live upstream the
same way and diffs, exit 3 on drift.

That diff is the capability nothing else has: a client can print today's tool
schema, only a lock can say it moved. _meta is part of the compared value, so
an MCP Apps widget address that silently repoints is drift rather than a swap
nobody sees.

The lock is deterministic (sorted by name, sorted map keys), so re-locking an
unchanged upstream is an empty diff rather than committed churn. Tested by
locking twice and comparing bytes, and by moving a live server's schema
mid-test and asserting ErrSkew.

A spec member, an exec member, and an mcp member merge into one binary.

Refs coilyco-flight-deck/umbra#336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
docs/mcpverb.md carries the grammar, the lock-is-the-surface rule, why the
guards read one direction, and the non-goals. FEATURES, README, index, and
specgen.md pick up the third transport; AGENTS names the new package.

architecture.md gets the one paragraph a reader would otherwise have to
discover: an mcp stdio transport starts a subprocess, which looks like cli/
work sitting in http/. It is not, because the split is about what is guarded
rather than about syscalls, and the spawn passes the same argv gate either
way. Stating it beats leaving it to be found.

The example starts its own MCP server, so it runs with no upstream in
existence. It shows a granted tool, a guarded argument, a denied tool and an
unnamed one both absent, and a missing required input. It stays out of
treebuilders because its tree comes from a live surface, not a literal.

Refs coilyco-flight-deck/umbra#336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
style(mcpverb): hold the new code to umbra's 2-line comment cap
All checks were successful
ci / secrets (pull_request) Successful in 11s
ci / lint (pull_request) Successful in 39s
ci / test (pull_request) Successful in 52s
e97b68e087
The code-comments hook caps a contiguous comment block at 2 lines and sends
longer explanation to docs/. The mcp dialect landed 97 violations over that,
which is the rule this repo already had rather than a new one. Every block is
now two lines or fewer, and docs/mcpverb.md already carried the reasoning.

Two smaller ones in the same pass. The selector test named `onwer`, a letter
transposition; it now names `owner_id`, which is the realistic failure (an
argument that does not exist) and not a misspelling the typos hook has to be
told about.

_typos.toml learns `ser8`, a real host named in an execverb test. That hook
fails on main today, unrelated to this branch: typos reads the `ser` inside
the hostname as a misspelt `set`. The identifier regex is the same shape the
file already uses for CONNECT.

Refs coilyco-flight-deck/umbra#336

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
coilysiren deleted branch mcp-dialect 2026-08-30 03:50:07 +00:00
Sign in to join this conversation.
No reviewers
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!337
No description provided.