feat(runtime): generic ward-mcp serve runtime - .mcp.kdl -> guarded MCP tools over SSE (thin shell over cli-guard#196) #7

Closed
opened 2026-07-07 08:17:27 +00:00 by coilysiren · 2 comments
Owner

What

Build the generic ward-mcp serve runtime - the one image that renders any inline .mcp.kdl into a guarded MCP server over HTTP/SSE. No per-guardfile Go, no per-server handler. Entrypoint ward-mcp serve /spec/<name>.mcp.kdl --http :8080: parse → one MCP tool per grant → bind an SSE / streamable-HTTP listener. Never stdio. cli-mcp is a code reference only, not a dependency.

The cli-guard engine is READY (opcore landed)

cli-guard#196 shipped as PR1-3 (cli-guard#197 / #198 / #199, all merged to main). The runtime is a thin shell over http/opcore. Pin a cli-guard version that includes http/opcore in go.mod, and consume this concrete API:

  • opcore.ParseInline(src []byte) ([]opcore.Descriptor, opcore.RuntimeConfig, error) - parses the whole .mcp.kdl (wrap header, base-url, auth <scheme> { value env <VAR> }, restrict, and each can <verb> <resource> { path/query/body/set } grant). Method inferred from the verb, path params from the {template}. This is the frozen ward-mcp#6 grammar.
  • descriptor.InputSchema() opcore.Schema and schema.JSONSchema() []byte - the tool inputSchema (draft-07). cli-guard is MCP-agnostic, so you wrap that schema in the MCP tool envelope.
  • opcore.Operation{Desc: descriptor, RT: runtime}.Execute(ctx, opcore.Args{Path,Query,Body}) (opcore.Response, error) - self-guarding (metachar + restrict gates → assemble → sign with the env token → fire). .Preview(args) gives a resolved-but-unfired request if you want a dry-run.

Build the opcore.Runtime from the RuntimeConfig that ParseInline returns (auth env-value provider, base-url, restrictions).

What ward-mcp owns (the thin shell - everything else is opcore)

  • the ward-mcp serve <spec> --http :addr command.
  • project each Descriptor → one MCP tool: name = verb_resource, inputSchema = descriptor.InputSchema().JSONSchema(), description = the grant's Describe.
  • serve MCP over SSE / streamable-HTTP (cli-mcp as the wire-protocol code reference; implement, do not depend).
  • on a tool call: map the MCP arguments → opcore.ArgsOperation.Execute → render the Response back over the MCP channel.
  • action composition (one tool chaining multiple ops, e.g. view issue = issue + comments) if opcore exposes the chain; otherwise defer per DESIGN.md's collect follow-up and keep the floor grants reachable.

Deny-by-absence, never, restrict, and fixed-body all come from ParseInline / opcore - do not re-implement the guard.

First end-to-end target

skillsmp's two read tools (search_skills, ai_search_skills) served over SSE against skillsmp.com with a SKILLSMP_API_KEY env token - the narrowest slice that proves parse → project → serve → guarded-execute. forgejo (writes, path params, action) is additive once the spine works, no new runtime code.

Done

  • ward-mcp serve runs a .mcp.kdl, serves the derived tools over SSE, and guarded-executes each call through opcore.
  • go.mod pins a cli-guard version that includes http/opcore.
  • Tests: the tool list is derived from a sample .mcp.kdl, and a tool call round-trips through Execute against a mock upstream.
  • The repo's gate (ward vet / ward test or equivalent) is green.

cli-guard#196 + #197 / #198 / #199 (the opcore engine, landed), ward-mcp#6 (grammar + distribution), ward-mcp#8 (the chart that runs this image), coilysiren/inbox#164 (status tracker).

## What Build the generic **`ward-mcp serve` runtime** - the one image that renders any inline `.mcp.kdl` into a guarded MCP server over HTTP/SSE. No per-guardfile Go, no per-server handler. Entrypoint `ward-mcp serve /spec/<name>.mcp.kdl --http :8080`: parse → one MCP tool per grant → bind an SSE / streamable-HTTP listener. Never stdio. cli-mcp is a **code reference only**, not a dependency. ## The cli-guard engine is READY (opcore landed) cli-guard#196 shipped as PR1-3 (cli-guard#197 / #198 / #199, all merged to main). The runtime is a **thin shell** over `http/opcore`. Pin a cli-guard version that includes `http/opcore` in go.mod, and consume this concrete API: - **`opcore.ParseInline(src []byte) ([]opcore.Descriptor, opcore.RuntimeConfig, error)`** - parses the whole `.mcp.kdl` (wrap header, `base-url`, `auth <scheme> { value env <VAR> }`, `restrict`, and each `can <verb> <resource> { path/query/body/set }` grant). Method inferred from the verb, path params from the `{template}`. This is the frozen ward-mcp#6 grammar. - **`descriptor.InputSchema() opcore.Schema`** and **`schema.JSONSchema() []byte`** - the tool inputSchema (draft-07). cli-guard is MCP-agnostic, so you wrap that schema in the MCP tool envelope. - **`opcore.Operation{Desc: descriptor, RT: runtime}.Execute(ctx, opcore.Args{Path,Query,Body}) (opcore.Response, error)`** - self-guarding (metachar + restrict gates → assemble → sign with the env token → fire). `.Preview(args)` gives a resolved-but-unfired request if you want a dry-run. Build the `opcore.Runtime` from the `RuntimeConfig` that `ParseInline` returns (auth env-value provider, base-url, restrictions). ## What ward-mcp owns (the thin shell - everything else is opcore) - the `ward-mcp serve <spec> --http :addr` command. - **project** each `Descriptor` → one MCP tool: name = `verb_resource`, inputSchema = `descriptor.InputSchema().JSONSchema()`, description = the grant's `Describe`. - **serve** MCP over SSE / streamable-HTTP (cli-mcp as the wire-protocol code reference; implement, do not depend). - on a **tool call**: map the MCP arguments → `opcore.Args` → `Operation.Execute` → render the `Response` back over the MCP channel. - `action` composition (one tool chaining multiple ops, e.g. `view issue` = issue + comments) if opcore exposes the chain; otherwise defer per DESIGN.md's `collect` follow-up and keep the floor grants reachable. Deny-by-absence, `never`, `restrict`, and fixed-body all come from ParseInline / opcore - do **not** re-implement the guard. ## First end-to-end target skillsmp's two read tools (`search_skills`, `ai_search_skills`) served over SSE against skillsmp.com with a `SKILLSMP_API_KEY` env token - the narrowest slice that proves parse → project → serve → guarded-execute. forgejo (writes, path params, `action`) is additive once the spine works, no new runtime code. ## Done - `ward-mcp serve` runs a `.mcp.kdl`, serves the derived tools over SSE, and guarded-executes each call through opcore. - go.mod pins a cli-guard version that includes `http/opcore`. - Tests: the tool list is derived from a sample `.mcp.kdl`, and a tool call round-trips through `Execute` against a mock upstream. - The repo's gate (`ward vet` / `ward test` or equivalent) is green. ## Related cli-guard#196 + #197 / #198 / #199 (the opcore engine, landed), ward-mcp#6 (grammar + distribution), ward-mcp#8 (the chart that runs this image), coilysiren/inbox#164 (status tracker).
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-mcp-7 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-07T09:33:21Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

run seed context — what this run is carrying (ward#609)
  • Resolved: coilyco-flight-deck/ward-mcp#7 · branch issue-7 · driver claude · workflow direct-main
  • Run: engineer-claude-ward-mcp-7 · ward v0.419.0 · dispatched 2026-07-07T09:33:21Z
  • Comment thread: 0 included in the pre-flight read, 0 stripped (ward's own automated comments).

Issue body as seeded:

## What

Build the generic **`ward-mcp serve` runtime** - the one image that renders any inline `.mcp.kdl` into a guarded MCP server over HTTP/SSE. No per-guardfile Go, no per-server handler. Entrypoint `ward-mcp serve /spec/<name>.mcp.kdl --http :8080`: parse → one MCP tool per grant → bind an SSE / streamable-HTTP listener. Never stdio. cli-mcp is a **code reference only**, not a dependency.

## The cli-guard engine is READY (opcore landed)

cli-guard#196 shipped as PR1-3 (cli-guard#197 / #198 / #199, all merged to main). The runtime is a **thin shell** over `http/opcore`. Pin a cli-guard version that includes `http/opcore` in go.mod, and consume this concrete API:

- **`opcore.ParseInline(src []byte) ([]opcore.Descriptor, opcore.RuntimeConfig, error)`** - parses the whole `.mcp.kdl` (wrap header, `base-url`, `auth <scheme> { value env <VAR> }`, `restrict`, and each `can <verb> <resource> { path/query/body/set }` grant). Method inferred from the verb, path params from the `{template}`. This is the frozen ward-mcp#6 grammar.
- **`descriptor.InputSchema() opcore.Schema`** and **`schema.JSONSchema() []byte`** - the tool inputSchema (draft-07). cli-guard is MCP-agnostic, so you wrap that schema in the MCP tool envelope.
- **`opcore.Operation{Desc: descriptor, RT: runtime}.Execute(ctx, opcore.Args{Path,Query,Body}) (opcore.Response, error)`** - self-guarding (metachar + restrict gates → assemble → sign with the env token → fire). `.Preview(args)` gives a resolved-but-unfired request if you want a dry-run.

Build the `opcore.Runtime` from the `RuntimeConfig` that `ParseInline` returns (auth env-value provider, base-url, restrictions).

## What ward-mcp owns (the thin shell - everything else is opcore)

- the `ward-mcp serve <spec> --http :addr` command.
- **project** each `Descriptor` → one MCP tool: name = `verb_resource`, inputSchema = `descriptor.InputSchema().JSONSchema()`, description = the grant's `Describe`.
- **serve** MCP over SSE / streamable-HTTP 

… (truncated to 2000 chars; full body is on this issue)

Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.419.0).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-mcp-7` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-07T09:33:21Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <details><summary>run seed context — what this run is carrying (ward#609)</summary> - **Resolved:** `coilyco-flight-deck/ward-mcp#7` · branch `issue-7` · driver `claude` · workflow `direct-main` - **Run:** `engineer-claude-ward-mcp-7` · ward `v0.419.0` · dispatched `2026-07-07T09:33:21Z` - **Comment thread:** 0 included in the pre-flight read, 0 stripped (ward's own automated comments). **Issue body as seeded:** ``` ## What Build the generic **`ward-mcp serve` runtime** - the one image that renders any inline `.mcp.kdl` into a guarded MCP server over HTTP/SSE. No per-guardfile Go, no per-server handler. Entrypoint `ward-mcp serve /spec/<name>.mcp.kdl --http :8080`: parse → one MCP tool per grant → bind an SSE / streamable-HTTP listener. Never stdio. cli-mcp is a **code reference only**, not a dependency. ## The cli-guard engine is READY (opcore landed) cli-guard#196 shipped as PR1-3 (cli-guard#197 / #198 / #199, all merged to main). The runtime is a **thin shell** over `http/opcore`. Pin a cli-guard version that includes `http/opcore` in go.mod, and consume this concrete API: - **`opcore.ParseInline(src []byte) ([]opcore.Descriptor, opcore.RuntimeConfig, error)`** - parses the whole `.mcp.kdl` (wrap header, `base-url`, `auth <scheme> { value env <VAR> }`, `restrict`, and each `can <verb> <resource> { path/query/body/set }` grant). Method inferred from the verb, path params from the `{template}`. This is the frozen ward-mcp#6 grammar. - **`descriptor.InputSchema() opcore.Schema`** and **`schema.JSONSchema() []byte`** - the tool inputSchema (draft-07). cli-guard is MCP-agnostic, so you wrap that schema in the MCP tool envelope. - **`opcore.Operation{Desc: descriptor, RT: runtime}.Execute(ctx, opcore.Args{Path,Query,Body}) (opcore.Response, error)`** - self-guarding (metachar + restrict gates → assemble → sign with the env token → fire). `.Preview(args)` gives a resolved-but-unfired request if you want a dry-run. Build the `opcore.Runtime` from the `RuntimeConfig` that `ParseInline` returns (auth env-value provider, base-url, restrictions). ## What ward-mcp owns (the thin shell - everything else is opcore) - the `ward-mcp serve <spec> --http :addr` command. - **project** each `Descriptor` → one MCP tool: name = `verb_resource`, inputSchema = `descriptor.InputSchema().JSONSchema()`, description = the grant's `Describe`. - **serve** MCP over SSE / streamable-HTTP ``` … (truncated to 2000 chars; full body is on this issue) Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.419.0). </details> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - ward-mcp serve runtime landed on main (opcore v0.80.0 thin shell, MCP over streamable-HTTP + legacy SSE, guarded-execute), closes #7.

This one went smoothly, mostly because opcore did the heavy lifting exactly as the issue promised. ParseInline -> Descriptor -> InputSchema().JSONSchema() -> Operation.Execute is a genuinely clean seam: the whole runtime is projection + JSON-RPC + transport, with zero guard logic re-implemented. Reading the opcore source and its tests first (before writing a line) paid off - the API was frozen and honest.

Two things fought back, both worth flagging as follow-ups rather than fixes:

  1. The committed grammar drifted from the shipped engine. The old examples/forgejo-issues.mcp.kdl used spec/never/no-path from an earlier draft; the frozen ParseInline grammar has none of those - it fails closed on unknown nodes, and denial is deny-by-absence, not a never node. I rewrote the example to the real grammar and updated DESIGN/README to match. Worth a glance to confirm the deny-by-absence framing reads right to you.
  2. Tool naming is lossy. Projection is verb_resource, so skillsmp's ai_search_skills can't come out clean (verbs are single tokens; I got search_ai-skills). Filed as a naming follow-up in DESIGN's "Still open" - it's cosmetic, the guarded surface is correct either way. Also deferred action composition since opcore v0.80.0 exposes single-op Execute only, no chain.

Confidence is high on the spine: build/vet/test green, and I smoke-tested both example specs end-to-end over real HTTP (initialize, tools/list, the SSE frame) plus a mock-upstream round-trip through Execute with auth-signing and a restrict denial. The one thing I could NOT exercise is a live call against real skillsmp/forgejo (no creds, and the skillsmp endpoints/params in the example are my best guess at the vendor's real OpenAPI) - so the skillsmp example is illustrative-pending-real-API, and deploy#40 will be the true first live proof. Merge with #8's chart was a 2-file doc conflict, resolved by keeping both the runtime and chart sections.

WARD-OUTCOME: done - `ward-mcp serve` runtime landed on main (opcore v0.80.0 thin shell, MCP over streamable-HTTP + legacy SSE, guarded-execute), closes #7. This one went smoothly, mostly because opcore did the heavy lifting exactly as the issue promised. `ParseInline` -> `Descriptor` -> `InputSchema().JSONSchema()` -> `Operation.Execute` is a genuinely clean seam: the whole runtime is projection + JSON-RPC + transport, with zero guard logic re-implemented. Reading the opcore source and its tests first (before writing a line) paid off - the API was frozen and honest. Two things fought back, both worth flagging as follow-ups rather than fixes: 1. **The committed grammar drifted from the shipped engine.** The old `examples/forgejo-issues.mcp.kdl` used `spec`/`never`/no-`path` from an earlier draft; the frozen `ParseInline` grammar has none of those - it fails closed on unknown nodes, and denial is **deny-by-absence**, not a `never` node. I rewrote the example to the real grammar and updated DESIGN/README to match. Worth a glance to confirm the deny-by-absence framing reads right to you. 2. **Tool naming is lossy.** Projection is `verb_resource`, so skillsmp's `ai_search_skills` can't come out clean (verbs are single tokens; I got `search_ai-skills`). Filed as a naming follow-up in DESIGN's "Still open" - it's cosmetic, the guarded surface is correct either way. Also deferred `action` composition since opcore v0.80.0 exposes single-op `Execute` only, no chain. Confidence is high on the spine: build/vet/test green, and I smoke-tested both example specs end-to-end over real HTTP (initialize, tools/list, the SSE frame) plus a mock-upstream round-trip through Execute with auth-signing and a restrict denial. The one thing I could NOT exercise is a live call against real skillsmp/forgejo (no creds, and the skillsmp endpoints/params in the example are my best guess at the vendor's real OpenAPI) - so the skillsmp example is illustrative-pending-real-API, and deploy#40 will be the true first live proof. Merge with #8's chart was a 2-file doc conflict, resolved by keeping both the runtime and chart sections.
Sign in to join this conversation.
No description provided.