@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 +3 -3
- package/package.json +4 -4
- package/server.json +2 -2
- package/src/index.ts +611 -30
- 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
|
*
|
|
@@ -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
|
|
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=${
|
|
1501
|
+
[...parts, `closes_at=gte.${nowIso}`, LOT_SELECT, 'order=closes_at.asc', `limit=${want}`].join('&'),
|
|
961
1502
|
);
|
|
962
|
-
const
|
|
963
|
-
dated.length >=
|
|
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=${
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
1609
|
+
const freshness = classifyFreshness(sourceRows, Date.now());
|
|
1065
1610
|
const perSource = await Promise.all(
|
|
1066
|
-
KNOWN_SOURCES.map(async (source) =>
|
|
1067
|
-
source
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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:
|
|
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
|
|
1700
|
+
return withFreshLots(cfg, args, (a) => search(cfg, a) as Promise<Record<string, unknown>>);
|
|
1120
1701
|
case 'auctions_closing_soon':
|
|
1121
|
-
return
|
|
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.
|
|
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 {
|