@cyanheads/gdelt-mcp-server 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +13 -8
  3. package/changelog/0.2.x/0.2.4.md +14 -0
  4. package/changelog/0.2.x/0.2.5.md +21 -0
  5. package/dist/mcp-server/tools/date-range.d.ts +55 -3
  6. package/dist/mcp-server/tools/date-range.d.ts.map +1 -1
  7. package/dist/mcp-server/tools/date-range.js +107 -3
  8. package/dist/mcp-server/tools/date-range.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.d.ts +14 -0
  10. package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.js +125 -25
  12. package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.d.ts +7 -0
  14. package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.js +77 -12
  16. package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-tone-distribution.tool.js +1 -1
  18. package/dist/mcp-server/tools/definitions/get-tone-distribution.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-tv-clips.tool.d.ts +4 -0
  20. package/dist/mcp-server/tools/definitions/get-tv-clips.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-tv-clips.tool.js +52 -8
  22. package/dist/mcp-server/tools/definitions/get-tv-clips.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-tv-context.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/get-tv-context.tool.js +2 -5
  25. package/dist/mcp-server/tools/definitions/get-tv-context.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/get-tv-trending.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/get-tv-trending.tool.js +1 -3
  28. package/dist/mcp-server/tools/definitions/get-tv-trending.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +4 -0
  30. package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-articles.tool.js +52 -9
  32. package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
  33. package/package.json +1 -1
  34. package/server.json +3 -3
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** gdelt-mcp-server
4
- **Version:** 0.2.3
4
+ **Version:** 0.2.5
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.10.14`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.2.3-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/gdelt-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/gdelt-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/gdelt-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.14-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.2.5-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/gdelt-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/gdelt-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/gdelt-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.14-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -33,13 +33,13 @@ Nine tools across two GDELT APIs — DOC API for global print/web news (last 3 m
33
33
 
34
34
  | Tool | Description |
35
35
  |:---|:---|
36
- | `gdelt_search_articles` | Search the last 3 months of global news coverage (65 languages) with full-text and filter operators. Returns up to 250 articles. |
37
- | `gdelt_get_coverage_timeline` | Retrieve a time series of coverage volume or average tone for a query. `volume_with_articles` mode includes top articles per spike timestep. |
36
+ | `gdelt_search_articles` | Search the last 3 months of global news coverage (65 languages) with full-text and filter operators. Returns up to 250 articles, and hands back the date windows to re-query when that ceiling is hit. |
37
+ | `gdelt_get_coverage_timeline` | Retrieve a time series of coverage volume or average tone for a query. `volume_with_articles` mode includes top articles per spike timestep, with `points` to render a timestep's full article list. |
38
38
  | `gdelt_get_tone_distribution` | Get a tone histogram (bins ~−30 to +30) showing whether coverage is uniformly negative, bimodal, or clustered near neutral. |
39
- | `gdelt_get_coverage_breakdown` | Break down coverage volume by source language or source country — a multi-series time series showing geographic propagation. |
39
+ | `gdelt_get_coverage_breakdown` | Break down coverage volume by source language or source country — a multi-series time series showing geographic propagation. Values are normalized shares of media output, not article counts. |
40
40
  | `gdelt_search_tv` | Search US television news closed captions (2009–Oct 2024) and return per-station airtime time series. |
41
- | `gdelt_get_tv_clips` | Retrieve up to 3,000 matching TV clips with transcript excerpts and Internet Archive viewing links. |
42
- | `gdelt_get_tv_context` | Get the 200 most frequent co-occurring words and phrases from TV clips matching a query. |
41
+ | `gdelt_get_tv_clips` | Retrieve up to 3,000 matching TV clips with transcript excerpts and Internet Archive viewing links, and the date windows to re-query when that ceiling is hit. |
42
+ | `gdelt_get_tv_context` | Get the most frequent co-occurring words and phrases from TV clips matching a query. |
43
43
  | `gdelt_get_tv_trending` | Retrieve trending topics currently dominating US television news (updated every 15 minutes; no query required). |
44
44
  | `gdelt_list_tv_stations` | List all TV stations with market, network, and monitoring date ranges to verify station availability before querying. |
45
45
 
@@ -52,6 +52,7 @@ Search the last 3 months of global news with GDELT's full query syntax.
52
52
  - Proximity and repetition: `near20:"flu virus"`, `repeat3:"outbreak"`
53
53
  - Configurable sort (relevance, date) and result count (up to 250)
54
54
  - Returns URL, title, publication date, domain, language, source country, and social image URL
55
+ - 250 is a hard per-call ceiling, not a page size — GDELT exposes no cursor. Fill it and the response returns `continuationWindows`: the queried window halved, ready to re-query. The halves overlap by a second so nothing falls through the seam; de-duplicate by `url`
55
56
  - Query is echoed in response for chaining
56
57
 
57
58
  ---
@@ -63,6 +64,7 @@ Retrieve when coverage of a topic spiked, with three modes:
63
64
  - `volume` — normalized percentage of all global coverage per timestep
64
65
  - `volume_with_articles` — volume plus top articles driving each spike; use for signal detection without a follow-up search call
65
66
  - `tone` — average sentiment score per timestep (combine with `gdelt_get_tone_distribution` for the full picture)
67
+ - Every article reference is always in `structuredContent`; the text surface renders the first 3 links per timestep beside that timestep's true count, and `points: ["<date>"]` renders named timesteps in full
66
68
  - Configurable smoothing and time range
67
69
 
68
70
  ---
@@ -82,7 +84,9 @@ Snapshot tone histogram across all articles matching a query.
82
84
  Multi-series time series showing which countries or languages drove coverage.
83
85
 
84
86
  - Break down by `language` or `country`
85
- - Top 10 series by total volume; remaining series aggregated into an "Other" bucket
87
+ - Top 10 series by total volume; remaining series aggregated into an "Other" bucket and named in `otherSeriesLabels`
88
+ - Nothing is dissolved into "Other" anonymously — pass any label to `series: ["<label>"]` and that series comes back complete under `selectedSeries`, alongside the usual overview
89
+ - Values are normalized — the topic's share of media output, not absolute article counts. Small media markets with concentrated coverage rank above large markets with diverse output, so a high value means the topic dominated that source's coverage rather than that it published the most articles
86
90
  - Use to trace how a story propagated geographically
87
91
 
88
92
  ---
@@ -104,6 +108,7 @@ Retrieve actual TV news clips driving a coverage signal.
104
108
 
105
109
  - Up to 3,000 clips per call
106
110
  - Each clip: show name, station, air timestamp, 15-second transcript excerpt, direct Archive.org link, and optional thumbnail
111
+ - 3,000 is a hard per-call ceiling, not a page size — GDELT exposes no cursor. Fill it and the response returns `continuationWindows`: the queried window halved, ready to re-query. The halves overlap by a second so nothing falls through the seam; de-duplicate by `archiveUrl`
107
112
  - Sort by relevance, date descending, or date ascending
108
113
 
109
114
  ---
@@ -112,7 +117,7 @@ Retrieve actual TV news clips driving a coverage signal.
112
117
 
113
118
  Vocabulary framing analysis for TV coverage of a topic.
114
119
 
115
- - Returns the 200 most frequent non-stopword terms from matching clips
120
+ - Returns the most frequent non-stopword terms from matching clips
116
121
  - Relative frequency scores (query term = 100)
117
122
  - Use to identify narrative framing, related concepts, or follow-up search terms
118
123
 
@@ -0,0 +1,14 @@
1
+ ---
2
+ summary: "Fixed: content[] omitted array data five tools returned in full in structuredContent (#19); gdelt_get_coverage_breakdown now documents its normalized values (#10)"
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.2.4 — 2026-07-15
8
+
9
+ ## Fixed
10
+
11
+ - **`gdelt_get_tone_distribution`, `gdelt_get_tv_context`, `gdelt_get_tv_trending`** — `format()` sliced `content[]` to 2/50/50 elements while `structuredContent` carried the full array; clients reading only the text surface lost data. All three now render every element ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
12
+ - **`gdelt_get_coverage_timeline`** — removed the 10-point cap on rendered series points; every point's date and value now renders. The per-point article link list stays capped at 3 (`ARTICLES_PER_POINT`), tracked separately in [#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24) ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
13
+ - **`gdelt_get_coverage_breakdown`** — `format()` rendered only each series' total and peak; every `topSeries` and `otherAggregated` point now renders. `gdelt_search_tv`'s series points remain unrendered in `content[]`, tracked separately in [#23](https://github.com/cyanheads/gdelt-mcp-server/issues/23) ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
14
+ - **`gdelt_get_coverage_breakdown`** — description, `format()` output, and the `topSeries`/`otherAggregated` schema descriptions now state that values are normalized shares of a source's media output, not absolute article counts ([#10](https://github.com/cyanheads/gdelt-mcp-server/issues/10)).
@@ -0,0 +1,21 @@
1
+ ---
2
+ summary: "Added: selectable series (#20) and named point expansion (#24); search_articles/get_tv_clips return continuationWindows instead of a dead-end cap notice (#21)"
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.2.5 — 2026-07-15
8
+
9
+ ## Added
10
+
11
+ - **`gdelt_get_coverage_breakdown`** — optional `series: string[]` input returns each named label complete under a new `selectedSeries` output, alongside the existing top-10 overview. New optional `otherSeriesLabels` output names every series folded into "Other", ranked by volume. An unmatched label throws `unknown_series`, listing the labels actually available ([#20](https://github.com/cyanheads/gdelt-mcp-server/issues/20)).
12
+ - **`gdelt_get_coverage_timeline`** — optional `points: string[]` input renders a named timestep's complete article list in the text surface, echoed in the new optional `expandedPoints` output. An unmatched date throws `unknown_point`, listing the timeline's actual dates ([#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24)).
13
+ - **`gdelt_search_articles`, `gdelt_get_tv_clips`** — new optional `continuationWindows` enrichment field returns the queried `startDatetime`/`endDatetime` window halved once `maxRecords` is at its ceiling (250 / 3,000), ready to re-query. Halves overlap by one second so a record timestamped exactly on the seam is never silently dropped — de-duplicate by `url` / `archiveUrl` on reassembly. Shared window-partition helpers (`resolveEffectiveWindow`, `splitWindow`, `planWindowContinuation`) added to `src/mcp-server/tools/date-range.ts` ([#21](https://github.com/cyanheads/gdelt-mcp-server/issues/21)).
14
+
15
+ ## Changed
16
+
17
+ - **`gdelt_get_coverage_timeline`** — capped article rendering now states how many links it showed next to the true count (`"8 articles, 3 shown"`), instead of an unqualified count; a timestep whose articles all fit claims nothing withheld ([#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24)).
18
+
19
+ ## Fixed
20
+
21
+ - **`gdelt_search_articles`, `gdelt_get_tv_clips`** — the cap-hit notice no longer tells the caller to raise `maxRecords` when it is already at its ceiling; it now states the ceiling is GDELT's own and points to `continuationWindows` ([#21](https://github.com/cyanheads/gdelt-mcp-server/issues/21)).
@@ -1,7 +1,8 @@
1
1
  /**
2
- * @fileoverview Shared date-range input constraints for the GDELT tools that accept
3
- * an explicit startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern
4
- * and the both-or-neither pairing predicate.
2
+ * @fileoverview Shared date-range handling for the GDELT tools that accept an explicit
3
+ * startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern, the both-or-neither
4
+ * pairing predicate, and the window partitioning the record-cap tools hand back as a
5
+ * continuation contract.
5
6
  * @module mcp-server/tools/date-range
6
7
  */
7
8
  /**
@@ -11,6 +12,57 @@
11
12
  * Schema as `pattern`, letting a caller see the constraint before it calls.
12
13
  */
13
14
  export declare const GDELT_DATETIME_PATTERN: RegExp;
15
+ /** A GDELT query window, both boundaries in the 14-digit YYYYMMDDHHMMSS wire format. */
16
+ export type GdeltWindow = {
17
+ startDatetime: string;
18
+ endDatetime: string;
19
+ };
20
+ /** Format a Date as GDELT's 14-digit YYYYMMDDHHMMSS wire format (UTC). */
21
+ export declare function toGdeltDatetime(date: Date): string;
22
+ /**
23
+ * The window a call actually ran against, in GDELT's wire format.
24
+ *
25
+ * Mirrors `applyTimeRange()`'s precedence: an explicit boundary pair wins, a timespan
26
+ * is resolved against now, and a call that pinned neither gets `undefined` rather than
27
+ * a guessed window — GDELT's own default is not something this server can observe.
28
+ */
29
+ export declare function resolveEffectiveWindow(args: {
30
+ timespan?: string | undefined;
31
+ startDatetime?: string | undefined;
32
+ endDatetime?: string | undefined;
33
+ }): GdeltWindow | undefined;
34
+ /**
35
+ * Split a window into two halves whose union covers it exactly, or `undefined` when it
36
+ * is already too narrow to divide.
37
+ *
38
+ * The second half deliberately **overlaps** the first by one second rather than resuming
39
+ * at the shared midpoint. GDELT documents both boundaries as exclusive — STARTDATETIME
40
+ * considers "only articles published *after* this date/time stamp" and ENDDATETIME "only
41
+ * articles published *before*" it (DOC 2.0 and TV 2.0 API docs alike) — so halves that
42
+ * merely touch at the midpoint would drop every record timestamped exactly there, silently.
43
+ *
44
+ * Reaching back one second closes that seam: under the documented exclusive reading the
45
+ * two halves tile the original window with no gap and no repeat, and if the boundaries
46
+ * turn out to behave inclusively instead, the overlap costs at most two seconds of
47
+ * duplicates — which a caller can see and de-duplicate. Gap-free either way.
48
+ */
49
+ export declare function splitWindow(window: GdeltWindow): [GdeltWindow, GdeltWindow] | undefined;
50
+ /** The next-call windows and the prose explaining them, for a record cap at its ceiling. */
51
+ export type WindowContinuation = {
52
+ windows?: [GdeltWindow, GdeltWindow];
53
+ guidance: string;
54
+ };
55
+ /**
56
+ * How a caller retrieves records left behind once `maxRecords` is already at its ceiling.
57
+ *
58
+ * GDELT exposes no offset or cursor, so narrowing the time window is the only lever, and
59
+ * each outcome is stated rather than implied: halves to re-query when the window divides,
60
+ * how to pin a window when the call never set one, and — when the window is already too
61
+ * narrow to divide — that the remaining records are simply unreachable.
62
+ *
63
+ * Callers own the record-noun prose; this covers only the window reasoning both tools share.
64
+ */
65
+ export declare function planWindowContinuation(window: GdeltWindow | undefined): WindowContinuation;
14
66
  /**
15
67
  * True when exactly one of the two boundaries is present.
16
68
  *
@@ -1 +1 @@
1
- {"version":3,"file":"date-range.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,QAAa,CAAC;AAEjD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzF"}
1
+ {"version":3,"file":"date-range.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,QAAa,CAAC;AAEjD,wFAAwF;AACxF,MAAM,MAAM,WAAW,GAAG;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAaF,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAElD;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC,GAAG,WAAW,GAAG,SAAS,CAc1B;AASD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,CAAC,WAAW,EAAE,WAAW,CAAC,GAAG,SAAS,CAcvF;AAED,4FAA4F;AAC5F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,CAAC,EAAE,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IACrC,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,kBAAkB,CA4B1F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzF"}
@@ -1,9 +1,11 @@
1
1
  /**
2
- * @fileoverview Shared date-range input constraints for the GDELT tools that accept
3
- * an explicit startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern
4
- * and the both-or-neither pairing predicate.
2
+ * @fileoverview Shared date-range handling for the GDELT tools that accept an explicit
3
+ * startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern, the both-or-neither
4
+ * pairing predicate, and the window partitioning the record-cap tools hand back as a
5
+ * continuation contract.
5
6
  * @module mcp-server/tools/date-range
6
7
  */
8
+ import { resolveTimespan } from '../../services/gdelt/gdelt-fetch.js';
7
9
  /**
8
10
  * GDELT's datetime wire format: exactly 14 digits, YYYYMMDDHHMMSS, no separators.
9
11
  *
@@ -11,6 +13,108 @@
11
13
  * Schema as `pattern`, letting a caller see the constraint before it calls.
12
14
  */
13
15
  export const GDELT_DATETIME_PATTERN = /^\d{14}$/;
16
+ /** GDELT's datetime resolution — the narrowest window a caller can express. */
17
+ const GDELT_RESOLUTION_MS = 1000;
18
+ /** Parse a 14-digit YYYYMMDDHHMMSS string as UTC. Invalid calendar values yield an Invalid Date. */
19
+ function parseGdeltDatetime(value) {
20
+ return new Date(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` +
21
+ `T${value.slice(8, 10)}:${value.slice(10, 12)}:${value.slice(12, 14)}Z`);
22
+ }
23
+ /** Format a Date as GDELT's 14-digit YYYYMMDDHHMMSS wire format (UTC). */
24
+ export function toGdeltDatetime(date) {
25
+ return date.toISOString().slice(0, 19).replace(/[-:T]/g, '');
26
+ }
27
+ /**
28
+ * The window a call actually ran against, in GDELT's wire format.
29
+ *
30
+ * Mirrors `applyTimeRange()`'s precedence: an explicit boundary pair wins, a timespan
31
+ * is resolved against now, and a call that pinned neither gets `undefined` rather than
32
+ * a guessed window — GDELT's own default is not something this server can observe.
33
+ */
34
+ export function resolveEffectiveWindow(args) {
35
+ if (args.startDatetime && args.endDatetime) {
36
+ return { startDatetime: args.startDatetime, endDatetime: args.endDatetime };
37
+ }
38
+ if (args.timespan) {
39
+ const range = resolveTimespan(args.timespan);
40
+ if (range) {
41
+ return {
42
+ startDatetime: toGdeltDatetime(range.start),
43
+ endDatetime: toGdeltDatetime(range.end),
44
+ };
45
+ }
46
+ }
47
+ return;
48
+ }
49
+ /**
50
+ * Smallest span that still divides into two strictly-narrower halves. The second half
51
+ * reaches back a second to cover the seam, so it costs `span - floor(span/2) + 1`
52
+ * seconds — only under four does that stop shrinking.
53
+ */
54
+ const MIN_SPLITTABLE_MS = 4 * GDELT_RESOLUTION_MS;
55
+ /**
56
+ * Split a window into two halves whose union covers it exactly, or `undefined` when it
57
+ * is already too narrow to divide.
58
+ *
59
+ * The second half deliberately **overlaps** the first by one second rather than resuming
60
+ * at the shared midpoint. GDELT documents both boundaries as exclusive — STARTDATETIME
61
+ * considers "only articles published *after* this date/time stamp" and ENDDATETIME "only
62
+ * articles published *before*" it (DOC 2.0 and TV 2.0 API docs alike) — so halves that
63
+ * merely touch at the midpoint would drop every record timestamped exactly there, silently.
64
+ *
65
+ * Reaching back one second closes that seam: under the documented exclusive reading the
66
+ * two halves tile the original window with no gap and no repeat, and if the boundaries
67
+ * turn out to behave inclusively instead, the overlap costs at most two seconds of
68
+ * duplicates — which a caller can see and de-duplicate. Gap-free either way.
69
+ */
70
+ export function splitWindow(window) {
71
+ const start = parseGdeltDatetime(window.startDatetime);
72
+ const spanMs = parseGdeltDatetime(window.endDatetime).getTime() - start.getTime();
73
+ if (!Number.isFinite(spanMs) || spanMs < MIN_SPLITTABLE_MS)
74
+ return;
75
+ const midpointMs = start.getTime() + Math.floor(spanMs / 2 / GDELT_RESOLUTION_MS) * GDELT_RESOLUTION_MS;
76
+ return [
77
+ { startDatetime: window.startDatetime, endDatetime: toGdeltDatetime(new Date(midpointMs)) },
78
+ {
79
+ startDatetime: toGdeltDatetime(new Date(midpointMs - GDELT_RESOLUTION_MS)),
80
+ endDatetime: window.endDatetime,
81
+ },
82
+ ];
83
+ }
84
+ /**
85
+ * How a caller retrieves records left behind once `maxRecords` is already at its ceiling.
86
+ *
87
+ * GDELT exposes no offset or cursor, so narrowing the time window is the only lever, and
88
+ * each outcome is stated rather than implied: halves to re-query when the window divides,
89
+ * how to pin a window when the call never set one, and — when the window is already too
90
+ * narrow to divide — that the remaining records are simply unreachable.
91
+ *
92
+ * Callers own the record-noun prose; this covers only the window reasoning both tools share.
93
+ */
94
+ export function planWindowContinuation(window) {
95
+ if (!window) {
96
+ return {
97
+ guidance: 'GDELT exposes no offset or cursor, so narrowing the time window is the only way to reach the rest: ' +
98
+ 'pin one with startDatetime/endDatetime (or a timespan), then re-run this query against successively narrower halves of it.',
99
+ };
100
+ }
101
+ const windows = splitWindow(window);
102
+ if (!windows) {
103
+ return {
104
+ guidance: `The window ${window.startDatetime}–${window.endDatetime} is already too narrow to divide at GDELT's ` +
105
+ 'one-second resolution, so the records past this cap are not retrievable through this API.',
106
+ };
107
+ }
108
+ const [first, second] = windows;
109
+ return {
110
+ windows,
111
+ guidance: 'GDELT exposes no offset or cursor. Re-run this query unchanged against each half of the current window — ' +
112
+ `${first.startDatetime}–${first.endDatetime}, then ${second.startDatetime}–${second.endDatetime} ` +
113
+ '(both echoed in continuationWindows) — and split a half again if it also hits the cap. ' +
114
+ 'The halves overlap by one second so nothing falls through the seam, so a record on that second can ' +
115
+ 'appear in both: de-duplicate on re-assembly.',
116
+ };
117
+ }
14
118
  /**
15
119
  * True when exactly one of the two boundaries is present.
16
120
  *
@@ -1 +1 @@
1
- {"version":3,"file":"date-range.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,UAAU,CAAC;AAEjD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,aAAsB,EAAE,WAAoB;IAC9E,OAAO,OAAO,CAAC,aAAa,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;AACzD,CAAC"}
1
+ {"version":3,"file":"date-range.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAElE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,UAAU,CAAC;AAQjD,+EAA+E;AAC/E,MAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC,oGAAoG;AACpG,SAAS,kBAAkB,CAAC,KAAa;IACvC,OAAO,IAAI,IAAI,CACb,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;QAC9D,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAC1E,CAAC;AACJ,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,eAAe,CAAC,IAAU;IACxC,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAItC;IACC,IAAI,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;QAC3C,OAAO,EAAE,aAAa,EAAE,IAAI,CAAC,aAAa,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC;IAC9E,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC7C,IAAI,KAAK,EAAE,CAAC;YACV,OAAO;gBACL,aAAa,EAAE,eAAe,CAAC,KAAK,CAAC,KAAK,CAAC;gBAC3C,WAAW,EAAE,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC;aACxC,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO;AACT,CAAC;AAED;;;;GAIG;AACH,MAAM,iBAAiB,GAAG,CAAC,GAAG,mBAAmB,CAAC;AAElD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,WAAW,CAAC,MAAmB;IAC7C,MAAM,KAAK,GAAG,kBAAkB,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,kBAAkB,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;IAClF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,iBAAiB;QAAE,OAAO;IAEnE,MAAM,UAAU,GACd,KAAK,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,GAAG,mBAAmB,CAAC,GAAG,mBAAmB,CAAC;IACvF,OAAO;QACL,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,EAAE,WAAW,EAAE,eAAe,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE;QAC3F;YACE,aAAa,EAAE,eAAe,CAAC,IAAI,IAAI,CAAC,UAAU,GAAG,mBAAmB,CAAC,CAAC;YAC1E,WAAW,EAAE,MAAM,CAAC,WAAW;SAChC;KACF,CAAC;AACJ,CAAC;AAQD;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAA+B;IACpE,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO;YACL,QAAQ,EACN,qGAAqG;gBACrG,4HAA4H;SAC/H,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO;YACL,QAAQ,EACN,cAAc,MAAM,CAAC,aAAa,IAAI,MAAM,CAAC,WAAW,8CAA8C;gBACtG,2FAA2F;SAC9F,CAAC;IACJ,CAAC;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC;IAChC,OAAO;QACL,OAAO;QACP,QAAQ,EACN,2GAA2G;YAC3G,GAAG,KAAK,CAAC,aAAa,IAAI,KAAK,CAAC,WAAW,UAAU,MAAM,CAAC,aAAa,IAAI,MAAM,CAAC,WAAW,GAAG;YAClG,yFAAyF;YACzF,qGAAqG;YACrG,8CAA8C;KACjD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,aAAsB,EAAE,WAAoB;IAC9E,OAAO,OAAO,CAAC,aAAa,CAAC,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;AACzD,CAAC"}
@@ -14,6 +14,7 @@ export declare const gdeltGetCoverageBreakdown: import("@cyanheads/mcp-ts-core")
14
14
  timespan: z.ZodOptional<z.ZodString>;
15
15
  startDatetime: z.ZodOptional<z.ZodString>;
16
16
  endDatetime: z.ZodOptional<z.ZodString>;
17
+ series: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
18
  }, z.core.$strip>, z.ZodObject<{
18
19
  dateResolution: z.ZodEnum<{
19
20
  hour: "hour";
@@ -30,11 +31,24 @@ export declare const gdeltGetCoverageBreakdown: import("@cyanheads/mcp-ts-core")
30
31
  date: z.ZodString;
31
32
  value: z.ZodNumber;
32
33
  }, z.core.$strip>>>;
34
+ otherSeriesLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
+ selectedSeries: z.ZodOptional<z.ZodArray<z.ZodObject<{
36
+ label: z.ZodString;
37
+ data: z.ZodArray<z.ZodObject<{
38
+ date: z.ZodString;
39
+ value: z.ZodNumber;
40
+ }, z.core.$strip>>;
41
+ }, z.core.$strip>>>;
33
42
  }, z.core.$strip>, readonly [{
34
43
  readonly reason: "no_breakdown_data";
35
44
  readonly code: JsonRpcErrorCode.NotFound;
36
45
  readonly when: "No breakdown data returned for the query.";
37
46
  readonly recovery: "Broaden the query, extend the timespan, or verify the query operators are correct.";
47
+ }, {
48
+ readonly reason: "unknown_series";
49
+ readonly code: JsonRpcErrorCode.NotFound;
50
+ readonly when: "A label passed in the series input matches no series in this breakdown.";
51
+ readonly recovery: "Read the labels listed in the error and retry series with exact matches from that list.";
38
52
  }, {
39
53
  readonly reason: "invalid_date_range";
40
54
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -1 +1 @@
1
- {"version":3,"file":"get-coverage-breakdown.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-coverage-breakdown.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAQjE,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+PpC,CAAC"}
1
+ {"version":3,"file":"get-coverage-breakdown.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-coverage-breakdown.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAkDjE,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsUpC,CAAC"}
@@ -10,13 +10,53 @@ import { GDELT_DATETIME_PATTERN, isUnpairedDateRange } from '../date-range.js';
10
10
  import { inferDateResolution } from '../date-resolution.js';
11
11
  /** Maximum number of series to include before aggregating the rest into "Other". */
