@pipeworx/mcp-gov-bids 0.1.0 → 0.2.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/README.md +3 -3
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/index.ts +71 -121
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# mcp-gov-bids
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Open & historical US government bid solicitations from city/county portals — updated daily
|
|
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 1334+ live data sources.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
|
|
|
23
23
|
}
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Or connect to the full Pipeworx gateway for access to all
|
|
26
|
+
Or connect to the full Pipeworx gateway for access to all 1334+ data sources:
|
|
27
27
|
|
|
28
28
|
```json
|
|
29
29
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-gov-bids",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Open & historical US government bid solicitations from city/county portals — updated daily",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
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-bids",
|
|
4
4
|
"title": "Gov Bids",
|
|
5
|
-
"description": "
|
|
6
|
-
"version": "0.
|
|
5
|
+
"description": "Open & historical US government bid solicitations from city/county portals — updated daily",
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/gov-bids",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-gov-bids",
|
package/src/index.ts
CHANGED
|
@@ -17,169 +17,119 @@ interface McpToolExport {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Gov Bids MCP —
|
|
20
|
+
* Gov Bids MCP — open & historical US government bid solicitations (Wave 4b, hosted).
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* + the XSRF token echoed as `X-XSRF-TOKEN` → JSON {payload:{projects:{...}}}.
|
|
29
|
-
* No API key. This pack fronts that across a registry of Bonfire portals,
|
|
30
|
-
* normalized, so an agent can find open bids by jurisdiction/keyword.
|
|
22
|
+
* Reads the Pipeworx-hosted `procurement_bids` table (Supabase), populated daily
|
|
23
|
+
* by the bonfire-sync Edge Function from Bonfire (Euna) city/county portals — the
|
|
24
|
+
* most common US public bid platform, whose live open-opportunity list has no
|
|
25
|
+
* sanctioned API. The moat: we never delete — when a bid drops off the source's
|
|
26
|
+
* open list we flip it to 'closed' and keep it, so the history of what each
|
|
27
|
+
* jurisdiction bids out accumulates (nobody else keeps this).
|
|
31
28
|
*
|
|
32
|
-
*
|
|
33
|
-
* pattern) is Wave 4b — see docs/us-procurement-build-plan.md.
|
|
29
|
+
* Stateless/keyless for callers; the gateway injects _supabaseUrl/_supabaseKey.
|
|
34
30
|
*/
|
|
35
31
|
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Bonfire portals (subdomain → display jurisdiction). Grows freely; every
|
|
40
|
-
// bonfirehub.com subdomain uses the identical endpoint. Seed = big verified ones.
|
|
41
|
-
const PORTALS: Array<{ key: string; sub: string; name: string }> = [
|
|
42
|
-
{ key: 'fort-worth-tx', sub: 'fortworthtexas', name: 'Fort Worth, TX' },
|
|
43
|
-
{ key: 'harris-county-tx', sub: 'harriscountytx', name: 'Harris County, TX' },
|
|
44
|
-
{ key: 'broward-county-fl', sub: 'broward', name: 'Broward County, FL' },
|
|
45
|
-
{ key: 'cook-county-il', sub: 'cookcountyil', name: 'Cook County, IL' },
|
|
46
|
-
{ key: 'clark-county-nv', sub: 'clarkcountynv', name: 'Clark County, NV' },
|
|
47
|
-
{ key: 'dallas-tx', sub: 'dallascityhall', name: 'Dallas, TX' },
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
const BY_KEY = new Map(PORTALS.map((p) => [p.key, p]));
|
|
33
|
+
type Cfg = { url: string; key: string };
|
|
51
34
|
|
|
52
35
|
const tools: McpToolExport['tools'] = [
|
|
53
36
|
{
|
|
54
37
|
name: 'open_bids_search',
|
|
55
38
|
description:
|
|
56
|
-
"Find OPEN government bid solicitations (RFPs/RFQs/IFBs) that vendors can currently respond to, from
|
|
39
|
+
"Find OPEN US government bid solicitations (RFPs/RFQs/IFBs) that vendors can currently respond to, from city & county procurement portals — updated daily. Filter by keyword (matches solicitation title/reference), jurisdiction, and closing window. Returns each opportunity with title, reference number, awarding jurisdiction, close date, and the URL to respond. By default returns OPEN bids sorted by soonest close date; set status to 'closed' or 'all' to search past solicitations too (we retain history). Use for 'what is <city/county> currently bidding out' or 'open RFPs for <keyword>'. This is LIVE OPEN BIDS (for awarded contracts use gov_contracts_search).",
|
|
57
40
|
inputSchema: {
|
|
58
41
|
type: 'object' as const,
|
|
59
42
|
properties: {
|
|
60
|
-
jurisdiction: { type: 'string', description: '
|
|
61
|
-
keyword: { type: 'string', description: 'Case-insensitive substring
|
|
62
|
-
|
|
43
|
+
jurisdiction: { type: 'string', description: 'Jurisdiction key to target (e.g. "fort-worth-tx", "harris-county-tx", "cook-county-il"). Omit for all covered jurisdictions. Use gov_bids_jurisdictions to list keys.' },
|
|
44
|
+
keyword: { type: 'string', description: 'Case-insensitive substring matched against the solicitation title/reference, e.g. "construction", "audit", "software".' },
|
|
45
|
+
closing_within_days: { type: ['number', 'string'], description: 'Only OPEN bids closing within this many days from now.' },
|
|
46
|
+
status: { type: 'string', enum: ['open', 'closed', 'all'], description: "Bid status (default 'open'). 'closed' / 'all' search retained history." },
|
|
47
|
+
limit: { type: ['number', 'string'], description: 'Max bids (default 25, max 100).' },
|
|
63
48
|
},
|
|
64
49
|
},
|
|
65
50
|
},
|
|
66
51
|
{
|
|
67
52
|
name: 'gov_bids_jurisdictions',
|
|
68
53
|
description:
|
|
69
|
-
'List the US city & county
|
|
54
|
+
'List the US city & county procurement portals covered by open_bids_search, with each jurisdiction key, name, and current open-bid count.',
|
|
70
55
|
inputSchema: { type: 'object' as const, properties: {} },
|
|
71
56
|
},
|
|
72
57
|
];
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
DateClose?: string;
|
|
79
|
-
ProjectStatusID?: string;
|
|
80
|
-
DepartmentID?: string;
|
|
59
|
+
async function pg(cfg: Cfg, path: string, extraHeaders?: Record<string, string>): Promise<Response> {
|
|
60
|
+
return fetch(`${cfg.url}/rest/v1/${path}`, {
|
|
61
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}`, ...(extraHeaders ?? {}) },
|
|
62
|
+
});
|
|
81
63
|
}
|
|
82
64
|
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
const base = `https://${sub}.bonfirehub.com`;
|
|
87
|
-
const page = await fetch(`${base}/portal/`, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
|
|
88
|
-
if (!page.ok) throw new Error(`portal ${page.status}`);
|
|
89
|
-
const setCookies: string[] =
|
|
90
|
-
typeof (page.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie === 'function'
|
|
91
|
-
? (page.headers as unknown as { getSetCookie: () => string[] }).getSetCookie()
|
|
92
|
-
: page.headers.get('set-cookie')
|
|
93
|
-
? [page.headers.get('set-cookie') as string]
|
|
94
|
-
: [];
|
|
95
|
-
const jar = setCookies.map((c) => c.split(';')[0]);
|
|
96
|
-
const cookie = jar.join('; ');
|
|
97
|
-
const xsrf = jar.find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? '';
|
|
98
|
-
const res = await fetch(`${base}/PublicPortal/getOpenPublicOpportunitiesSectionData`, {
|
|
99
|
-
headers: {
|
|
100
|
-
'User-Agent': UA,
|
|
101
|
-
Accept: 'application/json',
|
|
102
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
103
|
-
Cookie: cookie,
|
|
104
|
-
...(xsrf ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrf) } : {}),
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
if (!res.ok) throw new Error(`opportunities ${res.status}`);
|
|
108
|
-
const data = (await res.json()) as { success?: number; payload?: { projects?: Record<string, BonfireProject> } };
|
|
109
|
-
if (!data.success || !data.payload?.projects) return [];
|
|
110
|
-
return Object.values(data.payload.projects);
|
|
65
|
+
function esc(s: string): string {
|
|
66
|
+
// PostgREST ilike value: escape commas/parens that would break the filter.
|
|
67
|
+
return s.replace(/[(),]/g, ' ');
|
|
111
68
|
}
|
|
112
69
|
|
|
113
|
-
function
|
|
114
|
-
const id = p.ProjectID ?? '';
|
|
70
|
+
function shape(r: Record<string, unknown>): Record<string, unknown> {
|
|
115
71
|
return {
|
|
116
|
-
jurisdiction:
|
|
117
|
-
jurisdiction_name:
|
|
118
|
-
title:
|
|
119
|
-
reference:
|
|
120
|
-
close_date:
|
|
121
|
-
status:
|
|
122
|
-
url:
|
|
123
|
-
source:
|
|
72
|
+
jurisdiction: r.jurisdiction_key,
|
|
73
|
+
jurisdiction_name: r.jurisdiction,
|
|
74
|
+
title: r.title ?? null,
|
|
75
|
+
reference: r.reference ?? null,
|
|
76
|
+
close_date: r.close_date ?? null,
|
|
77
|
+
status: r.status,
|
|
78
|
+
url: r.url ?? null,
|
|
79
|
+
source: r.source,
|
|
124
80
|
};
|
|
125
81
|
}
|
|
126
82
|
|
|
127
|
-
async function
|
|
128
|
-
let projects = await fetchOpenProjects(portal.sub);
|
|
129
|
-
if (keyword) {
|
|
130
|
-
const k = keyword.toLowerCase();
|
|
131
|
-
projects = projects.filter((p) => (p.ProjectName ?? '').toLowerCase().includes(k));
|
|
132
|
-
}
|
|
133
|
-
return projects.slice(0, limit).map((p) => normalize(p, portal));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function search(args: Record<string, unknown>): Promise<unknown> {
|
|
83
|
+
async function search(cfg: Cfg, args: Record<string, unknown>): Promise<unknown> {
|
|
137
84
|
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
85
|
+
const status = typeof args.status === 'string' && ['open', 'closed', 'all'].includes(args.status) ? args.status : 'open';
|
|
86
|
+
const q: string[] = ['select=jurisdiction_key,jurisdiction,title,reference,close_date,status,url,source'];
|
|
87
|
+
if (status !== 'all') q.push(`status=eq.${status}`);
|
|
88
|
+
const jur = typeof args.jurisdiction === 'string' && args.jurisdiction.trim() ? args.jurisdiction.trim().toLowerCase() : undefined;
|
|
89
|
+
if (jur) q.push(`jurisdiction_key=eq.${encodeURIComponent(jur)}`);
|
|
90
|
+
const kw = typeof args.keyword === 'string' && args.keyword.trim() ? args.keyword.trim().toLowerCase() : undefined;
|
|
91
|
+
if (kw) q.push(`search_blob=ilike.*${encodeURIComponent(esc(kw))}*`);
|
|
92
|
+
const within = Number(args.closing_within_days);
|
|
93
|
+
if (Number.isFinite(within) && within > 0) {
|
|
94
|
+
const until = new Date(Date.now() + within * 86400_000).toISOString();
|
|
95
|
+
q.push(`close_date=lte.${encodeURIComponent(until)}`, 'close_date=gte.now()');
|
|
145
96
|
}
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
else errors[targets[i].key] = s.reason instanceof Error ? s.reason.message : String(s.reason);
|
|
152
|
-
});
|
|
153
|
-
return {
|
|
154
|
-
jurisdictions_searched: targets.map((p) => p.key),
|
|
155
|
-
count: bids.length,
|
|
156
|
-
open_bids: bids,
|
|
157
|
-
...(Object.keys(errors).length ? { errors } : {}),
|
|
158
|
-
};
|
|
97
|
+
q.push('order=close_date.asc.nullslast', `limit=${limit}`);
|
|
98
|
+
const res = await pg(cfg, `procurement_bids?${q.join('&')}`);
|
|
99
|
+
if (!res.ok) throw new Error(`bids store: ${res.status} ${(await res.text()).slice(0, 150)}`);
|
|
100
|
+
const rows = (await res.json()) as Array<Record<string, unknown>>;
|
|
101
|
+
return { status, count: rows.length, open_bids: rows.map(shape) };
|
|
159
102
|
}
|
|
160
103
|
|
|
161
|
-
async function jurisdictionsList(): Promise<unknown> {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
104
|
+
async function jurisdictionsList(cfg: Cfg): Promise<unknown> {
|
|
105
|
+
// Distinct jurisdictions + open count. PostgREST group-by isn't available
|
|
106
|
+
// without an RPC, so pull open rows' jurisdiction fields (bounded) and tally.
|
|
107
|
+
const res = await pg(cfg, 'procurement_bids?select=jurisdiction_key,jurisdiction&status=eq.open&limit=5000');
|
|
108
|
+
if (!res.ok) throw new Error(`bids store: ${res.status}`);
|
|
109
|
+
const rows = (await res.json()) as Array<{ jurisdiction_key: string; jurisdiction: string }>;
|
|
110
|
+
const counts = new Map<string, { name: string; open_bids: number }>();
|
|
111
|
+
for (const r of rows) {
|
|
112
|
+
const e = counts.get(r.jurisdiction_key) ?? { name: r.jurisdiction, open_bids: 0 };
|
|
113
|
+
e.open_bids++;
|
|
114
|
+
counts.set(r.jurisdiction_key, e);
|
|
115
|
+
}
|
|
116
|
+
const jurisdictions = [...counts.entries()]
|
|
117
|
+
.map(([key, v]) => ({ key, name: v.name, open_bids: v.open_bids }))
|
|
118
|
+
.sort((a, b) => b.open_bids - a.open_bids);
|
|
119
|
+
return { count: jurisdictions.length, jurisdictions };
|
|
174
120
|
}
|
|
175
121
|
|
|
176
122
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
123
|
+
const url = (args._supabaseUrl as string | undefined)?.trim();
|
|
124
|
+
const key = (args._supabaseKey as string | undefined)?.trim();
|
|
125
|
+
if (!url || !key) return { error: 'gov-bids requires platform Supabase credentials (operator-configured).' };
|
|
126
|
+
const cfg: Cfg = { url, key };
|
|
177
127
|
try {
|
|
178
128
|
switch (name) {
|
|
179
129
|
case 'open_bids_search':
|
|
180
|
-
return await search(args);
|
|
130
|
+
return await search(cfg, args);
|
|
181
131
|
case 'gov_bids_jurisdictions':
|
|
182
|
-
return await jurisdictionsList();
|
|
132
|
+
return await jurisdictionsList(cfg);
|
|
183
133
|
default:
|
|
184
134
|
return { error: `Unknown tool: ${name}` };
|
|
185
135
|
}
|