upgrade specgen into a mcporter replacement #336

Open
opened 2026-08-29 20:57:35 +00:00 by coilysiren · 2 comments
Owner

Design filed by the platform seat, 2026-08-29, at Kai's direction. Issue was a stub with an empty body. Downstream consumer: coilysiren/inbox#505, which blocks on this and asks one question this body answers.

Everything below was read at main of this repo, cloned to a temporary path because no checkout of umbra exists on this mac. Versions and greps are measured on that clone and on the installed binaries, not recalled.

The claim

MCP is a third transport dialect at a seam that already exists. It is not a new subsystem, and specgen does not become an MCP tool. It gains a third answer to a question it already asks once per guardfile.

http/specgen/codegen/codegen.go:16 states the dialect as two constants:

TransportSpec = "spec"
TransportExec = "exec"

specgen.go:135 picks between them by looking at one thing:

// sniffTransport reads a guardfile's dialect: an `exec` child of the `wrap`
// block is exec, otherwise spec.

member at specgen.go:50 carries GF *guardfile.Guardfile for spec and ExecGF *execverb.Guardfile for exec, one nil at a time. The generated main.go dispatches through specverb.Mount or execverb.Mount, spec-only imports gated behind a spec member, so a binary compiles with either dialect alone or both. Adding TransportMCP is an addition at each of those four places rather than a rewrite of any of them.

The whole lifecycle above the transport already exists and is transport-agnostic: discovery, member merge, deterministic ordering, the cache stamp, the module lock, --skills-out. None of it knows what a request is.

Precedent on disk: the sql grant

The strongest evidence that this fits is that the same move already landed once. http/opcore/operation.go, in Execute:

// A sql grant never assembles a URL, so it leaves before the HTTP floor.
if o.Desc.SQL != nil {
    resp, err := o.executeSQL(ctx, a)
    ...
    if err := o.checkResponse(resp.Decoded, a); err != nil {

A sql grant reaches a database rather than a URL, branches out before Resolve, and rejoins at checkResponse so fail-when still applies. An MCP grant is the same shape: executeMCP fires tools/call, then rejoins checkResponse. RawResponse is a second branch already in that function, so MCP is the third, not the first.

This is what specgen has that mcporter structurally cannot. Every guard sits above the transport. restrict gates, fail-when JMESPath postconditions, the destructive marking, the audit row, the exit-code taxonomy, and respfmt output all apply to an MCP call the moment the transport lands, because none of them touch HTTP. mcporter call linear.list_issues limit:5 has no policy, no audit row, no postcondition, and no deny-by-absence. That gap is not a feature mcporter is missing, it is the thing umbra is.

The grant already exists too, and is half-built

http/opcore/descriptor.go:54:

// Proxy is one inline MCP proxy grant: local tool, exact upstream mapping, and
// request/response guards. The consumer resolves the upstream schema at runtime.
type Proxy struct {
	Name     string
	Upstream UpstreamTool // exact upstream MCP tool mapping
	Allow    []ProxyRule
	Deny     []ProxyRule
	PostCall []ProxyRule
	Describe string
}

proxy <tool> { upstream <server> <tool>; allow|deny <field> matches <regex>; post-call ... } parses today in ParseInline. What it lacks is a schema source, and the comment says so out loud: the consumer resolves the upstream schema at runtime. So the tool's input shape is either hand-restated in KDL or discovered live and trusted.

docs/specverb-descriptors.md already names that failure for the HTTP case:

a consumer projecting operations onto MCP tools or an HTTP route table wanted the descriptors and not the tree, so it had no way in and restated every path and query field by hand in the inline grammar instead. That hand restatement is what drifts.

lock is the missing schema source. That is the design in one sentence.

Grep result: umbra speaks no MCP today

40 occurrences of mcp across .go files. Every one is a test fixture (wrap ward mcp forgejo, which is the command path, not a transport), a doc comment naming ward-mcp as a downstream consumer, or the Proxy parse above. http/opcore/schema.go:106 is explicit:

JSONSchema emits the Schema as a generic draft-07 object, never an MCP tool type (that wrapper lives in ward-mcp).

No client, no transport, no tools/list, no tools/call. Greenfield inside umbra, with the policy grammar already sitting there waiting for it.

The dialect

wrap aosguard ops forgejo {
    mcp stdio {
        command "npx"
        argv "-y" "@example/forgejo-mcp"
        value env "FORGEJO_TOKEN"
    }
    can call list_issue
    can call create_issue
    never call delete_repository
}

and the remote transport:

    mcp http {
        url "https://host/mcp"
        auth header-token { header "Authorization"; prefix "Bearer "; value env "TOK" }
    }
  • the sniff - sniffTransport iterates wrap.Children().Nodes. wrap ward mcp forgejo puts mcp in the positional args, never in the children, so a child node named mcp does not collide with the existing command path. It reads badly on the page and parses cleanly. Taking mcp for symmetry with exec rather than inventing mcp-upstream, and flagging it below as reversible before anything ships.
  • the grant - can call <tool> names an upstream tool exactly, no verb-plus-resource resolution, because MCP tool names are a flat namespace with no spec to resolve against. never and override can carry over unchanged from specverb-policy.
  • deny is absence - docs/specverb-descriptors.md already rules this: a denied leaf returns nothing rather than a refusing handler, because a denied tool that exists still costs context and still invites the call. That rule was written for MCP consumers and now binds a dialect that generates them.
  • the guards - allow / deny / post-call from the existing Proxy grammar move in as-is.

lock and skew

lock is the deliberate online step. For a spec member it fetches Swagger and prunes to the granted surface. For an MCP member it connects, runs initialize plus tools/list, prunes to the granted tools, and writes <member>.tools.gz.

Reused unchanged: encodeSpecLock, decodeSpecLock, writeSpecLock (all byte-generic in speclock.go), the .stamp.json input hashes, and the cache key. The only new code on the lock path is the fetcher. fetchSpec(specURL) does an HTTP GET, fetchTools(member) runs a session and disconnects.

skew prunes live upstream to the granted surface and diffs against the lock, exit 3 on drift, never writes. Pointed at MCP that becomes schema drift detection for MCP tools, which nothing else on the market does. mcporter has list --schema and no lock, so it can print today's schema and cannot tell you it moved. This is the single most defensible reason for #336 to exist rather than shipping a wrapper around mcporter call.

Determinism note: tools/list ordering is not guaranteed by the protocol, so the lock sorts by tool name and canonicalises JSON before gzip, the way orderedSpecs and canonical already do for specs.

Runtime shape: pick the client, get the server later

Two binaries fall out of the same descriptors, and the title picks one.

  1. Client CLI, phase 1. The generated binary is an MCP client. aosguard ops forgejo list-issue --owner x fires tools/call under the full guard floor. This is the mcporter replacement the title asks for.
  2. Server projection, later and mostly free. opcore.Descriptor plus Proxy already describes a guarded served surface, and ward-mcp already drives Operation.Execute as a non-CLI consumer. Once the client exists, the upstream half of a proxy stops being hand-restated.

Do not build 2 first. It is the shape that already half-exists, which makes it look closer than it is.

The _meta question from inbox#505

inbox#505 asks this to be answered before anything else, so answering it here rather than making a reader chase it.

Read: the descriptor model is schema-only. opcore.Descriptor carries 21 fields and not one is a metadata map. opcore.Field likewise. There is nowhere to put _meta today, and no generic extension bag anywhere in the model. So on the literal question, MCP Apps support is a model change rather than a field addition.

But the model change is not on the critical path. The tool lock is pruned JSON, not the descriptor model, so _meta survives into <member>.tools.gz for free as long as pruning keeps it rather than projecting to a descriptor and back. A ui:// consumer reads the lock. Descriptor only needs a Meta field on the day the generated runtime has to act on _meta.ui.resourceUri itself, which is phase 2 of the MCP Apps work and not this issue.

Two consequences for #505:

  • The blocking gate it named is now settled by decision rather than by experiment. "Does _meta.ui.resourceUri survive aggregation" was a question about a third party's proxy. Here it is a pruning rule in our own lock, so the answer is "it does, because we write the pruner."
  • skew over a locked _meta is what catches a ui:// resource whose backing tool schema moved. #505 claims that capability and this is the mechanism under it.

One requirement lands on this issue from #505: the tool-lock pruner must preserve _meta verbatim rather than dropping unknown keys. Cheap now, expensive to retrofit once locks are committed across the fleet.

Dependency: the official Go SDK

umbra's go.mod is lean: go 1.25.5, eight direct requires. Adding a ninth is a real decision, so I checked the candidate rather than assuming it.

  • github.com/modelcontextprotocol/go-sdk - measured 2026-08-29 - v1.7.0 latest on the module proxy, twelve releases in the v1.x line, last push 2026-08-28, 5,035 stars, not archived, 92 open issues, described as "the official Go SDK for Model Context Protocol servers and clients. Maintained in collaboration with Google."
  • licence - Apache-2.0, mid-transition from MIT, with unrelicensed contributions still MIT. umbra ships MIT. Apache-2.0 is permissive and one-way compatible into an MIT-licensed distribution, and this is an ordinary module dependency rather than vendored code, so no relicensing of umbra follows.
  • the alternative - hand-rolling JSON-RPC over stdio and Streamable HTTP. Cheap for tools/list, and it stops being cheap at session lifecycle, notifications, cancellation, and content-block decoding. Take the SDK, and keep it behind the new package so it does not leak into opcore.

v1.x on a spec still moving is worth naming as exposure. The mitigation is that lock freezes what we consume, so a protocol change shows up as skew rather than as a runtime surprise.

Where the code lives, and the one real architectural cost

docs/architecture.md states the import rule as downward-only, cli/ and http/ both depending on pkg/ and never on each other, with the surviving cross-surface reaches described as legacy being unwound.

MCP spans both. A stdio server is an execve that umbra would now perform itself, and a Streamable HTTP server is an outbound request. Putting the dialect under http/ means http/mcpverb reaching into cli/execverb to spawn, which is a new instance of exactly the reach that doc is unwinding.

Recommendation: a third top-level surface, mcp/, depending downward on pkg/ only. pkg/policy already validates argv before execve, so the stdio spawn goes through the same validation as any other, and the http/ egress proxy stays available for the remote transport. The directory keeps telling a reader which surface they are in.

The cost, stated plainly: umbra's README and architecture doc both say "two surfaces" and this makes it three. That is an identity change to the framing, not just a new folder, and it needs the README, docs/architecture.md, and docs/FEATURES.md edited in the same commit rather than after.

The alternative is http/mcpverb with one accepted cross-surface reach, which is a smaller diff and adds to the pile that doc wants shrinking. My call is the third surface. Flagged below in case Kai wants the smaller diff.

What does not come into umbra

mcporter's value is not its CLI verbs. Four things carry it, and they do not all belong here.

  • six-editor config import (Cursor, Claude, Codex, and the rest) - stays out. It is host-aware and churny, and the layer law puts it above umbra. AOS already runs the projection in the other direction, taking one canonical inventory into the Claude Code and Codex native registries. specgen takes a guardfile and nothing else.
  • the OAuth vault - out of phase 1. pkg/valuesource and pkg/tokenmint already mint client_credentials tokens. MCP's browser authorization_code flow with refresh storage is a different grant and its own issue. Phase 1 guardfiles name a value provider.
  • the keep-alive daemon - out of phase 1, measure first. It exists because stdio servers are slow to start and hold session state. Phase 1 connects cold per invocation and records the latency, and that number decides whether a daemon is worth a process manager inside a policy engine. Building it on the assumption is how a gate becomes a service.
  • serve as an aggregating proxy - out. That is server-side and belongs with mcp-beaver and ward-mcp, per the read already recorded in inbox#505.
  • emit-ts - out. No consumer.

So this issue replaces mcporter's list, list --schema, call, resource, and generate-cli, and deliberately does not replace daemon, serve, vault, config import, or emit-ts. A cold reader should not expect brew uninstall mcporter at the end of phase 1. Say so in the FEATURES entry.

Dispatchable units

  1. mcp/ surface with a client - transport plus session against the Go SDK, stdio argv validated through pkg/policy, tools/list and tools/call and resources/read. No specgen wiring yet.
  2. The dialect - TransportMCP, the mcp child in sniffTransport, MCPGF on member, can call grants reusing the Proxy guards, codegen mount arm.
  3. lock and skew - fetchTools, the pruner that keeps _meta verbatim, deterministic ordering, <member>.tools.gz, skew diff and exit 3.
  4. Execute - the executeMCP branch in opcore.Operation.Execute alongside the sql one, rejoining checkResponse so fail-when binds.
  5. Docs and identity - README, docs/architecture.md, docs/FEATURES.md, a new docs/mcpverb.md, examples/<name>/ runnable, and godoc-current.txt regenerated.

1 through 4 are sequential. 5 lands with 2 and 4 rather than after.

Acceptance

  • A guardfile with an mcp stdio block and two can call grants builds a binary that calls both tools and refuses a third that is not granted, with the refusal absent from --help rather than present and refusing.
  • specgen lock writes a committed tool lock, and specgen run afterwards works with the network down.
  • specgen skew exits 3 when an upstream tool's input schema changes, and 0 when it has not.
  • A tool carrying _meta round-trips it through lock byte-identically.
  • fail-when rejects a tools/call that returned successfully but failed its postcondition, using the same expression syntax an HTTP grant uses.
  • The audit log carries an MCP call with the same row shape as an HTTP one.
  • A spec member, an exec member, and an MCP member merge into one binary.
  • make lint, make vet, make test, and the godoc pin all pass.

Risks

  • The third surface changes umbra's stated identity, and the two-surface framing is load-bearing in the README and in how the boundary is explained. Whichever way this goes, the prose changes in the same commit.
  • stdio spawn is an execve inside the request surface. If it skips pkg/policy, the http side quietly gains an unvalidated exec path. This is the one item where getting it wrong is a security regression rather than a design wart.
  • v1.x SDK against a moving spec. Bounded by the lock, not eliminated.
  • umbra#295 renames this binary to umbra-shroud and renames the six docs/specgen-*.md pages with it. A new docs/mcpverb.md and a new transport arm collide with that diff. Sequencing is a choice rather than an accident: land #336 first and let #295 sweep it, or land #295 first and write the new pages under the new name. Either works, doing them concurrently does not.
  • Cold-start latency per invocation is unmeasured. If a stdio server takes seconds to boot, the client CLI is unpleasant to use and the daemon question comes back immediately rather than after measurement.

Open questions

Batched rather than blocking. Defaults are picked and stated, so implementation can start on any of them.

  1. Third surface mcp/, or http/mcpverb with one cross-surface reach? Default taken: third surface.
  2. Child node named mcp, or something that does not read strangely next to wrap ward mcp forgejo? Default taken: mcp, for symmetry with exec.
  3. Does .specgen/ discovery need anything for MCP members, or is a .kdl with a top-level wrap enough? Default taken: enough, no discovery change.
  4. Is phase 2, the server projection, in this issue or its own? Default taken: its own, filed once phase 1 lands.

Sources

All read at main, 2026-08-29.

  • http/specgen/codegen/codegen.go - the two transport constants and the mount template
  • http/specgen/specgen.go - member, sniffTransport, readMember, Lock, Skew, fetchSpec
  • http/specgen/speclock.go - the byte-generic gzip lock helpers
  • http/opcore/descriptor.go - Descriptor, Proxy, UpstreamTool, and the runtime-resolution comment
  • http/opcore/operation.go - Execute and the sql branch out of the HTTP floor
  • http/opcore/schema.go - the "never an MCP tool type" boundary
  • docs/specgen.md, docs/specgen-materialization.md, docs/opcore-inline.md, docs/specverb-descriptors.md, docs/architecture.md
  • AGENTS.md, README.md, go.mod
  • specgen version on this mac - v0.185.0 (umbra ref v0.185.0)
  • mcporter --help and mcporter 0.11.3 dist - the command surface, and zero occurrences of ui://
  • go list -m -versions github.com/modelcontextprotocol/go-sdk and the GitHub API for that repo
  • coilysiren/inbox#505 - the downstream consumer and the _meta question
  • coilyco-flight-deck/umbra#295 - the pending binary rename this sequences against
Design filed by the platform seat, 2026-08-29, at Kai's direction. Issue was a stub with an empty body. Downstream consumer: coilysiren/inbox#505, which blocks on this and asks one question this body answers. Everything below was read at `main` of this repo, cloned to a temporary path because no checkout of umbra exists on this mac. Versions and greps are measured on that clone and on the installed binaries, not recalled. ## The claim **MCP is a third transport dialect at a seam that already exists.** It is not a new subsystem, and specgen does not become an MCP tool. It gains a third answer to a question it already asks once per guardfile. `http/specgen/codegen/codegen.go:16` states the dialect as two constants: ```go TransportSpec = "spec" TransportExec = "exec" ``` `specgen.go:135` picks between them by looking at one thing: ```go // sniffTransport reads a guardfile's dialect: an `exec` child of the `wrap` // block is exec, otherwise spec. ``` `member` at `specgen.go:50` carries `GF *guardfile.Guardfile` for spec and `ExecGF *execverb.Guardfile` for exec, one nil at a time. The generated `main.go` dispatches through `specverb.Mount` or `execverb.Mount`, spec-only imports gated behind a spec member, so a binary compiles with either dialect alone or both. Adding `TransportMCP` is an addition at each of those four places rather than a rewrite of any of them. The whole lifecycle above the transport already exists and is transport-agnostic: discovery, member merge, deterministic ordering, the cache stamp, the module lock, `--skills-out`. None of it knows what a request is. ## Precedent on disk: the sql grant The strongest evidence that this fits is that the same move already landed once. `http/opcore/operation.go`, in `Execute`: ```go // A sql grant never assembles a URL, so it leaves before the HTTP floor. if o.Desc.SQL != nil { resp, err := o.executeSQL(ctx, a) ... if err := o.checkResponse(resp.Decoded, a); err != nil { ``` A sql grant reaches a database rather than a URL, branches out before `Resolve`, and rejoins at `checkResponse` so `fail-when` still applies. An MCP grant is the same shape: `executeMCP` fires `tools/call`, then rejoins `checkResponse`. `RawResponse` is a second branch already in that function, so MCP is the third, not the first. **This is what specgen has that mcporter structurally cannot.** Every guard sits above the transport. `restrict` gates, `fail-when` JMESPath postconditions, the destructive marking, the audit row, the exit-code taxonomy, and `respfmt` output all apply to an MCP call the moment the transport lands, because none of them touch HTTP. `mcporter call linear.list_issues limit:5` has no policy, no audit row, no postcondition, and no deny-by-absence. That gap is not a feature mcporter is missing, it is the thing umbra is. ## The grant already exists too, and is half-built `http/opcore/descriptor.go:54`: ```go // Proxy is one inline MCP proxy grant: local tool, exact upstream mapping, and // request/response guards. The consumer resolves the upstream schema at runtime. type Proxy struct { Name string Upstream UpstreamTool // exact upstream MCP tool mapping Allow []ProxyRule Deny []ProxyRule PostCall []ProxyRule Describe string } ``` `proxy <tool> { upstream <server> <tool>; allow|deny <field> matches <regex>; post-call ... }` parses today in `ParseInline`. What it lacks is a schema source, and the comment says so out loud: **the consumer resolves the upstream schema at runtime.** So the tool's input shape is either hand-restated in KDL or discovered live and trusted. `docs/specverb-descriptors.md` already names that failure for the HTTP case: > a consumer projecting operations onto MCP tools or an HTTP route table wanted the descriptors and not the tree, so it had no way in and restated every path and query field by hand in the inline grammar instead. That hand restatement is what drifts. `lock` is the missing schema source. That is the design in one sentence. ## Grep result: umbra speaks no MCP today 40 occurrences of `mcp` across `.go` files. Every one is a test fixture (`wrap ward mcp forgejo`, which is the command path, not a transport), a doc comment naming ward-mcp as a downstream consumer, or the `Proxy` parse above. `http/opcore/schema.go:106` is explicit: > JSONSchema emits the Schema as a generic draft-07 object, never an MCP tool type (that wrapper lives in ward-mcp). No client, no transport, no `tools/list`, no `tools/call`. Greenfield inside umbra, with the policy grammar already sitting there waiting for it. ## The dialect ```kdl wrap aosguard ops forgejo { mcp stdio { command "npx" argv "-y" "@example/forgejo-mcp" value env "FORGEJO_TOKEN" } can call list_issue can call create_issue never call delete_repository } ``` and the remote transport: ```kdl mcp http { url "https://host/mcp" auth header-token { header "Authorization"; prefix "Bearer "; value env "TOK" } } ``` * **the sniff** - `sniffTransport` iterates `wrap.Children().Nodes`. `wrap ward mcp forgejo` puts `mcp` in the **positional args**, never in the children, so a child node named `mcp` does not collide with the existing command path. It reads badly on the page and parses cleanly. Taking `mcp` for symmetry with `exec` rather than inventing `mcp-upstream`, and flagging it below as reversible before anything ships. * **the grant** - `can call <tool>` names an upstream tool exactly, no verb-plus-resource resolution, because MCP tool names are a flat namespace with no spec to resolve against. `never` and `override can` carry over unchanged from `specverb-policy`. * **deny is absence** - `docs/specverb-descriptors.md` already rules this: a denied leaf returns nothing rather than a refusing handler, because a denied tool that exists still costs context and still invites the call. That rule was written for MCP consumers and now binds a dialect that generates them. * **the guards** - `allow` / `deny` / `post-call` from the existing `Proxy` grammar move in as-is. ## lock and skew `lock` is the deliberate online step. For a spec member it fetches Swagger and prunes to the granted surface. For an MCP member it connects, runs `initialize` plus `tools/list`, prunes to the granted tools, and writes `<member>.tools.gz`. Reused unchanged: `encodeSpecLock`, `decodeSpecLock`, `writeSpecLock` (all byte-generic in `speclock.go`), the `.stamp.json` input hashes, and the cache key. The only new code on the lock path is the fetcher. `fetchSpec(specURL)` does an HTTP GET, `fetchTools(member)` runs a session and disconnects. `skew` prunes live upstream to the granted surface and diffs against the lock, exit 3 on drift, never writes. Pointed at MCP that becomes **schema drift detection for MCP tools, which nothing else on the market does.** mcporter has `list --schema` and no lock, so it can print today's schema and cannot tell you it moved. This is the single most defensible reason for #336 to exist rather than shipping a wrapper around `mcporter call`. Determinism note: `tools/list` ordering is not guaranteed by the protocol, so the lock sorts by tool name and canonicalises JSON before gzip, the way `orderedSpecs` and `canonical` already do for specs. ## Runtime shape: pick the client, get the server later Two binaries fall out of the same descriptors, and the title picks one. 1. **Client CLI**, phase 1. The generated binary is an MCP client. `aosguard ops forgejo list-issue --owner x` fires `tools/call` under the full guard floor. This is the mcporter replacement the title asks for. 2. **Server projection**, later and mostly free. `opcore.Descriptor` plus `Proxy` already describes a guarded served surface, and ward-mcp already drives `Operation.Execute` as a non-CLI consumer. Once the client exists, the upstream half of a proxy stops being hand-restated. Do not build 2 first. It is the shape that already half-exists, which makes it look closer than it is. ## The `_meta` question from inbox#505 inbox#505 asks this to be answered before anything else, so answering it here rather than making a reader chase it. **Read: the descriptor model is schema-only.** `opcore.Descriptor` carries 21 fields and not one is a metadata map. `opcore.Field` likewise. There is nowhere to put `_meta` today, and no generic extension bag anywhere in the model. So on the literal question, MCP Apps support is a model change rather than a field addition. **But the model change is not on the critical path.** The tool lock is pruned JSON, not the descriptor model, so `_meta` survives into `<member>.tools.gz` for free as long as pruning keeps it rather than projecting to a descriptor and back. A `ui://` consumer reads the lock. `Descriptor` only needs a `Meta` field on the day the **generated runtime** has to act on `_meta.ui.resourceUri` itself, which is phase 2 of the MCP Apps work and not this issue. Two consequences for #505: * The blocking gate it named is now settled by decision rather than by experiment. "Does `_meta.ui.resourceUri` survive aggregation" was a question about a third party's proxy. Here it is a pruning rule in our own `lock`, so the answer is "it does, because we write the pruner." * `skew` over a locked `_meta` is what catches a `ui://` resource whose backing tool schema moved. #505 claims that capability and this is the mechanism under it. **One requirement lands on this issue from #505:** the tool-lock pruner must preserve `_meta` verbatim rather than dropping unknown keys. Cheap now, expensive to retrofit once locks are committed across the fleet. ## Dependency: the official Go SDK umbra's `go.mod` is lean: `go 1.25.5`, eight direct requires. Adding a ninth is a real decision, so I checked the candidate rather than assuming it. * **`github.com/modelcontextprotocol/go-sdk`** - measured 2026-08-29 - v1.7.0 latest on the module proxy, twelve releases in the v1.x line, last push 2026-08-28, 5,035 stars, not archived, 92 open issues, described as "the official Go SDK for Model Context Protocol servers and clients. Maintained in collaboration with Google." * **licence** - Apache-2.0, mid-transition from MIT, with unrelicensed contributions still MIT. umbra ships MIT. Apache-2.0 is permissive and one-way compatible into an MIT-licensed distribution, and this is an ordinary module dependency rather than vendored code, so no relicensing of umbra follows. * **the alternative** - hand-rolling JSON-RPC over stdio and Streamable HTTP. Cheap for `tools/list`, and it stops being cheap at session lifecycle, notifications, cancellation, and content-block decoding. Take the SDK, and keep it behind the new package so it does not leak into `opcore`. v1.x on a spec still moving is worth naming as exposure. The mitigation is that `lock` freezes what we consume, so a protocol change shows up as skew rather than as a runtime surprise. ## Where the code lives, and the one real architectural cost `docs/architecture.md` states the import rule as downward-only, `cli/` and `http/` both depending on `pkg/` and never on each other, with the surviving cross-surface reaches described as legacy being unwound. MCP spans both. A stdio server is an `execve` that umbra would now perform itself, and a Streamable HTTP server is an outbound request. Putting the dialect under `http/` means `http/mcpverb` reaching into `cli/execverb` to spawn, which is a new instance of exactly the reach that doc is unwinding. **Recommendation: a third top-level surface, `mcp/`, depending downward on `pkg/` only.** `pkg/policy` already validates argv before `execve`, so the stdio spawn goes through the same validation as any other, and the http/ egress proxy stays available for the remote transport. The directory keeps telling a reader which surface they are in. The cost, stated plainly: umbra's README and architecture doc both say "two surfaces" and this makes it three. That is an identity change to the framing, not just a new folder, and it needs the README, `docs/architecture.md`, and `docs/FEATURES.md` edited in the same commit rather than after. The alternative is `http/mcpverb` with one accepted cross-surface reach, which is a smaller diff and adds to the pile that doc wants shrinking. My call is the third surface. Flagged below in case Kai wants the smaller diff. ## What does not come into umbra mcporter's value is not its CLI verbs. Four things carry it, and they do not all belong here. * **six-editor config import** (Cursor, Claude, Codex, and the rest) - **stays out.** It is host-aware and churny, and the layer law puts it above umbra. AOS already runs the projection in the other direction, taking one canonical inventory into the Claude Code and Codex native registries. specgen takes a guardfile and nothing else. * **the OAuth vault** - **out of phase 1.** `pkg/valuesource` and `pkg/tokenmint` already mint `client_credentials` tokens. MCP's browser `authorization_code` flow with refresh storage is a different grant and its own issue. Phase 1 guardfiles name a value provider. * **the keep-alive daemon** - **out of phase 1, measure first.** It exists because stdio servers are slow to start and hold session state. Phase 1 connects cold per invocation and records the latency, and that number decides whether a daemon is worth a process manager inside a policy engine. Building it on the assumption is how a gate becomes a service. * **`serve` as an aggregating proxy** - **out.** That is server-side and belongs with mcp-beaver and ward-mcp, per the read already recorded in inbox#505. * **`emit-ts`** - **out.** No consumer. So this issue replaces mcporter's `list`, `list --schema`, `call`, `resource`, and `generate-cli`, and deliberately does not replace `daemon`, `serve`, `vault`, `config import`, or `emit-ts`. A cold reader should not expect `brew uninstall mcporter` at the end of phase 1. Say so in the FEATURES entry. ## Dispatchable units 1. **`mcp/` surface with a client** - transport plus session against the Go SDK, stdio argv validated through `pkg/policy`, `tools/list` and `tools/call` and `resources/read`. No specgen wiring yet. 2. **The dialect** - `TransportMCP`, the `mcp` child in `sniffTransport`, `MCPGF` on `member`, `can call` grants reusing the `Proxy` guards, `codegen` mount arm. 3. **lock and skew** - `fetchTools`, the pruner that keeps `_meta` verbatim, deterministic ordering, `<member>.tools.gz`, skew diff and exit 3. 4. **Execute** - the `executeMCP` branch in `opcore.Operation.Execute` alongside the sql one, rejoining `checkResponse` so `fail-when` binds. 5. **Docs and identity** - README, `docs/architecture.md`, `docs/FEATURES.md`, a new `docs/mcpverb.md`, `examples/<name>/` runnable, and `godoc-current.txt` regenerated. 1 through 4 are sequential. 5 lands with 2 and 4 rather than after. ## Acceptance * A guardfile with an `mcp stdio` block and two `can call` grants builds a binary that calls both tools and refuses a third that is not granted, with the refusal absent from `--help` rather than present and refusing. * `specgen lock` writes a committed tool lock, and `specgen run` afterwards works with the network down. * `specgen skew` exits 3 when an upstream tool's input schema changes, and 0 when it has not. * A tool carrying `_meta` round-trips it through lock byte-identically. * `fail-when` rejects a `tools/call` that returned successfully but failed its postcondition, using the same expression syntax an HTTP grant uses. * The audit log carries an MCP call with the same row shape as an HTTP one. * A spec member, an exec member, and an MCP member merge into one binary. * `make lint`, `make vet`, `make test`, and the godoc pin all pass. ## Risks * **The third surface changes umbra's stated identity**, and the two-surface framing is load-bearing in the README and in how the boundary is explained. Whichever way this goes, the prose changes in the same commit. * **stdio spawn is an execve inside the request surface.** If it skips `pkg/policy`, the http side quietly gains an unvalidated exec path. This is the one item where getting it wrong is a security regression rather than a design wart. * **v1.x SDK against a moving spec.** Bounded by the lock, not eliminated. * **umbra#295 renames this binary to `umbra-shroud`** and renames the six `docs/specgen-*.md` pages with it. A new `docs/mcpverb.md` and a new transport arm collide with that diff. Sequencing is a choice rather than an accident: land #336 first and let #295 sweep it, or land #295 first and write the new pages under the new name. Either works, doing them concurrently does not. * **Cold-start latency per invocation** is unmeasured. If a stdio server takes seconds to boot, the client CLI is unpleasant to use and the daemon question comes back immediately rather than after measurement. ## Open questions Batched rather than blocking. Defaults are picked and stated, so implementation can start on any of them. 1. **Third surface `mcp/`, or `http/mcpverb` with one cross-surface reach?** Default taken: third surface. 2. **Child node named `mcp`, or something that does not read strangely next to `wrap ward mcp forgejo`?** Default taken: `mcp`, for symmetry with `exec`. 3. **Does `.specgen/` discovery need anything for MCP members, or is a `.kdl` with a top-level `wrap` enough?** Default taken: enough, no discovery change. 4. **Is phase 2, the server projection, in this issue or its own?** Default taken: its own, filed once phase 1 lands. ## Sources All read at `main`, 2026-08-29. * `http/specgen/codegen/codegen.go` - the two transport constants and the mount template * `http/specgen/specgen.go` - `member`, `sniffTransport`, `readMember`, `Lock`, `Skew`, `fetchSpec` * `http/specgen/speclock.go` - the byte-generic gzip lock helpers * `http/opcore/descriptor.go` - `Descriptor`, `Proxy`, `UpstreamTool`, and the runtime-resolution comment * `http/opcore/operation.go` - `Execute` and the sql branch out of the HTTP floor * `http/opcore/schema.go` - the "never an MCP tool type" boundary * `docs/specgen.md`, `docs/specgen-materialization.md`, `docs/opcore-inline.md`, `docs/specverb-descriptors.md`, `docs/architecture.md` * `AGENTS.md`, `README.md`, `go.mod` * `specgen version` on this mac - v0.185.0 (umbra ref v0.185.0) * `mcporter --help` and `mcporter` 0.11.3 dist - the command surface, and zero occurrences of `ui://` * `go list -m -versions github.com/modelcontextprotocol/go-sdk` and the GitHub API for that repo * coilysiren/inbox#505 - the downstream consumer and the `_meta` question * coilyco-flight-deck/umbra#295 - the pending binary rename this sequences against
Owner

Implemented in #337. Not merged: the push to main was refused by the harness, not by policy.

Platform seat, 2026-08-29. All five dispatchable units in the body are built, tested, and pushed to mcp-dialect. The work is not landed. This repo runs merge-remote-main, and the direct push to main was blocked by the session's permission layer, so the branch is the only thing carrying it until #337 merges. That is the blocking wall, and it needs a human.

Verification, all green on the branch: go build, go vet, go test ./..., golangci-lint run ./..., and pre-commit run --all-files.

Two design decisions in this body turned out wrong against the code

The third surface was not needed, and the cost I named for it does not exist. This body recommended a top-level mcp/ on the reasoning that http/mcpverb would have to reach into cli/execverb to spawn a stdio child, a new instance of exactly the cross-surface reach docs/architecture.md is unwinding. Reading pkg/policy showed the spawn needs the core, not cli/: argv validation already lives in pkg/. So the reach never arises, mcpverb sits in http/, mcpclient sits in pkg/, and umbra stays two surfaces. The identity change this body called "the one real architectural cost" was a cost of my own framing rather than of the feature. docs/architecture.md now states the one thing a reader would otherwise trip on, that an mcp stdio transport starts a subprocess inside the request surface, and why that is still the request surface.

Open question 1 is therefore answered by the code rather than by Kai.

Reusing the inline grammar's proxy rules was wrong. This body said the allow / deny / post-call guards "move in as-is". They do not: opcore's selector vocabulary is a fixed list (url, target, element, text, key, state) because the inline grammar has no schema behind it. The mcp dialect has the lock, so it checks a selector against the tool's real arguments at build time. A misspelled selector now fails the build instead of compiling into a rule that matches nothing and reads like a guard that passed. That is strictly stronger than what this body proposed, and it is the reason the export I first added to opcore was reverted.

The _meta requirement from inbox#505 is met and tested

The tool-lock pruner preserves _meta verbatim. Two tests hold it: one asserts the map survives a real session byte-for-byte, and one moves a live server's _meta mid-test and asserts skew reports it as drift. So an MCP Apps widget address that silently repoints is drift rather than a swap nobody sees.

What the other open questions resolved to

  1. mcp as the child node name - kept, for symmetry with exec. The collision I flagged is genuinely absent: sniffTransport reads children, and the mcp in wrap ward mcp forgejo is a positional argument. There is a test asserting an mcp-named spec member still sniffs as spec.
  2. .specgen/ discovery - unchanged, as predicted. A .kdl with a top-level wrap was enough.
  3. Phase 2 - its own issue, #339.

Deferrals, each with an issue rather than a sentence here

  • #338 - measure mcp stdio cold-start per invocation, then decide the keep-alive daemon. This body deferred the daemon on the condition that the number be measured, so that condition is now a task rather than an intention.
  • #339 - phase 2, the served MCP surface.
  • #340 - OAuth authorization_code is unreachable; only a pre-resolved token works. Filed because the gap is silent from the guardfile's side.

One unrelated repair carried in

_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 because it belongs to this work.

What is left

Merging #337. Everything else in this issue is done.

## Implemented in #337. Not merged: the push to `main` was refused by the harness, not by policy. Platform seat, 2026-08-29. All five dispatchable units in the body are built, tested, and pushed to `mcp-dialect`. **The work is not landed.** This repo runs `merge-remote-main`, and the direct push to `main` was blocked by the session's permission layer, so the branch is the only thing carrying it until #337 merges. That is the blocking wall, and it needs a human. Verification, all green on the branch: `go build`, `go vet`, `go test ./...`, `golangci-lint run ./...`, and `pre-commit run --all-files`. ## Two design decisions in this body turned out wrong against the code **The third surface was not needed, and the cost I named for it does not exist.** This body recommended a top-level `mcp/` on the reasoning that `http/mcpverb` would have to reach into `cli/execverb` to spawn a stdio child, a new instance of exactly the cross-surface reach `docs/architecture.md` is unwinding. Reading `pkg/policy` showed the spawn needs the **core**, not `cli/`: argv validation already lives in `pkg/`. So the reach never arises, `mcpverb` sits in `http/`, `mcpclient` sits in `pkg/`, and umbra stays two surfaces. The identity change this body called "the one real architectural cost" was a cost of my own framing rather than of the feature. `docs/architecture.md` now states the one thing a reader would otherwise trip on, that an mcp stdio transport starts a subprocess inside the request surface, and why that is still the request surface. Open question 1 is therefore answered by the code rather than by Kai. **Reusing the inline grammar's proxy rules was wrong.** This body said the `allow` / `deny` / `post-call` guards "move in as-is". They do not: `opcore`'s selector vocabulary is a fixed list (`url`, `target`, `element`, `text`, `key`, `state`) because the inline grammar has no schema behind it. The mcp dialect has the lock, so it checks a selector against the tool's **real arguments** at build time. A misspelled selector now fails the build instead of compiling into a rule that matches nothing and reads like a guard that passed. That is strictly stronger than what this body proposed, and it is the reason the export I first added to `opcore` was reverted. ## The `_meta` requirement from inbox#505 is met and tested The tool-lock pruner preserves `_meta` verbatim. Two tests hold it: one asserts the map survives a real session byte-for-byte, and one moves a live server's `_meta` mid-test and asserts `skew` reports it as drift. So an MCP Apps widget address that silently repoints is drift rather than a swap nobody sees. ## What the other open questions resolved to 2. **`mcp` as the child node name** - kept, for symmetry with `exec`. The collision I flagged is genuinely absent: `sniffTransport` reads children, and the `mcp` in `wrap ward mcp forgejo` is a positional argument. There is a test asserting an mcp-named spec member still sniffs as spec. 3. **`.specgen/` discovery** - unchanged, as predicted. A `.kdl` with a top-level `wrap` was enough. 4. **Phase 2** - its own issue, #339. ## Deferrals, each with an issue rather than a sentence here * #338 - measure mcp stdio cold-start per invocation, then decide the keep-alive daemon. This body deferred the daemon **on the condition** that the number be measured, so that condition is now a task rather than an intention. * #339 - phase 2, the served MCP surface. * #340 - OAuth `authorization_code` is unreachable; only a pre-resolved token works. Filed because the gap is silent from the guardfile's side. ## One unrelated repair carried in `_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 because it belongs to this work. ## What is left Merging #337. Everything else in this issue is done.
Owner

Correction: the exit-code taxonomy does not survive the generated binary

Merged and released as v0.186.0. Demonstrated end to end from the released binary against the node-stats MCP server on kai-server, and one claim I made here and on #337 does not hold up.

What I wrote: "restrict, fail-when, the destructive marking, the audit row, and the exit-code taxonomy all bind to a tools/call unchanged."

What is true: the first four bind. The taxonomy does not reach either consumer, for any dialect. opcore builds the coded error correctly, and the generated main.go prints it and calls os.Exit(1) without ever importing pkg/exitcode. Measured on the release: a policy refusal exits 1 where PolicyDenied is 2, and a missing required input exits 1 where UserError is 5. The audit row records the same collapsed 1.

This is pre-existing and not caused by #337 (the template's os.Exit(1) predates it), so the claim was wrong rather than the code being broken by this work. Filed as #341 with the measurements and the two-consumer scope.

The correction matters because the taxonomy is exactly what an orchestrator would act on, and I put the claim in two durable artifacts.

What the demo did confirm

Against the real upstream, on the released v0.186.0:

  • specgen lock reported 2 tools of 23 upstream, so pruning to the granted surface works against a server nobody wrote for this.
  • The built binary mounts get-memory-info and stat-path and nothing else. read_text_head (denied) and the other 20 tools (unnamed) are equally absent, which is the deny-by-absence rule holding in practice.
  • Flags came from the locked schema, and a real call returned live memory figures.
  • allow path matches "^/var/log" permitted /var/log and refused /etc/shadow before any network call.
  • --query 'virtual.percent' projected the result, --dry-run printed the resolved tool and arguments without firing, and specgen skew reported no drift against the live server.
  • One audit row per invocation, carrying the dotted verb name and argv.

So the dialect works as documented. The taxonomy sentence was the one overclaim, and it is now #341.

## Correction: the exit-code taxonomy does not survive the generated binary Merged and released as v0.186.0. Demonstrated end to end from the released binary against the node-stats MCP server on kai-server, and one claim I made here and on #337 does not hold up. **What I wrote:** "`restrict`, `fail-when`, the destructive marking, the audit row, and the exit-code taxonomy all bind to a `tools/call` unchanged." **What is true:** the first four bind. The taxonomy does not reach either consumer, for **any** dialect. `opcore` builds the coded error correctly, and the generated `main.go` prints it and calls `os.Exit(1)` without ever importing `pkg/exitcode`. Measured on the release: a policy refusal exits 1 where `PolicyDenied` is 2, and a missing required input exits 1 where `UserError` is 5. The audit row records the same collapsed 1. This is pre-existing and not caused by #337 (the template's `os.Exit(1)` predates it), so the claim was wrong rather than the code being broken by this work. Filed as #341 with the measurements and the two-consumer scope. The correction matters because the taxonomy is exactly what an orchestrator would act on, and I put the claim in two durable artifacts. ## What the demo did confirm Against the real upstream, on the released v0.186.0: * `specgen lock` reported `2 tools of 23 upstream`, so pruning to the granted surface works against a server nobody wrote for this. * The built binary mounts `get-memory-info` and `stat-path` and nothing else. `read_text_head` (denied) and the other 20 tools (unnamed) are equally absent, which is the deny-by-absence rule holding in practice. * Flags came from the locked schema, and a real call returned live memory figures. * `allow path matches "^/var/log"` permitted `/var/log` and refused `/etc/shadow` before any network call. * `--query 'virtual.percent'` projected the result, `--dry-run` printed the resolved tool and arguments without firing, and `specgen skew` reported no drift against the live server. * One audit row per invocation, carrying the dotted verb name and argv. So the dialect works as documented. The taxonomy sentence was the one overclaim, and it is now #341.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#336
No description provided.