@pipeworx/mcp-gov-auctions 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
@@ -1,8 +1,8 @@
1
1
  # mcp-gov-auctions
2
2
 
3
- Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,
3
+ Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited, tax-deed) that agencies sell to the public. Reads the auction_lots table (ingested daily by workers/auction-scraper) via PostgREST.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1679+ live data sources.
5
+ 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.
6
6
 
7
7
  ## Tools
8
8
 
@@ -58,7 +58,7 @@ directly, instead of just this one's:
58
58
  }
59
59
  ```
60
60
 
61
- Both URLs reach the same gateway and the same 1679+ data sources. The
61
+ Both URLs reach the same gateway and the same 1879+ data sources. The
62
62
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
63
63
  reaches all of them from either one.
64
64
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-gov-auctions",
3
- "version": "0.1.2",
4
- "description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,",
3
+ "version": "0.1.4",
4
+ "description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited, tax-deed) that agencies sell to the public. Reads the auction_lots table (ingested daily by workers/auction-scraper) via PostgREST.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-fe5c5b3976179dfb6c6861c71bda6a1b5c198e350b78470e71c1fa0b1c471a22",
30
- "sourceCommit": "76aa8f5d9b5dc2f665563030647c68e661478864"
29
+ "sourceHash": "v1-f1f876a8837ac00d319a1934ecce2b6584ad359f90b5951c04590f38894f9d2b",
30
+ "sourceCommit": "445bacc5596a845c12d6945fbfcdf330ef60dcc4"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.pipeworx-io/gov-auctions",
4
4
  "title": "Gov Auctions",
5
- "description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,",
6
- "version": "0.1.2",
5
+ "description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited, tax-deed) that…",
6
+ "version": "0.1.4",
7
7
  "websiteUrl": "https://pipeworx.io/packs/gov-auctions",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-gov-auctions",
package/src/index.ts CHANGED
@@ -24,6 +24,187 @@ interface McpToolExport {
24
24
  provider?: string;
25
25
  }
26
26
 
27
+ /**
28
+ * Shared freshness + cross-storefront dedupe logic for the Pipeworx auction
29
+ * packs (mcps/us-auctions, mcps/gov-auctions), which both read auction_lots /
30
+ * auction_sources over PostgREST. Self-contained — no imports — so the
31
+ * standalone-publish inliner (scripts/lib/resolve-shared-helpers.mjs) can pull
32
+ * it into either pack's published bundle whole.
33
+ *
34
+ * STALE-SOURCE WITHHOLDING (ported from mcps/us-auctions, fleet #3065): a lot
35
+ * is "active" in auction_lots only because the last pass that saw it said so.
36
+ * When a source stops completing passes nothing says otherwise, so a source
37
+ * whose last completed pass is older than its schedule's threshold is STALE,
38
+ * and its lots should be WITHHELD from open-lot tools rather than returned
39
+ * with a per-row flag — an agent assembling a list of open lots drops
40
+ * advisory flags and keeps the row. See mcps/us-auctions/src/index.ts's
41
+ * original commentary for the measured pass-cadence numbers the thresholds
42
+ * are based on.
43
+ *
44
+ * CROSS-STOREFRONT DEDUPE (fleet #3137): GovDeals and AllSurplus are one
45
+ * Liquidity Services catalogue reached through two storefronts, so a lot can
46
+ * carry an IDENTICAL source_lot_id under both. The two copies are scraped at
47
+ * DIFFERENT times and so can disagree (current_bid, bid_count) — keep
48
+ * whichever was seen MORE RECENTLY (last_seen_at), not a fixed winner: a fixed
49
+ * winner can silently serve the staler of two snapshots (measured pair
50
+ * 8445-2-47961: govdeals $160 vs allsurplus $121).
51
+ */
52
+
53
+ const AUCTION_STALE_AFTER_MS: Record<string, number> = {
54
+ '6h': 24 * 3600_000,
55
+ daily: 50 * 3600_000,
56
+ weekly: 15 * 24 * 3600_000,
57
+ };
58
+
59
+ interface AuctionSourceStateRow {
60
+ source: string;
61
+ schedule: string | null;
62
+ last_success_at: string | null;
63
+ consecutive_failures: number | null;
64
+ last_error: string | null;
65
+ }
66
+
67
+ interface AuctionSourceFreshness {
68
+ source: string;
69
+ last_success_at: string | null;
70
+ stale: boolean;
71
+ stale_after_hours: number;
72
+ hours_since_success: number | null;
73
+ consecutive_failures: number;
74
+ last_error: string | null;
75
+ }
76
+
77
+ function classifyAuctionFreshness(
78
+ rows: AuctionSourceStateRow[],
79
+ nowMs: number,
80
+ ): Map<string, AuctionSourceFreshness> {
81
+ const out = new Map<string, AuctionSourceFreshness>();
82
+ for (const r of rows) {
83
+ const after = AUCTION_STALE_AFTER_MS[r.schedule ?? ''] ?? AUCTION_STALE_AFTER_MS.daily;
84
+ const t = r.last_success_at ? Date.parse(r.last_success_at) : NaN;
85
+ const age = Number.isFinite(t) ? nowMs - t : null;
86
+ out.set(r.source, {
87
+ source: r.source,
88
+ last_success_at: r.last_success_at,
89
+ // Never completed a pass is stale too: nothing it holds was confirmed.
90
+ stale: age === null || age > after,
91
+ stale_after_hours: after / 3600_000,
92
+ hours_since_success: age === null ? null : Math.round((age / 3600_000) * 10) / 10,
93
+ consecutive_failures: r.consecutive_failures ?? 0,
94
+ last_error: r.last_error ? r.last_error.slice(0, 200) : null,
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function describeStaleAuctionSource(f: AuctionSourceFreshness): string {
101
+ const when = f.last_success_at
102
+ ? `last completed a refresh ${f.last_success_at} (${f.hours_since_success}h ago)`
103
+ : 'has never completed a refresh';
104
+ return `${f.source} ${when}`;
105
+ }
106
+
107
+ /** The disclosure every open-lot response carries when a source was withheld. */
108
+ function staleAuctionDisclosure(
109
+ stale: AuctionSourceFreshness[],
110
+ freshnessKnown: boolean,
111
+ ): Record<string, unknown> {
112
+ if (!freshnessKnown) {
113
+ return {
114
+ freshness_unverified:
115
+ 'Could not read per-source refresh times, so lots below were NOT checked for staleness. Treat them as last-known listings and confirm on the auction page.',
116
+ };
117
+ }
118
+ if (stale.length === 0) return {};
119
+ return {
120
+ withheld_stale_sources: stale.map((f) => ({
121
+ source: f.source,
122
+ last_success_at: f.last_success_at,
123
+ hours_since_success: f.hours_since_success,
124
+ stale_after_hours: f.stale_after_hours,
125
+ })),
126
+ stale_note:
127
+ `Lots from ${stale.map(describeStaleAuctionSource).join('; ')} are withheld: a source that has not refreshed within ` +
128
+ `${stale.map((f) => `${f.stale_after_hours}h`).join('/')} cannot vouch that its lots are still open. ` +
129
+ `Coverage for that source is missing from these results until it refreshes.`,
130
+ };
131
+ }
132
+
133
+ // ── Cross-storefront dedupe ─────────────────────────────────────────────────
134
+
135
+ /** GovDeals and AllSurplus are two storefronts over one maestro API catalogue. */
136
+ const DEFAULT_DUPLICATE_STOREFRONTS = new Set(['govdeals', 'allsurplus']);
137
+
138
+ /**
139
+ * Tie-break order used ONLY when last_seen_at is missing or equal on both
140
+ * copies (e.g. a caller that didn't select the column, or a zip-radius RPC
141
+ * that doesn't project it). Matches the pre-fleet#3137 fixed preference, so a
142
+ * caller that cannot supply freshness data gets the same deterministic result
143
+ * it always did rather than an arbitrary one.
144
+ */
145
+ const DEFAULT_STOREFRONT_TIEBREAK_ORDER: readonly string[] = ['govdeals', 'allsurplus'];
146
+
147
+ function parseTimeOrMinusInfinity(v: string | null | undefined): number {
148
+ if (!v) return -Infinity;
149
+ const t = Date.parse(v);
150
+ return Number.isFinite(t) ? t : -Infinity;
151
+ }
152
+
153
+ interface DedupeAuctionStorefrontsOptions {
154
+ duplicateStorefronts?: ReadonlySet<string>;
155
+ tiebreakOrder?: readonly string[];
156
+ }
157
+
158
+ /**
159
+ * Drop the second copy of a lot listed on two storefronts of the same
160
+ * underlying platform (by default govdeals/allsurplus; the same
161
+ * source_lot_id — the composite accountId-auctionId-assetId — identifies the
162
+ * same physical lot on both).
163
+ *
164
+ * Keeps the FRESHER copy (later last_seen_at). Falls back to tiebreakOrder
165
+ * (default: govdeals) when last_seen_at is missing or tied on both sides, so
166
+ * behaviour stays fully deterministic even for a caller that has no freshness
167
+ * column to offer.
168
+ *
169
+ * Preserves the caller's row ORDER: the kept row occupies the position of the
170
+ * FIRST-seen copy, so a list already sorted (e.g. by closes_at) is not
171
+ * re-sorted by this pass — only its content may be swapped for the fresher
172
+ * duplicate's.
173
+ */
174
+ function dedupeAuctionStorefronts<
175
+ T extends { source: string; source_lot_id: string; last_seen_at?: string | null },
176
+ >(rows: T[], opts: DedupeAuctionStorefrontsOptions = {}): T[] {
177
+ const duplicateStorefronts = opts.duplicateStorefronts ?? DEFAULT_DUPLICATE_STOREFRONTS;
178
+ const tiebreakOrder = opts.tiebreakOrder ?? DEFAULT_STOREFRONT_TIEBREAK_ORDER;
179
+ const rankOf = (source: string): number => {
180
+ const i = tiebreakOrder.indexOf(source);
181
+ return i === -1 ? tiebreakOrder.length : i;
182
+ };
183
+
184
+ const indexByKey = new Map<string, number>();
185
+ const out: T[] = [];
186
+ for (const r of rows) {
187
+ if (!duplicateStorefronts.has(r.source)) {
188
+ out.push(r);
189
+ continue;
190
+ }
191
+ const key = r.source_lot_id;
192
+ const existingIdx = indexByKey.get(key);
193
+ if (existingIdx === undefined) {
194
+ indexByKey.set(key, out.length);
195
+ out.push(r);
196
+ continue;
197
+ }
198
+ const existing = out[existingIdx];
199
+ const rTime = parseTimeOrMinusInfinity(r.last_seen_at);
200
+ const eTime = parseTimeOrMinusInfinity(existing.last_seen_at);
201
+ const rIsBetter = rTime !== eTime ? rTime > eTime : rankOf(r.source) < rankOf(existing.source);
202
+ if (rIsBetter) out[existingIdx] = r;
203
+ }
204
+ return out;
205
+ }
206
+
207
+
27
208
  /**
28
209
  * Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
29
210
  *
@@ -295,12 +476,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
295
476
  // internal-host-class.ts; no-op for every third-party upstream, which is why
296
477
  // this touches 481 packs' error text and changes none of it.
297
478
  return markInternalOrigin(
298
- `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
479
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
299
480
  res.url,
300
481
  res.status,
301
482
  );
302
483
  }
303
484
 
485
+ /**
486
+ * Facts about a FAILED response that live in its headers, not its body, appended
487
+ * so the gateway's classifier (which only ever sees this string) can tell a limit
488
+ * from a wall from a refusal (fleet #3477). Both are standard, both are absent on
489
+ * almost every response, and neither is invented: `Retry-After` is the server
490
+ * saying "come back later" (the one thing that makes a 403 a throttle), and
491
+ * Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
492
+ */
493
+ function responseHints(res: Response): string {
494
+ const hints: string[] = [];
495
+ const retryAfter = res.headers.get('retry-after');
496
+ if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
497
+ if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
498
+ return hints.length ? ` (${hints.join('; ')})` : '';
499
+ }
500
+
304
501
  /**
305
502
  * Just the upstream's own explanation — no name, no status.
306
503
  *
@@ -452,7 +649,42 @@ async function fetchWithTimeout(
452
649
  ),
453
650
  );
454
651
  }
455
- throw err;
652
+ // Fleet #2382. Everything that isn't a timeout/abort here is a genuine
653
+ // NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
654
+ // Cloudflare's own "Network connection lost." — meaning `fetch()` itself
655
+ // threw and no HTTP response of any kind was ever received. Until this fix
656
+ // that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
657
+ // (or the Workers-runtime equivalent) names no upstream, carries no class
658
+ // token, and reads exactly like a defect in OUR code — because it says
659
+ // nothing about the call at all. It landed in `error`, the tier that means
660
+ // "Pipeworx has a defect", for every one of the (at the time of writing)
661
+ // ~470 packs that call this helper directly with no wrapper of their own.
662
+ //
663
+ // `dexscreener` hit this independently (fleet #1579) and fixed it with a
664
+ // bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
665
+ // but only covers one pack; every other caller of this shared helper still
666
+ // leaked the raw exception. Moving the same fix HERE — the one place that
667
+ // already carries the timeout case — covers every pack that uses
668
+ // `fetchWithTimeout` without a wrapper, for free, and without widening
669
+ // `classifyToolError`'s regex list: the fix is giving the message a proper
670
+ // `upstream_down:` token at the point the two facts (no response was ever
671
+ // received, and which host we were trying to reach) are actually in hand,
672
+ // not teaching the classifier to guess from prose after the fact.
673
+ //
674
+ // Safe on the same grounds as the timeout branch above: no argument a
675
+ // caller passes can make `fetch()` itself throw a connection-level error,
676
+ // so this is always an availability failure, never a caller mistake. Same
677
+ // `markInternalOrigin` treatment — an origin we run that never answered is
678
+ // still ours, not a third party's outage.
679
+ const raw = err instanceof Error ? err.message : String(err);
680
+ throw new Error(
681
+ markInternalOrigin(
682
+ `upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
683
+ `No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
684
+ 'are valid — do not re-check them on the strength of this error. Retry shortly.',
685
+ url,
686
+ ),
687
+ );
456
688
  }
457
689
  }
458
690
 
@@ -635,6 +867,262 @@ function pickMessage(node: unknown, depth: number): string | null {
635
867
  function collapse(s: string): string {
636
868
  return s.replace(/\s+/g, ' ').trim();
637
869
  }
870
+
871
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
872
+ //
873
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
874
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
875
+ // and once as the object graph (which for an array of small objects runs
876
+ // several times the byte size). Nothing in `workers/gateway` can see that
877
+ // peak — it happens inside the pack, before any gateway code holds a payload —
878
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
879
+ // the 503/1102 rate at all.
880
+ //
881
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
882
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
883
+ // gnomad_gene BRCA1 2,732,706 B
884
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
885
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
886
+ // request in flight returns 503/1102, which is why the victims look random.
887
+ //
888
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
889
+ // returns one enormous top-level JSON array with no server-side paging. It
890
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
891
+ // retained memory is one element plus one chunk rather than the whole body,
892
+ // and it cancels the body as soon as it has what it was asked for.
893
+
894
+ /** Default ceiling for a single upstream JSON body read into an isolate.
895
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
896
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
897
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
898
+
899
+ interface JsonArrayPrefix<T> {
900
+ /** The decoded elements, at most `maxItems` of them. */
901
+ items: T[];
902
+ /** True when the upstream array had more elements than we read. */
903
+ truncated: boolean;
904
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
905
+ bytesRead: number;
906
+ /** `Content-Length` if the upstream declared one, else null. */
907
+ declaredBytes: number | null;
908
+ }
909
+
910
+ /**
911
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
912
+ * stream without ever materialising the whole body.
913
+ *
914
+ * Use this instead of `parseJson` when the upstream returns one big array and
915
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
916
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
917
+ * reached, so the remaining megabytes are never transferred at all.
918
+ *
919
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
920
+ * res, 'StatCan WDS', { maxItems: limit + offset });
921
+ *
922
+ * `maxBytes` is a second, independent backstop for a pathological element (a
923
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
924
+ * than throwing, because a partial answer plus an explicit truncated flag is
925
+ * more useful to a caller than an error.
926
+ */
927
+ async function readJsonArrayPrefix<T>(
928
+ res: Response,
929
+ name: string,
930
+ opts: { maxItems: number; maxBytes?: number },
931
+ ): Promise<JsonArrayPrefix<T>> {
932
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
933
+ const declaredRaw = Number(res.headers.get('content-length'));
934
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
935
+
936
+ // No streaming body available (a mock, or an already-buffered response):
937
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
938
+ if (!res.body) {
939
+ const all = await parseJson<T[]>(res, name);
940
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
941
+ return {
942
+ items: all.slice(0, opts.maxItems),
943
+ truncated: all.length > opts.maxItems,
944
+ bytesRead: declaredBytes ?? 0,
945
+ declaredBytes,
946
+ };
947
+ }
948
+
949
+ const reader = res.body.getReader();
950
+ const decoder = new TextDecoder();
951
+ const items: T[] = [];
952
+
953
+ let buf = '';
954
+ let scan = 0;
955
+ let bytesRead = 0;
956
+ let started = false; // consumed the opening '['
957
+ let arrayEnded = false;
958
+ let depth = 0;
959
+ let inStr = false;
960
+ let esc = false;
961
+ let elemStart = -1;
962
+ let truncated = false;
963
+
964
+ const push = (text: string) => {
965
+ const t = text.trim();
966
+ if (!t) return;
967
+ items.push(JSON.parse(t) as T);
968
+ };
969
+
970
+ try {
971
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
972
+ const { value, done } = await reader.read();
973
+ if (done) break;
974
+ bytesRead += value.byteLength;
975
+ buf += decoder.decode(value, { stream: true });
976
+ if (bytesRead > maxBytes) { truncated = true; break; }
977
+
978
+ for (; scan < buf.length; scan++) {
979
+ const ch = buf[scan];
980
+
981
+ if (inStr) {
982
+ if (esc) { esc = false; continue; }
983
+ if (ch === '\\') { esc = true; continue; }
984
+ if (ch === '"') inStr = false;
985
+ continue;
986
+ }
987
+
988
+ if (!started) {
989
+ if (ch === '[') { started = true; continue; }
990
+ if (ch.trim() === '') continue;
991
+ throw new Error(
992
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
993
+ 'This reader is only for upstreams that answer with one top-level array.',
994
+ );
995
+ }
996
+
997
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
998
+
999
+ if (ch === '{' || ch === '[') {
1000
+ if (depth === 0 && elemStart < 0) elemStart = scan;
1001
+ depth++;
1002
+ continue;
1003
+ }
1004
+
1005
+ if (ch === '}' || ch === ']') {
1006
+ if (depth > 0) {
1007
+ depth--;
1008
+ if (depth === 0 && elemStart >= 0) {
1009
+ push(buf.slice(elemStart, scan + 1));
1010
+ elemStart = -1;
1011
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
1012
+ }
1013
+ continue;
1014
+ }
1015
+ // depth 0 and a closing bracket: the end of the top-level array.
1016
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
1017
+ arrayEnded = true;
1018
+ break outer;
1019
+ }
1020
+
1021
+ if (ch === ',' && depth === 0) {
1022
+ if (elemStart >= 0) {
1023
+ push(buf.slice(elemStart, scan));
1024
+ elemStart = -1;
1025
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
1026
+ }
1027
+ continue;
1028
+ }
1029
+
1030
+ // A bare scalar element (number / true / false / null).
1031
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
1032
+ }
1033
+
1034
+ // Drop everything already consumed. THIS is what bounds memory: without
1035
+ // it `buf` grows to the full body and the reader is streaming in name only.
1036
+ if (elemStart >= 0) {
1037
+ buf = buf.slice(elemStart);
1038
+ scan -= elemStart;
1039
+ elemStart = 0;
1040
+ } else {
1041
+ buf = buf.slice(scan);
1042
+ scan = 0;
1043
+ }
1044
+ }
1045
+ } finally {
1046
+ // Cancel rather than drain: the point is that the remaining megabytes are
1047
+ // never transferred. A cancel on an already-finished body is a no-op.
1048
+ await reader.cancel().catch(() => {});
1049
+ }
1050
+
1051
+ return { items, truncated, bytesRead, declaredBytes };
1052
+ }
1053
+
1054
+ /**
1055
+ * `parseJson`, with a hard ceiling on how many bytes are read.
1056
+ *
1057
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
1058
+ * does not apply) but which can still answer with megabytes. Unlike the array
1059
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
1060
+ * arbitrary JSON document is not parseable and there is no partial answer to
1061
+ * hand back. A declared `Content-Length` over the cap is refused before a
1062
+ * single chunk is read.
1063
+ */
1064
+ async function parseJsonBounded<T>(
1065
+ res: Response,
1066
+ name: string,
1067
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
1068
+ ): Promise<T> {
1069
+ const declaredRaw = Number(res.headers.get('content-length'));
1070
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
1071
+
1072
+ const tooBig = (n: number) =>
1073
+ new Error(
1074
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
1075
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
1076
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
1077
+ 'a specific id, or a lower limit) and retry.',
1078
+ );
1079
+
1080
+ if (declared !== null && declared > maxBytes) {
1081
+ await res.body?.cancel().catch(() => {});
1082
+ throw tooBig(declared);
1083
+ }
1084
+ if (!res.body) return parseJson<T>(res, name);
1085
+
1086
+ const reader = res.body.getReader();
1087
+ const decoder = new TextDecoder();
1088
+ let raw = '';
1089
+ let bytesRead = 0;
1090
+ try {
1091
+ for (;;) {
1092
+ const { value, done } = await reader.read();
1093
+ if (done) break;
1094
+ bytesRead += value.byteLength;
1095
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
1096
+ raw += decoder.decode(value, { stream: true });
1097
+ }
1098
+ raw += decoder.decode();
1099
+ } finally {
1100
+ await reader.cancel().catch(() => {});
1101
+ }
1102
+
1103
+ if (!raw.trim()) {
1104
+ throw new Error(
1105
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
1106
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
1107
+ );
1108
+ }
1109
+ try {
1110
+ return JSON.parse(raw) as T;
1111
+ } catch {
1112
+ throw new Error(
1113
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
1114
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
1115
+ );
1116
+ }
1117
+ }
1118
+
1119
+
1120
+ type SourceStateRow = AuctionSourceStateRow;
1121
+ type SourceFreshness = AuctionSourceFreshness;
1122
+ const classifyFreshness = classifyAuctionFreshness;
1123
+ const describeStale = describeStaleAuctionSource;
1124
+ const staleDisclosure = staleAuctionDisclosure;
1125
+ const dedupeStorefronts = dedupeAuctionStorefronts;
638
1126
  /**
639
1127
  * Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,
640
1128
  * tax-deed) that agencies sell to the public. Reads the
@@ -812,10 +1300,78 @@ interface LotRow {
812
1300
  status: string;
813
1301
  seller_agency: string | null;
814
1302
  url: string | null;
1303
+ // Not shaped into any tool's output — read only so dedupeStorefronts can
1304
+ // pick the fresher of two cross-listed govdeals/allsurplus copies.
1305
+ last_seen_at: string | null;
815
1306
  }
816
1307
 
817
1308
  const LOT_SELECT =
818
- 'select=source,source_lot_id,title,category,asset_type,location_city,location_state,location_zip,currency,current_bid,bid_count,closes_at,status,seller_agency,url';
1309
+ 'select=source,source_lot_id,title,category,asset_type,location_city,location_state,location_zip,currency,current_bid,bid_count,closes_at,status,seller_agency,url,last_seen_at';
1310
+
1311
+ // ── Source freshness (ported from mcps/us-auctions, fleet #3065 -> #3137) ──
1312
+ //
1313
+ // gov-auctions reads the SAME auction_lots/auction_sources tables as
1314
+ // us-auctions but never got the stale-source withholding that pack carries:
1315
+ // a source whose last completed pass is older than its schedule's threshold
1316
+ // is STALE, and its lots are WITHHELD from the open-lot tools (search,
1317
+ // closing_soon) rather than returned with a per-row flag that an agent
1318
+ // assembling "lots you can bid on now" would drop and keep the row anyway.
1319
+ // classifyFreshness/describeStale/staleDisclosure/their thresholds live in
1320
+
1321
+ // One small read (one row per source) per call, cached per isolate for a
1322
+ // minute: freshness moves on the scale of hours, and every search needs it.
1323
+ let freshnessCache: { at: number; map: Map<string, SourceFreshness> } | null = null;
1324
+ const FRESHNESS_TTL_MS = 60_000;
1325
+
1326
+ async function sourceFreshness(cfg: SupabaseConfig): Promise<Map<string, SourceFreshness> | null> {
1327
+ const now = Date.now();
1328
+ if (freshnessCache && now - freshnessCache.at < FRESHNESS_TTL_MS) return freshnessCache.map;
1329
+ try {
1330
+ const rows = await pg<SourceStateRow[]>(
1331
+ cfg,
1332
+ 'auction_sources',
1333
+ 'select=source,schedule,last_success_at,consecutive_failures,last_error&limit=200',
1334
+ );
1335
+ const map = classifyFreshness(rows, now);
1336
+ freshnessCache = { at: now, map };
1337
+ return map;
1338
+ } catch {
1339
+ // Cannot tell is not "fresh". The caller says so in the response.
1340
+ return null;
1341
+ }
1342
+ }
1343
+
1344
+ function staleList(fresh: Map<string, SourceFreshness> | null): SourceFreshness[] {
1345
+ return fresh ? [...fresh.values()].filter((f) => f.stale) : [];
1346
+ }
1347
+
1348
+ /**
1349
+ * Wrap an open-lot tool so stale sources are excluded at the QUERY (not just
1350
+ * filtered after the LIMIT, which would shrink pages unpredictably) and the
1351
+ * response names what was withheld. Mirrors us-auctions' withFreshLots.
1352
+ */
1353
+ async function withFreshLots(
1354
+ cfg: SupabaseConfig,
1355
+ args: Record<string, unknown>,
1356
+ run: (args: Record<string, unknown>) => Promise<Record<string, unknown>>,
1357
+ ): Promise<Record<string, unknown>> {
1358
+ const fresh = await sourceFreshness(cfg);
1359
+ const stale = staleList(fresh);
1360
+ const named = String(args.source ?? '').trim().toLowerCase();
1361
+ const namedStale = named ? stale.find((f) => f.source === named) : undefined;
1362
+ if (namedStale) {
1363
+ return {
1364
+ count: 0,
1365
+ lots: [],
1366
+ source_stale: true,
1367
+ ...staleDisclosure([namedStale], true),
1368
+ message: `No open lots can be reported for ${named}: it ${describeStale(namedStale).slice(named.length + 1)}, so its listings may have closed or changed since. This is a stale source, not an empty one.`,
1369
+ };
1370
+ }
1371
+ const result = await run({ ...args, _exclude_sources: named ? [] : stale.map((f) => f.source) });
1372
+ if ('error' in result) return result;
1373
+ return { ...result, ...staleDisclosure(named ? [] : stale, fresh !== null) };
1374
+ }
819
1375
 