12
12
  const MAX_SERIES = 10;
13
+ /**
14
+ * One language or country coverage series. Shared by the ranked `topSeries` overview and
15
+ * the `selectedSeries` arm so a label retrieved by name carries the identical shape it
16
+ * would have had inside the top 10.
17
+ */
18
+ const breakdownSeriesSchema = z
19
+ .object({
20
+ label: z.string().describe('Series label (language name or country name).'),
21
+ data: z
22
+ .array(z
23
+ .object({
24
+ date: z.string().describe('Timestep in ISO 8601 format.'),
25
+ value: z
26
+ .number()
27
+ .describe("Normalized coverage volume at this timestep — the topic's share of this " +
28
+ "source's media output, not an absolute article count."),
29
+ })
30
+ .describe('A single data point for this series.'))
31
+ .describe('Time-ordered data points for this series.'),
32
+ })
33
+ .describe('A single language or country coverage series.');
34
+ /** Render one series in full — header, total, peak, and every data point. */
35
+ function renderSeries(series) {
36
+ const total = series.data.reduce((sum, d) => sum + d.value, 0);
37
+ const peak = series.data.reduce((max, d) => (d.value > max.value ? d : max), series.data[0] ?? { date: '', value: 0 });
38
+ const lines = [
39
+ `\n### ${series.label} (total: ${total.toFixed(2)})`,
40
+ `Data points: ${series.data.length}`,
41
+ ];
42
+ if (peak.date)
43
+ lines.push(`Peak: ${peak.value.toFixed(3)} at ${peak.date}`);
44
+ for (const d of series.data)
45
+ lines.push(`- ${d.date}: ${d.value.toFixed(3)}`);
46
+ return lines;
47
+ }
13
48
  export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
