extract as="feed-entries": project RSS and Atom into entries, so a feed guardfile stops handing the model raw XML #81

Closed
opened 2026-08-17 18:17:10 +00:00 by coilyco-ops · 2 comments
Member

Filed by Angie (engineer, claude seat) on Kai's call, as the follow-on to #60 and the last open cost of coilyco-bridge/deploy#608.

The gap

examples/reddit.mcp.kdl serves all four reads, and every one of them returns raw Atom. The bespoke server it replaces returns normalized entries. That downgrade is the single remaining carried cost of retiring reddit-mcp, and it is measurable on both sides:

  • Guardfile, measured on this repo's own live verification - {"coverage":{"truncated":false,"bytes":53381,"over_budget":true},"result":"<?xml version=\"1.0\" .... The model parses 53 KB of XML in context on every call, past the 8192-byte threshold internal/mcpserver/coverage.go:14 names.
  • reddit-mcp, called live today - 25 entries carrying title, author, url, permalink, published, updated, subreddit, dedup_key.

Kai accepted this trade when she chose migration, with the cost stated rather than hidden. This issue is the offer to stop paying it, because the seam it needs already exists.

Why here rather than in umbra

The obvious reading is that umbra should learn to decode a non-JSON body, since umbra is where raw-response lives. It should not, and this repo already says so at internal/mcpserver/pdf.go:51:

The projection lives here rather than in the guardfile grammar because turning an upstream response into tool content is this runtime's half of the boundary: umbra owns guarded execution, mcp-beaver owns projection.

Confirmed in umbra's tree at 599429f rather than taken from that comment. opcore.Descriptor carries exactly two response-side fields: FailWhen, a JMESPath postcondition that rejects a call and never reshapes it, and RawResponse, which is pass-through. There is no encoding/xml in any non-test file. A feed parser there would be the first response transform umbra has ever had, in the layer whose job is to not have one.

The shape

as already exists to make exactly this additive, per internal/mcpserver/pdf.go:54. One new value, one new bound:

extract "get_subreddit_rss" as="feed-entries" max-items="25"

Wiring points, all of which already do the right thing for a second extraction kind:

  • parseExtracts (pdf.go:59) reads the node and fails closed on an unknown property. The as switch at pdf.go:91 currently hard-rejects anything but pdf-text, so this is where the second value lands, alongside max-items beside max-pages at pdf.go:82.
  • validateExtracts (pdf.go:108) already refuses an extract whose grant lacks raw-response (pdf.go:123). All four reddit grants declare it, so all four are eligible with no guardfile change beyond the extract lines.
  • server.go:635 dispatches to pdfToolSuccess when an extract is present and to toolSuccess otherwise. That branch becomes a switch on the extraction kind.
  • Coverage reports entries shown of entries total, the way pdfToolSuccess does pages, so a bounded feed read cannot be mistaken for the whole feed.

max-items wants the same defaulting posture as max-pages: a default the guardfile raises rather than a ceiling it lowers into. 25 matches what reddit's Atom actually carries per feed, with a hard ceiling well under it in spirit.

The dependency call

Recommend stdlib encoding/xml. mmcdole/gofeed handles RSS 2.0, Atom, and RDF and would be the reflex choice, but this runtime carries one parser dependency total today and #60 treated dependency footprint as a deciding factor rather than an afterthought. Reddit publishes two shapes and both are small. If a later source needs RDF or a hostile-feed tolerance the stdlib does not give, that is the moment to take gofeed, and the as property makes that switch invisible to every guardfile.

Whichever way it goes, the size gate comes before the parser, matching maxPDFBytes at pdf.go:20.

Scope past reddit

Not reddit-specific in principle. Any RSS or Atom source is unreadable by a generated server today for the same reason PDFs were before #60. It is reddit-only in practice right now: no deployed guardfile in coilyco-bridge/deploy declares raw-response or extract, so reddit is the first and currently the only consumer.

Acceptance

  • A guardfile can declare extract "<tool>" as="feed-entries" and receive structured entries rather than an XML string.
  • Both shapes reddit serves parse: the Atom of /r/{subreddits}/new/.rss and whatever the homepage grant's path "/.rss" returns.
  • max-items bounds the result, and coverage states entries shown of entries total.
  • An extract naming a grant without raw-response stays a build error, unchanged.
  • An unknown as value stays a build error, unchanged.
  • Verified against live reddit.com, not only against a fixture.

Sequencing

This is a pre-flight for coilyco-bridge/deploy#608 rather than a cleanup after it. It blocks none of that issue's three deployment items, but landing it first is the difference between a migration that is a pure win and one that takes a downgrade Kai would then pay again to undo.

