@pipeworx/mcp-newsdata 0.1.4 → 0.1.6
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.
- package/README.md +3 -3
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +322 -3
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
NewsData.io MCP — wraps the NewsData.io global news API (newsdata.io)
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1877+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
9
9
|
| Tool | Description |
|
|
10
10
|
|------|-------------|
|
|
11
|
-
| `latest_news` | Get
|
|
11
|
+
| `latest_news` | Get news headlines and articles — world news, breaking news, and business/financial/stock-market news. Filter by keyword, country (2-letter, e.g. "us"), category (business, technology, politics, sports, health, science), and language. IMPORTANT: for stock-market / financial-market / economy / "world market news" questions, ALWAYS pass category: "business" — it returns real market-news outlets and filters out low-quality SEO/crypto-promo articles. Returns article title, description, link, source, publish date, category, and country. Paginate via the nextPage token. WITHOUT from_date/to_date this covers only roughly the LAST 48 HOURS (NewsData.io's rolling "latest" feed) — asking about an older event with no date window silently returns today's unrelated articles instead of an empty result. For a PAST date range, pass from_date/to_date (YYYY-MM-DD): this routes to NewsData.io's archive endpoint, which requires a plan tier above the shared platform key's default — if the account cannot serve it, the call returns a `filter_gap` field naming the unsupported window instead of silently substituting the latest 48h. Examples: latest_news({ query: "stock market", category: "business" }) for current world market news; latest_news({ query: "election", country: "us", category: "politics" }); latest_news({ query: "Houthi Yemen attack", from_date: "2026-08-01", to_date: "2026-08-15" }) for an August event. |
|
|
12
12
|
| `crypto_news` | Get the latest cryptocurrency news headlines and articles. Filter by keyword, coin ticker(s) (e.g. "btc,eth"), and language. Returns article title, description, link, source, publish date, category, and country. Example: crypto_news({ coin: "btc,eth", query: "etf" }) |
|
|
13
13
|
| `news_sources` | List available global news sources. Filter by country (2-letter), category, and language. Returns source id, name, url, category, country, and language — useful for discovering which outlets cover a given region or topic. Example: news_sources({ country: "us", category: "technology" }) |
|
|
14
14
|
|
|
@@ -56,7 +56,7 @@ directly, instead of just this one's:
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Both URLs reach the same gateway and the same
|
|
59
|
+
Both URLs reach the same gateway and the same 1877+ data sources. The
|
|
60
60
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
61
61
|
reaches all of them from either one.
|
|
62
62
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-newsdata",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "NewsData.io MCP — wraps the NewsData.io global news API (newsdata.io)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-cc46b5452257982114d1624bb3b6c9f72020b0320b506803f242d709c09965eb",
|
|
30
|
+
"sourceCommit": "774de7f685a75e02d0b828d24a7b94794102152c"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/newsdata",
|
|
4
4
|
"title": "Newsdata",
|
|
5
5
|
"description": "NewsData.io MCP — wraps the NewsData.io global news API (newsdata.io)",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.6",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/newsdata",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-newsdata",
|
package/src/index.ts
CHANGED
|
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
|
|
|
295
295
|
// internal-host-class.ts; no-op for every third-party upstream, which is why
|
|
296
296
|
// this touches 481 packs' error text and changes none of it.
|
|
297
297
|
return markInternalOrigin(
|
|
298
|
-
`${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
|
|
298
|
+
`${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
|
|
299
299
|
res.url,
|
|
300
300
|
res.status,
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Facts about a FAILED response that live in its headers, not its body, appended
|
|
306
|
+
* so the gateway's classifier (which only ever sees this string) can tell a limit
|
|
307
|
+
* from a wall from a refusal (fleet #3477). Both are standard, both are absent on
|
|
308
|
+
* almost every response, and neither is invented: `Retry-After` is the server
|
|
309
|
+
* saying "come back later" (the one thing that makes a 403 a throttle), and
|
|
310
|
+
* Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
|
|
311
|
+
*/
|
|
312
|
+
function responseHints(res: Response): string {
|
|
313
|
+
const hints: string[] = [];
|
|
314
|
+
const retryAfter = res.headers.get('retry-after');
|
|
315
|
+
if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
|
|
316
|
+
if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
|
|
317
|
+
return hints.length ? ` (${hints.join('; ')})` : '';
|
|
318
|
+
}
|
|
319
|
+
|
|
304
320
|
/**
|
|
305
321
|
* Just the upstream's own explanation — no name, no status.
|
|
306
322
|
*
|
|
@@ -670,6 +686,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
670
686
|
function collapse(s: string): string {
|
|
671
687
|
return s.replace(/\s+/g, ' ').trim();
|
|
672
688
|
}
|
|
689
|
+
|
|
690
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
691
|
+
//
|
|
692
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
693
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
694
|
+
// and once as the object graph (which for an array of small objects runs
|
|
695
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
696
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
697
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
698
|
+
// the 503/1102 rate at all.
|
|
699
|
+
//
|
|
700
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
701
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
702
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
703
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
704
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
705
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
706
|
+
//
|
|
707
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
708
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
709
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
710
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
711
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
712
|
+
|
|
713
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
714
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
715
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
716
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
717
|
+
|
|
718
|
+
interface JsonArrayPrefix<T> {
|
|
719
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
720
|
+
items: T[];
|
|
721
|
+
/** True when the upstream array had more elements than we read. */
|
|
722
|
+
truncated: boolean;
|
|
723
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
724
|
+
bytesRead: number;
|
|
725
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
726
|
+
declaredBytes: number | null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
731
|
+
* stream without ever materialising the whole body.
|
|
732
|
+
*
|
|
733
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
734
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
735
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
736
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
737
|
+
*
|
|
738
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
739
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
740
|
+
*
|
|
741
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
742
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
743
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
744
|
+
* more useful to a caller than an error.
|
|
745
|
+
*/
|
|
746
|
+
async function readJsonArrayPrefix<T>(
|
|
747
|
+
res: Response,
|
|
748
|
+
name: string,
|
|
749
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
750
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
751
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
752
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
753
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
754
|
+
|
|
755
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
756
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
757
|
+
if (!res.body) {
|
|
758
|
+
const all = await parseJson<T[]>(res, name);
|
|
759
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
760
|
+
return {
|
|
761
|
+
items: all.slice(0, opts.maxItems),
|
|
762
|
+
truncated: all.length > opts.maxItems,
|
|
763
|
+
bytesRead: declaredBytes ?? 0,
|
|
764
|
+
declaredBytes,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const reader = res.body.getReader();
|
|
769
|
+
const decoder = new TextDecoder();
|
|
770
|
+
const items: T[] = [];
|
|
771
|
+
|
|
772
|
+
let buf = '';
|
|
773
|
+
let scan = 0;
|
|
774
|
+
let bytesRead = 0;
|
|
775
|
+
let started = false; // consumed the opening '['
|
|
776
|
+
let arrayEnded = false;
|
|
777
|
+
let depth = 0;
|
|
778
|
+
let inStr = false;
|
|
779
|
+
let esc = false;
|
|
780
|
+
let elemStart = -1;
|
|
781
|
+
let truncated = false;
|
|
782
|
+
|
|
783
|
+
const push = (text: string) => {
|
|
784
|
+
const t = text.trim();
|
|
785
|
+
if (!t) return;
|
|
786
|
+
items.push(JSON.parse(t) as T);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
791
|
+
const { value, done } = await reader.read();
|
|
792
|
+
if (done) break;
|
|
793
|
+
bytesRead += value.byteLength;
|
|
794
|
+
buf += decoder.decode(value, { stream: true });
|
|
795
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
796
|
+
|
|
797
|
+
for (; scan < buf.length; scan++) {
|
|
798
|
+
const ch = buf[scan];
|
|
799
|
+
|
|
800
|
+
if (inStr) {
|
|
801
|
+
if (esc) { esc = false; continue; }
|
|
802
|
+
if (ch === '\\') { esc = true; continue; }
|
|
803
|
+
if (ch === '"') inStr = false;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!started) {
|
|
808
|
+
if (ch === '[') { started = true; continue; }
|
|
809
|
+
if (ch.trim() === '') continue;
|
|
810
|
+
throw new Error(
|
|
811
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
812
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
817
|
+
|
|
818
|
+
if (ch === '{' || ch === '[') {
|
|
819
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
820
|
+
depth++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (ch === '}' || ch === ']') {
|
|
825
|
+
if (depth > 0) {
|
|
826
|
+
depth--;
|
|
827
|
+
if (depth === 0 && elemStart >= 0) {
|
|
828
|
+
push(buf.slice(elemStart, scan + 1));
|
|
829
|
+
elemStart = -1;
|
|
830
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
835
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
836
|
+
arrayEnded = true;
|
|
837
|
+
break outer;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (ch === ',' && depth === 0) {
|
|
841
|
+
if (elemStart >= 0) {
|
|
842
|
+
push(buf.slice(elemStart, scan));
|
|
843
|
+
elemStart = -1;
|
|
844
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A bare scalar element (number / true / false / null).
|
|
850
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
854
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
855
|
+
if (elemStart >= 0) {
|
|
856
|
+
buf = buf.slice(elemStart);
|
|
857
|
+
scan -= elemStart;
|
|
858
|
+
elemStart = 0;
|
|
859
|
+
} else {
|
|
860
|
+
buf = buf.slice(scan);
|
|
861
|
+
scan = 0;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
866
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
867
|
+
await reader.cancel().catch(() => {});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
875
|
+
*
|
|
876
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
877
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
878
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
879
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
880
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
881
|
+
* single chunk is read.
|
|
882
|
+
*/
|
|
883
|
+
async function parseJsonBounded<T>(
|
|
884
|
+
res: Response,
|
|
885
|
+
name: string,
|
|
886
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
887
|
+
): Promise<T> {
|
|
888
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
889
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
890
|
+
|
|
891
|
+
const tooBig = (n: number) =>
|
|
892
|
+
new Error(
|
|
893
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
894
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
895
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
896
|
+
'a specific id, or a lower limit) and retry.',
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
if (declared !== null && declared > maxBytes) {
|
|
900
|
+
await res.body?.cancel().catch(() => {});
|
|
901
|
+
throw tooBig(declared);
|
|
902
|
+
}
|
|
903
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
904
|
+
|
|
905
|
+
const reader = res.body.getReader();
|
|
906
|
+
const decoder = new TextDecoder();
|
|
907
|
+
let raw = '';
|
|
908
|
+
let bytesRead = 0;
|
|
909
|
+
try {
|
|
910
|
+
for (;;) {
|
|
911
|
+
const { value, done } = await reader.read();
|
|
912
|
+
if (done) break;
|
|
913
|
+
bytesRead += value.byteLength;
|
|
914
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
915
|
+
raw += decoder.decode(value, { stream: true });
|
|
916
|
+
}
|
|
917
|
+
raw += decoder.decode();
|
|
918
|
+
} finally {
|
|
919
|
+
await reader.cancel().catch(() => {});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!raw.trim()) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
925
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
return JSON.parse(raw) as T;
|
|
930
|
+
} catch {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
933
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
673
937
|
/**
|
|
674
938
|
* NewsData.io MCP — wraps the NewsData.io global news API (newsdata.io)
|
|
675
939
|
*
|
|
@@ -699,7 +963,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
699
963
|
{
|
|
700
964
|
name: 'latest_news',
|
|
701
965
|
description:
|
|
702
|
-
'Get
|
|
966
|
+
'Get news headlines and articles — world news, breaking news, and business/financial/stock-market news. Filter by keyword, country (2-letter, e.g. "us"), category (business, technology, politics, sports, health, science), and language. IMPORTANT: for stock-market / financial-market / economy / "world market news" questions, ALWAYS pass category: "business" — it returns real market-news outlets and filters out low-quality SEO/crypto-promo articles. Returns article title, description, link, source, publish date, category, and country. Paginate via the nextPage token. WITHOUT from_date/to_date this covers only roughly the LAST 48 HOURS (NewsData.io\'s rolling "latest" feed) — asking about an older event with no date window silently returns today\'s unrelated articles instead of an empty result. For a PAST date range, pass from_date/to_date (YYYY-MM-DD): this routes to NewsData.io\'s archive endpoint, which requires a plan tier above the shared platform key\'s default — if the account cannot serve it, the call returns a `filter_gap` field naming the unsupported window instead of silently substituting the latest 48h. Examples: latest_news({ query: "stock market", category: "business" }) for current world market news; latest_news({ query: "election", country: "us", category: "politics" }); latest_news({ query: "Houthi Yemen attack", from_date: "2026-08-01", to_date: "2026-08-15" }) for an August event.',
|
|
703
967
|
inputSchema: {
|
|
704
968
|
type: 'object' as const,
|
|
705
969
|
properties: {
|
|
@@ -719,6 +983,14 @@ const tools: McpToolExport['tools'] = [
|
|
|
719
983
|
type: 'string',
|
|
720
984
|
description: 'Language code to filter news by, e.g. "en", "es", "fr"',
|
|
721
985
|
},
|
|
986
|
+
from_date: {
|
|
987
|
+
type: 'string',
|
|
988
|
+
description: 'Start of a PAST date window, YYYY-MM-DD. Supplying this (or to_date) switches the call from the 48h "latest" feed to NewsData.io\'s archive endpoint, which may require a higher plan tier than the shared platform key has — if so, the response carries `filter_gap` naming the unsupported window rather than silently returning current articles.',
|
|
989
|
+
},
|
|
990
|
+
to_date: {
|
|
991
|
+
type: 'string',
|
|
992
|
+
description: 'End of a PAST date window, YYYY-MM-DD. See from_date.',
|
|
993
|
+
},
|
|
722
994
|
page: {
|
|
723
995
|
type: 'string',
|
|
724
996
|
description: 'nextPage token returned from a prior call, to fetch the next page of results',
|
|
@@ -862,6 +1134,8 @@ const ARG_ALIASES: Record<string, readonly string[]> = {
|
|
|
862
1134
|
language: ['lang', 'languages', 'language_code'],
|
|
863
1135
|
coin: ['coins', 'ticker', 'tickers', 'symbol', 'symbols'],
|
|
864
1136
|
page: ['nextPage', 'next_page', 'page_token', 'pageToken', 'cursor'],
|
|
1137
|
+
from_date: ['fromDate', 'start_date', 'startDate', 'since'],
|
|
1138
|
+
to_date: ['toDate', 'end_date', 'endDate', 'until'],
|
|
865
1139
|
};
|
|
866
1140
|
|
|
867
1141
|
/**
|
|
@@ -936,7 +1210,7 @@ async function callTool(name: string, rawArgs: Record<string, unknown>): Promise
|
|
|
936
1210
|
}
|
|
937
1211
|
|
|
938
1212
|
const ACCEPTED: Record<string, readonly string[]> = {
|
|
939
|
-
latest_news: ['query', 'country', 'category', 'language', 'page'],
|
|
1213
|
+
latest_news: ['query', 'country', 'category', 'language', 'page', 'from_date', 'to_date'],
|
|
940
1214
|
crypto_news: ['query', 'coin', 'language'],
|
|
941
1215
|
news_sources: ['country', 'category', 'language'],
|
|
942
1216
|
};
|
|
@@ -944,6 +1218,51 @@ async function callTool(name: string, rawArgs: Record<string, unknown>): Promise
|
|
|
944
1218
|
|
|
945
1219
|
switch (name) {
|
|
946
1220
|
case 'latest_news': {
|
|
1221
|
+
const fromDate = args.from_date as string | undefined;
|
|
1222
|
+
const toDate = args.to_date as string | undefined;
|
|
1223
|
+
|
|
1224
|
+
// A caller asking for a PAST window gets routed to NewsData.io's
|
|
1225
|
+
// /archive endpoint instead of /latest (which only ever serves a
|
|
1226
|
+
// rolling last-~48h feed and would otherwise silently answer an
|
|
1227
|
+
// August question with October articles — fleet #2911, 7 judged
|
|
1228
|
+
// misses on 3 questions). /archive commonly needs a plan tier above
|
|
1229
|
+
// the shared platform key's default; rather than assume either way,
|
|
1230
|
+
// make the live call and, if the upstream call itself fails (for ANY
|
|
1231
|
+
// reason — a plan-tier rejection, an unsupported date range, a bad
|
|
1232
|
+
// date format), DECLARE the gap instead of falling back to /latest,
|
|
1233
|
+
// which would silently re-introduce exactly this bug.
|
|
1234
|
+
if (fromDate || toDate) {
|
|
1235
|
+
const archiveParams = new URLSearchParams();
|
|
1236
|
+
if (args.query) archiveParams.set('q', args.query as string);
|
|
1237
|
+
if (args.country) archiveParams.set('country', args.country as string);
|
|
1238
|
+
if (args.category) archiveParams.set('category', args.category as string);
|
|
1239
|
+
if (args.language) archiveParams.set('language', args.language as string);
|
|
1240
|
+
if (fromDate) archiveParams.set('from_date', fromDate);
|
|
1241
|
+
if (toDate) archiveParams.set('to_date', toDate);
|
|
1242
|
+
if (args.page) archiveParams.set('page', args.page as string);
|
|
1243
|
+
|
|
1244
|
+
const archiveData = await newsdataGet(apiKey, '/archive', archiveParams);
|
|
1245
|
+
const upstreamError =
|
|
1246
|
+
archiveData && typeof archiveData === 'object' && 'error' in (archiveData as object)
|
|
1247
|
+
? (archiveData as { error: number; message: string })
|
|
1248
|
+
: archiveData && typeof archiveData === 'object' && (archiveData as NewsListResponse).status === 'error'
|
|
1249
|
+
? { error: 'api_error', message: JSON.stringify(archiveData) }
|
|
1250
|
+
: null;
|
|
1251
|
+
|
|
1252
|
+
if (upstreamError) {
|
|
1253
|
+
return {
|
|
1254
|
+
filter_gap: `date window (from_date=${fromDate ?? '(none)'}, to_date=${toDate ?? '(none)'}) could not be served — the archive endpoint call failed, most likely because this NewsData.io plan does not include dated/archive search`,
|
|
1255
|
+
window_requested: { from_date: fromDate ?? null, to_date: toDate ?? null },
|
|
1256
|
+
window_served: 'none — no fallback to the 48h latest feed was taken, to avoid silently answering a past-dated question with current articles',
|
|
1257
|
+
upstream_error: upstreamError,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
return {
|
|
1261
|
+
window_requested: { from_date: fromDate ?? null, to_date: toDate ?? null },
|
|
1262
|
+
...mapArticles(archiveData as NewsListResponse),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
947
1266
|
const params = new URLSearchParams();
|
|
948
1267
|
if (args.query) params.set('q', args.query as string);
|
|
949
1268
|
if (args.country) params.set('country', args.country as string);
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-newsdata', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-newsdata', version: '0.1.6' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|