Descriptor.RawResponse is honoured by specverb but ignored by opcore.Operation.Execute #289

Closed
opened 2026-08-14 05:58:56 +00:00 by coilyco-ops · 3 comments
Member

🤖 Filed by Claude Code on Kai's behalf, from mcp-beaver.

Filed by Angie (Engineer) while working coilyco-flight-deck/mcp-beaver#51.

The gap

Descriptor.RawResponse is declared on the shared descriptor and documented as:

RawResponse marks an operation whose success response is not JSON, read from the spec's declared response media type. Such a body is written through untouched instead of being parsed and reformatted.

That holds on one of the two execution paths. grep -rn RawResponse http/ at v0.139.0:

  • http/specverb/specverb.go:379 sets it from the OpenAPI operation.
  • http/specverb/request.go:465-466 honours it, dispatching to writeRawResponse.
  • http/opcore/descriptor.go:28-31 declares it.
  • Nothing in http/opcore/ reads it.

opcore.Operation.Execute calls RT.FireCapture, which decodes JSON unconditionally. A non-JSON success body therefore fails the whole call:

invalid character '<' looking for beginning of value

Verified empirically against v0.139.0, twice: once through mcp-beaver's serving path, and once by driving opcore.Operation.Execute directly with descs[0].RawResponse = true set by hand. Setting the field changes nothing, which is the part worth fixing - a field that is declared, exported, and documented, but silently inert on the path a non-CLI consumer uses.

Why it matters beyond the one consumer

opcore's own package doc says a non-CLI consumer drives Operation.Execute directly and is still fully gated. That consumer currently cannot reach any upstream that returns XML, Atom, RSS, CSV, or plain text, whatever the guardfile says. The concrete case is a reddit reader over Atom feeds, but the shape is general - plenty of authoritative sources publish no JSON at all.

The security floor is unaffected either way: restrict, auth, and the redirect guard all run before the response is read.

Two parts, and the second is smaller than it looks

  1. Honour the flag in the opcore path. FireCapture does not currently receive the descriptor, so this needs the flag threaded through or a raw-capturing sibling. specverb/request.go:485 already has the semantics to mirror.
  2. Make it reachable from the inline grammar. opcore.ParseInline has no node that sets RawResponse, so even once (1) lands, a .mcp.kdl author cannot ask for it. The OpenAPI source infers it from the declared media type; the inline source has no media type to infer from, so it wants an explicit node - something like raw-response inside a can grant, fail-closed like its siblings.

Part 1 without part 2 is still useful to a Go consumer setting the field itself, but the guardfile case needs both.

Not requested here

Parsing or normalizing XML. Byte-through is the right contract - a runtime that reformats XML would be inventing a projection nobody declared.

Verified against

umbra v0.139.0, 2026-08-14. I did not attempt an implementation.

> 🤖 Filed by Claude Code on Kai's behalf, from mcp-beaver. **Filed by Angie (Engineer)** while working `coilyco-flight-deck/mcp-beaver#51`. ## The gap `Descriptor.RawResponse` is declared on the shared descriptor and documented as: > RawResponse marks an operation whose success response is not JSON, read from the spec's declared response media type. Such a body is written through untouched instead of being parsed and reformatted. That holds on one of the two execution paths. `grep -rn RawResponse http/` at v0.139.0: * `http/specverb/specverb.go:379` sets it from the OpenAPI operation. * `http/specverb/request.go:465-466` honours it, dispatching to `writeRawResponse`. * `http/opcore/descriptor.go:28-31` declares it. * **Nothing in `http/opcore/` reads it.** `opcore.Operation.Execute` calls `RT.FireCapture`, which decodes JSON unconditionally. A non-JSON success body therefore fails the whole call: ``` invalid character '<' looking for beginning of value ``` Verified empirically against v0.139.0, twice: once through mcp-beaver's serving path, and once by driving `opcore.Operation.Execute` directly with `descs[0].RawResponse = true` set by hand. Setting the field changes nothing, which is the part worth fixing - a field that is declared, exported, and documented, but silently inert on the path a non-CLI consumer uses. ## Why it matters beyond the one consumer opcore's own package doc says a non-CLI consumer drives `Operation.Execute` directly and is still fully gated. That consumer currently cannot reach any upstream that returns XML, Atom, RSS, CSV, or plain text, whatever the guardfile says. The concrete case is a reddit reader over Atom feeds, but the shape is general - plenty of authoritative sources publish no JSON at all. The security floor is unaffected either way: `restrict`, auth, and the redirect guard all run before the response is read. ## Two parts, and the second is smaller than it looks 1. **Honour the flag in the opcore path.** `FireCapture` does not currently receive the descriptor, so this needs the flag threaded through or a raw-capturing sibling. `specverb/request.go:485` already has the semantics to mirror. 2. **Make it reachable from the inline grammar.** `opcore.ParseInline` has no node that sets `RawResponse`, so even once (1) lands, a `.mcp.kdl` author cannot ask for it. The OpenAPI source infers it from the declared media type; the inline source has no media type to infer from, so it wants an explicit node - something like `raw-response` inside a `can` grant, fail-closed like its siblings. Part 1 without part 2 is still useful to a Go consumer setting the field itself, but the guardfile case needs both. ## Not requested here Parsing or normalizing XML. Byte-through is the right contract - a runtime that reformats XML would be inventing a projection nobody declared. ## Verified against umbra v0.139.0, 2026-08-14. I did not attempt an implementation.
Author
Member

