@pipeworx/mcp-ca-procurement 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 +411 -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-ca-procurement
|
|
2
|
+
|
|
3
|
+
California state contract awards & purchase orders: search, top vendors, agency spend — keyless
|
|
4
|
+
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1321+ 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
|
+
"ca-procurement": {
|
|
20
|
+
"url": "https://gateway.pipeworx.io/ca-procurement/mcp"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or connect to the full Pipeworx gateway for access to all 1321+ 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 Ca Procurement 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-ca-procurement",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "California state contract awards & purchase orders: search, top vendors, agency spend — keyless",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "ca-procurement"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/pipeworx-io/mcp-ca-procurement"
|
|
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/ca-procurement",
|
|
4
|
+
"title": "Ca Procurement",
|
|
5
|
+
"description": "California state contract awards & purchase orders: search, top vendors, agency spend — keyless",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://pipeworx.io/packs/ca-procurement",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/pipeworx-io/mcp-ca-procurement",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://gateway.pipeworx.io/ca-procurement/mcp"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
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
|
+
* California State Procurement MCP — State contract awards & purchase orders (keyless).
|
|
21
|
+
*
|
|
22
|
+
* Wraps the official California Open Data portal (data.ca.gov, CKAN) "Purchase Order
|
|
23
|
+
* Data" dataset — an extract from the State Contract and Procurement Registration
|
|
24
|
+
* System (SCPRS) covering State contracts/purchases. Every row is a State purchase
|
|
25
|
+
* order / contract award with an awarding department (agency), a winning supplier
|
|
26
|
+
* (vendor), a dollar amount, and dates.
|
|
27
|
+
*
|
|
28
|
+
* Dataset: https://data.ca.gov/dataset/purchase-order-data
|
|
29
|
+
* Resource: bb82edc5-9c78-44e2-8947-68ece26197c5 (fiscal years 2012-13 .. 2014-15)
|
|
30
|
+
*
|
|
31
|
+
* Keyless. Uses CKAN's datastore_search_sql (read-only Postgres) so we can filter and
|
|
32
|
+
* rank by parsed dollar amount and aggregate a supplier's total State spend. An optional
|
|
33
|
+
* CKAN API key may be passed as args._apiKey (sent as the Authorization header) but is
|
|
34
|
+
* not required for this public dataset.
|
|
35
|
+
*
|
|
36
|
+
* All tools return shaped, LLM-friendly objects and never throw — failures resolve to
|
|
37
|
+
* { error }. Tool prefix: ca_procurement_.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
const BASE = 'https://data.ca.gov/api/3/action';
|
|
42
|
+
const RESOURCE = 'bb82edc5-9c78-44e2-8947-68ece26197c5';
|
|
43
|
+
const UA = 'pipeworx-mcp-ca-procurement/1.0 (+https://pipeworx.io)';
|
|
44
|
+
const SOURCE = 'data.ca.gov — Purchase Order Data (State Contract and Procurement Registration System, FY2012-13..2014-15)';
|
|
45
|
+
|
|
46
|
+
// SQL expression that turns the text "Total Price" field ("$1,234.00 ") into a numeric dollar amount.
|
|
47
|
+
const AMOUNT = `regexp_replace("Total Price", '[^0-9.]', '', 'g')::numeric`;
|
|
48
|
+
// Guard for aggregations that SUM ${AMOUNT} across many rows: an empty/dashes-only
|
|
49
|
+
// "Total Price" cleans to "" and ""::numeric throws, killing the whole query. Only
|
|
50
|
+
// cast rows that contain a digit. (NULLIF is not whitelisted by CKAN's SQL endpoint.)
|
|
51
|
+
const HAS_AMOUNT = `"Total Price" ~ '[0-9]'`;
|
|
52
|
+
|
|
53
|
+
const tools: McpToolExport['tools'] = [
|
|
54
|
+
{
|
|
55
|
+
name: 'ca_procurement_awards',
|
|
56
|
+
description:
|
|
57
|
+
"Search California STATE government contract awards & purchase orders from the official State Contract and Procurement Registration System (SCPRS) on data.ca.gov. Each result is a State purchase order / award with its awarding department (agency), the winning supplier (vendor), the dollar amount, acquisition type/method, item description, and dates. Filter by supplier, department/agency, keyword, fiscal year, acquisition type, and/or a minimum amount; results are ranked by dollar amount (biggest awards first) by default. Use this for questions like \"who won California state contracts\", \"biggest suppliers to the CA Department of Health Care Services\", or \"CA state purchase orders for laptops\". This is CALIFORNIA STATE data (not federal).",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
supplier: { type: 'string', description: 'Winning supplier / vendor name to match (case-insensitive substring), e.g. "Microsoft", "Pitney Bowes".' },
|
|
62
|
+
department: { type: 'string', description: 'Awarding State department / agency name (case-insensitive substring), e.g. "Health Care Services", "Transportation".' },
|
|
63
|
+
keyword: { type: 'string', description: 'Case-insensitive substring to match against the item name/description, e.g. "laptop", "consulting".' },
|
|
64
|
+
fiscal_year: { type: 'string', description: 'Exact state fiscal year, e.g. "2014-2015" (available: 2012-2013, 2013-2014, 2014-2015).' },
|
|
65
|
+
acquisition_type: { type: 'string', description: 'Acquisition type substring, e.g. "IT Goods", "NON-IT Services", "IT Services".' },
|
|
66
|
+
min_amount: { type: ['number', 'string'], description: 'Only return awards whose total price is at least this many dollars, e.g. 100000.' },
|
|
67
|
+
sort: { type: 'string', enum: ['amount', 'date'], description: 'Sort order: "amount" = largest award first (default), "date" = most recent creation date first.' },
|
|
68
|
+
limit: { type: ['number', 'string'], description: 'Max records to return (default 20, max 100).' },
|
|
69
|
+
offset: { type: ['number', 'string'], description: 'Number of records to skip for pagination (default 0).' },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'ca_procurement_supplier',
|
|
75
|
+
description:
|
|
76
|
+
"Aggregate a supplier's California STATE contract awards from the SCPRS purchase-order data on data.ca.gov: total dollars awarded, number of purchase orders, and a breakdown by awarding department (agency) and by fiscal year. Matches the supplier name as a case-insensitive substring, so it also surfaces name variants (e.g. \"Pitney Bowes\" vs \"Pitney Bowes, Inc\"). Use this to size up a single vendor's business with the State of California. This is CALIFORNIA STATE data (not federal).",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
supplier: { type: 'string', description: 'Supplier / vendor name to aggregate (case-insensitive substring), e.g. "Deloitte", "Dell".' },
|
|
81
|
+
fiscal_year: { type: 'string', description: 'Optional: restrict to one fiscal year, e.g. "2014-2015".' },
|
|
82
|
+
},
|
|
83
|
+
required: ['supplier'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'ca_procurement_top_suppliers',
|
|
88
|
+
description:
|
|
89
|
+
"Rank the biggest suppliers (vendors) to the State of California by total contract/purchase-order dollars, from the SCPRS purchase-order data on data.ca.gov. Answers \"who are California's largest state contractors / award winners\", optionally scoped to a department/agency, a fiscal year, an acquisition type, or an item keyword. Returns each supplier with total dollars awarded and purchase-order count, largest first. This is CALIFORNIA STATE data (not federal).",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
department: { type: 'string', description: 'Optional: restrict to one awarding department/agency (case-insensitive substring), e.g. "Health Care Services".' },
|
|
94
|
+
fiscal_year: { type: 'string', description: 'Optional exact fiscal year, e.g. "2014-2015".' },
|
|
95
|
+
acquisition_type: { type: 'string', description: 'Optional acquisition-type substring, e.g. "IT Goods", "IT Services".' },
|
|
96
|
+
keyword: { type: 'string', description: 'Optional item name/description substring, e.g. "software", "consulting".' },
|
|
97
|
+
limit: { type: ['number', 'string'], description: 'How many top suppliers to return (default 20, max 100).' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'ca_procurement_department',
|
|
103
|
+
description:
|
|
104
|
+
"Profile a California STATE department/agency's procurement spend from the SCPRS purchase-order data on data.ca.gov: total dollars, purchase-order count, top suppliers (vendors) it buys from, top commodity categories it spends on, and a by-fiscal-year breakdown. Matches the department name as a case-insensitive substring. Answers \"what does the CA Department of Justice buy and from whom\", \"which agencies spend the most\". This is CALIFORNIA STATE data (not federal).",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
department: { type: 'string', description: 'State department/agency name (case-insensitive substring), e.g. "Justice", "Health Care Services", "Transportation".' },
|
|
109
|
+
fiscal_year: { type: 'string', description: 'Optional: restrict to one fiscal year, e.g. "2014-2015".' },
|
|
110
|
+
},
|
|
111
|
+
required: ['department'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'ca_procurement_commodities',
|
|
116
|
+
description:
|
|
117
|
+
"Rank what the State of California spends the most on, by commodity category (UNSPSC commodity title), from the SCPRS purchase-order data on data.ca.gov. Returns each category with total dollars and purchase-order count, largest first, optionally scoped to a department/agency or fiscal year. Answers \"what does California buy the most of\", \"top spending categories for the CA Department of Health Care Services\". This is CALIFORNIA STATE data (not federal).",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
department: { type: 'string', description: 'Optional: restrict to one awarding department/agency (case-insensitive substring).' },
|
|
122
|
+
fiscal_year: { type: 'string', description: 'Optional exact fiscal year, e.g. "2014-2015".' },
|
|
123
|
+
limit: { type: ['number', 'string'], description: 'How many categories to return (default 20, max 100).' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
130
|
+
try {
|
|
131
|
+
switch (name) {
|
|
132
|
+
case 'ca_procurement_awards':
|
|
133
|
+
return await searchAwards(args);
|
|
134
|
+
case 'ca_procurement_supplier':
|
|
135
|
+
return await supplierSummary(args);
|
|
136
|
+
case 'ca_procurement_top_suppliers':
|
|
137
|
+
return await topSuppliers(args);
|
|
138
|
+
case 'ca_procurement_department':
|
|
139
|
+
return await departmentProfile(args);
|
|
140
|
+
case 'ca_procurement_commodities':
|
|
141
|
+
return await commodities(args);
|
|
142
|
+
default:
|
|
143
|
+
return { error: `Unknown tool: ${name}` };
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function searchAwards(args: Record<string, unknown>): Promise<unknown> {
|
|
151
|
+
const limit = clampInt(args.limit, 20, 1, 100);
|
|
152
|
+
const offset = clampInt(args.offset, 0, 0, 1_000_000);
|
|
153
|
+
const where: string[] = [];
|
|
154
|
+
const supplier = strArg(args.supplier);
|
|
155
|
+
const department = strArg(args.department);
|
|
156
|
+
const keyword = strArg(args.keyword);
|
|
157
|
+
const fiscalYear = strArg(args.fiscal_year);
|
|
158
|
+
const acqType = strArg(args.acquisition_type);
|
|
159
|
+
const minAmount = numArg(args.min_amount);
|
|
160
|
+
|
|
161
|
+
if (supplier) where.push(`"Supplier Name" ILIKE ${lit(`%${supplier}%`)}`);
|
|
162
|
+
if (department) where.push(`"Department Name" ILIKE ${lit(`%${department}%`)}`);
|
|
163
|
+
if (keyword) where.push(`("Item Name" ILIKE ${lit(`%${keyword}%`)} OR "Item Description" ILIKE ${lit(`%${keyword}%`)})`);
|
|
164
|
+
if (fiscalYear) where.push(`"Fiscal Year" = ${lit(fiscalYear)}`);
|
|
165
|
+
if (acqType) where.push(`"Acquisition Type" ILIKE ${lit(`%${acqType}%`)}`);
|
|
166
|
+
if (minAmount != null) where.push(`${AMOUNT} >= ${minAmount}`);
|
|
167
|
+
|
|
168
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
169
|
+
const orderSql = strArg(args.sort) === 'date' ? '"Creation Date" DESC' : `${AMOUNT} DESC`;
|
|
170
|
+
const sql =
|
|
171
|
+
`SELECT "Purchase Order Number", "Requisition Number", "Creation Date", "Purchase Date", ` +
|
|
172
|
+
`"Fiscal Year", "Department Name", "Supplier Name", "Supplier Code", "Acquisition Type", ` +
|
|
173
|
+
`"Acquisition Method", "Item Name", "Item Description", "Quantity", "Unit Price", ` +
|
|
174
|
+
`"Total Price", "Normalized UNSPSC" ` +
|
|
175
|
+
`FROM "${RESOURCE}" ${whereSql} ORDER BY ${orderSql} LIMIT ${limit} OFFSET ${offset}`;
|
|
176
|
+
|
|
177
|
+
const records = await sqlSearch(sql, args);
|
|
178
|
+
const awards = records.map((r) => ({
|
|
179
|
+
po_number: pick(r, 'Purchase Order Number'),
|
|
180
|
+
requisition_number: pick(r, 'Requisition Number'),
|
|
181
|
+
supplier: pick(r, 'Supplier Name'),
|
|
182
|
+
supplier_code: pick(r, 'Supplier Code'),
|
|
183
|
+
department: pick(r, 'Department Name'),
|
|
184
|
+
acquisition_type: pick(r, 'Acquisition Type'),
|
|
185
|
+
acquisition_method: pick(r, 'Acquisition Method'),
|
|
186
|
+
item: pick(r, 'Item Name'),
|
|
187
|
+
description: pick(r, 'Item Description'),
|
|
188
|
+
quantity: pick(r, 'Quantity'),
|
|
189
|
+
unit_price: pick(r, 'Unit Price'),
|
|
190
|
+
amount: parseMoney(pick(r, 'Total Price')),
|
|
191
|
+
amount_text: pick(r, 'Total Price'),
|
|
192
|
+
creation_date: pick(r, 'Creation Date'),
|
|
193
|
+
purchase_date: pick(r, 'Purchase Date'),
|
|
194
|
+
fiscal_year: pick(r, 'Fiscal Year'),
|
|
195
|
+
unspsc: pick(r, 'Normalized UNSPSC'),
|
|
196
|
+
raw: r,
|
|
197
|
+
}));
|
|
198
|
+
return { source: SOURCE, limit, offset, count: awards.length, awards };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function supplierSummary(args: Record<string, unknown>): Promise<unknown> {
|
|
202
|
+
const supplier = strArg(args.supplier);
|
|
203
|
+
if (!supplier) throw new Error('Required argument "supplier" is missing. Pass a vendor name like "Deloitte".');
|
|
204
|
+
const fiscalYear = strArg(args.fiscal_year);
|
|
205
|
+
|
|
206
|
+
const cond = [`"Supplier Name" ILIKE ${lit(`%${supplier}%`)}`];
|
|
207
|
+
if (fiscalYear) cond.push(`"Fiscal Year" = ${lit(fiscalYear)}`);
|
|
208
|
+
const whereSql = `WHERE ${cond.join(' AND ')}`;
|
|
209
|
+
|
|
210
|
+
const agg = (col: string, limit = 50) =>
|
|
211
|
+
`SELECT ${col}, count(*) orders, sum(${AMOUNT}) amount FROM "${RESOURCE}" ${whereSql} ` +
|
|
212
|
+
`GROUP BY ${col} ORDER BY amount DESC NULLS LAST LIMIT ${limit}`;
|
|
213
|
+
|
|
214
|
+
const [byName, byDept, byYear] = await Promise.all([
|
|
215
|
+
sqlSearch(agg('"Supplier Name"'), args),
|
|
216
|
+
sqlSearch(agg('"Department Name"', 25), args),
|
|
217
|
+
sqlSearch(agg('"Fiscal Year"'), args),
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
if (byName.length === 0) {
|
|
221
|
+
return { source: SOURCE, supplier_query: supplier, matched: false, message: `No California state purchase orders found for a supplier matching "${supplier}".` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const total_orders = byName.reduce((s, r) => s + toInt(pick(r, 'orders')), 0);
|
|
225
|
+
const total_amount = byName.reduce((s, r) => s + toNum(pick(r, 'amount')), 0);
|
|
226
|
+
|
|
227
|
+
const shape = (rows: Record<string, any>[], key: string, keyOut: string) =>
|
|
228
|
+
rows.map((r) => ({ [keyOut]: pick(r, key), orders: toInt(pick(r, 'orders')), amount: toNum(pick(r, 'amount')) }));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
source: SOURCE,
|
|
232
|
+
supplier_query: supplier,
|
|
233
|
+
matched: true,
|
|
234
|
+
total_orders,
|
|
235
|
+
total_amount,
|
|
236
|
+
matched_supplier_names: shape(byName, 'Supplier Name', 'supplier'),
|
|
237
|
+
by_department: shape(byDept, 'Department Name', 'department'),
|
|
238
|
+
by_fiscal_year: shape(byYear, 'Fiscal Year', 'fiscal_year'),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Shared filter builder for the aggregation tools. Every aggregation SUMs
|
|
243
|
+
// ${AMOUNT}, so HAS_AMOUNT is always included to skip un-castable rows.
|
|
244
|
+
function aggWhere(args: Record<string, unknown>, opts: { department?: boolean; keyword?: boolean; acqType?: boolean } = {}): string {
|
|
245
|
+
const cond = [HAS_AMOUNT];
|
|
246
|
+
const department = strArg(args.department);
|
|
247
|
+
const fiscalYear = strArg(args.fiscal_year);
|
|
248
|
+
const keyword = strArg(args.keyword);
|
|
249
|
+
const acqType = strArg(args.acquisition_type);
|
|
250
|
+
if (opts.department !== false && department) cond.push(`"Department Name" ILIKE ${lit(`%${department}%`)}`);
|
|
251
|
+
if (fiscalYear) cond.push(`"Fiscal Year" = ${lit(fiscalYear)}`);
|
|
252
|
+
if (opts.keyword && keyword) cond.push(`("Item Name" ILIKE ${lit(`%${keyword}%`)} OR "Item Description" ILIKE ${lit(`%${keyword}%`)})`);
|
|
253
|
+
if (opts.acqType && acqType) cond.push(`"Acquisition Type" ILIKE ${lit(`%${acqType}%`)}`);
|
|
254
|
+
return `WHERE ${cond.join(' AND ')}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const shapeAgg = (rows: Record<string, any>[], key: string, keyOut: string) =>
|
|
258
|
+
rows.map((r) => ({ [keyOut]: pick(r, key), orders: toInt(pick(r, 'orders')), amount: toNum(pick(r, 'amount')) }));
|
|
259
|
+
|
|
260
|
+
async function topSuppliers(args: Record<string, unknown>): Promise<unknown> {
|
|
261
|
+
const limit = clampInt(args.limit, 20, 1, 100);
|
|
262
|
+
const whereSql = aggWhere(args, { keyword: true, acqType: true });
|
|
263
|
+
const sql =
|
|
264
|
+
`SELECT "Supplier Name", count(*) orders, sum(${AMOUNT}) amount FROM "${RESOURCE}" ${whereSql} ` +
|
|
265
|
+
`AND "Supplier Name" IS NOT NULL GROUP BY "Supplier Name" ORDER BY amount DESC NULLS LAST LIMIT ${limit}`;
|
|
266
|
+
const rows = await sqlSearch(sql, args);
|
|
267
|
+
return {
|
|
268
|
+
source: SOURCE,
|
|
269
|
+
scope: {
|
|
270
|
+
department: strArg(args.department) ?? null,
|
|
271
|
+
fiscal_year: strArg(args.fiscal_year) ?? null,
|
|
272
|
+
acquisition_type: strArg(args.acquisition_type) ?? null,
|
|
273
|
+
keyword: strArg(args.keyword) ?? null,
|
|
274
|
+
},
|
|
275
|
+
count: rows.length,
|
|
276
|
+
top_suppliers: shapeAgg(rows, 'Supplier Name', 'supplier'),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function departmentProfile(args: Record<string, unknown>): Promise<unknown> {
|
|
281
|
+
const department = strArg(args.department);
|
|
282
|
+
if (!department) throw new Error('Required argument "department" is missing. Pass an agency name like "Justice, Department of".');
|
|
283
|
+
const whereSql = aggWhere(args);
|
|
284
|
+
|
|
285
|
+
const agg = (col: string, limit: number) =>
|
|
286
|
+
`SELECT ${col}, count(*) orders, sum(${AMOUNT}) amount FROM "${RESOURCE}" ${whereSql} ` +
|
|
287
|
+
`AND ${col} IS NOT NULL GROUP BY ${col} ORDER BY amount DESC NULLS LAST LIMIT ${limit}`;
|
|
288
|
+
|
|
289
|
+
const [byDept, bySupplier, byCommodity, byYear] = await Promise.all([
|
|
290
|
+
sqlSearch(agg('"Department Name"', 25), args),
|
|
291
|
+
sqlSearch(agg('"Supplier Name"', 20), args),
|
|
292
|
+
sqlSearch(agg('"Commodity Title"', 20), args),
|
|
293
|
+
sqlSearch(agg('"Fiscal Year"', 10), args),
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
if (byDept.length === 0) {
|
|
297
|
+
return { source: SOURCE, department_query: department, matched: false, message: `No California state purchase orders found for a department matching "${department}".` };
|
|
298
|
+
}
|
|
299
|
+
const total_orders = byDept.reduce((s, r) => s + toInt(pick(r, 'orders')), 0);
|
|
300
|
+
const total_amount = byDept.reduce((s, r) => s + toNum(pick(r, 'amount')), 0);
|
|
301
|
+
return {
|
|
302
|
+
source: SOURCE,
|
|
303
|
+
department_query: department,
|
|
304
|
+
matched: true,
|
|
305
|
+
total_orders,
|
|
306
|
+
total_amount,
|
|
307
|
+
matched_departments: shapeAgg(byDept, 'Department Name', 'department'),
|
|
308
|
+
top_suppliers: shapeAgg(bySupplier, 'Supplier Name', 'supplier'),
|
|
309
|
+
top_commodities: shapeAgg(byCommodity, 'Commodity Title', 'commodity'),
|
|
310
|
+
by_fiscal_year: shapeAgg(byYear, 'Fiscal Year', 'fiscal_year'),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function commodities(args: Record<string, unknown>): Promise<unknown> {
|
|
315
|
+
const limit = clampInt(args.limit, 20, 1, 100);
|
|
316
|
+
const whereSql = aggWhere(args);
|
|
317
|
+
const sql =
|
|
318
|
+
`SELECT "Commodity Title", count(*) orders, sum(${AMOUNT}) amount FROM "${RESOURCE}" ${whereSql} ` +
|
|
319
|
+
`AND "Commodity Title" IS NOT NULL GROUP BY "Commodity Title" ORDER BY amount DESC NULLS LAST LIMIT ${limit}`;
|
|
320
|
+
const rows = await sqlSearch(sql, args);
|
|
321
|
+
return {
|
|
322
|
+
source: SOURCE,
|
|
323
|
+
scope: { department: strArg(args.department) ?? null, fiscal_year: strArg(args.fiscal_year) ?? null },
|
|
324
|
+
count: rows.length,
|
|
325
|
+
top_commodities: shapeAgg(rows, 'Commodity Title', 'commodity'),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function sqlSearch(sql: string, args: Record<string, unknown>): Promise<Record<string, any>[]> {
|
|
330
|
+
const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': UA };
|
|
331
|
+
const apiKey = strArg(args._apiKey);
|
|
332
|
+
if (apiKey) headers.Authorization = apiKey; // optional CKAN key; not required for public data
|
|
333
|
+
|
|
334
|
+
const url = `${BASE}/datastore_search_sql?sql=${encodeURIComponent(sql)}`;
|
|
335
|
+
let res: Response;
|
|
336
|
+
try {
|
|
337
|
+
res = await fetch(url, { headers });
|
|
338
|
+
} catch (e) {
|
|
339
|
+
throw new Error(`CA procurement request failed (network): ${e instanceof Error ? e.message : String(e)}`);
|
|
340
|
+
}
|
|
341
|
+
if (res.status === 429) throw new Error('CA procurement rate-limited (HTTP 429). Retry shortly or pass a CKAN API key as _apiKey.');
|
|
342
|
+
if (res.status === 404) throw new Error('CA procurement dataset not found (HTTP 404) — the data.ca.gov resource may have moved.');
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
const body = await res.text().then((t) => t.slice(0, 200)).catch(() => '');
|
|
345
|
+
throw new Error(`CA procurement error: HTTP ${res.status}${body ? ` — ${body}` : ''}`);
|
|
346
|
+
}
|
|
347
|
+
let data: any;
|
|
348
|
+
try {
|
|
349
|
+
data = await res.json();
|
|
350
|
+
} catch {
|
|
351
|
+
throw new Error('CA procurement returned a non-JSON response.');
|
|
352
|
+
}
|
|
353
|
+
if (data?.success === false) {
|
|
354
|
+
const msg = data?.error?.message || JSON.stringify(data?.error || {}).slice(0, 200);
|
|
355
|
+
throw new Error(`CA procurement query rejected: ${msg}`);
|
|
356
|
+
}
|
|
357
|
+
return data?.result?.records ?? [];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---- helpers ----
|
|
361
|
+
|
|
362
|
+
function pick(obj: Record<string, any>, key: string): any {
|
|
363
|
+
const v = obj?.[key];
|
|
364
|
+
return v === undefined || v === '' ? null : v;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseMoney(v: unknown): number | null {
|
|
368
|
+
if (v == null) return null;
|
|
369
|
+
const n = Number(String(v).replace(/[^0-9.-]/g, ''));
|
|
370
|
+
return Number.isFinite(n) ? n : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function toNum(v: unknown): number {
|
|
374
|
+
const n = Number(v);
|
|
375
|
+
return Number.isFinite(n) ? n : 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toInt(v: unknown): number {
|
|
379
|
+
const n = parseInt(String(v), 10);
|
|
380
|
+
return Number.isFinite(n) ? n : 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Escape a value for a single-quoted SQL string literal (Postgres standard quoting).
|
|
384
|
+
function lit(s: string): string {
|
|
385
|
+
return `'${String(s).replace(/'/g, "''")}'`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function strArg(v: unknown): string | undefined {
|
|
389
|
+
if (typeof v === 'string') {
|
|
390
|
+
const t = v.trim();
|
|
391
|
+
return t ? t : undefined;
|
|
392
|
+
}
|
|
393
|
+
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function numArg(v: unknown): number | undefined {
|
|
398
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
399
|
+
if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function clampInt(v: unknown, def: number, min: number, max: number): number {
|
|
404
|
+
let n: number;
|
|
405
|
+
if (typeof v === 'number' && Number.isFinite(v)) n = Math.floor(v);
|
|
406
|
+
else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Math.floor(Number(v));
|
|
407
|
+
else return def;
|
|
408
|
+
return Math.min(max, Math.max(min, n));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
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
|
+
}
|