@pipeworx/mcp-gov-auctions 0.1.0
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/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +20 -0
- package/server.json +18 -0
- package/src/index.ts +351 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pipeworx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# mcp-gov-auctions
|
|
2
|
+
|
|
3
|
+
Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,
|
|
4
|
+
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 885+ live data sources.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"gov-auctions": {
|
|
20
|
+
"url": "https://gateway.pipeworx.io/gov-auctions/mcp"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or connect to the full Pipeworx gateway for access to all 885+ data sources:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"pipeworx": {
|
|
32
|
+
"url": "https://gateway.pipeworx.io/mcp"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Using with ask_pipeworx
|
|
39
|
+
|
|
40
|
+
Instead of calling tools directly, you can ask questions in plain English:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
ask_pipeworx({ question: "your question about Gov Auctions data" })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The gateway picks the right tool and fills the arguments automatically.
|
|
47
|
+
|
|
48
|
+
## More
|
|
49
|
+
|
|
50
|
+
- [All tools and guides](https://github.com/pipeworx-io/examples)
|
|
51
|
+
- [pipeworx.io](https://pipeworx.io)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pipeworx/mcp-gov-auctions",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "gov-auctions"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/pipeworx-io/mcp-gov-auctions"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.7.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.pipeworx-io/gov-auctions",
|
|
4
|
+
"title": "Gov Auctions",
|
|
5
|
+
"description": "Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://pipeworx.io/packs/gov-auctions",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/pipeworx-io/mcp-gov-auctions",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://gateway.pipeworx.io/gov-auctions/mcp"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
interface McpToolDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: 'object';
|
|
6
|
+
properties: Record<string, unknown>;
|
|
7
|
+
required?: string[];
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface McpToolExport {
|
|
12
|
+
tools: McpToolDefinition[];
|
|
13
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
14
|
+
meter?: { credits: number };
|
|
15
|
+
cost?: Record<string, unknown>;
|
|
16
|
+
provider?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Government Auctions MCP — physical-asset auctions (surplus, seized, forfeited,
|
|
21
|
+
* tax-deed) that agencies sell to the public. Queries the Pipeworx-hosted
|
|
22
|
+
* auction_lots table (ingested daily by workers/auction-scraper) via PostgREST.
|
|
23
|
+
*
|
|
24
|
+
* Coverage: GovDeals + AllSurplus (state/local surplus — vehicles, equipment,
|
|
25
|
+
* real estate) and IRS seized/forfeited property. Distinct from the gsa-auctions
|
|
26
|
+
* pack (live GSA federal-surplus API); this pack is the multi-source, DB-backed
|
|
27
|
+
* store whose differentiator is SOLD-PRICE HISTORY (auctions_sold_comps) — we
|
|
28
|
+
* never delete closed lots, so hammer prices accumulate.
|
|
29
|
+
*
|
|
30
|
+
* The pack is stateless (gateway handles auth/rate-limit) and never throws for
|
|
31
|
+
* expected empty results — it shapes LLM-friendly objects and returns { error }.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const ASSET_TYPES = ['vehicle', 'equipment', 'realestate', 'electronics', 'other'] as const;
|
|
36
|
+
|
|
37
|
+
const tools: McpToolExport['tools'] = [
|
|
38
|
+
{
|
|
39
|
+
name: 'auctions_search',
|
|
40
|
+
description:
|
|
41
|
+
'Search live US government auction lots for physical assets sold to the public — surplus vehicles, heavy equipment, real estate, electronics — from state/local governments (GovDeals, AllSurplus) and IRS seized/forfeited property. Filter by free-text keyword (matched on the item title, e.g. "truck", "excavator", "pickup"), 2-letter state, asset_type (vehicle|equipment|realestate|electronics|other), max_price (current bid ceiling), and closing_within_hours. Returns each lot with source, title, location, current bid, close time, and a link. For historical sold prices use auctions_sold_comps instead.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
keyword: { type: 'string', description: 'Free-text matched (case-insensitive) against the lot title, e.g. "truck", "forklift", "generator".' },
|
|
46
|
+
state: { type: 'string', description: '2-letter state code of the item location, e.g. "TX", "CA".' },
|
|
47
|
+
asset_type: { type: 'string', description: `One of: ${ASSET_TYPES.join(' | ')}.` },
|
|
48
|
+
source: { type: 'string', description: 'Restrict to one source: govdeals | allsurplus | irs.' },
|
|
49
|
+
max_price: { type: ['number', 'string'], description: 'Only lots whose current bid is at or below this.' },
|
|
50
|
+
closing_within_hours: { type: ['number', 'string'], description: 'Only lots closing within this many hours from now.' },
|
|
51
|
+
limit: { type: ['number', 'string'], description: 'Max lots (1-100, default 25).' },
|
|
52
|
+
},
|
|
53
|
+
required: [],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'auctions_closing_soon',
|
|
58
|
+
description:
|
|
59
|
+
'Live government auction lots ordered by soonest close time — the "what can I still bid on before it ends" view across all sources (GovDeals, AllSurplus, IRS). Optionally filter by state, asset_type, or keyword. Returns lots with time remaining, current bid, location, and link.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
state: { type: 'string', description: '2-letter state code.' },
|
|
64
|
+
asset_type: { type: 'string', description: `One of: ${ASSET_TYPES.join(' | ')}.` },
|
|
65
|
+
keyword: { type: 'string', description: 'Free-text title match.' },
|
|
66
|
+
limit: { type: ['number', 'string'], description: 'Max lots (1-100, default 25).' },
|
|
67
|
+
},
|
|
68
|
+
required: [],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'auction_lot_details',
|
|
73
|
+
description:
|
|
74
|
+
'Full details for a single government auction lot, looked up by its source + source_lot_id (as returned by auctions_search) or by its listing URL. Returns title, description, category, location, bid, close time, seller agency, and link.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
source: { type: 'string', description: 'Lot source: govdeals | allsurplus | irs.' },
|
|
79
|
+
source_lot_id: { type: 'string', description: 'The lot id within that source.' },
|
|
80
|
+
url: { type: 'string', description: 'Alternatively, the full listing URL.' },
|
|
81
|
+
},
|
|
82
|
+
required: [],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'auctions_sold_comps',
|
|
87
|
+
description:
|
|
88
|
+
'Historical SOLD prices for government auction items — the final hammer price of closed lots, which no upstream site keeps but Pipeworx retains. Use to answer "what do seized pickup trucks actually sell for" or to comp an asset. Filter by keyword (title match), asset_type, and/or state. Returns count, min/median/max/average final price, and recent examples. Only includes lots that have closed with a recorded final price.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
keyword: { type: 'string', description: 'Free-text title match, e.g. "f-150", "excavator", "trailer".' },
|
|
93
|
+
asset_type: { type: 'string', description: `One of: ${ASSET_TYPES.join(' | ')}.` },
|
|
94
|
+
state: { type: 'string', description: '2-letter state code.' },
|
|
95
|
+
limit: { type: ['number', 'string'], description: 'Max example lots to return (1-50, default 10).' },
|
|
96
|
+
},
|
|
97
|
+
required: [],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'auctions_coverage',
|
|
102
|
+
description:
|
|
103
|
+
'What government-auction data Pipeworx currently holds: per-source active lot counts and data freshness (when each source was last refreshed). Use to gauge coverage and how current the data is before relying on it.',
|
|
104
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
interface SupabaseConfig {
|
|
109
|
+
url: string;
|
|
110
|
+
key: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function pg<T>(cfg: SupabaseConfig, table: string, query: string): Promise<T> {
|
|
114
|
+
const res = await fetch(`${cfg.url}/rest/v1/${table}?${query}`, {
|
|
115
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}` },
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
throw new Error(`Supabase ${table}: ${res.status} ${text}`);
|
|
120
|
+
}
|
|
121
|
+
return res.json() as Promise<T>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Exact row count for a filter, via PostgREST's Content-Range header. Used
|
|
125
|
+
// instead of a count() aggregate (aggregate functions are disabled on the
|
|
126
|
+
// project). Requests a single row with Prefer: count=exact and parses the
|
|
127
|
+
// "0-0/1234" range tail.
|
|
128
|
+
async function pgCount(cfg: SupabaseConfig, table: string, query: string): Promise<number> {
|
|
129
|
+
const res = await fetch(`${cfg.url}/rest/v1/${table}?${query}&limit=1`, {
|
|
130
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}`, Prefer: 'count=exact' },
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) return 0;
|
|
133
|
+
const range = res.headers.get('content-range') ?? '';
|
|
134
|
+
const total = range.split('/')[1];
|
|
135
|
+
const n = Number(total);
|
|
136
|
+
return Number.isFinite(n) ? n : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The sources this pack ingests. Kept explicit (aggregate/distinct is disabled)
|
|
140
|
+
// and matched to the auction-scraper adapters.
|
|
141
|
+
const KNOWN_SOURCES = ['govdeals', 'allsurplus', 'irs'] as const;
|
|
142
|
+
|
|
143
|
+
interface LotRow {
|
|
144
|
+
source: string;
|
|
145
|
+
source_lot_id: string;
|
|
146
|
+
title: string;
|
|
147
|
+
description: string | null;
|
|
148
|
+
category: string | null;
|
|
149
|
+
asset_type: string | null;
|
|
150
|
+
location_city: string | null;
|
|
151
|
+
location_state: string | null;
|
|
152
|
+
location_zip: string | null;
|
|
153
|
+
currency: string;
|
|
154
|
+
current_bid: number | null;
|
|
155
|
+
bid_count: number | null;
|
|
156
|
+
final_price: number | null;
|
|
157
|
+
starts_at: string | null;
|
|
158
|
+
closes_at: string | null;
|
|
159
|
+
status: string;
|
|
160
|
+
seller_agency: string | null;
|
|
161
|
+
url: string | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const LOT_SELECT =
|
|
165
|
+
'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';
|
|
166
|
+
|
|
167
|
+
function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
|
|
168
|
+
const n = Number(v);
|
|
169
|
+
if (!Number.isFinite(n)) return dflt;
|
|
170
|
+
return Math.min(Math.max(Math.trunc(n), lo), hi);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function shapeLot(r: LotRow) {
|
|
174
|
+
return {
|
|
175
|
+
source: r.source,
|
|
176
|
+
source_lot_id: r.source_lot_id,
|
|
177
|
+
title: r.title,
|
|
178
|
+
category: r.category,
|
|
179
|
+
asset_type: r.asset_type,
|
|
180
|
+
location: [r.location_city, r.location_state].filter(Boolean).join(', ') || null,
|
|
181
|
+
state: r.location_state,
|
|
182
|
+
current_bid: r.current_bid,
|
|
183
|
+
currency: r.currency,
|
|
184
|
+
bid_count: r.bid_count,
|
|
185
|
+
closes_at: r.closes_at,
|
|
186
|
+
seller: r.seller_agency,
|
|
187
|
+
url: r.url,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Shared filter builder for the active-lot list tools.
|
|
192
|
+
function activeLotFilters(args: Record<string, unknown>): string[] {
|
|
193
|
+
const parts = ['status=eq.active'];
|
|
194
|
+
const keyword = String(args.keyword ?? '').trim();
|
|
195
|
+
if (keyword) parts.push(`title=ilike.*${encodeURIComponent(keyword)}*`);
|
|
196
|
+
const state = String(args.state ?? '').trim();
|
|
197
|
+
if (state) parts.push(`location_state=eq.${encodeURIComponent(state.toUpperCase())}`);
|
|
198
|
+
const assetType = String(args.asset_type ?? '').trim();
|
|
199
|
+
if (assetType) parts.push(`asset_type=eq.${encodeURIComponent(assetType)}`);
|
|
200
|
+
const source = String(args.source ?? '').trim();
|
|
201
|
+
if (source) parts.push(`source=eq.${encodeURIComponent(source)}`);
|
|
202
|
+
return parts;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function search(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
206
|
+
const parts = activeLotFilters(args);
|
|
207
|
+
if (args.max_price !== undefined && String(args.max_price).trim() !== '') {
|
|
208
|
+
parts.push(`current_bid=lte.${Number(args.max_price)}`);
|
|
209
|
+
}
|
|
210
|
+
if (args.closing_within_hours !== undefined && String(args.closing_within_hours).trim() !== '') {
|
|
211
|
+
const cutoff = new Date(Date.now() + Number(args.closing_within_hours) * 3600_000).toISOString();
|
|
212
|
+
parts.push(`closes_at=lte.${cutoff}`);
|
|
213
|
+
parts.push('closes_at=gte.' + new Date().toISOString());
|
|
214
|
+
}
|
|
215
|
+
const limit = clampInt(args.limit, 1, 100, 25);
|
|
216
|
+
parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
|
|
217
|
+
const rows = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
|
|
218
|
+
return { count: rows.length, lots: rows.map(shapeLot) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function closingSoon(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
222
|
+
const parts = activeLotFilters(args);
|
|
223
|
+
parts.push('closes_at=gte.' + new Date().toISOString());
|
|
224
|
+
const limit = clampInt(args.limit, 1, 100, 25);
|
|
225
|
+
parts.push(LOT_SELECT, 'order=closes_at.asc.nullslast', `limit=${limit}`);
|
|
226
|
+
const rows = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
return {
|
|
229
|
+
count: rows.length,
|
|
230
|
+
lots: rows.map((r) => ({
|
|
231
|
+
...shapeLot(r),
|
|
232
|
+
hours_remaining: r.closes_at ? Math.round(((new Date(r.closes_at).getTime() - now) / 3600_000) * 10) / 10 : null,
|
|
233
|
+
})),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function lotDetails(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
238
|
+
const parts: string[] = ['limit=1', 'select=*'];
|
|
239
|
+
const url = String(args.url ?? '').trim();
|
|
240
|
+
const source = String(args.source ?? '').trim();
|
|
241
|
+
const id = String(args.source_lot_id ?? '').trim();
|
|
242
|
+
if (url) {
|
|
243
|
+
parts.push(`url=eq.${encodeURIComponent(url)}`);
|
|
244
|
+
} else if (source && id) {
|
|
245
|
+
parts.push(`source=eq.${encodeURIComponent(source)}`, `source_lot_id=eq.${encodeURIComponent(id)}`);
|
|
246
|
+
} else {
|
|
247
|
+
return { error: 'missing_args', message: 'Provide either url, or both source and source_lot_id.' };
|
|
248
|
+
}
|
|
249
|
+
const rows = await pg<LotRow[]>(cfg, 'auction_lots', parts.join('&'));
|
|
250
|
+
if (rows.length === 0) return { error: 'not_found', message: 'No matching lot.' };
|
|
251
|
+
const r = rows[0];
|
|
252
|
+
return {
|
|
253
|
+
...shapeLot(r),
|
|
254
|
+
description: r.description,
|
|
255
|
+
zip: r.location_zip,
|
|
256
|
+
status: r.status,
|
|
257
|
+
final_price: r.final_price,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function soldComps(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
262
|
+
const parts = ['status=eq.closed', 'final_price=not.is.null'];
|
|
263
|
+
const keyword = String(args.keyword ?? '').trim();
|
|
264
|
+
if (keyword) parts.push(`title=ilike.*${encodeURIComponent(keyword)}*`);
|
|
265
|
+
const assetType = String(args.asset_type ?? '').trim();
|
|
266
|
+
if (assetType) parts.push(`asset_type=eq.${encodeURIComponent(assetType)}`);
|
|
267
|
+
const state = String(args.state ?? '').trim();
|
|
268
|
+
if (state) parts.push(`location_state=eq.${encodeURIComponent(state.toUpperCase())}`);
|
|
269
|
+
|
|
270
|
+
// Pull final prices for stats (cap to keep it bounded) + a few recent examples.
|
|
271
|
+
const statRows = await pg<Array<{ final_price: number }>>(
|
|
272
|
+
cfg,
|
|
273
|
+
'auction_lots',
|
|
274
|
+
[...parts, 'select=final_price', 'order=closes_at.desc', 'limit=1000'].join('&'),
|
|
275
|
+
);
|
|
276
|
+
if (statRows.length === 0) {
|
|
277
|
+
return {
|
|
278
|
+
error: 'no_comps',
|
|
279
|
+
message: `No closed lots with a recorded sold price match${keyword ? ` "${keyword}"` : ''} yet. Sold-price history accumulates as active lots close.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const prices = statRows.map((r) => Number(r.final_price)).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
|
283
|
+
const median = prices[Math.floor(prices.length / 2)];
|
|
284
|
+
const avg = Math.round(prices.reduce((s, n) => s + n, 0) / prices.length);
|
|
285
|
+
const exLimit = clampInt(args.limit, 1, 50, 10);
|
|
286
|
+
const examples = await pg<LotRow[]>(
|
|
287
|
+
cfg,
|
|
288
|
+
'auction_lots',
|
|
289
|
+
[...parts, 'select=title,location_state,final_price,closes_at,url', 'order=closes_at.desc', `limit=${exLimit}`].join('&'),
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
matched_lots: prices.length,
|
|
293
|
+
final_price: { min: prices[0], median, average: avg, max: prices[prices.length - 1], currency: 'USD' },
|
|
294
|
+
recent_examples: examples.map((r) => ({
|
|
295
|
+
title: r.title,
|
|
296
|
+
state: r.location_state,
|
|
297
|
+
sold_for: r.final_price,
|
|
298
|
+
closed_at: r.closes_at,
|
|
299
|
+
url: r.url,
|
|
300
|
+
})),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function coverage(cfg: SupabaseConfig) {
|
|
305
|
+
const sources = await pg<Array<{ source: string; last_success_at: string | null }>>(
|
|
306
|
+
cfg,
|
|
307
|
+
'auction_sources',
|
|
308
|
+
'select=source,last_success_at',
|
|
309
|
+
);
|
|
310
|
+
const freshness = new Map(sources.map((s) => [s.source, s.last_success_at]));
|
|
311
|
+
const perSource = await Promise.all(
|
|
312
|
+
KNOWN_SOURCES.map(async (source) => ({
|
|
313
|
+
source,
|
|
314
|
+
active_lots: await pgCount(cfg, 'auction_lots', `status=eq.active&source=eq.${source}`),
|
|
315
|
+
last_refreshed_at: freshness.get(source) ?? null,
|
|
316
|
+
})),
|
|
317
|
+
);
|
|
318
|
+
const soldComps = await pgCount(cfg, 'auction_lots', 'status=eq.closed&final_price=not.is.null');
|
|
319
|
+
return {
|
|
320
|
+
total_active_lots: perSource.reduce((s, c) => s + c.active_lots, 0),
|
|
321
|
+
sold_comps_retained: soldComps,
|
|
322
|
+
sources: perSource,
|
|
323
|
+
note: 'Data is refreshed daily. Sold-price comps (auctions_sold_comps) retain closed lots indefinitely.',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
328
|
+
const supabaseUrl = (args._supabaseUrl as string | undefined)?.trim();
|
|
329
|
+
const supabaseKey = (args._supabaseKey as string | undefined)?.trim();
|
|
330
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
331
|
+
throw new Error('gov-auctions pack requires platform Supabase credentials (operator-configured).');
|
|
332
|
+
}
|
|
333
|
+
const cfg: SupabaseConfig = { url: supabaseUrl, key: supabaseKey };
|
|
334
|
+
|
|
335
|
+
switch (name) {
|
|
336
|
+
case 'auctions_search':
|
|
337
|
+
return search(cfg, args);
|
|
338
|
+
case 'auctions_closing_soon':
|
|
339
|
+
return closingSoon(cfg, args);
|
|
340
|
+
case 'auction_lot_details':
|
|
341
|
+
return lotDetails(cfg, args);
|
|
342
|
+
case 'auctions_sold_comps':
|
|
343
|
+
return soldComps(cfg, args);
|
|
344
|
+
case 'auctions_coverage':
|
|
345
|
+
return coverage(cfg);
|
|
346
|
+
default:
|
|
347
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|