Skip to content

feat(mcp): expose story corroboration to agents (#4925 item 3) - #6421

Merged
koala73 merged 5 commits into
koala73:mainfrom
Yigtwxx:feat/mcp-expose-story-corroboration-4925
Aug 11, 2026
Merged

feat(mcp): expose story corroboration to agents (#4925 item 3)#6421
koala73 merged 5 commits into
koala73:mainfrom
Yigtwxx:feat/mcp-expose-story-corroboration-4925

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements item 3 of #4925. The story-identity work in #4924 made corroboration trustworthy, and the insights seeder and the digest both publish it — but every MCP path stripped it before an agent could see it. The dashboard's own NewsPanel renders exactly this signal (src/components/NewsPanel.ts:549-551 on storyMeta.phase), so today an agent is strictly worse informed than the UI about how well-reported a story is.

Three surfaces, in increasing order of how much code they needed:

Tool Was Now
get_world_brief topStories collapsed to a flat headlines: string[] — every corroboration field discarded index-aligned topStories[] with sourceCount, uniqueSourceCount, corroborationSourceCount, entityCorroboration, sourceTier, outlet names
get_country_brief corroborationCount and storyMeta present on every grounding item, dropped at the projection new groundingStories[] with corroborationCount, mentionCount, storyPhase
get_news_intelligence already served nine corroboration fields; the schema did not declare them declared (pure documentation, no runtime change)

Three places the issue's premise needed correcting

I would rather say these than ship quietly against them.

  1. The seeded snapshot carries no storyMeta. The issue asks for corroborationCount and storyMeta.phase on both brief tools. scripts/seed-insights.mjs:867-898 drops storyMeta when it builds news:insights:v1 topStories, keeping sourceCount / uniqueSourceCount / corroborationSourceCount / entityCorroboration / memberTitles / sourceTier. So storyMeta.phase is exposable only on the get_country_brief digest path, and get_world_brief gets corroboration counts instead. Putting phase back on the world brief means editing the seeder and re-running the cron, which is a different change.

  2. get_news_intelligence does not under-document sourceCount. It is declared at api/mcp/registry/cache-tools.ts:768. The genuinely undeclared set is nine other fields, all traceable to scripts/seed-insights.mjs:877-897.

  3. Corroboration cannot be attached to get_country_brief.sources[]. That array is the proto BriefSource shape returned by the gateway (src/generated/server/worldmonitor/intelligence/v1/service_server.ts:140-145) — exactly title/source/url/publishedAt. Widening it is a proto change. Worse, rpc-tools.ts:962 prefers the gateway's own result.sources over the MCP-local grounding set, so on the common path an enriched local array would never be returned at all. Hence the sibling groundingStories field, with a test that stubs a non-empty upstream sources and asserts groundingStories is still populated, so that decision cannot be quietly undone.

Design decisions for maintainer review

  • shared/insights-snapshot.js is untouched, deliberately. normalizeInsightSource is the citation-record normalizer and is the choke point precisely because all four of its fields are citation fields. Corroboration is a property of the story cluster, not of a citation. Widening it would drag shared/insights-snapshot.d.ts and src/utils/brief-sources.ts along and would put permanently-undefined fields on the digest-sourced call path — and it is unnecessary, because projectSeededWorldBrief already holds the story record. Say the word if you want it there anyway.
  • memberTitles is omitted from get_world_brief on output-budget grounds: it is a full set of clustered headlines, the largest field available and the most redundant with title, in a 64 KB budget whose overflow replaces the entire response (api/mcp/dispatch.ts:286-332). It is declared on get_news_intelligence, which has 128 KB and already puts those bytes on the wire. There is a test asserting the omission on the world brief, so a later "just add everything" change has to argue with it.
  • The per-story outlet list is capped at 12 in the projector, not trusted from the producer. It is the only unbounded sub-array on this payload. A 12-story worst case measures ~30 KB against the 64 KB budget, and there is a test at that worst case.
  • headlines is unchanged and topStories is built in the same loop, so "topStories[i] describes headlines[i]" is a guarantee rather than a coincidence two loops could drift apart on.
  • Items with no corroboration metadata are dropped from groundingStories rather than emitted as zeroes. A digest predating feat(news): unified story-identity service — one similarity definition for clustering, dedup, corroboration (#4919) #4924 would otherwise read as "single unconfirmed source", which is a claim the data does not support.
  • STORY_PHASE_FADING is declared but unreachable. derivePhase (server/worldmonitor/news/v1/list-feed-digest.ts:1013-1022) documents that it cannot currently return it. Declared with a "reserved" note rather than misstating the enum's domain — tell me if you would rather it be omitted.

One honest limit on the description half

tools/list truncates a tool description to its first sentence and 120 bytes (api/mcp/utils.ts:42-65, TOOL_DESCRIPTION_MAX_BYTES in api/mcp/constants.ts:329), and get_world_brief's first sentence is already 117 bytes. So the description additions here reach describe_tool, the generated OpenAPI and the docs — not the initial tool listing. First sentences are byte-identical to before. This is the pre-existing constraint rpc-tools.ts:386-393 documents, and it is the reason the emit half of this change matters more than the declaration half.

Type of change

  • Bug fix
  • New feature
  • New data source / feed
  • New map layer
  • Refactor / code cleanup
  • Documentation
  • CI / Build / Infrastructure

Affected areas

  • Map / Globe
  • News panels / RSS feeds — read-side only; the digest and NewsPanel are unchanged, this exposes what they already produce
  • AI Insights / World Brief
  • Market Radar / Crypto
  • Desktop app (Tauri)
  • API endpoints (/api/*) — the MCP tool registry under api/mcp/
  • Config / Settings
  • Other: published docs (docs/, docs/zh/) and public/.well-known/mcp/server-card.json

Checklist

  • Tested on worldmonitor.app variant — N/A as a browser check; this is an agent-facing MCP surface with no UI. Covered instead by 11 new automated cases driving the real projectors end-to-end through mcpHandler, plus the mutation proofs below.
  • Tested on tech.worldmonitor.app variant (if applicable) — N/A. The MCP registry is variant-independent; tests/mcp-world-brief-routing.test.mjs already covers every variant host and still passes.
  • New RSS feed domains added to api/rss-proxy.js allowlist (if adding feeds) — N/A, no feeds added.
  • No API keys or secrets committed
  • TypeScript compiles without errors (npm run typecheck) — clean, after the pre-existing TS2688 webmcp-types workaround.

Documentation Alignment Checklist

  • Claim ledger attached or linked — the "three places the issue's premise needed correcting" section above is the ledger; each correction carries the file:line that establishes it.
  • All required Audit Council role signoffs attached — N/A. I am an outside contributor with no access to that process; every claim is backed by a file:line instead.
  • Generated docs regenerated from proto where applicable — N/A for proto: no .proto is touched, and MCP outputSchema is hand-authored under api/mcp/registry/ then structuredCloned onto the wire at api/mcp/registry/index.ts:97. One generated artifact did need regenerating: public/.well-known/mcp/server-card.json mirrors every tool description for the orank pre-connection scanner, and tests/mcp-tools-list-compression.test.mjs:454 fails if it drifts. Regenerated from TOOL_REGISTRY using the command that test documents; the diff is exactly the three edited descriptions. No SERVER_VERSION bump — the tool set is unchanged.
  • Fixture-backed examples recomputed — N/A: the three jmespath fixtures in tests/fixtures/jmespath-samples/ cover get_market_data, get_conflict_events and get_chokepoint_status, none of which is touched. The new suites build their fixtures inline from the producer's actual field names.
  • Redis writers/readers enumerated for every documented key:
    • news:insights:v1 — writer scripts/seed-insights.mjs; readers api/mcp/registry/cache-tools.ts (get_news_intelligence, via _cacheKeys) and get_world_brief through the gateway get-bootstrap-data?keys=insights. No new key, no shape change — this PR only stops discarding fields already in the payload.
    • news:digest:* / story:track:* — written by the digest path (server/worldmonitor/news/v1/list-feed-digest.ts writeStoryTracking); this PR adds no writer and no reader, it reads the HTTP digest response.

Screenshots

N/A — agent-facing MCP output and documentation only, no UI surface.

Verification

npm run typecheck                                     clean
npx tsx --test tests/mcp-world-brief-corroboration.test.mjs \
    tests/mcp-country-brief-grounding.test.mjs tests/mcp-world-brief-routing.test.mjs \
    tests/mcp-output-schema-coverage.test.mjs tests/mcp-tool-output-contracts.test.mjs \
    tests/mcp-tools-reference-docs.test.mjs tests/mcp-analysis-rpc-tools-contract.test.mjs \
    tests/mcp-tools-list-compression.test.mjs tests/llms-txt-mcp-tools.test.mjs
                                                      all pass

npm run test:data: failure name sets diffed against a clean origin/main checkout on the same machine, since the suite is not green there. 61 names shared; the only difference is tests/feed-catalog-drift.test.mts, which passes 38/38 alone and reds only under --test-concurrency=16. Notably mcp registry publication artifacts fails identically on both — that is the mcp-registry-auth ed25519 key line, unrelated to this change.

The first test:data run did catch a real regression from this PR: the stale server-card.json. It is fixed in the third commit rather than papered over.

Mutation proofs

Mutation Result
stop pushing to storyCorroboration in projectSeededWorldBrief all 6 world-brief cases red
never populate groundingStories 4 of 5 country-brief cases red
delete corroborationSourceCount from the get_news_intelligence schema per-tool outputSchema coverage red

get_world_brief and get_country_brief had no served-shape enforcement at all before this: tests/mcp-tool-output-contracts.test.mjs monkey-patches _execute and states that field-level drift for RPC tools is out of scope, and no jmespath fixture reaches either tool. The two new suites drive the real projectors, which is why the mutations above bite.

Not in this PR

  • Item 1 of chore(news): story-identity hardening follow-ups from the #4924 adversarial review — adopt-existing-track canonical, feature-hash salt, corroboration exposure on brief tools #4925 (adopt-existing-track canonical, the backdated-publishedAt identity-hijack hardening) — a genuinely separate change with a Redis EXISTS pipeline and an injected resolveExistingTracks dep. Happy to take it next if you want it.
  • Item 2 (feature-hash salt). Worth flagging why I stopped: salting fnv1a changes which items cluster, so the canonical titleHash can shift on the first cycle after a rotation and reset story:track phase and mention counts for affected stories. It also moves the collision distribution that STORY_SIMILARITY_THRESHOLD = 0.615 was tuned against, and that threshold has only a 0.019 margin (min positive 0.634, max negative 0.595). The real cost is re-tuning and re-validating that separation, not the runtime — that should be a deliberate decision, not a rider on this PR.
  • Item 4 (candidate-token stopword list) is marked advisory in the issue and is untouched.
  • get_world_brief still returns summary as a byte-identical duplicate of brief (rpc-tools.ts), roughly 4-8 KB of the output budget spent on a compatibility alias. Not touched, but it is the cheapest budget reclaim available if memberTitles ever needs to fit.

Sizing, field naming, and the groundingStories-versus-proto-change call are the parts most worth your judgement — say the word and I will reshape any of them.

The insights seeder computes corroboration on every cluster and publishes it
in news:insights:v1 — uniqueSourceCount, corroborationSourceCount,
entityCorroboration, sourceTier, and the contributing outlet names. The MCP
projector dropped all of it: the headline loop kept primaryTitle and nothing
else, collapsing topStories into a flat string array. An agent calling
get_world_brief could not tell a six-outlet corroborated story from a single
unconfirmed claim, while the dashboard's own NewsPanel shows exactly that
distinction.

headlines is unchanged, and the new topStories array is built inside the same
loop so "topStories[i] describes headlines[i]" is a guarantee rather than a
coincidence two loops could drift apart on. sources is left alone: it carries
producer citation records on the brief's citation index, which is a different
cardinality and a different meaning from a story cluster.

memberTitles is deliberately omitted. It is a full set of clustered headlines,
which would be the largest field in a 64 KB output budget and the most
redundant with title. There is a test asserting the omission so a later
"just add everything" change has to argue with it.

The per-story outlet list is capped at 12 in the projector rather than trusted
from the producer, because it is the only unbounded sub-array here and a
budget overflow replaces the whole response rather than truncating it. Every
field is coerced: this projector is the trust boundary for the MCP surface,
and a pre-koala73#4925 snapshot carries none of these keys.

This is also the first served-shape coverage get_world_brief has had.
mcp-tool-output-contracts monkey-patches _execute and says field-level drift
for RPC tools is out of scope, and none of the jmespath fixtures in
mcp-output-schema-coverage reach this tool, so the new suite drives the real
projector against a real snapshot instead.
Every item the country brief grounds on carries corroborationCount and
storyMeta — toProtoItem emits both in list-feed-digest — and the tool threw
them away before an agent ever saw them.

Corroboration cannot ride on `sources`. That array is the proto BriefSource
shape (title/source/url/publishedAt) returned by the gateway, so widening it
would be a proto change, and on the common path get-country-intel-brief
supplies its own sources which win over the MCP-local grounding set. It goes
on a sibling `groundingStories` field instead, which leaves the citation list
exactly as it is.

For the same reason the field is built from groundingItems rather than from
`sources`: deriving it from the returned sources would leave it empty most of
the time, which is precisely the failure it exists to avoid. There is a test
that stubs a non-empty upstream sources array and asserts groundingStories is
still populated, so that decision cannot be quietly undone.

Items carrying neither field are dropped rather than emitted as zeroes — a
digest predating the story-identity service would otherwise read as "single
unconfirmed source", a claim we cannot make. An empty array is also what a
failed digest read produces, which is the honest signal that no corroboration
was observed rather than that there was none.

collectMcpBriefSources is left untouched: it feeds briefSourceContextLines,
which becomes LLM prompt text, and story metadata has no business there.

STORY_PHASE_FADING is declared in the enum with a note that derivePhase cannot
currently emit it, rather than leaving the enum's domain misstated.
…rves

get_news_intelligence is a cache tool: the raw news:insights:v1 blob is served
and _postFilter only narrows and caps, never strips. So nine corroboration and
clustering fields the seeder writes into every topStories entry already reach
the client, and the schema simply did not admit to them — uniqueSourceCount,
sources, memberTitles, lastUpdated, sourceTier, entityCorroboration,
corroborationSourceCount, upstreamImportanceScore, effectiveImportanceScore.
This is a pure documentation change with no runtime effect.

memberTitles IS declared here, unlike on get_world_brief, because this tool has
twice the output budget and these are bytes already on the wire; omitting it
would leave the schema lying by omission about its largest field.

The schema-coverage suite now also pins the two brief tools' declarations, so a
schema-versus-emit divergence is caught even if the behavioural suites are
edited, including the deliberate absence of memberTitles on get_world_brief.

Docs updated for all three tools in EN and zh. Worth stating plainly: tools/list
truncates a description to its first sentence and 120 bytes (api/mcp/utils.ts),
and get_world_brief's first sentence is already 117 bytes, so these description
additions reach describe_tool, the generated OpenAPI and the docs — not the
initial tool listing. That is the pre-existing constraint documented in
rpc-tools.ts, and the reason the emit half of this stack matters more than the
declaration half.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

@Yigtwxx is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the trust:safe Brin: contributor trust score safe label Aug 10, 2026
Normalize numeric digest timestamps, preserve unknown legacy world-brief fields, and bound duplicated grounding URLs before serialization.
@koala73
koala73 merged commit 0c9f2c7 into koala73:main Aug 11, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trust:safe Brin: contributor trust score safe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants