Add graphql support #70

Closed
opened 2026-08-15 23:00:17 +00:00 by coilysiren · 4 comments
Owner
No description provided.
Member

Decision recorded by Darren (director seat), 2026-08-16, from a full triage pass of this repo. Kai's call in a consult round.

The call

#70 is a first-class graphql grant type, and it is the epic. A graphql block carrying the document, with variables mapped to typed tool arguments and ideally schema-checked at lint. #65 is subsumed by it and closes as superseded.

What was rejected, and why

  • Both, sequenced. This was the recommended option: land #65's narrow body-literal construct now as a tracer to unblock AniList this week, and keep #70 open as the epic for the richer surface. Kai declined it. The cost is real and worth stating plainly rather than discovering later - coilyco-bridge/deploy#467 AniList stays blocked until this lands, and deploy#557 Wikidata cannot choose curated queries over a raw SPARQL passthrough until then either.
  • #65's construct alone, closing #70 as delivered by it. Declined. It is the smallest diff and reuses an existing concept, and it leaves every GraphQL guardfile hand-writing its document as an unvalidated string.

Findings carried forward from #65

Recorded here so closing #65 loses nothing. All verified by Olaf against the live APIs.

  • body key=value sets an entirely fixed document with no caller input, so a search tool could not take a title.
  • Inline body { field ... } mounts caller flags with no way to give one a fixed literal value.
  • body { map "a" to="b" } remaps caller input and cannot introduce a value the caller did not send.
  • Fields and map cannot be combined, refused outright at http/opcore/inline.go:231, and validateBodyMappingMode refuses a fixed body alongside mappings.
  • set is body-only and equally all-or-nothing. pin reaches query parameters rather than body fields.
  • The pin workaround fits a GraphQL API accepting GET /?query=...&variables=.... AniList is POST-only and returns 404 on GET, so it does not apply there.

The construct therefore has to introduce an authored literal and caller-supplied fields into one body, and no existing grammar node can.

Acceptance this implies

  • A guardfile expresses a fixed document plus caller-supplied variables in one request.
  • The document is not caller-influenceable and does not appear in the tool schema.
  • Variables surface as typed tool arguments rather than one opaque object.
  • ward-mcp lint fails closed on a half-specified graphql block rather than silently sending one.
  • deploy#467 AniList becomes an ordinary guardfile once it lands.

Unresolved

Whether lint validates the document against a fetched schema or only parses it. That is a scoping call for the children rather than a blocker on starting.

Relabelled priority/P2 autonomy/epic role/director role/engineer. Children carry their own autonomy ceilings, so nothing dispatches this as a single task.

**Decision recorded by Darren (director seat), 2026-08-16, from a full triage pass of this repo. Kai's call in a consult round.** ## The call **#70 is a first-class `graphql` grant type, and it is the epic.** A `graphql` block carrying the document, with variables mapped to typed tool arguments and ideally schema-checked at lint. **#65 is subsumed by it and closes as superseded.** ## What was rejected, and why * **Both, sequenced.** This was the recommended option: land #65's narrow body-literal construct now as a tracer to unblock AniList this week, and keep #70 open as the epic for the richer surface. Kai declined it. The cost is real and worth stating plainly rather than discovering later - `coilyco-bridge/deploy#467` AniList stays blocked until this lands, and `deploy#557` Wikidata cannot choose curated queries over a raw SPARQL passthrough until then either. * **#65's construct alone**, closing #70 as delivered by it. Declined. It is the smallest diff and reuses an existing concept, and it leaves every GraphQL guardfile hand-writing its document as an unvalidated string. ## Findings carried forward from #65 Recorded here so closing #65 loses nothing. All verified by Olaf against the live APIs. * `body key=value` sets an entirely fixed document with no caller input, so a search tool could not take a title. * Inline `body { field ... }` mounts caller flags with no way to give one a fixed literal value. * `body { map "a" to="b" }` remaps caller input and cannot introduce a value the caller did not send. * Fields and `map` cannot be combined, refused outright at `http/opcore/inline.go:231`, and `validateBodyMappingMode` refuses a fixed body alongside mappings. * `set` is body-only and equally all-or-nothing. `pin` reaches query parameters rather than body fields. * The `pin` workaround fits a GraphQL API accepting `GET /?query=...&variables=...`. AniList is POST-only and returns 404 on GET, so it does not apply there. The construct therefore has to introduce an authored literal and caller-supplied fields into one body, and no existing grammar node can. ## Acceptance this implies * A guardfile expresses a fixed document plus caller-supplied variables in one request. * The document is not caller-influenceable and does not appear in the tool schema. * Variables surface as typed tool arguments rather than one opaque object. * `ward-mcp lint` fails closed on a half-specified graphql block rather than silently sending one. * `deploy#467` AniList becomes an ordinary guardfile once it lands. ## Unresolved Whether lint validates the document against a fetched schema or only parses it. That is a scoping call for the children rather than a blocker on starting. Relabelled `priority/P2` `autonomy/epic` `role/director` `role/engineer`. Children carry their own autonomy ceilings, so nothing dispatches this as a single task.
Member

Engineer read by Angie (claude seat), 2026-08-19. Not a priority call and not a decomposition into children, which the director comment reserves. This is the repo-boundary finding a child would otherwise hit on day one, recorded before anyone starts.

Most of this epic cannot be built in this repo

Read from umbra's pinned tree at v0.154.0, the version go.mod names, rather than from the #65 findings quoted above.

A graphql block carrying the document is a grant-body node, and grant-body nodes are umbra's. applyInlineGrantChild at http/opcore/inline.go:210 is the switch that owns them, and its full set today is path, query, body, method, raw-response, describe, set, fail-when. A graphql case lands there and nowhere else.

grep -rn -i graphql over the whole umbra tree at that version returns nothing, and the same grep over this repo returns nothing. There is also no umbra issue for this: a search across umbra's open and closed issues returns only #286, which is unrelated. So the epic's main deliverable currently has no home issue in the repo that would have to carry it.

This repo's own boundary says the same thing twice. AGENTS.md: "umbra owns guarded HTTP execution. mcp-beaver owns MCP and HTTP tool projection plus transport." And parseInlineDoc in internal/mcpserver/inlinedoc.go: sibling nodes "never touch the frozen wrap-body grammar or the umbra pin."

Three of the four acceptance bullets are therefore umbra work:

  • A fixed document plus caller-supplied variables in one request - umbra grammar.
  • The document absent from the tool schema and not caller-influenceable - umbra, since Descriptor.InputSchema() is what this runtime projects and it is built there.
  • lint failing closed on a half-specified graphql block - umbra, same reason validateBodyMappingMode at http/opcore/body_mapping.go:106 lives there.

deploy#467 AniList becoming an ordinary guardfile is then a consequence, not a separate build.

There is a real mcp-beaver slice, and it is small

Worth naming so the epic does not read as "nothing to do here". GraphQL answers a failed query with HTTP 200 and an errors array, and its payload is wrapped in data. Two consequences measured against this repo as it stands:

  • Refusing on errors is umbra's fail-when, a JMESPath postcondition that already exists.
  • Unwrapping data and surfacing errors is this runtime's half, by exactly the rule that put #60 and #81 here rather than in umbra: turning an upstream response into tool content is projection.

That slice has a concrete symptom today. countArrays in internal/mcpserver/coverage.go counts arrays one level deep, so on {"data":{"Page":{"media":[...]}}} the top-level value is a map and no items count is produced at all. Every GraphQL response would arrive with an empty coverage count, which is the #68 failure mode the coverage block exists to prevent.

That is a headless, engineer-shaped child of the size #81 just was, and it is the only part of this epic that is buildable here.

What I did not do

I did not file the umbra issue, open children, or relabel anything. The split above is a technical finding. Which repo carries the epic, and whether it moves, is the director call this issue is already labelled for.

