@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # mcp-gov-bids
2
2
 
3
- Live open US government bid solicitations (RFPs/RFQs) from Bonfire city & county portals — keyless
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 1330+ live data sources.
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 1330+ data sources:
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.1.0",
4
- "description": "Live open US government bid solicitations (RFPs/RFQs) from Bonfire city & county portals — keyless",
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": "Live open US government bid solicitations (RFPs/RFQs) from Bonfire city & county portals — keyless",
6
- "version": "0.1.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 — live OPEN government bid solicitations from Bonfire portals.
20
+ * Gov Bids MCP — open & historical US government bid solicitations (Wave 4b, hosted).
21
21
  *
22
- * US state/local bid portals have no sanctioned public API, but Bonfire (Euna),
23
- * the most common big-city/county bid platform, serves its public opportunity
24
- * list from an anonymous session endpoint that its own SPA calls:
25
- * 1. GET https://<portal>.bonfirehub.com/portal/ → sets `bonfirehub` session
26
- * cookie + `XSRF-TOKEN` cookie.
27
- * 2. GET /PublicPortal/getOpenPublicOpportunitiesSectionData with those cookies
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
- * Wave 4a (live-fetch). The hosted-history version (never-delete moat, gov-auction
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
- const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
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 US city & county Bonfire procurement portals — live, keyless. Filter by keyword (matches the solicitation title) and/or a specific jurisdiction; omit jurisdiction to search all covered portals. Returns each open opportunity with its title, reference number, awarding jurisdiction, close date, and the URL to view/respond. Use this for 'what contracts is <city/county> currently bidding out' or 'open RFPs for <keyword>'. This is LIVE OPEN BIDS (for awarded/historical contracts use gov_contracts_search).",
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: 'Portal key to target (e.g. "fort-worth-tx", "harris-county-tx", "cook-county-il"). Omit to search all covered jurisdictions. Use gov_bids_jurisdictions to list keys.' },
61
- keyword: { type: 'string', description: 'Case-insensitive substring to match against the solicitation title, e.g. "construction", "audit", "software".' },
62
- limit: { type: ['number', 'string'], description: 'Max open bids per jurisdiction (default 25).' },
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 Bonfire procurement portals covered by open_bids_search, with each portal key, jurisdiction name, and current live open-bid count.',
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
- interface BonfireProject {
75
- ProjectID?: string;
76
- ReferenceID?: string;
77
- ProjectName?: string;
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
- // Anonymous session handshake: fetch the portal page for the session +
84
- // XSRF-TOKEN cookies, then call the section endpoint echoing the XSRF token.
85
- async function fetchOpenProjects(sub: string): Promise<BonfireProject[]> {
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 normalize(p: BonfireProject, portal: { key: string; sub: string; name: string }): Record<string, unknown> {
114
- const id = p.ProjectID ?? '';
70
+ function shape(r: Record<string, unknown>): Record<string, unknown> {
115
71
  return {
116
- jurisdiction: portal.key,
117
- jurisdiction_name: portal.name,
118
- title: p.ProjectName ?? null,
119
- reference: p.ReferenceID ?? null,
120
- close_date: p.DateClose ?? null,
121
- status: 'open',
122
- url: id ? `https://${portal.sub}.bonfirehub.com/opportunities/${id}` : `https://${portal.sub}.bonfirehub.com/portal/`,
123
- source: 'bonfire',
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 openFor(portal: { key: string; sub: string; name: string }, keyword: string | undefined, limit: number): Promise<Record<string, unknown>[]> {
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 keyword = typeof args.keyword === 'string' && args.keyword.trim() ? args.keyword.trim() : undefined;
139
- const jurKey = typeof args.jurisdiction === 'string' && args.jurisdiction.trim() ? args.jurisdiction.trim().toLowerCase() : undefined;
140
- let targets = PORTALS;
141
- if (jurKey) {
142
- const p = BY_KEY.get(jurKey);
143
- if (!p) return { error: 'user_error', message: `Unknown jurisdiction "${jurKey}". Call gov_bids_jurisdictions for keys.` };
144
- targets = [p];
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
- const settled = await Promise.allSettled(targets.map((p) => openFor(p, keyword, limit)));
147
- const bids: Record<string, unknown>[] = [];
148
- const errors: Record<string, string> = {};
149
- settled.forEach((s, i) => {
150
- if (s.status === 'fulfilled') bids.push(...s.value);
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
- const rows = await Promise.all(
163
- PORTALS.map(async (p) => {
164
- let open_bids: number | null = null;
165
- try {
166
- open_bids = (await fetchOpenProjects(p.sub)).length;
167
- } catch {
168
- open_bids = null;
169
- }
170
- return { key: p.key, name: p.name, portal: `${p.sub}.bonfirehub.com`, open_bids };
171
- }),
172
- );
173
- return { count: rows.length, jurisdictions: rows };
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
  }