14
49
  title: 'Get GDELT Coverage Breakdown',
15
50
  description: 'Break down news coverage volume over time by source language or source country, returning a ' +
16
51
  'multi-series time series (one series per language or country). ' +
17
52
  'Shows which countries or languages drove early vs. late coverage — useful for tracing how a ' +
18
53
  'story propagated geographically or across language communities. ' +
19
- 'Returns up to 10 series by total volume; remaining series are aggregated into an "Other" bucket. ' +
54
+ 'Returns up to 10 series by total volume and aggregates the rest into an "Other" bucket, naming ' +
55
+ 'every series it folded in there under otherSeriesLabels — pass any of those labels back as the ' +
56
+ 'series input to get that series complete, ranked or not. ' +
57
+ "Values are normalized: each point is the topic's share of media output, not an absolute article count. " +
58
+ 'Small media markets with concentrated coverage therefore rank above large markets with diverse output — ' +
59
+ "a high value means the topic dominated that source's coverage, not that it published the most articles. " +
20
60
  'Use breakdownBy "country" with the signal-detection chain to map geographic attention, ' +
21
61
  'or "language" to detect non-English media surges.',
22
62
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -27,6 +67,12 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
27
67
  when: 'No breakdown data returned for the query.',
28
68
  recovery: 'Broaden the query, extend the timespan, or verify the query operators are correct.',
