feat(mcp): expose story corroboration to agents (#4925 item 3) - #6421
Merged
koala73 merged 5 commits intoAug 11, 2026
Merged
Conversation
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.
|
@Yigtwxx is attempting to deploy a commit to the World Monitor Team on Vercel. A member of the Team first needs to authorize it. |
Normalize numeric digest timestamps, preserve unknown legacy world-brief fields, and bound duplicated grounding URLs before serialization.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-551onstoryMeta.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:
get_world_brieftopStoriescollapsed to a flatheadlines: string[]— every corroboration field discardedtopStories[]withsourceCount,uniqueSourceCount,corroborationSourceCount,entityCorroboration,sourceTier, outlet namesget_country_briefcorroborationCountandstoryMetapresent on every grounding item, dropped at the projectiongroundingStories[]withcorroborationCount,mentionCount,storyPhaseget_news_intelligenceThree places the issue's premise needed correcting
I would rather say these than ship quietly against them.
The seeded snapshot carries no
storyMeta. The issue asks forcorroborationCountandstoryMeta.phaseon both brief tools.scripts/seed-insights.mjs:867-898dropsstoryMetawhen it buildsnews:insights:v1topStories, keepingsourceCount/uniqueSourceCount/corroborationSourceCount/entityCorroboration/memberTitles/sourceTier. SostoryMeta.phaseis exposable only on theget_country_briefdigest path, andget_world_briefgets corroboration counts instead. Putting phase back on the world brief means editing the seeder and re-running the cron, which is a different change.get_news_intelligencedoes not under-documentsourceCount. It is declared atapi/mcp/registry/cache-tools.ts:768. The genuinely undeclared set is nine other fields, all traceable toscripts/seed-insights.mjs:877-897.Corroboration cannot be attached to
get_country_brief.sources[]. That array is the protoBriefSourceshape 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:962prefers the gateway's ownresult.sourcesover the MCP-local grounding set, so on the common path an enriched local array would never be returned at all. Hence the siblinggroundingStoriesfield, with a test that stubs a non-empty upstreamsourcesand assertsgroundingStoriesis still populated, so that decision cannot be quietly undone.Design decisions for maintainer review
shared/insights-snapshot.jsis untouched, deliberately.normalizeInsightSourceis 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 dragshared/insights-snapshot.d.tsandsrc/utils/brief-sources.tsalong and would put permanently-undefined fields on the digest-sourced call path — and it is unnecessary, becauseprojectSeededWorldBriefalready holds the story record. Say the word if you want it there anyway.memberTitlesis omitted fromget_world_briefon output-budget grounds: it is a full set of clustered headlines, the largest field available and the most redundant withtitle, in a 64 KB budget whose overflow replaces the entire response (api/mcp/dispatch.ts:286-332). It is declared onget_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.headlinesis unchanged andtopStoriesis built in the same loop, so "topStories[i]describesheadlines[i]" is a guarantee rather than a coincidence two loops could drift apart on.groundingStoriesrather 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_FADINGis 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/listtruncates a tool description to its first sentence and 120 bytes (api/mcp/utils.ts:42-65,TOOL_DESCRIPTION_MAX_BYTESinapi/mcp/constants.ts:329), andget_world_brief's first sentence is already 117 bytes. So the description additions here reachdescribe_tool, the generated OpenAPI and the docs — not the initial tool listing. First sentences are byte-identical to before. This is the pre-existing constraintrpc-tools.ts:386-393documents, and it is the reason the emit half of this change matters more than the declaration half.Type of change
Affected areas
/api/*) — the MCP tool registry underapi/mcp/docs/,docs/zh/) andpublic/.well-known/mcp/server-card.jsonChecklist
mcpHandler, plus the mutation proofs below.tests/mcp-world-brief-routing.test.mjsalready covers every variant host and still passes.api/rss-proxy.jsallowlist (if adding feeds) — N/A, no feeds added.npm run typecheck) — clean, after the pre-existingTS2688 webmcp-typesworkaround.Documentation Alignment Checklist
.protois touched, and MCPoutputSchemais hand-authored underapi/mcp/registry/thenstructuredCloned onto the wire atapi/mcp/registry/index.ts:97. One generated artifact did need regenerating:public/.well-known/mcp/server-card.jsonmirrors every tool description for the orank pre-connection scanner, andtests/mcp-tools-list-compression.test.mjs:454fails if it drifts. Regenerated fromTOOL_REGISTRYusing the command that test documents; the diff is exactly the three edited descriptions. NoSERVER_VERSIONbump — the tool set is unchanged.tests/fixtures/jmespath-samples/coverget_market_data,get_conflict_eventsandget_chokepoint_status, none of which is touched. The new suites build their fixtures inline from the producer's actual field names.news:insights:v1— writerscripts/seed-insights.mjs; readersapi/mcp/registry/cache-tools.ts(get_news_intelligence, via_cacheKeys) andget_world_briefthrough the gatewayget-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.tswriteStoryTracking); 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 test:data: failure name sets diffed against a cleanorigin/maincheckout on the same machine, since the suite is not green there. 61 names shared; the only difference istests/feed-catalog-drift.test.mts, which passes 38/38 alone and reds only under--test-concurrency=16. Notablymcp registry publication artifactsfails identically on both — that is themcp-registry-authed25519 key line, unrelated to this change.The first
test:datarun did catch a real regression from this PR: the staleserver-card.json. It is fixed in the third commit rather than papered over.Mutation proofs
storyCorroborationinprojectSeededWorldBriefgroundingStoriescorroborationSourceCountfrom theget_news_intelligenceschemaper-tool outputSchema coverageredget_world_briefandget_country_briefhad no served-shape enforcement at all before this:tests/mcp-tool-output-contracts.test.mjsmonkey-patches_executeand 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
publishedAtidentity-hijack hardening) — a genuinely separate change with a RedisEXISTSpipeline and an injectedresolveExistingTracksdep. Happy to take it next if you want it.fnv1achanges which items cluster, so the canonicaltitleHashcan shift on the first cycle after a rotation and resetstory:trackphase and mention counts for affected stories. It also moves the collision distribution thatSTORY_SIMILARITY_THRESHOLD = 0.615was 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.get_world_briefstill returnssummaryas a byte-identical duplicate ofbrief(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 ifmemberTitlesever 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.