**Filed by Angie (engineer, `claude` seat)** on Kai's call, as the follow-on to `#60` and the last open cost of `coilyco-bridge/deploy#608`. ## The gap `examples/reddit.mcp.kdl` serves all four reads, and every one of them returns raw Atom. The bespoke server it replaces returns normalized entries. That downgrade is the single remaining carried cost of retiring reddit-mcp, and it is measurable on both sides: * **Guardfile**, measured on this repo's own live verification - `{"coverage":{"truncated":false,"bytes":53381,"over_budget":true},"result":"<?xml version=\"1.0\" ...`. The model parses 53 KB of XML in context on every call, past the 8192-byte threshold `internal/mcpserver/coverage.go:14` names. * **reddit-mcp**, called live today - 25 entries carrying `title`, `author`, `url`, `permalink`, `published`, `updated`, `subreddit`, `dedup_key`. Kai accepted this trade when she chose migration, with the cost stated rather than hidden. This issue is the offer to stop paying it, because the seam it needs already exists. ## Why here rather than in umbra The obvious reading is that umbra should learn to decode a non-JSON body, since umbra is where `raw-response` lives. It should not, and this repo already says so at `internal/mcpserver/pdf.go:51`: > The projection lives here rather than in the guardfile grammar because turning an upstream response into tool content is this runtime's half of the boundary: umbra owns guarded execution, mcp-beaver owns projection. Confirmed in umbra's tree at `599429f` rather than taken from that comment. `opcore.Descriptor` carries exactly two response-side fields: `FailWhen`, a JMESPath postcondition that rejects a call and never reshapes it, and `RawResponse`, which is pass-through. There is no `encoding/xml` in any non-test file. A feed parser there would be the first response transform umbra has ever had, in the layer whose job is to not have one. ## The shape `as` already exists to make exactly this additive, per `internal/mcpserver/pdf.go:54`. One new value, one new bound: ```kdl extract "get_subreddit_rss" as="feed-entries" max-items="25" ``` Wiring points, all of which already do the right thing for a second extraction kind: * `parseExtracts` (`pdf.go:59`) reads the node and fails closed on an unknown property. The `as` switch at `pdf.go:91` currently hard-rejects anything but `pdf-text`, so this is where the second value lands, alongside `max-items` beside `max-pages` at `pdf.go:82`. * `validateExtracts` (`pdf.go:108`) already refuses an `extract` whose grant lacks `raw-response` (`pdf.go:123`). All four reddit grants declare it, so all four are eligible with no guardfile change beyond the `extract` lines. * `server.go:635` dispatches to `pdfToolSuccess` when an extract is present and to `toolSuccess` otherwise. That branch becomes a switch on the extraction kind. * Coverage reports **entries shown of entries total**, the way `pdfToolSuccess` does pages, so a bounded feed read cannot be mistaken for the whole feed. `max-items` wants the same defaulting posture as `max-pages`: a default the guardfile raises rather than a ceiling it lowers into. 25 matches what reddit's Atom actually carries per feed, with a hard ceiling well under it in spirit. ## The dependency call **Recommend stdlib `encoding/xml`.** `mmcdole/gofeed` handles RSS 2.0, Atom, and RDF and would be the reflex choice, but this runtime carries one parser dependency total today and `#60` treated dependency footprint as a deciding factor rather than an afterthought. Reddit publishes two shapes and both are small. If a later source needs RDF or a hostile-feed tolerance the stdlib does not give, that is the moment to take gofeed, and the `as` property makes that switch invisible to every guardfile. Whichever way it goes, the size gate comes before the parser, matching `maxPDFBytes` at `pdf.go:20`. ## Scope past reddit Not reddit-specific in principle. Any RSS or Atom source is unreadable by a generated server today for the same reason PDFs were before `#60`. It is reddit-only in practice right now: no deployed guardfile in `coilyco-bridge/deploy` declares `raw-response` or `extract`, so reddit is the first and currently the only consumer. ## Acceptance * A guardfile can declare `extract "<tool>" as="feed-entries"` and receive structured entries rather than an XML string. * Both shapes reddit serves parse: the Atom of `/r/{subreddits}/new/.rss` and whatever the homepage grant's `path "/.rss"` returns. * `max-items` bounds the result, and coverage states entries shown of entries total. * An `extract` naming a grant without `raw-response` stays a build error, unchanged. * An unknown `as` value stays a build error, unchanged. * Verified against live reddit.com, not only against a fixture. ## Sequencing This is a **pre-flight** for `coilyco-bridge/deploy#608` rather than a cleanup after it. It blocks none of that issue's three deployment items, but landing it first is the difference between a migration that is a pure win and one that takes a downgrade Kai would then pay again to undo.
Author
Member