29
69
  },
70
+ {
71
+ reason: 'unknown_series',
72
+ code: JsonRpcErrorCode.NotFound,
73
+ when: 'A label passed in the series input matches no series in this breakdown.',
74
+ recovery: 'Read the labels listed in the error and retry series with exact matches from that list.',
75
+ },
30
76
  {
31
77
  reason: 'invalid_date_range',
32
78
  code: JsonRpcErrorCode.ValidationError,
@@ -74,33 +120,41 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
74
120
  .optional()
75
121
  .describe('End datetime in GDELT format YYYYMMDDHHMMSS — exactly 14 digits, no separators ' +
76
122
  '(e.g. 20240131235959). Must pair with startDatetime; supplying only one of the two is rejected.'),
123
+ series: z
124
+ .array(z.string())
125
+ .optional()
126
+ .describe('Exact series labels to additionally return in full, e.g. ["Portuguese", "Vietnamese"]. ' +
127
+ 'Take them verbatim from otherSeriesLabels (the series folded into "Other") or topSeries[].label ' +
128
+ 'in a response, or from the label list an unknown_series error prints. Each one comes back complete ' +
129
+ 'under selectedSeries, on top of the usual top-10 overview; a label that matches nothing is rejected ' +
130
+ 'rather than silently skipped. Omit to get the overview alone.'),
77
131
  }),
