@pipeworx/mcp-guam-code 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
@@ -3,7 +3,7 @@
3
3
  Guam Code Annotated (GCA) — Guam territorial statutes, by citation and by
4
4
  topic/keyword search. Keyless.
5
5
 
6
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1815+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
6
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1879+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
7
7
 
8
8
  ## Tools
9
9
 
@@ -224,7 +224,7 @@ directly, instead of just this one's:
224
224
  }
225
225
  ```
226
226
 
227
- Both URLs reach the same gateway and the same 1815+ data sources. The
227
+ Both URLs reach the same gateway and the same 1879+ data sources. The
228
228
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
229
229
  reaches all of them from either one.
230
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-guam-code",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Guam Code Annotated (GCA) — section lookup and search. Fleet #2888.",
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-0e8b06ad75d31b79367ea7bccec933bfae1665a234e87b2fd47a9220c214f878",
30
- "sourceCommit": "fe38188882d0fe91e5a45d82b7b989b4aae1e8b6"
29
+ "sourceHash": "v1-def998606f47935be067505462c08c2cb618014e238faf74206f49ad1d914e33",
30
+ "sourceCommit": "445bacc5596a845c12d6945fbfcdf330ef60dcc4"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/guam-code",
4
4
  "title": "Guam Code",
5
5
  "description": "Guam Code Annotated (GCA) — section lookup and search. Fleet #2888.",
6
- "version": "0.1.2",
6
+ "version": "0.1.4",
7
7
  "websiteUrl": "https://pipeworx.io/packs/guam-code",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-guam-code",
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
  *
@@ -671,6 +687,254 @@ function collapse(s: string): string {
671
687
  return s.replace(/\s+/g, ' ').trim();
672
688
  }
673
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
+ }
937
+
674
938
 
675
939
  /**
676
940
  * Minimal text extractor for simple, text-based PDFs — the kind a
@@ -858,7 +1122,10 @@ interface PdfOutlineEntry {
858
1122
  }
859
1123
 
860
1124
  type FontDecoder =
861
- | { kind: 'winansi' }
1125
+ // `simple`: the font is a declared simple (1-byte-code) font with
1126
+ // /Encoding /WinAnsiEncoding, so a HEX string shown through it is a run of
1127
+ // 1-byte windows-1252 codes, not 2-byte CIDs (fleet #3478).
1128
+ | { kind: 'winansi'; simple?: boolean }
862
1129
  | { kind: 'cid'; map: Map<number, number> }
863
1130
  // Simple (1-byte-code) font decoded via its own /ToUnicode CMap rather than
864
1131
  // WinAnsi — see fontDecoderFor's comment on North Dakota's subsetted fonts.
@@ -1319,13 +1586,25 @@ async function extractPdfText(buf: ArrayBuffer, options?: PdfExtractOptions): Pr
1319
1586
  if (map.size) return { kind: 'mapped1', map };
1320
1587
  }
1321
1588
  warnings.push(`font object ${fontObjNum} has a non-WinAnsi simple encoding and no usable /ToUnicode; decoded as WinAnsi anyway`);
1589
+ return { kind: 'winansi' };
1322
1590
  }
1323
- return { kind: 'winansi' };
1591
+ return { kind: 'winansi', simple: /\/Subtype\s*\/(?:TrueType|Type1)\b/.test(obj.dict) };
1324
1592
  }
1325
1593
 
1326
1594
  const TOKEN_RE =
1327
1595
  /\/(\S+)\s+[\d.]+\s+Tf|\[((?:\\.|[^\]])*)\]\s*TJ|<([0-9A-Fa-f\s]*)>\s*Tj|\(((?:\\.|[^()])*)\)\s*Tj|1\s+0\s+0\s+1\s+[\d.-]+\s+([\d.-]+)\s+Tm/g;
1328
- const TJ_PIECE_RE = /\(((?:\\.|[^()])*)\)|<([0-9A-Fa-f\s]*)>/g;
1596
+ // Third alternative (bare number) added for Indonesia's JDIH statute PDFs
1597
+ // (jdih.setneg.go.id, fleet #3121): that producer never writes a literal
1598
+ // space byte between words — "REPUBLIK INDONESIA" is encoded as one TJ run
1599
+ // `[(…K)-269.0(I)…]`, where the -269 IS the word gap, not kerning. Every
1600
+ // sampled font's intra-word kerning tops out around +-30 (observed: 2.0,
1601
+ // -2.9, -14.0, -24.0); the real word gaps in the same document measured
1602
+ // -269, -286, -290, -692, -1396, -1400 — two clusters with nothing between
1603
+ // them, so a flat threshold cleanly separates "kerning" from "space" without
1604
+ // needing the current font size (the TJ number is already in thousandths of
1605
+ // text space, before the Tfs/Th scale-up ISO 32000 §9.4.3 applies).
1606
+ const TJ_PIECE_RE = /\(((?:\\.|[^()])*)\)|<([0-9A-Fa-f\s]*)>|(-?\d+(?:\.\d+)?)/g;
1607
+ const TJ_WORD_GAP_THRESHOLD = -100;
1329
1608
 
1330
1609
  function decodeRun(dec: FontDecoder, literal: string | undefined, hex: string | undefined): string {
1331
1610
  if (literal !== undefined) {
@@ -1378,6 +1657,16 @@ async function extractPdfText(buf: ArrayBuffer, options?: PdfExtractOptions): Pr
1378
1657
  }
1379
1658
  const cleaned = (hex ?? '').replace(/\s+/g, '');
1380
1659
  let out = '';
1660
+ // A hex string shown through a declared simple WinAnsi font is 1-byte
1661
+ // codes. Reading it as 2-byte CIDs (the fall-through below) kept only
1662
+ // every other byte's low half and silently dropped Danish ø/å/æ from
1663
+ // Folketinget minister answers: `<F86265>` is "øbe", not one glyph 'b'
1664
+ // (fleet #3478). Odd trailing nibble is ignored.
1665
+ if (dec.kind === 'winansi' && dec.simple) {
1666
+ const bytes = new Uint8Array(Math.floor(cleaned.length / 2));
1667
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(cleaned.slice(i * 2, i * 2 + 2), 16);
1668
+ return new TextDecoder('windows-1252').decode(bytes);
1669
+ }
1381
1670
  if (dec.kind === 'mapped1') {
1382
1671
  // 1-byte code per glyph (a simple font's code space), unlike Type0's
1383
1672
  // 2-byte CIDs below — see fontDecoderFor.
@@ -1450,7 +1739,17 @@ async function extractPdfText(buf: ArrayBuffer, options?: PdfExtractOptions): Pr
1450
1739
  }
1451
1740
  const dec = (currentFont && fontMap.get(currentFont)) || { kind: 'winansi' as const };
1452
1741
  if (m[2] !== undefined) {
1453
- for (const piece of m[2].matchAll(TJ_PIECE_RE)) out += decodeRun(dec, piece[1], piece[2]);
1742
+ for (const piece of m[2].matchAll(TJ_PIECE_RE)) {
1743
+ if (piece[3] !== undefined) {
1744
+ // A big negative adjustment between two TJ pieces with no
1745
+ // literal space byte at all — see TJ_PIECE_RE's comment above.
1746
+ // Skip if `out` already ends in whitespace so a producer that
1747
+ // DOES write literal spaces never gets a doubled one.
1748
+ if (parseFloat(piece[3]) <= TJ_WORD_GAP_THRESHOLD && !/\s$/.test(out)) out += ' ';
1749
+ continue;
1750
+ }
1751
+ out += decodeRun(dec, piece[1], piece[2]);
1752
+ }
1454
1753
  } else if (m[3] !== undefined) {
1455
1754
  out += decodeRun(dec, undefined, m[3]);
1456
1755
  } else if (m[4] !== undefined) {
package/src/server.ts CHANGED
@@ -11,7 +11,7 @@ import * as bakedIndexModule from './gu-baked-data.js';
11
11
  const bakedIndex = Object.fromEntries(Object.entries(bakedIndexModule));
12
12
 
13
13
  const server = new Server(
14
- { name: '@pipeworx/mcp-guam-code', version: '0.1.2' },
14
+ { name: '@pipeworx/mcp-guam-code', version: '0.1.4' },
15
15
  { capabilities: { tools: {} } },
16
16
  );
17
17