Reprioritized to P4 the same day it was filed, because its motivation moved. Angie (engineer, claude seat).

I filed this as a pre-flight for coilyco-bridge/deploy#608, on the argument that landing it first is the difference between a migration that is a pure win and one that takes a downgrade. Kai then made a call that removes that argument entirely: reddit support stays as the shelf guardfile and nothing gets deployed, so coilyco-bridge/deploy#639 retires reddit-mcp without standing anything up.

What changed

  • The 53 KB of Atom nobody parses. No deployment means no tool call means no cost paid. The measurement in the body is still real, and it is now hypothetical rather than recurring.
  • This is no longer sequenced against anything. Nothing waits on it and it blocks nothing.

What did not change

The capability argument, which never depended on reddit. A generated server cannot read any RSS or Atom source today, exactly as it could not read a PDF before #60. The next feed-shaped upstream hits this wall on day one, and examples/reddit.mcp.kdl is a ready worked example the moment someone wants the parse.

Keeping it open at P4 rather than closing it. Closing would file the gap under "solved", and it is not solved, it is just nobody's problem this week. Whoever picks it up should read the body's motivation as the next feed source, not as the reddit migration it was written for.

**Reprioritized to P4 the same day it was filed, because its motivation moved.** Angie (engineer, `claude` seat). I filed this as a **pre-flight** for `coilyco-bridge/deploy#608`, on the argument that landing it first is the difference between a migration that is a pure win and one that takes a downgrade. Kai then made a call that removes that argument entirely: reddit support stays as the shelf guardfile and **nothing gets deployed**, so `coilyco-bridge/deploy#639` retires reddit-mcp without standing anything up. ## What changed * **The 53 KB of Atom nobody parses.** No deployment means no tool call means no cost paid. The measurement in the body is still real, and it is now hypothetical rather than recurring. * **This is no longer sequenced against anything.** Nothing waits on it and it blocks nothing. ## What did not change The capability argument, which never depended on reddit. A generated server cannot read any RSS or Atom source today, exactly as it could not read a PDF before `#60`. The next feed-shaped upstream hits this wall on day one, and `examples/reddit.mcp.kdl` is a ready worked example the moment someone wants the parse. Keeping it open at P4 rather than closing it. Closing would file the gap under "solved", and it is not solved, it is just nobody's problem this week. Whoever picks it up should read the body's motivation as **the next feed source**, not as the reddit migration it was written for.
Author
Member

Landed on main as 6e8fe6a. Built by Angie (engineer, claude seat).

Acceptance, against the body's list

  • extract "<tool>" as="feed-entries" returns structured entries. Entries carry title, link, author, id, published, updated, categories, each omitted when the source did not supply it. That is the reddit-mcp field set, with categories carrying what it called subreddit and id carrying dedup_key.
  • Both shapes parse, verified live against reddit.com. get_subreddit_rss on golang+python and get_homepage_rss both returned 25 of 25 entries with every field populated on every entry.
  • max-items bounds the result and coverage states shown of total. Coverage gains entries: {shown, total} beside the existing pages.
  • An extract without raw-response stays a build error. Unchanged, and now covered by a test on the feed path too.
  • An unknown as value stays a build error. Unchanged. The message now names both valid values.

The measurement

The body measured 53,381 bytes with over_budget: true. Same tool, same subreddits, after this change:

{"coverage":{"truncated":false,"bytes":7895,"over_budget":false,"entries":{"shown":25,"total":25}},
 "result":{"title":"newest submissions : multi","entries":[...]}}

The homepage feed lands at 8,932 bytes and stays over_budget: true, just past the 8192 threshold. That is honest rather than a miss: it is a 6x reduction, and the flag is doing what #68 built it to do.

Most of the reduction is one decision worth naming. The entry body is dropped. Atom content and RSS description carry the whole post as HTML, and they are the bulk of what the projection exists to remove. What survives is what a model uses to pick an entry worth fetching in full.

What landed beyond the ask

  • RSS 2.0 as well as Atom. The body scoped this to the two shapes reddit serves, both Atom. Covering RSS 2.0 in the same normalized shape cost one struct, and without it the next feed source is a coin flip on whether it works. dc:creator, guid, pubDate, and text <link> all fold into the same entry.
  • A bound belonging to the other kind is a build error. max-pages on a feed extract and max-items on a PDF extract both fail closed, rather than being ignored.
  • docs/pdf-extraction.md and the new feed page merged into docs/extraction.md. Not a preference. docs/ was at the 20-doc cap, and the catalog-doc-size hook says merge related pages rather than split, so two extraction pages became one.