820
1376
  function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
821
1377
  const n = Number(v);
@@ -823,6 +1379,16 @@ function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
823
1379
  return Math.min(Math.max(Math.trunc(n), lo), hi);
824
1380
  }
825
1381
 
1382
+ /**
1383
+ * How many rows to ask for so that `limit` SURVIVE dedupeStorefronts. GovDeals
1384
+ * and AllSurplus are two storefronts over one catalogue (mcps/us-auctions'
1385
+ * dedupeStorefronts commentary has the measured ~94% overlap), so a plain
1386
+ * fetch of `limit` rows can come back nearly half empty post-dedupe.
1387
+ */
1388
+ function overFetch(limit: number): number {
1389
+ return Math.min(limit * 2, 200);
1390
+ }
1391
+
826
1392
  function shapeLot(r: LotRow) {
827
1393
  return {
828
1394
  source: r.source,
@@ -854,6 +1420,14 @@ function activeLotFilters(args: Record<string, unknown>): string[] {
854
1420
  if (assetType) parts.push(`asset_type=eq.${encodeURIComponent(assetType)}`);
855
1421
  const source = String(args.source ?? '').trim().toLowerCase();
856
1422
  if (source) parts.push(`source=eq.${encodeURIComponent(source)}`);
1423
+ // Stale sources (withFreshLots). Top-level `not.in` — no enclosing group for
1424
+ // the commas to escape into — and the values are quoted anyway.
1425
+ const exclude = Array.isArray(args._exclude_sources)
1426
+ ? (args._exclude_sources as unknown[]).map(String).filter((x) => /^[a-z0-9-]+$/.test(x))
1427
+ : [];
1428
+ if (!source && exclude.length > 0) {
1429
+ parts.push(`source=not.in.(${exclude.map((x) => encodeURIComponent(`"${x}"`)).join(',')})`);
1430
+ }
857
1431
  return parts;
858
1432
  }
859
1433
 
@@ -896,8 +1470,9 @@ async function search(cfg: SupabaseConfig, args: Record<string, unknown>) {
896
1470
  const cutoff = new Date(Date.now() + Number(args.closing_within_hours) * 3600_000).toISOString();
897
1471
  parts.push(`closes_at=lte.${cutoff}`);
898
1472
  parts.push(`closes_at=gte.${nowIso}`);
899
- parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
900
- const rows = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
1473
+ parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${overFetch(limit)}`);
1474
+ const raw = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
1475
+ const rows = dedupeStorefronts(raw).slice(0, limit);
901
1476
  return { count: rows.length, lots: rows.map(shapeLot) };
902
1477
  }
903
1478
 
@@ -919,21 +1494,23 @@ async function search(cfg: SupabaseConfig, args: Record<string, unknown>) {
919
1494
  // was for sale while the table held live lots. Two index-friendly passes
920
1495
  // return the identical rows in the identical order, because `nullslast` puts
921
1496
  // every null-close lot after every dated one anyway.
1497
+ const want = overFetch(limit);
922
1498
  const dated = await pg<LotRow[]>(
923
1499
  cfg,
924
1500
  'auction_lots',
925
- [...parts, `closes_at=gte.${nowIso}`, LOT_SELECT, 'order=closes_at.asc', `limit=${limit}`].join('&'),
1501
+ [...parts, `closes_at=gte.${nowIso}`, LOT_SELECT, 'order=closes_at.asc', `limit=${want}`].join('&'),
926
1502
  );
927
- const rows =
928
- dated.length >= limit
1503
+ const raw =
1504
+ dated.length >= want
929
1505
  ? dated
930
1506
  : dated.concat(
931
1507
  await pg<LotRow[]>(
932
1508
  cfg,
933
1509
  'auction_lots',
934
- [...parts, 'closes_at=is.null', LOT_SELECT, `limit=${limit - dated.length}`].join('&'),
1510
+ [...parts, 'closes_at=is.null', LOT_SELECT, `limit=${want - dated.length}`].join('&'),
935
1511
  ),
936
1512
  );
1513
+ const rows = dedupeStorefronts(raw).slice(0, limit);
937
1514
  return { count: rows.length, lots: rows.map(shapeLot) };
938
1515
  }
939
1516
 
@@ -941,8 +1518,9 @@ async function closingSoon(cfg: SupabaseConfig, args: Record<string, unknown>) {
941
1518
  const parts = activeLotFilters(args);
942
1519
  parts.push('closes_at=gte.' + new Date().toISOString());
943
1520
  const limit = clampInt(args.limit, 1, 100, 25);
944
- parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
945
- const rows = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
1521
+ parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${overFetch(limit)}`);
1522
+ const raw = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
1523
+ const rows = dedupeStorefronts(raw).slice(0, limit);
946
1524
  const now = Date.now();
947
1525
  return {
948
1526
  count: rows.length,
@@ -1002,11 +1580,13 @@ async function soldComps(cfg: SupabaseConfig, args: Record<string, unknown>) {
1002
1580
  const median = prices[Math.floor(prices.length / 2)];
1003
1581
  const avg = Math.round(prices.reduce((s, n) => s + n, 0) / prices.length);
1004
1582
  const exLimit = clampInt(args.limit, 1, 50, 10);
1005
- const examples = await pg<LotRow[]>(
1583
+ const EX_SELECT = 'select=source,source_lot_id,title,location_state,final_price,closes_at,url,last_seen_at';
1584
+ const rawEx = await pg<LotRow[]>(
1006
1585
  cfg,
1007
1586
  'auction_lots',
1008
- [...parts, 'select=title,location_state,final_price,closes_at,url', 'order=closes_at.desc', `limit=${exLimit}`].join('&'),
1587
+ [...parts, EX_SELECT, 'order=closes_at.desc', `limit=${overFetch(exLimit)}`].join('&'),
1009
1588
  );
1589
+ const examples = dedupeStorefronts(rawEx).slice(0, exLimit);
1010
1590
  return {
1011
1591
  matched_lots: prices.length,
1012
1592
  final_price: { min: prices[0], median, average: avg, max: prices[prices.length - 1], currency: 'USD' },
@@ -1021,24 +1601,32 @@ async function soldComps(cfg: SupabaseConfig, args: Record<string, unknown>) {
1021
1601
  }
1022
1602
 
1023
1603
  async function coverage(cfg: SupabaseConfig) {
1024
- const sources = await pg<Array<{ source: string; last_success_at: string | null }>>(
1604
+ const sourceRows = await pg<SourceStateRow[]>(
1025
1605
  cfg,
1026
1606
  'auction_sources',
1027
- 'select=source,last_success_at&limit=200',
1607
+ 'select=source,schedule,last_success_at,consecutive_failures,last_error&limit=200',
1028
1608
  );
1029
- const freshness = new Map(sources.map((s) => [s.source, s.last_success_at]));
1609
+ const freshness = classifyFreshness(sourceRows, Date.now());
1030
1610
  const perSource = await Promise.all(
1031
- KNOWN_SOURCES.map(async (source) => ({
1032
- source,
1033
- // No GOV_SEGMENT here, deliberately, and it is not a hole in the rule
1034
- // above: every entry in KNOWN_SOURCES *is* a government source, so
1035
- // source=eq.<x> already implies segment=government and the extra
1036
- // predicate can only ever be true. It was not free, though — carrying it
1037
- // was the difference between this count timing out and us-auctions'
1038
- // identical count succeeding on a source 15x larger (fleet #1081).
1039
- active_lots: await pgCount(cfg, 'auction_lots', `status=eq.active&source=eq.${source}`),
1040
- last_refreshed_at: freshness.get(source) ?? null,
1041
- })),
1611
+ KNOWN_SOURCES.map(async (source) => {
1612
+ const f = freshness.get(source);
1613
+ return {
1614
+ source,
1615
+ // No GOV_SEGMENT here, deliberately, and it is not a hole in the rule
1616
+ // above: every entry in KNOWN_SOURCES *is* a government source, so
1617
+ // source=eq.<x> already implies segment=government and the extra
1618
+ // predicate can only ever be true. It was not free, though — carrying it
1619
+ // was the difference between this count timing out and us-auctions'
1620
+ // identical count succeeding on a source 15x larger (fleet #1081).
1621
+ active_lots: await pgCount(cfg, 'auction_lots', `status=eq.active&source=eq.${source}`),
1622
+ last_refreshed_at: f?.last_success_at ?? null,
1623
+ last_success_at: f?.last_success_at ?? null,
1624
+ // No state row at all is stale: the scraper has never reported it.
1625
+ stale: f ? f.stale : true,
1626
+ stale_after_hours: f?.stale_after_hours ?? AUCTION_STALE_AFTER_MS.daily / 3600_000,
1627
+ hours_since_success: f?.hours_since_success ?? null,
1628
+ };
1629
+ }),
1042
1630
  );
1043
1631
  // GOV_SEGMENT is NOT redundant here: comps span both segments.
1044
1632
  const soldComps = await pgCount(cfg, 'auction_lots', `status=eq.closed&final_price=not.is.null&${GOV_SEGMENT}`);
@@ -1047,8 +1635,20 @@ async function coverage(cfg: SupabaseConfig) {
1047
1635
  // named instead, so the total reads as an explicit partial rather than as a
1048
1636
  // smaller number presented as a complete one.
1049
1637
  const uncounted = perSource.filter((s) => s.active_lots === null).map((s) => s.source);
1638
+ const freshSources = perSource.filter((s) => !s.stale);
1639
+ const staleSources = perSource.filter((s) => s.stale);
1640
+ // Stale-source withholding (ported from mcps/us-auctions, fleet #3065 ->
1641
+ // #3137): a source whose last completed pass is too old cannot vouch its
1642
+ // lots are still open, so it is excluded from total_active_lots — the same
1643
+ // rule that excludes its lots from auctions_search/auctions_closing_soon.
1644
+ const totalActiveLotsFresh = freshSources.every((s) => s.active_lots !== null)
1645
+ ? freshSources.reduce((s, c) => s + (c.active_lots ?? 0), 0)
1646
+ : null;
1647
+ const totalActiveLotsIncludingStale = perSource.every((s) => s.active_lots !== null)
1648
+ ? perSource.reduce((s, c) => s + (c.active_lots ?? 0), 0)
1649
+ : null;
1050
1650
  return {
1051
- total_active_lots: perSource.reduce((s, c) => s + (c.active_lots ?? 0), 0),
1651
+ total_active_lots: totalActiveLotsFresh,
1052
1652
  ...(uncounted.length
1053
1653
  ? {
1054
1654
  total_active_lots_partial:
@@ -1058,6 +1658,22 @@ async function coverage(cfg: SupabaseConfig) {
1058
1658
  uncounted_sources: uncounted,
1059
1659
  }
1060
1660
  : {}),
1661
+ ...(staleSources.length
1662
+ ? {
1663
+ total_active_lots_including_stale: totalActiveLotsIncludingStale,
1664
+ stale_sources: staleSources.map((s) => ({
1665
+ source: s.source,
1666
+ last_success_at: s.last_success_at,
1667
+ hours_since_success: s.hours_since_success,
1668
+ active_lots: s.active_lots,
1669
+ })),
1670
+ stale_note:
1671
+ `${staleSources.map((s) => s.source).join(', ')} ${staleSources.length === 1 ? 'has' : 'have'} not completed a refresh ` +
1672
+ `within ${staleSources.map((s) => `${s.stale_after_hours}h`).join('/')}, so lots marked active there are last-known ` +
1673
+ `listings, not confirmed open ones. They are excluded from total_active_lots and withheld from search results. ` +
1674
+ `total_active_lots_including_stale is the old headline.`,
1675
+ }
1676
+ : {}),
1061
1677
  sold_comps_retained: soldComps,
1062
1678
  ...(soldComps === null
1063
1679
  ? {
@@ -1067,7 +1683,7 @@ async function coverage(cfg: SupabaseConfig) {
1067
1683
  }
1068
1684
  : {}),
1069
1685
  sources: perSource,
1070
- note: 'Data is refreshed daily. Sold-price comps (auctions_sold_comps) retain closed lots indefinitely. An active_lots of null means the count did not complete, not that the source is empty.',
1686
+ note: 'Data is refreshed daily. Sold-price comps (auctions_sold_comps) retain closed lots indefinitely. An active_lots of null means the count did not complete, not that the source is empty. GovDeals and AllSurplus are two storefronts over one catalogue — search results are de-duplicated, but these per-source counts are raw and overlap.',
1071
1687
  };
1072
1688
  }
1073
1689
 
@@ -1081,9 +1697,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1081
1697
 
1082
1698
  switch (name) {
1083
1699
  case 'auctions_search':
1084
- return search(cfg, args);
1700
+ return withFreshLots(cfg, args, (a) => search(cfg, a) as Promise<Record<string, unknown>>);
1085
1701
  case 'auctions_closing_soon':
1086
- return closingSoon(cfg, args);
1702
+ return withFreshLots(cfg, args, (a) => closingSoon(cfg, a) as Promise<Record<string, unknown>>);
1087
1703
  case 'auction_lot_details':
1088
1704
  return lotDetails(cfg, args);
1089
1705
  case 'auctions_sold_comps':
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-gov-auctions', version: '0.1.2' },
13
+ { name: '@pipeworx/mcp-gov-auctions', 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 {