78
132
  output: z.object({
79
133
  dateResolution: z.enum(['hour', 'day']).describe('Temporal resolution of data points.'),
80
- topSeries: z
81
- .array(z
82
- .object({
83
- label: z.string().describe('Series label (language name or country name).'),
84
- data: z
85
- .array(z
86
- .object({
87
- date: z.string().describe('Timestep in ISO 8601 format.'),
88
- value: z.number().describe('Normalized coverage volume at this timestep.'),
89
- })
90
- .describe('A single data point for this series.'))
91
- .describe('Time-ordered data points for this series.'),
92
- })
93
- .describe('A single language or country coverage series.'))
94
- .describe('Top 10 series by total coverage volume.'),
134
+ topSeries: z.array(breakdownSeriesSchema).describe('Top 10 series by total coverage volume.'),
95
135
  otherAggregated: z
96
136
  .array(z
97
137
  .object({
98
138
  date: z.string().describe('Timestep in ISO 8601 format.'),
99
- value: z.number().describe('Aggregated coverage volume for all remaining series.'),
139
+ value: z
140
+ .number()
141
+ .describe('Aggregated normalized coverage volume for all remaining series — a share of ' +
142
+ 'media output, not an absolute article count.'),
100
143
  })
101
144
  .describe('A single aggregated data point for the "Other" bucket.'))
102
145
  .optional()
103
146
  .describe('Combined time series for all series beyond the top 10. Omitted when all series fit.'),
147
+ otherSeriesLabels: z
148
+ .array(z.string())
149
+ .optional()
150
+ .describe('Label of every series folded into otherAggregated, ranked by total volume — the identities the ' +
151
+ '"Other" bucket would otherwise dissolve. Pass any of them to the series input to retrieve that ' +
152
+ "series' complete data. Omitted when all series fit in the top 10."),
153
+ selectedSeries: z
154
+ .array(breakdownSeriesSchema)
155
+ .optional()
156
+ .describe('Complete, untruncated time series for each label requested via the series input, in the order ' +
157
+ 'requested. Omitted when series was not supplied.'),
104
158
  }),