Dependency call

Took the body's recommendation: stdlib encoding/xml, no gofeed. The decoder is non-strict and passes an unknown charset through rather than refusing the document, because syndication in the wild still ships ISO-8859-1 and dropping a feed over one accented byte is a worse answer.

Shape

parseExtracts and validateExtracts moved out of pdf.go into a new extract.go, since the node stopped being PDF-specific, and feed.go carries the projection. server.go:681 dispatches through extractToolSuccess, which switches on the kind.

An empty feed returns an empty entry list at 0 of 0 rather than an error, because a subreddit with no new posts is a fact. A response that is not a feed is a clean tool error naming the document.

Full suite green, go vet clean, pre-commit run --all-files clean, lint-examples passes on all seven committed guardfiles.

Sequencing note

The #81 reprioritization comment stands: nothing was deployed and nothing waited on this. examples/reddit.mcp.kdl declares all four extracts now, so it is a worked example rather than a hypothetical, and the header comment that stated the raw-XML cost as knowingly accepted now records it as settled.

**Landed on `main` as `6e8fe6a`.** Built by Angie (engineer, `claude` seat). ## Acceptance, against the body's list * **`extract "<tool>" as="feed-entries"` returns structured entries.** Entries carry `title`, `link`, `author`, `id`, `published`, `updated`, `categories`, each omitted when the source did not supply it. That is the reddit-mcp field set, with `categories` carrying what it called `subreddit` and `id` carrying `dedup_key`. * **Both shapes parse, verified live against reddit.com.** `get_subreddit_rss` on `golang+python` and `get_homepage_rss` both returned 25 of 25 entries with every field populated on every entry. * **`max-items` bounds the result and coverage states shown of total.** Coverage gains `entries: {shown, total}` beside the existing `pages`. * **An `extract` without `raw-response` stays a build error.** Unchanged, and now covered by a test on the feed path too. * **An unknown `as` value stays a build error.** Unchanged. The message now names both valid values. ## The measurement The body measured 53,381 bytes with `over_budget: true`. Same tool, same subreddits, after this change: ``` {"coverage":{"truncated":false,"bytes":7895,"over_budget":false,"entries":{"shown":25,"total":25}}, "result":{"title":"newest submissions : multi","entries":[...]}} ``` The homepage feed lands at 8,932 bytes and stays `over_budget: true`, just past the 8192 threshold. That is honest rather than a miss: it is a 6x reduction, and the flag is doing what `#68` built it to do. Most of the reduction is one decision worth naming. **The entry body is dropped.** Atom `content` and RSS `description` carry the whole post as HTML, and they are the bulk of what the projection exists to remove. What survives is what a model uses to pick an entry worth fetching in full. ## What landed beyond the ask * **RSS 2.0 as well as Atom.** The body scoped this to the two shapes reddit serves, both Atom. Covering RSS 2.0 in the same normalized shape cost one struct, and without it the next feed source is a coin flip on whether it works. `dc:creator`, `guid`, `pubDate`, and text `<link>` all fold into the same entry. * **A bound belonging to the other kind is a build error.** `max-pages` on a feed extract and `max-items` on a PDF extract both fail closed, rather than being ignored. * **`docs/pdf-extraction.md` and the new feed page merged into `docs/extraction.md`.** Not a preference. `docs/` was at the 20-doc cap, and the `catalog-doc-size` hook says merge related pages rather than split, so two extraction pages became one. ## Dependency call Took the body's recommendation: stdlib `encoding/xml`, no `gofeed`. The decoder is non-strict and passes an unknown charset through rather than refusing the document, because syndication in the wild still ships ISO-8859-1 and dropping a feed over one accented byte is a worse answer. ## Shape `parseExtracts` and `validateExtracts` moved out of `pdf.go` into a new `extract.go`, since the node stopped being PDF-specific, and `feed.go` carries the projection. `server.go:681` dispatches through `extractToolSuccess`, which switches on the kind. An empty feed returns an empty entry list at `0 of 0` rather than an error, because a subreddit with no new posts is a fact. A response that is not a feed is a clean tool error naming the document. Full suite green, `go vet` clean, `pre-commit run --all-files` clean, `lint-examples` passes on all seven committed guardfiles. ## Sequencing note The `#81` reprioritization comment stands: nothing was deployed and nothing waited on this. `examples/reddit.mcp.kdl` declares all four extracts now, so it is a worked example rather than a hypothetical, and the header comment that stated the raw-XML cost as knowingly accepted now records it as settled.
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/mcp-beaver#81
No description provided.