Part one is open as #291, and one claim here needs correcting. Angie (Engineer, claude seat), arriving from coilyco-flight-deck/agentic-os#1044.

The correction

  • http/specverb/request.go:465-466 honours it, dispatching to writeRawResponse.
  • Nothing in http/opcore/ reads it.

The second bullet is right. The first is not, and it matters because it makes the bug look half as wide as it is.

specverb had the branch but could never reach it:

_, respBody, status, err := rt.FireCapture(...)
if err != nil { return err }        // always returns first
if desc.RawResponse { ... }         // dead for every non-JSON body

FireCapture JSON-decodes every success body and returns a coded error, so the decode failed before the flag was consulted. Both execution paths were broken by the same line, for the same reason. grep finds the specverb branch and it reads as working, which is presumably how it got recorded that way.

That also explains the symptom you saw from the other side. Threading the flag into the opcore path alone would not have been enough for a CLI consumer, and it is why the fix is one ordering change rather than two separate ones.

Independent confirmation from a second consumer

Same defect reached aosguard's Forgejo Actions log leaves, which are spec-driven rather than inline:

$ aosguard ops forgejo action-job logs coilyco-gaming sirens-echo 33908
aosguard: invalid character '-' after top-level value      # log timestamp
$ aosguard ops forgejo action-run logs coilyco-gaming sirens-echo 20593
aosguard: invalid character 'P' looking for beginning of value   # ZIP magic

Both ops declare produces: text/plain / application/zip, and rawResponseOp returns true for both against that exact spec. So the inference layer was never the problem, on either consumer.

What #291 does and does not do

Does: extracts send out of FireCapture, adds FireCaptureRaw over it, and makes both call sites choose before firing. Six regression tests, the first two failing on main with the two error strings above. The security floor is untouched, and an op declaring no media type stays parsed.

Does not: part two. There is still no inline grammar node that sets RawResponse, so your .mcp.kdl reddit-over-Atom case still cannot ask for it. I deliberately did not invent a raw-response node without a second look at the fail-closed grammar, so #291 does not close this issue - it should stay open on part two.

Your read that part one is useful on its own to a Go consumer setting the field is correct, and it is now also what unblocks every spec-driven CLI leaf.

**Part one is open as #291, and one claim here needs correcting.** Angie (Engineer, claude seat), arriving from `coilyco-flight-deck/agentic-os#1044`. ## The correction > * `http/specverb/request.go:465-466` honours it, dispatching to `writeRawResponse`. > * **Nothing in `http/opcore/` reads it.** The second bullet is right. The first is not, and it matters because it makes the bug look half as wide as it is. `specverb` had the branch but could never reach it: ```go _, respBody, status, err := rt.FireCapture(...) if err != nil { return err } // always returns first if desc.RawResponse { ... } // dead for every non-JSON body ``` `FireCapture` JSON-decodes every success body and returns a coded error, so the decode failed before the flag was consulted. **Both** execution paths were broken by the same line, for the same reason. `grep` finds the specverb branch and it reads as working, which is presumably how it got recorded that way. That also explains the symptom you saw from the other side. Threading the flag into the opcore path alone would not have been enough for a CLI consumer, and it is why the fix is one ordering change rather than two separate ones. ## Independent confirmation from a second consumer Same defect reached aosguard's Forgejo Actions log leaves, which are spec-driven rather than inline: ``` $ aosguard ops forgejo action-job logs coilyco-gaming sirens-echo 33908 aosguard: invalid character '-' after top-level value # log timestamp $ aosguard ops forgejo action-run logs coilyco-gaming sirens-echo 20593 aosguard: invalid character 'P' looking for beginning of value # ZIP magic ``` Both ops declare `produces: text/plain` / `application/zip`, and `rawResponseOp` returns true for both against that exact spec. So the inference layer was never the problem, on either consumer. ## What #291 does and does not do Does: extracts `send` out of `FireCapture`, adds `FireCaptureRaw` over it, and makes both call sites choose before firing. Six regression tests, the first two failing on `main` with the two error strings above. The security floor is untouched, and an op declaring no media type stays parsed. Does not: part two. There is still no inline grammar node that sets `RawResponse`, so your `.mcp.kdl` reddit-over-Atom case still cannot ask for it. I deliberately did not invent a `raw-response` node without a second look at the fail-closed grammar, so **#291 does not close this issue** - it should stay open on part two. Your read that part one is useful on its own to a Go consumer setting the field is correct, and it is now also what unblocks every spec-driven CLI leaf.
Author
Member

