@pipeworx/mcp-data360 0.1.2 → 0.1.4

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  World Bank Data360 MCP — the World Bank's modern unified data platform.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1683+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1854+ 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
 
@@ -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 1683+ data sources. The
59
+ Both URLs reach the same gateway and the same 1854+ 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-data360",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "World Bank Data360 MCP — the World Bank's modern unified data platform.",
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-61d39d1cf7fa43ecbd2836e19c734b143647b67ff511a68979ea6f88a8a0de97",
30
- "sourceCommit": "56d3c46c09bb3078ffdbdbca992a93f406bcd382"
29
+ "sourceHash": "v1-1511cd1832ee85fadcceee3343e69d08d9d9cd3b0ba78476982edcd1ebdfd01c",
30
+ "sourceCommit": "8355bf3d1eece256a62a89dba2278e7a77bebe07"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/data360",
4
4
  "title": "Data360",
5
5
  "description": "World Bank Data360 MCP — the World Bank's modern unified data platform.",
6
- "version": "0.1.2",
6
+ "version": "0.1.4",
7
7
  "websiteUrl": "https://pipeworx.io/packs/data360",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-data360",
package/src/index.ts CHANGED
@@ -670,6 +670,254 @@ function pickMessage(node: unknown, depth: number): string | null {
670
670
  function collapse(s: string): string {
671
671
  return s.replace(/\s+/g, ' ').trim();
672
672
  }
673
+
674
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
675
+ //
676
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
677
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
678
+ // and once as the object graph (which for an array of small objects runs
679
+ // several times the byte size). Nothing in `workers/gateway` can see that
680
+ // peak — it happens inside the pack, before any gateway code holds a payload —
681
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
682
+ // the 503/1102 rate at all.
683
+ //
684
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
685
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
686
+ // gnomad_gene BRCA1 2,732,706 B
687
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
688
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
689
+ // request in flight returns 503/1102, which is why the victims look random.
690
+ //
691
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
692
+ // returns one enormous top-level JSON array with no server-side paging. It
693
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
694
+ // retained memory is one element plus one chunk rather than the whole body,
695
+ // and it cancels the body as soon as it has what it was asked for.
696
+
697
+ /** Default ceiling for a single upstream JSON body read into an isolate.
698
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
699
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
700
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
701
+
702
+ interface JsonArrayPrefix<T> {
703
+ /** The decoded elements, at most `maxItems` of them. */
704
+ items: T[];
705
+ /** True when the upstream array had more elements than we read. */
706
+ truncated: boolean;
707
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
708
+ bytesRead: number;
709
+ /** `Content-Length` if the upstream declared one, else null. */
710
+ declaredBytes: number | null;
711
+ }
712
+
713
+ /**
714
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
715
+ * stream without ever materialising the whole body.
716
+ *
717
+ * Use this instead of `parseJson` when the upstream returns one big array and
718
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
719
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
720
+ * reached, so the remaining megabytes are never transferred at all.
721
+ *
722
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
723
+ * res, 'StatCan WDS', { maxItems: limit + offset });
724
+ *
725
+ * `maxBytes` is a second, independent backstop for a pathological element (a
726
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
727
+ * than throwing, because a partial answer plus an explicit truncated flag is
728
+ * more useful to a caller than an error.
729
+ */
730
+ async function readJsonArrayPrefix<T>(
731
+ res: Response,
732
+ name: string,
733
+ opts: { maxItems: number; maxBytes?: number },
734
+ ): Promise<JsonArrayPrefix<T>> {
735
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
736
+ const declaredRaw = Number(res.headers.get('content-length'));
737
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
738
+
739
+ // No streaming body available (a mock, or an already-buffered response):
740
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
741
+ if (!res.body) {
742
+ const all = await parseJson<T[]>(res, name);
743
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
744
+ return {
745
+ items: all.slice(0, opts.maxItems),
746
+ truncated: all.length > opts.maxItems,
747
+ bytesRead: declaredBytes ?? 0,
748
+ declaredBytes,
749
+ };
750
+ }
751
+
752
+ const reader = res.body.getReader();
753
+ const decoder = new TextDecoder();
754
+ const items: T[] = [];
755
+
756
+ let buf = '';
757
+ let scan = 0;
758
+ let bytesRead = 0;
759
+ let started = false; // consumed the opening '['
760
+ let arrayEnded = false;
761
+ let depth = 0;
762
+ let inStr = false;
763
+ let esc = false;
764
+ let elemStart = -1;
765
+ let truncated = false;
766
+
767
+ const push = (text: string) => {
768
+ const t = text.trim();
769
+ if (!t) return;
770
+ items.push(JSON.parse(t) as T);
771
+ };
772
+
773
+ try {
774
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
775
+ const { value, done } = await reader.read();
776
+ if (done) break;
777
+ bytesRead += value.byteLength;
778
+ buf += decoder.decode(value, { stream: true });
779
+ if (bytesRead > maxBytes) { truncated = true; break; }
780
+
781
+ for (; scan < buf.length; scan++) {
782
+ const ch = buf[scan];
783
+
784
+ if (inStr) {
785
+ if (esc) { esc = false; continue; }
786
+ if (ch === '\\') { esc = true; continue; }
787
+ if (ch === '"') inStr = false;
788
+ continue;
789
+ }
790
+
791
+ if (!started) {
792
+ if (ch === '[') { started = true; continue; }
793
+ if (ch.trim() === '') continue;
794
+ throw new Error(
795
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
796
+ 'This reader is only for upstreams that answer with one top-level array.',
797
+ );
798
+ }
799
+
800
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
801
+
802
+ if (ch === '{' || ch === '[') {
803
+ if (depth === 0 && elemStart < 0) elemStart = scan;
804
+ depth++;
805
+ continue;
806
+ }
807
+
808
+ if (ch === '}' || ch === ']') {
809
+ if (depth > 0) {
810
+ depth--;
811
+ if (depth === 0 && elemStart >= 0) {
812
+ push(buf.slice(elemStart, scan + 1));
813
+ elemStart = -1;
814
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
815
+ }
816
+ continue;
817
+ }
818
+ // depth 0 and a closing bracket: the end of the top-level array.
819
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
820
+ arrayEnded = true;
821
+ break outer;
822
+ }
823
+
824
+ if (ch === ',' && depth === 0) {
825
+ if (elemStart >= 0) {
826
+ push(buf.slice(elemStart, scan));
827
+ elemStart = -1;
828
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
829
+ }
830
+ continue;
831
+ }
832
+
833
+ // A bare scalar element (number / true / false / null).
834
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
835
+ }
836
+
837
+ // Drop everything already consumed. THIS is what bounds memory: without
838
+ // it `buf` grows to the full body and the reader is streaming in name only.
839
+ if (elemStart >= 0) {
840
+ buf = buf.slice(elemStart);
841
+ scan -= elemStart;
842
+ elemStart = 0;
843
+ } else {
844
+ buf = buf.slice(scan);
845
+ scan = 0;
846
+ }
847
+ }
848
+ } finally {
849
+ // Cancel rather than drain: the point is that the remaining megabytes are
850
+ // never transferred. A cancel on an already-finished body is a no-op.
851
+ await reader.cancel().catch(() => {});
852
+ }
853
+
854
+ return { items, truncated, bytesRead, declaredBytes };
855
+ }
856
+
857
+ /**
858
+ * `parseJson`, with a hard ceiling on how many bytes are read.
859
+ *
860
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
861
+ * does not apply) but which can still answer with megabytes. Unlike the array
862
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
863
+ * arbitrary JSON document is not parseable and there is no partial answer to
864
+ * hand back. A declared `Content-Length` over the cap is refused before a
865
+ * single chunk is read.
866
+ */
867
+ async function parseJsonBounded<T>(
868
+ res: Response,
869
+ name: string,
870
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
871
+ ): Promise<T> {
872
+ const declaredRaw = Number(res.headers.get('content-length'));
873
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
874
+
875
+ const tooBig = (n: number) =>
876
+ new Error(
877
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
878
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
879
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
880
+ 'a specific id, or a lower limit) and retry.',
881
+ );
882
+
883
+ if (declared !== null && declared > maxBytes) {
884
+ await res.body?.cancel().catch(() => {});
885
+ throw tooBig(declared);
886
+ }
887
+ if (!res.body) return parseJson<T>(res, name);
888
+
889
+ const reader = res.body.getReader();
890
+ const decoder = new TextDecoder();
891
+ let raw = '';
892
+ let bytesRead = 0;
893
+ try {
894
+ for (;;) {
895
+ const { value, done } = await reader.read();
896
+ if (done) break;
897
+ bytesRead += value.byteLength;
898
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
899
+ raw += decoder.decode(value, { stream: true });
900
+ }
901
+ raw += decoder.decode();
902
+ } finally {
903
+ await reader.cancel().catch(() => {});
904
+ }
905
+
906
+ if (!raw.trim()) {
907
+ throw new Error(
908
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
909
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
910
+ );
911
+ }
912
+ try {
913
+ return JSON.parse(raw) as T;
914
+ } catch {
915
+ throw new Error(
916
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
917
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
918
+ );
919
+ }
920
+ }
673
921
  /**
674
922
  * World Bank Data360 MCP — the World Bank's modern unified data platform.
675
923
  *
@@ -796,7 +1044,39 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
796
1044
  if (period) params.set('TIME_PERIOD', period);
797
1045
  params.set('skip', String(numArg(args.skip, 0)));
798
1046
  params.set('top', String(numArg(args.top, 100)));
799
- return get(`/data?${params.toString()}`);
1047
+ const res = await pwFetch(`${BASE}/data?${params.toString()}`, {
1048
+ headers: { Accept: 'application/json', 'User-Agent': UA },
1049
+ });
1050
+ if (!res.ok) {
1051
+ const text = await res.text();
1052
+ // Data360 answers an unknown DATABASE_ID with 417 EA500001 "Data
1053
+ // retrieval failed" -- the same body it sends on a real outage, so the
1054
+ // body cannot tell them apart. Check the id against the database list
1055
+ // and only call it not_found when it is genuinely absent (fleet #2497).
1056
+ const dbId = params.get('DATABASE_ID')!;
1057
+ const known = await databaseIds().catch(() => null);
1058
+ if (known && !known.includes(dbId)) {
1059
+ const near = known.find((k) => k.toLowerCase() === dbId.toLowerCase());
1060
+ return {
1061
+ error: 'not_found',
1062
+ message: near
1063
+ ? `Data360 has no database "${dbId}" -- ids are case-sensitive; did you mean "${near}"?`
1064
+ : `Data360 has no database "${dbId}". Call data360_list_databases for valid DATABASE_IDs (e.g. "WB_WDI").`,
1065
+ DATABASE_ID: dbId,
1066
+ };
1067
+ }
1068
+ throw new Error(`Data360: ${res.status} ${text.slice(0, 200)}`);
1069
+ }
1070
+ const data = (await res.json()) as { count?: number; value?: unknown[] };
1071
+ if ((data.count ?? data.value?.length ?? 0) === 0) {
1072
+ // An unknown INDICATOR / REF_AREA pair is a 200 with zero rows, not an error.
1073
+ return {
1074
+ ...data,
1075
+ empty_reason: 'no_match',
1076
+ note: `No observations for INDICATOR "${params.get('INDICATOR')}" in ${params.get('DATABASE_ID')} for REF_AREA "${params.get('REF_AREA')}"${period ? ` in ${period}` : ''}. Check the code with data360_search_indicators (database_id: "${params.get('DATABASE_ID')}"), or drop TIME_PERIOD.`,
1077
+ };
1078
+ }
1079
+ return data;
800
1080
  }
801
1081
 
802
1082
  default:
@@ -814,10 +1094,16 @@ async function searchPost(body: unknown): Promise<unknown> {
814
1094
  return res.json();
815
1095
  }
816
1096
 
817
- async function get(path: string): Promise<unknown> {
818
- const res = await pwFetch(`${BASE}${path}`, { headers: { Accept: 'application/json', 'User-Agent': UA } });
819
- if (!res.ok) throw new Error(`Data360: ${res.status} ${await res.text().then((t) => t.slice(0, 200))}`);
820
- return res.json();
1097
+ async function databaseIds(): Promise<string[]> {
1098
+ const res = (await searchPost({
1099
+ count: true,
1100
+ search: '*',
1101
+ top: 0,
1102
+ facets: ['series_description/database_id,count:1000'],
1103
+ })) as any;
1104
+ const ids: Array<{ value: string }> = res?.['@search.facets']?.['series_description/database_id'] ?? [];
1105
+ if (ids.length === 0) throw new Error('Data360 database list came back empty');
1106
+ return ids.map((d) => d.value);
821
1107
  }
822
1108
 
823
1109
  function reqStr(args: Record<string, unknown>, key: string, example: string): 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-data360', version: '0.1.2' },
13
+ { name: '@pipeworx/mcp-data360', version: '0.1.4' },
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 result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
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 {