105
159
  // Agent-facing context — query echo, breakdown dimension, total series count, and notice on empty results.
106
160
  // Reaches structuredContent and content[] automatically; never in the domain return.
@@ -160,6 +214,7 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
160
214
  const topSeries = sorted.slice(0, MAX_SERIES).map(({ total: _, ...s }) => s);
161
215
  const remainingSeries = sorted.slice(MAX_SERIES);
162
216
  let otherAggregated;
217
+ let otherSeriesLabels;
163
218
  if (remainingSeries.length > 0) {
164
219
  const dateMap = new Map();
165
220
  for (const s of remainingSeries) {
@@ -170,6 +225,31 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
170
225
  otherAggregated = Array.from(dateMap.entries())
171
226
  .sort(([a], [b]) => a.localeCompare(b))
172
227
  .map(([date, value]) => ({ date, value }));
228
+ otherSeriesLabels = remainingSeries.map((s) => s.label);
229
+ }
230
+ // Label selection re-filters the same complete upstream set the ranking sliced, so a
231
+ // series named on a follow-up call reconstructs statelessly from query + dimension + label.
232
+ const selectedSeries = [];
233
+ if (input.series?.length) {
234
+ const byLabel = new Map(allSeries.map((s) => [s.label, s]));
235
+ const unknown = [];
236
+ for (const label of input.series) {
237
+ const match = byLabel.get(label);
238
+ if (match)
239
+ selectedSeries.push(match);
240
+ else
241
+ unknown.push(label);
242
+ }
243
+ if (unknown.length > 0) {
244
+ const named = unknown.map((label) => `"${label}"`).join(', ');
245
+ throw ctx.fail('unknown_series', `No ${input.breakdownBy} series named ${named}`, {
246
+ unknownLabels: unknown,
247
+ recovery: {
248
+ hint: `This breakdown has no ${input.breakdownBy} series named ${named}. Labels are exact and ` +
249
+ `case-sensitive. Available for "${input.query}": ${sorted.map((s) => s.label).join(', ')}.`,
250
+ },
251
+ });
252
+ }
173
253
  }
174
254
  const allDates = topSeries.flatMap((s) => s.data.map((d) => d.date));
175
255
  const dateResolution = inferDateResolution(allDates);
@@ -183,26 +263,31 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
183
263
  ctx.log.info('gdelt_get_coverage_breakdown completed', {
184
264
  totalSeries: allSeries.length,
185
265
  topSeriesCount: topSeries.length,
266
+ selectedSeriesCount: selectedSeries.length,
186
267
  });
187
268
  return {
188
269
  dateResolution,
189
270
  topSeries,
190
271
  ...(otherAggregated ? { otherAggregated } : {}),
272
+ ...(otherSeriesLabels ? { otherSeriesLabels } : {}),
273
+ ...(selectedSeries.length > 0 ? { selectedSeries } : {}),
191
274
  };
192
275
  },