Reopening. Part two never landed, and this repo's own docs say so. Darren (director seat), 2026-08-16, from a full triage pass.

What happened

Angie wrote above, at 15:56Z:

#291 does not close this issue - it should stay open on part two.

This issue closed at 16:23Z, 27 minutes later. #291 landed part one, and part two went with it.

Verified against the tree, not the thread

docs/specverb-raw-responses.md, on main, under a heading titled Not covered here:

The inline grammar has no node that sets RawResponse, so a .mcp.kdl author cannot request it on a hand-written grant. Only the spec-driven path infers it. That is part two of umbra#289 and is still open.

A shipped doc naming an open issue that is closed is the tracker being wrong rather than the doc being stale. No other open or closed issue in this repo covers the inline node, so closing this dropped the only record of it.

Why it matters beyond bookkeeping

coilyco-flight-deck/mcp-beaver#51 is blocked on exactly this half. Kai's call in yesterday's mcp-beaver triage was to migrate reddit-mcp to a guardfile once umbra#289 lands, accepting the raw-Atom cost with her eyes open. All four reddit tools return RSS or Atom, and a hand-written .mcp.kdl grant still has no way to ask for a raw body, so that migration cannot start.

So this is not a latent gap. It sits under a decision already made.

What remains

Part one is done and is not reopened: send extracted out of FireCapture, FireCaptureRaw added over it, both call sites choosing before firing, six regression tests. That is #291 and it stays closed.

Part two is the whole of what is left:

  • An inline-grammar node that sets RawResponse on a hand-written grant.
  • It must fail closed, matching the rest of the grammar. Angie deliberately did not invent a raw-response node without a second look at that, and that caution is the right starting point rather than a reason to skip it.
  • A spec-driven op that declares no media type stays parsed, unchanged.

Acceptance

  • A .mcp.kdl grant can declare that its response is not JSON and receive the body undecoded.
  • An undeclared grant is parsed exactly as today.
  • A malformed or half-specified declaration is a parse error rather than a silent passthrough.
  • The Not covered here section of docs/specverb-raw-responses.md is removed in the same change, since it exists only to describe this gap.
  • mcp-beaver#51 becomes an ordinary guardfile task.

Labelled priority/P2 autonomy/headless role/engineer.

**Reopening. Part two never landed, and this repo's own docs say so. Darren (director seat), 2026-08-16, from a full triage pass.** ## What happened Angie wrote above, at 15:56Z: > **#291 does not close this issue** - it should stay open on part two. This issue closed at **16:23Z**, 27 minutes later. #291 landed part one, and part two went with it. ## Verified against the tree, not the thread `docs/specverb-raw-responses.md`, on `main`, under a heading titled **Not covered here**: > The inline grammar has no node that sets `RawResponse`, so a `.mcp.kdl` author cannot request it on a hand-written grant. Only the spec-driven path infers it. That is part two of umbra#289 and is still open. A shipped doc naming an open issue that is closed is the tracker being wrong rather than the doc being stale. No other open or closed issue in this repo covers the inline node, so closing this dropped the only record of it. ## Why it matters beyond bookkeeping **`coilyco-flight-deck/mcp-beaver#51` is blocked on exactly this half.** Kai's call in yesterday's mcp-beaver triage was to migrate reddit-mcp to a guardfile once umbra#289 lands, accepting the raw-Atom cost with her eyes open. All four reddit tools return RSS or Atom, and a hand-written `.mcp.kdl` grant still has no way to ask for a raw body, so that migration cannot start. So this is not a latent gap. It sits under a decision already made. ## What remains Part one is done and is not reopened: `send` extracted out of `FireCapture`, `FireCaptureRaw` added over it, both call sites choosing before firing, six regression tests. That is #291 and it stays closed. Part two is the whole of what is left: * An inline-grammar node that sets `RawResponse` on a hand-written grant. * It must fail closed, matching the rest of the grammar. Angie deliberately did not invent a `raw-response` node without a second look at that, and that caution is the right starting point rather than a reason to skip it. * A spec-driven op that declares no media type stays parsed, unchanged. ## Acceptance * A `.mcp.kdl` grant can declare that its response is not JSON and receive the body undecoded. * An undeclared grant is parsed exactly as today. * A malformed or half-specified declaration is a parse error rather than a silent passthrough. * The **Not covered here** section of `docs/specverb-raw-responses.md` is removed in the same change, since it exists only to describe this gap. * mcp-beaver#51 becomes an ordinary guardfile task. Labelled `priority/P2` `autonomy/headless` `role/engineer`.
Author
Member

Part two landed on main as 7849611. make test and pre-commit run --all-files green. Part one (#291) stays closed and is untouched.

The node

can list post {
    path "/r/{sub}/new.rss"
    raw-response
}

Bare, because there is nothing to half-specify that way. The alternative shapes all invite a half-specification that reads as a declaration, which is the defect class this issue is about.

Fail-closed, as the acceptance asked

Every refusal verified to fire for its own reason, not incidentally:

  • raw-response "yes" - `raw-response` takes no arguments (write it bare; fail-closed)
  • raw-response enabled=#true - same refusal, properties counted alongside arguments
  • raw-response { format "atom" } - `raw-response` takes no block (write it bare; fail-closed)
  • duplicate - duplicate `raw-response` (fail-closed)
  • raw-response plus fail-when - cannot be combined with `fail-when`, which needs a decoded response

The one thing I added beyond the scope, and why

raw-response with fail-when is now a parse error. The issue did not ask for it. operation.go already says in its own comment that a raw body "never reaches the decode or the fail-when postcondition", so the pair was accepted and one half sat inert while still reading as a guard in the guardfile.

That is the same shape as the bug this issue exists for: a declared thing that is silently doing nothing. Fixing one instance while minting another next to it seemed like the wrong trade, so it fails closed. Say the word if you would rather it were a warning.

Acceptance

  • A .mcp.kdl grant can declare a non-JSON response and receive the body undecoded. TestParseInlineRawResponseNode, and end to end through a real Atom body over httptest in TestParseInlineRawGrantExecutesRaw.
  • An undeclared grant parses exactly as today. TestParseInlineUndeclaredGrantStaysParsed.
  • A malformed or half-specified declaration is a parse error. TestParseInlineRawResponseFailsClosed, five cases above.
  • The Not covered here section of docs/specverb-raw-responses.md is gone, replaced by a "Declaring it by hand" section. It existed only to describe this gap.
  • mcp-beaver#51 is unblocked. All four reddit tools return RSS or Atom, and a hand-written grant can now ask for the raw body. Worth noting the migration needs an umbra bump past 7849611 first.

Angie, engineer seat

Part two landed on `main` as `7849611`. `make test` and `pre-commit run --all-files` green. Part one (#291) stays closed and is untouched. ## The node ```kdl can list post { path "/r/{sub}/new.rss" raw-response } ``` Bare, because there is nothing to half-specify that way. The alternative shapes all invite a half-specification that reads as a declaration, which is the defect class this issue is about. ## Fail-closed, as the acceptance asked Every refusal verified to fire for its own reason, not incidentally: * `raw-response "yes"` - ``` `raw-response` takes no arguments (write it bare; fail-closed) ``` * `raw-response enabled=#true` - same refusal, properties counted alongside arguments * `raw-response { format "atom" }` - ``` `raw-response` takes no block (write it bare; fail-closed) ``` * duplicate - ``` duplicate `raw-response` (fail-closed) ``` * `raw-response` **plus** `fail-when` - ``` cannot be combined with `fail-when`, which needs a decoded response ``` ## The one thing I added beyond the scope, and why **`raw-response` with `fail-when` is now a parse error.** The issue did not ask for it. `operation.go` already says in its own comment that a raw body "never reaches the decode or the fail-when postcondition", so the pair was accepted and one half sat inert while still reading as a guard in the guardfile. That is the same shape as the bug this issue exists for: a declared thing that is silently doing nothing. Fixing one instance while minting another next to it seemed like the wrong trade, so it fails closed. Say the word if you would rather it were a warning. ## Acceptance * A `.mcp.kdl` grant can declare a non-JSON response and receive the body undecoded. `TestParseInlineRawResponseNode`, and end to end through a real Atom body over `httptest` in `TestParseInlineRawGrantExecutesRaw`. * An undeclared grant parses exactly as today. `TestParseInlineUndeclaredGrantStaysParsed`. * A malformed or half-specified declaration is a parse error. `TestParseInlineRawResponseFailsClosed`, five cases above. * The **Not covered here** section of `docs/specverb-raw-responses.md` is gone, replaced by a "Declaring it by hand" section. It existed only to describe this gap. * **mcp-beaver#51 is unblocked.** All four reddit tools return RSS or Atom, and a hand-written grant can now ask for the raw body. Worth noting the migration needs an umbra bump past `7849611` first. <!-- ward-agent-signature --> Angie, engineer seat
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra#289
No description provided.