**Engineer read by Angie (`claude` seat), 2026-08-19.** Not a priority call and not a decomposition into children, which the director comment reserves. This is the repo-boundary finding a child would otherwise hit on day one, recorded before anyone starts. ## Most of this epic cannot be built in this repo Read from umbra's pinned tree at `v0.154.0`, the version `go.mod` names, rather than from the `#65` findings quoted above. A `graphql` block carrying the document is a **grant-body node**, and grant-body nodes are umbra's. `applyInlineGrantChild` at `http/opcore/inline.go:210` is the switch that owns them, and its full set today is `path`, `query`, `body`, `method`, `raw-response`, `describe`, `set`, `fail-when`. A `graphql` case lands there and nowhere else. `grep -rn -i graphql` over the whole umbra tree at that version returns **nothing**, and the same grep over this repo returns nothing. There is also **no umbra issue for this**: a search across umbra's open and closed issues returns only `#286`, which is unrelated. So the epic's main deliverable currently has no home issue in the repo that would have to carry it. This repo's own boundary says the same thing twice. `AGENTS.md`: "umbra owns guarded HTTP execution. mcp-beaver owns MCP and HTTP tool projection plus transport." And `parseInlineDoc` in `internal/mcpserver/inlinedoc.go`: sibling nodes "never touch the frozen wrap-body grammar or the umbra pin." Three of the four acceptance bullets are therefore umbra work: * A fixed document plus caller-supplied variables in one request - umbra grammar. * The document absent from the tool schema and not caller-influenceable - umbra, since `Descriptor.InputSchema()` is what this runtime projects and it is built there. * `lint` failing closed on a half-specified graphql block - umbra, same reason `validateBodyMappingMode` at `http/opcore/body_mapping.go:106` lives there. `deploy#467` AniList becoming an ordinary guardfile is then a consequence, not a separate build. ## There is a real mcp-beaver slice, and it is small Worth naming so the epic does not read as "nothing to do here". GraphQL answers a failed query with **HTTP 200 and an `errors` array**, and its payload is wrapped in `data`. Two consequences measured against this repo as it stands: * **Refusing on `errors`** is umbra's `fail-when`, a JMESPath postcondition that already exists. * **Unwrapping `data` and surfacing `errors`** is this runtime's half, by exactly the rule that put `#60` and `#81` here rather than in umbra: turning an upstream response into tool content is projection. That slice has a concrete symptom today. `countArrays` in `internal/mcpserver/coverage.go` counts arrays one level deep, so on `{"data":{"Page":{"media":[...]}}}` the top-level value is a map and **no `items` count is produced at all**. Every GraphQL response would arrive with an empty coverage count, which is the `#68` failure mode the coverage block exists to prevent. That is a headless, engineer-shaped child of the size `#81` just was, and it is the only part of this epic that is buildable here. ## What I did not do I did not file the umbra issue, open children, or relabel anything. The split above is a technical finding. Which repo carries the epic, and whether it moves, is the director call this issue is already labelled for.
Member

Delivered. Closing. Angie (engineer, claude seat), on Kai's call to teach umbra graphql rather than route around it.

The boundary read above turned out to be right: the grammar was umbra's, so the epic landed as umbra#306 plus two small consumer changes here.

Acceptance, against the director comment's five bullets

  • A guardfile expresses a fixed document plus caller-supplied variables in one request. graphql { document "..." }, umbra 47eed47.
  • The document is not caller-influenceable and does not appear in the tool schema. Verified through a served mcp-beaver: tools/list offers exactly search and page, and a caller passing query alongside search was served the authored document anyway.
  • Variables surface as typed tool arguments rather than one opaque object. search (required string), page (integer, 1..100, described). Derived from the operation signature, so the block cannot disagree with the document.
  • lint fails closed on a half-specified graphql block. Missing or blank document, unknown child, a variable the document does not declare, an unstated non-scalar, two operations, a duplicate block, combination with body/map/set, and a non-POST method. That last one is #65's AniList 404, refused at build.
  • deploy#467 AniList becomes an ordinary guardfile. Live call through mcp-beaver returned real results.

What landed here rather than in umbra

  • 5802a9a - pin umbra v0.159.0. No code change: variables project at LocationBody, so the existing splitArgs routes them and Execute does the rest.
  • e8eefc0 - #88, the coverage slice this issue's comment predicted. Measured live: data.Page.media was uncounted because countArrays walked one level, so every GraphQL response arrived with an empty items block. Now "items":{"data.Page.media":3}.

Still open, deliberately

Whether lint validates the document against a fetched schema, or only parses it. The director comment left this to the children and it stayed unresolved: what shipped is document-internal only, a signature reader with no network and no GraphQL dependency. The selection set is sent verbatim and never type-checked.

That is a real remaining capability, not a defect: a wrong field name still fails at the upstream rather than at lint. Say the word and I will file it as its own issue rather than let it sit inside a closed epic.

Refusing a response carrying errors stays the guardfile's fail-when, per the split recorded here: umbra owns refusing a call, this runtime owns projecting one.

Reopening is cheap if the epic was scoped wider than I have read it.

**Delivered. Closing.** Angie (engineer, `claude` seat), on Kai's call to teach umbra graphql rather than route around it. The boundary read above turned out to be right: the grammar was umbra's, so the epic landed as `umbra#306` plus two small consumer changes here. ## Acceptance, against the director comment's five bullets * **A guardfile expresses a fixed document plus caller-supplied variables in one request.** `graphql { document "..." }`, umbra `47eed47`. * **The document is not caller-influenceable and does not appear in the tool schema.** Verified through a served mcp-beaver: `tools/list` offers exactly `search` and `page`, and a caller passing `query` alongside `search` was served the authored document anyway. * **Variables surface as typed tool arguments rather than one opaque object.** `search` (required string), `page` (integer, 1..100, described). Derived from the operation signature, so the block cannot disagree with the document. * **`lint` fails closed on a half-specified graphql block.** Missing or blank document, unknown child, a `variable` the document does not declare, an unstated non-scalar, two operations, a duplicate block, combination with `body`/`map`/`set`, and a non-POST method. That last one is `#65`'s AniList 404, refused at build. * **`deploy#467` AniList becomes an ordinary guardfile.** Live call through mcp-beaver returned real results. ## What landed here rather than in umbra * `5802a9a` - pin umbra `v0.159.0`. No code change: variables project at `LocationBody`, so the existing `splitArgs` routes them and `Execute` does the rest. * `e8eefc0` - `#88`, the coverage slice this issue's comment predicted. Measured live: `data.Page.media` was uncounted because `countArrays` walked one level, so every GraphQL response arrived with an empty `items` block. Now `"items":{"data.Page.media":3}`. ## Still open, deliberately **Whether lint validates the document against a fetched schema, or only parses it.** The director comment left this to the children and it stayed unresolved: what shipped is document-internal only, a signature reader with no network and no GraphQL dependency. The selection set is sent verbatim and never type-checked. That is a real remaining capability, not a defect: a wrong field name still fails at the upstream rather than at lint. Say the word and I will file it as its own issue rather than let it sit inside a closed epic. **Refusing a response carrying `errors`** stays the guardfile's `fail-when`, per the split recorded here: umbra owns refusing a call, this runtime owns projecting one. Reopening is cheap if the epic was scoped wider than I have read it.
Member

The unresolved question this epic left open is now tracked at umbra#309, so it is not lost inside a closed issue.

Filed there rather than here because the document parser it would extend lives in umbra's opcore. Labelled priority/P4 autonomy/async-consult role/director role/engineer: it carries real scoping calls I deliberately did not guess at, so it should not be dispatched headless.

The constraint that shapes it, recorded there: ParseInline runs at pod startup on every boot, so it must stay offline and dependency-free. A schema fetch cannot go in the parse path without making a pod's readiness depend on the upstream being reachable. It has to be a separate lint-time capability, and where that lives, where the schema comes from, what an air-gapped CI does, and what dependency it costs are all open.

The unresolved question this epic left open is now tracked at **`umbra#309`**, so it is not lost inside a closed issue. Filed there rather than here because the document parser it would extend lives in umbra's `opcore`. Labelled `priority/P4` `autonomy/async-consult` `role/director` `role/engineer`: it carries real scoping calls I deliberately did not guess at, so it should not be dispatched headless. The constraint that shapes it, recorded there: `ParseInline` runs at pod startup on every boot, so it must stay offline and dependency-free. A schema fetch cannot go in the parse path without making a pod's readiness depend on the upstream being reachable. It has to be a separate lint-time capability, and where that lives, where the schema comes from, what an air-gapped CI does, and what dependency it costs are all open.
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/mcp-beaver#70
No description provided.