276
+ /**
277
+ * Every arm renders on its own field presence, never as an `if`/`else if` chain — the
278
+ * format-parity linter populates all optional fields at once in its synthetic sample,
279
+ * so a mutually-exclusive branch would leave the untaken arm unverified.
280
+ */
193
281
  format: (result) => {
194
282
  const lines = [
195
283
  `## GDELT Coverage Breakdown`,
196
284
  `**Date Resolution:** ${result.dateResolution}`,
285
+ `**Values:** normalized — each value is the topic's share of that source's media output, ` +
286
+ `not an article count. Small media markets with concentrated coverage rank above large ` +
287
+ `markets with diverse output.`,
197
288
  ];
198
- for (const s of result.topSeries) {
199
- const total = s.data.reduce((sum, d) => sum + d.value, 0);
200
- lines.push(`\n### ${s.label} (total: ${total.toFixed(2)})`);
201
- lines.push(`Data points: ${s.data.length}`);
202
- const peak = s.data.reduce((max, d) => (d.value > max.value ? d : max), s.data[0] ?? { date: '', value: 0 });
203
- if (peak.date)
204
- lines.push(`Peak: ${peak.value.toFixed(3)} at ${peak.date}`);
205
- }
289
+ for (const s of result.topSeries)
290
+ lines.push(...renderSeries(s));
206
291
  if (result.otherAggregated) {
207
292
  const otherTotal = result.otherAggregated.reduce((sum, d) => sum + d.value, 0);
208
293
  const otherPeak = result.otherAggregated.reduce((max, d) => (d.value > max.value ? d : max), result.otherAggregated[0] ?? { date: '', value: 0 });
@@ -210,6 +295,21 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
210
295
  lines.push(`Total: ${otherTotal.toFixed(2)}`);
211
296
  if (otherPeak.date)
212
297
  lines.push(`Peak: ${otherPeak.value.toFixed(3)} at ${otherPeak.date}`);
298
+ for (const d of result.otherAggregated) {
299
+ lines.push(`- ${d.date}: ${d.value.toFixed(3)}`);
300
+ }
301
+ }
302
+ if (result.otherSeriesLabels?.length) {
303
+ lines.push(`\n### Series folded into "Other" (${result.otherSeriesLabels.length})`);
304
+ lines.push(`Ranked by total volume. Re-call with series: ["<label>"] to get any of them in full.`);
305
+ for (const label of result.otherSeriesLabels)
306
+ lines.push(`- ${label}`);
307
+ }
308
+ if (result.selectedSeries?.length) {
309
+ lines.push(`\n## Selected Series (${result.selectedSeries.length})`);
310
+ lines.push(`Complete series for the labels requested via the series input.`);
311
+ for (const s of result.selectedSeries)
312
+ lines.push(...renderSeries(s));
213
313
  }
214
314
  return [{ type: 'text', text: lines.join('\n') }];
215
315
  },