@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 +3 -3
- package/package.json +4 -4
- package/server.json +2 -2
- package/src/index.ts +647 -31
- package/src/server.ts +5 -2
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
|
|
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
|
|
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.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
-
|
|
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
|
|
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=${
|
|
1501
|
+
[...parts, `closes_at=gte.${nowIso}`, LOT_SELECT, 'order=closes_at.asc', `limit=${want}`].join('&'),
|
|
926
1502
|
);
|
|
927
|
-
const
|
|
928
|
-
dated.length >=
|
|
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=${
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
1609
|
+
const freshness = classifyFreshness(sourceRows, Date.now());
|
|
1030
1610
|
const perSource = await Promise.all(
|
|
1031
|
-
KNOWN_SOURCES.map(async (source) =>
|
|
1032
|
-
source
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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:
|
|
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
|
|
1700
|
+
return withFreshLots(cfg, args, (a) => search(cfg, a) as Promise<Record<string, unknown>>);
|
|
1085
1701
|
case 'auctions_closing_soon':
|
|
1086
|
-
return
|
|
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.
|
|
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
|
|
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 {
|