@pipeworx/mcp-gov-auctions 0.1.3 → 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 1683+ 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 1683+ 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.3",
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-8031b011dae588570497cd0a81460c41a285768424bcb5f691572ac0ada94342",
30
- "sourceCommit": "1064560fd9dcd0d6fc2606298c2ba244b3a1fbeb"
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.3",
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
  *
@@ -670,6 +867,262 @@ function pickMessage(node: unknown, depth: number): string | null {
670
867
  function collapse(s: string): string {
671
868
  return s.replace(/\s+/g, ' ').trim();
672
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;
673
1126
  /**
674
1127
  * Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,
675
1128
  * tax-deed) that agencies sell to the public. Reads the
@@ -847,10 +1300,78 @@ interface LotRow {
847
1300
  status: string;
848
1301
  seller_agency: string | null;
849
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;
850
1306
  }
851
1307
 
852
1308
  const LOT_SELECT =
853
- '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
+ }
854
1375
 
855
1376
  function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
856
1377
  const n = Number(v);
@@ -858,6 +1379,16 @@ function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
858
1379
  return Math.min(Math.max(Math.trunc(n), lo), hi);
859
1380
  }
860
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
+
861
1392
  function shapeLot(r: LotRow) {
862
1393
  return {
863
1394
  source: r.source,
@@ -889,6 +1420,14 @@ function activeLotFilters(args: Record<string, unknown>): string[] {
889
1420
  if (assetType) parts.push(`asset_type=eq.${encodeURIComponent(assetType)}`);
890
1421
  const source = String(args.source ?? '').trim().toLowerCase();
891
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
+ }
892
1431
  return parts;
893
1432
  }
894
1433
 
@@ -931,8 +1470,9 @@ async function search(cfg: SupabaseConfig, args: Record<string, unknown>) {
931
1470
  const cutoff = new Date(Date.now() + Number(args.closing_within_hours) * 3600_000).toISOString();
932
1471
  parts.push(`closes_at=lte.${cutoff}`);
933
1472
  parts.push(`closes_at=gte.${nowIso}`);
934
- parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
935
- 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);
936
1476
  return { count: rows.length, lots: rows.map(shapeLot) };
937
1477
  }
938
1478
 
@@ -954,21 +1494,23 @@ async function search(cfg: SupabaseConfig, args: Record<string, unknown>) {
954
1494
  // was for sale while the table held live lots. Two index-friendly passes
955
1495
  // return the identical rows in the identical order, because `nullslast` puts
956
1496
  // every null-close lot after every dated one anyway.
1497
+ const want = overFetch(limit);
957
1498
  const dated = await pg<LotRow[]>(
958
1499
  cfg,
959
1500
  'auction_lots',
960
- [...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('&'),
961
1502
  );
962
- const rows =
963
- dated.length >= limit
1503
+ const raw =
1504
+ dated.length >= want
964
1505
  ? dated
965
1506
  : dated.concat(
966
1507
  await pg<LotRow[]>(
967
1508
  cfg,
968
1509
  'auction_lots',
969
- [...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('&'),
970
1511
  ),
971
1512
  );
1513
+ const rows = dedupeStorefronts(raw).slice(0, limit);
972
1514
  return { count: rows.length, lots: rows.map(shapeLot) };
973
1515
  }
974
1516
 
@@ -976,8 +1518,9 @@ async function closingSoon(cfg: SupabaseConfig, args: Record<string, unknown>) {
976
1518
  const parts = activeLotFilters(args);
977
1519
  parts.push('closes_at=gte.' + new Date().toISOString());
978
1520
  const limit = clampInt(args.limit, 1, 100, 25);
979
- parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
980
- 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);
981
1524
  const now = Date.now();
982
1525
  return {
983
1526
  count: rows.length,
@@ -1037,11 +1580,13 @@ async function soldComps(cfg: SupabaseConfig, args: Record<string, unknown>) {
1037
1580
  const median = prices[Math.floor(prices.length / 2)];
1038
1581
  const avg = Math.round(prices.reduce((s, n) => s + n, 0) / prices.length);
1039
1582
  const exLimit = clampInt(args.limit, 1, 50, 10);
1040
- 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[]>(
1041
1585
  cfg,
1042
1586
  'auction_lots',
1043
- [...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('&'),
1044
1588
  );
1589
+ const examples = dedupeStorefronts(rawEx).slice(0, exLimit);
1045
1590
  return {
1046
1591
  matched_lots: prices.length,
1047
1592
  final_price: { min: prices[0], median, average: avg, max: prices[prices.length - 1], currency: 'USD' },
@@ -1056,24 +1601,32 @@ async function soldComps(cfg: SupabaseConfig, args: Record<string, unknown>) {
1056
1601
  }
1057
1602
 
1058
1603
  async function coverage(cfg: SupabaseConfig) {
1059
- const sources = await pg<Array<{ source: string; last_success_at: string | null }>>(
1604
+ const sourceRows = await pg<SourceStateRow[]>(
1060
1605
  cfg,
1061
1606
  'auction_sources',
1062
- 'select=source,last_success_at&limit=200',
1607
+ 'select=source,schedule,last_success_at,consecutive_failures,last_error&limit=200',
1063
1608
  );
1064
- const freshness = new Map(sources.map((s) => [s.source, s.last_success_at]));
1609
+ const freshness = classifyFreshness(sourceRows, Date.now());
1065
1610
  const perSource = await Promise.all(
1066
- KNOWN_SOURCES.map(async (source) => ({
1067
- source,
1068
- // No GOV_SEGMENT here, deliberately, and it is not a hole in the rule
1069
- // above: every entry in KNOWN_SOURCES *is* a government source, so
1070
- // source=eq.<x> already implies segment=government and the extra
1071
- // predicate can only ever be true. It was not free, though — carrying it
1072
- // was the difference between this count timing out and us-auctions'
1073
- // identical count succeeding on a source 15x larger (fleet #1081).
1074
- active_lots: await pgCount(cfg, 'auction_lots', `status=eq.active&source=eq.${source}`),
1075
- last_refreshed_at: freshness.get(source) ?? null,
1076
- })),
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
+ }),
1077
1630
  );
1078
1631
  // GOV_SEGMENT is NOT redundant here: comps span both segments.
1079
1632
  const soldComps = await pgCount(cfg, 'auction_lots', `status=eq.closed&final_price=not.is.null&${GOV_SEGMENT}`);
@@ -1082,8 +1635,20 @@ async function coverage(cfg: SupabaseConfig) {
1082
1635
  // named instead, so the total reads as an explicit partial rather than as a
1083
1636
  // smaller number presented as a complete one.
1084
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;
1085
1650
  return {
1086
- total_active_lots: perSource.reduce((s, c) => s + (c.active_lots ?? 0), 0),
1651
+ total_active_lots: totalActiveLotsFresh,
1087
1652
  ...(uncounted.length
1088
1653
  ? {
1089
1654
  total_active_lots_partial:
@@ -1093,6 +1658,22 @@ async function coverage(cfg: SupabaseConfig) {
1093
1658
  uncounted_sources: uncounted,
1094
1659
  }
1095
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
+ : {}),
1096
1677
  sold_comps_retained: soldComps,
1097
1678
  ...(soldComps === null
1098
1679
  ? {
@@ -1102,7 +1683,7 @@ async function coverage(cfg: SupabaseConfig) {
1102
1683
  }
1103
1684
  : {}),
1104
1685
  sources: perSource,
1105
- 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.',
1106
1687
  };
1107
1688
  }
1108
1689
 
@@ -1116,9 +1697,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1116
1697
 
1117
1698
  switch (name) {
1118
1699
  case 'auctions_search':
1119
- return search(cfg, args);
1700
+ return withFreshLots(cfg, args, (a) => search(cfg, a) as Promise<Record<string, unknown>>);
1120
1701
  case 'auctions_closing_soon':
1121
- return closingSoon(cfg, args);
1702
+ return withFreshLots(cfg, args, (a) => closingSoon(cfg, a) as Promise<Record<string, unknown>>);
1122
1703
  case 'auction_lot_details':
1123
1704
  return lotDetails(cfg, args);
1124
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.3' },
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 {