@pipeworx/mcp-ca-grants 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 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,71 @@
1
+ # @pipeworx/ca-grants
2
+
3
+ California Grants Portal MCP — open State of California grant funding opportunities: who administers them, who may apply, how much money is available, and when applications are due. Keyless.
4
+
5
+ ## Tools
6
+
7
+ - `ca_search_grants(query?, status?, category?, applicant_type?, limit?, offset?)` — free-text search over California state grant opportunities, filterable by status, category, and eligible applicant type. Defaults to `status="active"`.
8
+ - `ca_get_grant(portal_id)` — the full record for one grant: purpose, description, eligibility notes, geography, funding source, matching-funds requirement, award amounts, funding method, contact info, and links.
9
+ - `ca_grants_closing_soon(days?, category?, applicant_type?, limit?)` — active grants with a fixed deadline in the next N days (default 30), soonest first, plus a count of rolling `Ongoing` opportunities.
10
+ - `ca_grants_by_agency(agency, status?, limit?, offset?)` — grants from one California department/agency, matched as a forgiving case-insensitive substring, with a status breakdown.
11
+ - `ca_list_grant_categories(status?)` — the category, applicant-type, agency, and status values actually present in the data, each with a grant count, so filter values can be read off real data.
12
+
13
+ ## Auth
14
+
15
+ Keyless. No API key, no BYO credentials.
16
+
17
+ ## Data sources
18
+
19
+ - Dataset: `https://data.ca.gov/dataset/california-grants-portal`
20
+ - Endpoint: `https://data.ca.gov/api/3/action/datastore_search`
21
+ - Resource id: `111c8c88-21f6-453c-ae2c-b4785a0624f5` (~1,974 rows, refreshed daily)
22
+ - Human portal: `https://www.grants.ca.gov/`
23
+
24
+ ### Gotchas
25
+
26
+ Field ids are TitleCase (`AgencyDept`, `ApplicationDeadline`, `EstAvailFunds`) and getting the case wrong makes CKAN silently return nothing rather than erroring, so every field name in this pack is spelled from the live `fields` list. Every value is stored as **text**, which has three consequences. `EstAvailFunds` is a display string like `"$750,000"` and `EstAmounts` is often a range like `"Between $1 and $375,000"` — this pack deliberately exposes no numeric minimum-amount filter, because any such filter would have to guess at ranges; `est_available_funds_approx` parses the leading figure as advisory context only and is never filtered on. `Categories` and `ApplicantType` are semicolon-joined lists (`"Nonprofit; Public Agency; Tribal Government"`), so they are split per-atom and matched case-insensitively client-side rather than through CKAN `filters`, which only does equality/IN. And `ApplicationDeadline` is text: usually `"YYYY-MM-DD HH:MM:SS"`, but **127 of the 177 currently-active rows hold the literal string `"Ongoing"`** (rolling intake, no due date) with one row null — so deadline math happens client-side and `ca_grants_closing_soon` reports `ongoing_no_deadline` as its own bucket rather than dropping two-thirds of the open grants on the floor. Deadline timestamps carry no timezone and are treated as UTC, which is accurate to within a day. `Status` takes exactly three values (`active` 177, `closed` 1,796, `forecasted` 1), so unfiltered searches are dominated by expired opportunities and `ca_search_grants` defaults to `active`. Because CKAN cannot do substring matching, `ca_grants_by_agency` resolves the caller's fragment against the 73 distinct `AgencyDept` values in one projected scan, then filters server-side on the exact names it matched.
27
+
28
+ ## Quick Start
29
+
30
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "ca-grants": {
36
+ "url": "https://gateway.pipeworx.io/ca-grants/mcp"
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Or connect to the full Pipeworx gateway for access to all 1375+ data sources:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "pipeworx": {
48
+ "url": "https://gateway.pipeworx.io/mcp"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## Using with ask_pipeworx
55
+
56
+ Instead of calling tools directly, you can ask questions in plain English:
57
+
58
+ ```
59
+ ask_pipeworx({ question: "your question about Ca Grants data" })
60
+ ```
61
+
62
+ The gateway picks the right tool and fills the arguments automatically.
63
+
64
+ ## More
65
+
66
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
67
+ - [pipeworx.io](https://pipeworx.io)
68
+
69
+ ## License
70
+
71
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-ca-grants",
3
+ "version": "0.1.0",
4
+ "description": "California Grants Portal MCP — open State of California grant funding opportunities (keyless).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "ca-grants"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-ca-grants"
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-grants",
4
+ "title": "Ca Grants",
5
+ "description": "California Grants Portal MCP — open State of California grant funding opportunities (keyless).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/ca-grants",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-ca-grants",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/ca-grants/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,689 @@
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 Grants Portal MCP — open State of California grant funding opportunities (keyless).
21
+ *
22
+ * Wraps the official California Grants Portal export published on the state open-data
23
+ * portal (data.ca.gov, CKAN). Every row is one State grant opportunity: the administering
24
+ * department, who may apply, how much money is available, and when applications are due.
25
+ * Refreshed daily by the state.
26
+ *
27
+ * Dataset: https://data.ca.gov/dataset/california-grants-portal
28
+ * Portal: https://www.grants.ca.gov/
29
+ * Resource: 111c8c88-21f6-453c-ae2c-b4785a0624f5
30
+ *
31
+ * Keyless. Uses CKAN datastore_search only (no SQL endpoint), with `fields=` to keep
32
+ * payloads small and `filters=`/`q=` for server-side narrowing.
33
+ *
34
+ * Field-name and value gotchas that shape this pack (all probe-verified 2026-07-29):
35
+ * - Field ids are TitleCase (`AgencyDept`, `ApplicationDeadline`). Wrong case silently
36
+ * returns nothing rather than erroring.
37
+ * - EVERY value is stored as text. `EstAvailFunds` is a display string like "$750,000",
38
+ * `EstAmounts` like "Between $1 and $375,000". There is deliberately no numeric
39
+ * min-amount filter here because it could not be honest about ranges.
40
+ * - `ApplicationDeadline` is text: usually "YYYY-MM-DD HH:MM:SS", but 127 of the 177
41
+ * currently-active rows hold the literal string "Ongoing" (rolling intake, no due
42
+ * date) and one row is null. Deadline math therefore happens client-side and
43
+ * "Ongoing" is reported as its own bucket instead of being silently dropped.
44
+ * - `Categories` and `ApplicantType` are semicolon-joined lists, e.g.
45
+ * "Nonprofit; Public Agency; Tribal Government" — matched per-atom, case-insensitively.
46
+ * - `Status` values are `active`, `closed`, `forecasted`.
47
+ *
48
+ * All tools return shaped, LLM-friendly objects. A tool that legitimately cannot answer
49
+ * resolves to { found: false, reason, hint }; only genuine transport/upstream faults throw.
50
+ */
51
+
52
+
53
+ const BASE = 'https://data.ca.gov/api/3/action/datastore_search';
54
+ const RESOURCE = '111c8c88-21f6-453c-ae2c-b4785a0624f5';
55
+ const UA = 'pipeworx-mcp-ca-grants/1.0 (+https://pipeworx.io)';
56
+ const SOURCE = 'data.ca.gov — California Grants Portal (grants.ca.gov), updated daily';
57
+
58
+ /** Max rows pulled in one upstream scan. The whole dataset is ~1,974 rows. */
59
+ const SCAN_CAP = 2000;
60
+
61
+ /** Compact projection used by every list-shaped tool. */
62
+ const LIST_FIELDS = [
63
+ 'PortalID',
64
+ 'Title',
65
+ 'AgencyDept',
66
+ 'Status',
67
+ 'Categories',
68
+ 'ApplicantType',
69
+ 'EstAvailFunds',
70
+ 'EstAmounts',
71
+ 'OpenDate',
72
+ 'ApplicationDeadline',
73
+ 'GrantURL',
74
+ ] as const;
75
+
76
+ const STATUS_VALUES = ['active', 'closed', 'forecasted'] as const;
77
+
78
+ const tools: McpToolExport['tools'] = [
79
+ {
80
+ name: 'ca_search_grants',
81
+ description:
82
+ 'Search open State of California grant funding opportunities from the official California Grants Portal (grants.ca.gov) on data.ca.gov. Free-text search across grant titles, purposes and descriptions, with optional filters for status, category, and eligible applicant type. Each result returns the grant title, the administering California state department or agency, its categories, who is eligible to apply, estimated funding available, the application deadline, and a link to the official grant page. Defaults to currently-open (active) opportunities. Answers questions like "what California state grants can a nonprofit apply for right now", "state climate resilience funding in California", or "CalRecycle grant opportunities for tribal governments". This is CALIFORNIA STATE grant data, refreshed daily.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ query: {
87
+ type: 'string',
88
+ description:
89
+ 'Free-text search across every field (title, purpose, description, agency), e.g. "climate", "wildfire resilience", "youth literacy".',
90
+ },
91
+ status: {
92
+ type: 'string',
93
+ enum: ['active', 'closed', 'forecasted', 'all'],
94
+ description:
95
+ 'Which opportunities to include: "active" = currently open for applications (default), "forecasted" = announced but not yet open, "closed" = past deadline, "all" = every status.',
96
+ },
97
+ category: {
98
+ type: 'string',
99
+ description:
100
+ 'Match one grant category, case-insensitive, against the semicolon-joined Categories list, e.g. "Environment & Water", "Housing", "Education". Call ca_list_grant_categories to see the values present in the data.',
101
+ },
102
+ applicant_type: {
103
+ type: 'string',
104
+ description:
105
+ 'Match one eligible-applicant type, case-insensitive, against the semicolon-joined ApplicantType list. Known values: "Nonprofit", "Public Agency", "Tribal Government", "Business", "Individual", "Other Legal Entity".',
106
+ },
107
+ limit: { type: ['number', 'string'], description: 'Max records to return (default 20, max 100).' },
108
+ offset: { type: ['number', 'string'], description: 'Number of matching records to skip, for pagination (default 0).' },
109
+ },
110
+ },
111
+ },
112
+ {
113
+ name: 'ca_get_grant',
114
+ description:
115
+ 'Fetch the complete detail record for one California state grant opportunity by its California Grants Portal ID (PortalID). Returns the full narrative purpose and description, eligible applicant types and eligibility notes, geography served, funding source, whether matching funds are required, estimated funds available and per-award amounts, funding method (reimbursement vs advance), the open date and application deadline, whether electronic submission is accepted, agency contact information, and links to the official grant page and agency site. Use after ca_search_grants to read the full terms of a specific California state grant.',
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ portal_id: {
120
+ type: ['string', 'number'],
121
+ description: 'The California Grants Portal ID from a search result, e.g. "189801".',
122
+ },
123
+ },
124
+ required: ['portal_id'],
125
+ },
126
+ },
127
+ {
128
+ name: 'ca_grants_closing_soon',
129
+ description:
130
+ 'List currently-open State of California grant opportunities whose application deadline falls within the next N days, soonest deadline first, so an applicant can see what is about to expire. Returns each grant with its days remaining, deadline timestamp, administering California state department, eligible applicant types, estimated funding available, and official link. Also reports separately how many active grants accept applications on a rolling "Ongoing" basis with no fixed due date, and how many rows carried an unreadable deadline. Answers "which California state grants close this month", "urgent CA grant deadlines", "what should I apply for before it closes".',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ days: { type: ['number', 'string'], description: 'Deadline window in days from today (default 30, max 365).' },
135
+ category: { type: 'string', description: 'Optional: restrict to one grant category, case-insensitive, e.g. "Environment & Water".' },
136
+ applicant_type: {
137
+ type: 'string',
138
+ description: 'Optional: restrict to one eligible-applicant type, case-insensitive, e.g. "Nonprofit", "Tribal Government".',
139
+ },
140
+ limit: { type: ['number', 'string'], description: 'Max records to return (default 25, max 100).' },
141
+ },
142
+ },
143
+ },
144
+ {
145
+ name: 'ca_grants_by_agency',
146
+ description:
147
+ 'List the grant opportunities administered by a given California state department, agency, commission, or conservancy, matching the agency name as a forgiving case-insensitive substring so partial names like "Water Resources" or "Recycling" work. Returns the matched official agency names, a count breakdown by status, and the grant records with title, categories, eligible applicants, estimated funding, and deadline. Answers "what grants does CalRecycle offer", "grant programs from the California Energy Commission", "Department of Parks and Recreation funding opportunities".',
148
+ inputSchema: {
149
+ type: 'object',
150
+ properties: {
151
+ agency: {
152
+ type: 'string',
153
+ description:
154
+ 'California state department / agency name or fragment, case-insensitive, e.g. "Recycling", "Energy Commission", "Parks and Recreation", "Fish and Wildlife".',
155
+ },
156
+ status: {
157
+ type: 'string',
158
+ enum: ['active', 'closed', 'forecasted', 'all'],
159
+ description: 'Which opportunities to include (default "all"; use "active" for only what is open now).',
160
+ },
161
+ limit: { type: ['number', 'string'], description: 'Max records to return (default 25, max 100).' },
162
+ offset: { type: ['number', 'string'], description: 'Number of matching records to skip, for pagination (default 0).' },
163
+ },
164
+ required: ['agency'],
165
+ },
166
+ },
167
+ {
168
+ name: 'ca_list_grant_categories',
169
+ description:
170
+ 'Enumerate the grant category values, eligible-applicant-type values, and administering agency names that actually occur in the California Grants Portal data, each with the number of grants carrying it, so filter values for ca_search_grants can be chosen from real data rather than guessed. Categories and applicant types are stored as semicolon-joined lists and are split into individual values here. Also reports the total row count and the count of currently-active opportunities. Call this first when a category or applicant-type filter returns nothing.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ status: {
175
+ type: 'string',
176
+ enum: ['active', 'closed', 'forecasted', 'all'],
177
+ description: 'Tally values over only this status (default "all"); use "active" to see what is available among currently-open grants.',
178
+ },
179
+ },
180
+ },
181
+ },
182
+ ];
183
+
184
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
185
+ switch (name) {
186
+ case 'ca_search_grants':
187
+ return searchGrants(args);
188
+ case 'ca_get_grant':
189
+ return getGrant(args);
190
+ case 'ca_grants_closing_soon':
191
+ return closingSoon(args);
192
+ case 'ca_grants_by_agency':
193
+ return byAgency(args);
194
+ case 'ca_list_grant_categories':
195
+ return listCategories(args);
196
+ default:
197
+ throw new Error(`Unknown tool: ${name}. Available: ${tools.map((t) => t.name).join(', ')}`);
198
+ }
199
+ }
200
+
201
+ // ── tools ──────────────────────────────────────────────────────────────────
202
+
203
+ async function searchGrants(args: Record<string, unknown>): Promise<unknown> {
204
+ const limit = clampInt(args.limit, 20, 1, 100);
205
+ const offset = clampInt(args.offset, 0, 0, 100_000);
206
+ const status = statusArg(args.status, 'active');
207
+ const query = strArg(args.query);
208
+ const category = strArg(args.category);
209
+ const applicantType = strArg(args.applicant_type);
210
+
211
+ const params: Record<string, string> = { limit: String(SCAN_CAP), fields: LIST_FIELDS.join(',') };
212
+ if (query) params.q = query;
213
+ if (status) params.filters = JSON.stringify({ Status: status });
214
+
215
+ const page = await ckan(params);
216
+ let rows = page.records;
217
+ const scanTruncated = page.total > rows.length;
218
+
219
+ if (category) rows = rows.filter((r) => listHas(r.Categories, category));
220
+ if (applicantType) rows = rows.filter((r) => listHas(r.ApplicantType, applicantType));
221
+
222
+ if (rows.length === 0) {
223
+ return {
224
+ source: SOURCE,
225
+ found: false,
226
+ reason: 'no_matching_grants',
227
+ hint: hintForEmpty({ query, status, category, applicantType }),
228
+ query: query ?? null,
229
+ status: status ?? 'all',
230
+ category: category ?? null,
231
+ applicant_type: applicantType ?? null,
232
+ };
233
+ }
234
+
235
+ // Soonest real deadline first; rolling "Ongoing" intake after dated ones; unknown last.
236
+ rows.sort((a, b) => deadlineRank(a.ApplicationDeadline) - deadlineRank(b.ApplicationDeadline));
237
+
238
+ return {
239
+ source: SOURCE,
240
+ found: true,
241
+ query: query ?? null,
242
+ status: status ?? 'all',
243
+ category: category ?? null,
244
+ applicant_type: applicantType ?? null,
245
+ total_matching: rows.length,
246
+ scan_truncated: scanTruncated,
247
+ limit,
248
+ offset,
249
+ count: Math.min(limit, Math.max(0, rows.length - offset)),
250
+ grants: rows.slice(offset, offset + limit).map(shapeListRow),
251
+ };
252
+ }
253
+
254
+ async function getGrant(args: Record<string, unknown>): Promise<unknown> {
255
+ const portalId = strArg(args.portal_id);
256
+ if (!portalId) {
257
+ throw new Error('Required argument "portal_id" is missing. Pass a California Grants Portal ID like "189801" (get one from ca_search_grants).');
258
+ }
259
+
260
+ const page = await ckan({ limit: '5', filters: JSON.stringify({ PortalID: portalId }) });
261
+ const row = page.records[0];
262
+ if (!row) {
263
+ return {
264
+ source: SOURCE,
265
+ found: false,
266
+ reason: 'portal_id_not_found',
267
+ hint: `No California Grants Portal record has PortalID "${portalId}". Run ca_search_grants with a keyword and use a PortalID from those results.`,
268
+ portal_id: portalId,
269
+ };
270
+ }
271
+
272
+ const deadline = parseDeadline(row.ApplicationDeadline);
273
+ return {
274
+ source: SOURCE,
275
+ found: true,
276
+ portal_id: txt(row.PortalID),
277
+ grant_id: txt(row.GrantID),
278
+ title: txt(row.Title),
279
+ agency: txt(row.AgencyDept),
280
+ status: txt(row.Status),
281
+ type: txt(row.Type),
282
+ last_updated: txt(row.LastUpdated),
283
+ change_notes: txt(row.ChangeNotes),
284
+ categories: splitList(row.Categories),
285
+ categories_text: txt(row.Categories),
286
+ category_suggestion: txt(row.CategorySuggestion),
287
+ purpose: txt(row.Purpose),
288
+ description: txt(row.Description),
289
+ applicant_types: splitList(row.ApplicantType),
290
+ applicant_type_notes: txt(row.ApplicantTypeNotes),
291
+ geography: txt(row.Geography),
292
+ letter_of_intent_required: txt(row.LOI),
293
+ funding_source: txt(row.FundingSource),
294
+ funding_source_notes: txt(row.FundingSourceNotes),
295
+ matching_funds_required: txt(row.MatchingFunds),
296
+ matching_funds_notes: txt(row.MatchingFundsNotes),
297
+ est_available_funds: txt(row.EstAvailFunds),
298
+ est_available_funds_approx: parseMoney(row.EstAvailFunds),
299
+ est_awards: txt(row.EstAwards),
300
+ est_award_amounts: txt(row.EstAmounts),
301
+ funding_method: txt(row.FundingMethod),
302
+ funding_method_notes: txt(row.FundingMethodNotes),
303
+ open_date: txt(row.OpenDate),
304
+ application_deadline: txt(row.ApplicationDeadline),
305
+ deadline_kind: deadline.kind,
306
+ deadline_iso: deadline.iso,
307
+ award_period: txt(row.AwardPeriod),
308
+ expected_award_date: txt(row.ExpAwardDate),
309
+ electronic_submission: txt(row.ElecSubmission),
310
+ grant_url: txt(row.GrantURL),
311
+ agency_url: txt(row.AgencyURL),
312
+ agency_subscribe_url: txt(row.AgencySubscribeURL),
313
+ grant_events_url: txt(row.GrantEventsURL),
314
+ contact_info: txt(row.ContactInfo),
315
+ award_stats: txt(row.AwardStats),
316
+ };
317
+ }
318
+
319
+ async function closingSoon(args: Record<string, unknown>): Promise<unknown> {
320
+ const days = clampInt(args.days, 30, 1, 365);
321
+ const limit = clampInt(args.limit, 25, 1, 100);
322
+ const category = strArg(args.category);
323
+ const applicantType = strArg(args.applicant_type);
324
+
325
+ const page = await ckan({
326
+ limit: String(SCAN_CAP),
327
+ fields: LIST_FIELDS.join(','),
328
+ filters: JSON.stringify({ Status: 'active' }),
329
+ });
330
+
331
+ let rows = page.records;
332
+ if (category) rows = rows.filter((r) => listHas(r.Categories, category));
333
+ if (applicantType) rows = rows.filter((r) => listHas(r.ApplicantType, applicantType));
334
+ const activeConsidered = rows.length;
335
+
336
+ // Recompute "now" per call — module-scope dates are frozen at epoch on Workers.
337
+ const nowMs = Date.now();
338
+ const cutoffMs = nowMs + days * 86_400_000;
339
+
340
+ let ongoing = 0;
341
+ let unparseable = 0;
342
+ let alreadyPast = 0;
343
+ const due: Array<{ row: Row; ms: number }> = [];
344
+
345
+ for (const r of rows) {
346
+ const d = parseDeadline(r.ApplicationDeadline);
347
+ if (d.kind === 'ongoing') {
348
+ ongoing++;
349
+ } else if (d.kind === 'unknown' || d.ms == null) {
350
+ unparseable++;
351
+ } else if (d.ms < nowMs) {
352
+ alreadyPast++;
353
+ } else if (d.ms <= cutoffMs) {
354
+ due.push({ row: r, ms: d.ms });
355
+ }
356
+ }
357
+
358
+ due.sort((a, b) => a.ms - b.ms);
359
+
360
+ if (due.length === 0) {
361
+ return {
362
+ source: SOURCE,
363
+ found: false,
364
+ reason: 'no_deadlines_in_window',
365
+ hint: `No active California state grant has a fixed deadline in the next ${days} day(s). ${ongoing} active grant(s) accept applications on a rolling "Ongoing" basis with no due date — list those with ca_search_grants. Widen the window with a larger "days" value.`,
366
+ window_days: days,
367
+ active_considered: activeConsidered,
368
+ ongoing_no_deadline: ongoing,
369
+ deadline_already_passed: alreadyPast,
370
+ skipped_unparseable_deadline: unparseable,
371
+ };
372
+ }
373
+
374
+ return {
375
+ source: SOURCE,
376
+ found: true,
377
+ window_days: days,
378
+ category: category ?? null,
379
+ applicant_type: applicantType ?? null,
380
+ active_considered: activeConsidered,
381
+ closing_in_window: due.length,
382
+ ongoing_no_deadline: ongoing,
383
+ deadline_already_passed: alreadyPast,
384
+ skipped_unparseable_deadline: unparseable,
385
+ count: Math.min(limit, due.length),
386
+ grants: due.slice(0, limit).map(({ row, ms }) => ({
387
+ ...shapeListRow(row),
388
+ days_remaining: Math.max(0, Math.ceil((ms - nowMs) / 86_400_000)),
389
+ })),
390
+ };
391
+ }
392
+
393
+ async function byAgency(args: Record<string, unknown>): Promise<unknown> {
394
+ const agency = strArg(args.agency);
395
+ if (!agency) {
396
+ throw new Error('Required argument "agency" is missing. Pass a California department name or fragment like "Recycling" or "Energy Commission".');
397
+ }
398
+ const status = statusArg(args.status, 'all');
399
+ const limit = clampInt(args.limit, 25, 1, 100);
400
+ const offset = clampInt(args.offset, 0, 0, 100_000);
401
+
402
+ // Step 1: resolve the substring to the exact agency names present in the data, so
403
+ // step 2 can filter server-side (CKAN filters are equality/IN only, never substring).
404
+ const namesPage = await ckan({ limit: String(SCAN_CAP), fields: 'AgencyDept' });
405
+ const distinct = new Map<string, number>();
406
+ for (const r of namesPage.records) {
407
+ const n = txt(r.AgencyDept);
408
+ if (n) distinct.set(n, (distinct.get(n) ?? 0) + 1);
409
+ }
410
+ const needle = agency.toLowerCase();
411
+ const matched = [...distinct.keys()].filter((n) => n.toLowerCase().includes(needle));
412
+
413
+ if (matched.length === 0) {
414
+ const sample = [...distinct.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([n]) => n);
415
+ return {
416
+ source: SOURCE,
417
+ found: false,
418
+ reason: 'agency_not_found',
419
+ hint: `No California grant-making agency name contains "${agency}". Try a shorter fragment, or pick from these agencies that publish the most grants: ${sample.join('; ')}. ca_list_grant_categories returns the full agency list.`,
420
+ agency_query: agency,
421
+ distinct_agencies: distinct.size,
422
+ sample_agencies: sample,
423
+ };
424
+ }
425
+
426
+ // Step 2: pull the matched agencies' rows (IN filter), optionally narrowed by status.
427
+ const filters: Record<string, unknown> = { AgencyDept: matched.length === 1 ? matched[0] : matched };
428
+ if (status) filters.Status = status;
429
+ const page = await ckan({ limit: String(SCAN_CAP), fields: LIST_FIELDS.join(','), filters: JSON.stringify(filters) });
430
+ const rows = page.records;
431
+
432
+ if (rows.length === 0) {
433
+ return {
434
+ source: SOURCE,
435
+ found: false,
436
+ reason: 'no_grants_for_status',
437
+ hint: `${matched.join('; ')} matched, but has no grants with status "${status}". Retry with status "all" to see closed and forecasted opportunities.`,
438
+ agency_query: agency,
439
+ matched_agencies: matched,
440
+ status: status ?? 'all',
441
+ };
442
+ }
443
+
444
+ const byStatus: Record<string, number> = {};
445
+ for (const r of rows) {
446
+ const s = txt(r.Status) ?? 'unknown';
447
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
448
+ }
449
+ rows.sort((a, b) => deadlineRank(a.ApplicationDeadline) - deadlineRank(b.ApplicationDeadline));
450
+
451
+ return {
452
+ source: SOURCE,
453
+ found: true,
454
+ agency_query: agency,
455
+ matched_agencies: matched,
456
+ status: status ?? 'all',
457
+ total_matching: rows.length,
458
+ by_status: byStatus,
459
+ limit,
460
+ offset,
461
+ count: Math.min(limit, Math.max(0, rows.length - offset)),
462
+ grants: rows.slice(offset, offset + limit).map(shapeListRow),
463
+ };
464
+ }
465
+
466
+ async function listCategories(args: Record<string, unknown>): Promise<unknown> {
467
+ const status = statusArg(args.status, 'all');
468
+ const params: Record<string, string> = {
469
+ limit: String(SCAN_CAP),
470
+ fields: 'Status,Categories,ApplicantType,AgencyDept',
471
+ };
472
+ if (status) params.filters = JSON.stringify({ Status: status });
473
+
474
+ const page = await ckan(params);
475
+ const rows = page.records;
476
+ if (rows.length === 0) {
477
+ return {
478
+ source: SOURCE,
479
+ found: false,
480
+ reason: 'no_rows_for_status',
481
+ hint: `No California Grants Portal rows carry status "${status}". Valid statuses: ${STATUS_VALUES.join(', ')}, or omit for all.`,
482
+ status: status ?? 'all',
483
+ };
484
+ }
485
+
486
+ const categories = new Map<string, number>();
487
+ const applicantTypes = new Map<string, number>();
488
+ const agencies = new Map<string, number>();
489
+ const statuses = new Map<string, number>();
490
+ for (const r of rows) {
491
+ for (const c of splitList(r.Categories)) categories.set(c, (categories.get(c) ?? 0) + 1);
492
+ for (const a of splitList(r.ApplicantType)) applicantTypes.set(a, (applicantTypes.get(a) ?? 0) + 1);
493
+ const ag = txt(r.AgencyDept);
494
+ if (ag) agencies.set(ag, (agencies.get(ag) ?? 0) + 1);
495
+ const s = txt(r.Status);
496
+ if (s) statuses.set(s, (statuses.get(s) ?? 0) + 1);
497
+ }
498
+
499
+ const tally = (m: Map<string, number>) =>
500
+ [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([value, grants]) => ({ value, grants }));
501
+
502
+ return {
503
+ source: SOURCE,
504
+ found: true,
505
+ status: status ?? 'all',
506
+ rows_counted: rows.length,
507
+ note:
508
+ 'Categories and ApplicantType are stored as semicolon-joined lists on each grant and are split into individual values here, so the counts sum to more than rows_counted. Pass any "value" below to the category / applicant_type argument of ca_search_grants.',
509
+ statuses: tally(statuses),
510
+ categories: tally(categories),
511
+ applicant_types: tally(applicantTypes),
512
+ agencies: tally(agencies),
513
+ };
514
+ }
515
+
516
+ // ── upstream ───────────────────────────────────────────────────────────────
517
+
518
+ type Row = Record<string, unknown>;
519
+
520
+ async function ckan(params: Record<string, string>): Promise<{ total: number; records: Row[] }> {
521
+ const qs = new URLSearchParams({ resource_id: RESOURCE, ...params });
522
+ let res: Response;
523
+ try {
524
+ res = await fetch(`${BASE}?${qs.toString()}`, { headers: { Accept: 'application/json', 'User-Agent': UA } });
525
+ } catch (e) {
526
+ throw new Error(`California Grants Portal request failed (network): ${e instanceof Error ? e.message : String(e)}`);
527
+ }
528
+ if (res.status === 429) throw new Error('data.ca.gov rate-limited this request (HTTP 429). Retry in a few seconds.');
529
+ if (res.status === 404) {
530
+ throw new Error(`data.ca.gov returned HTTP 404 — the California Grants Portal resource ${RESOURCE} may have been replaced. Check https://data.ca.gov/dataset/california-grants-portal`);
531
+ }
532
+ if (!res.ok) {
533
+ const body = await res.text().then((t) => t.slice(0, 200)).catch(() => '');
534
+ throw new Error(`data.ca.gov error: HTTP ${res.status}${body ? ` — ${body}` : ''}`);
535
+ }
536
+ let data: any;
537
+ try {
538
+ data = await res.json();
539
+ } catch {
540
+ throw new Error('data.ca.gov returned a non-JSON response.');
541
+ }
542
+ if (data?.success === false) {
543
+ const msg = data?.error?.message || JSON.stringify(data?.error ?? {}).slice(0, 200);
544
+ throw new Error(`data.ca.gov rejected the query: ${msg}`);
545
+ }
546
+ const records: Row[] = Array.isArray(data?.result?.records) ? data.result.records : [];
547
+ const total = typeof data?.result?.total === 'number' ? data.result.total : records.length;
548
+ return { total, records };
549
+ }
550
+
551
+ // ── shaping ────────────────────────────────────────────────────────────────
552
+
553
+ function shapeListRow(r: Row) {
554
+ const d = parseDeadline(r.ApplicationDeadline);
555
+ return {
556
+ portal_id: txt(r.PortalID),
557
+ title: txt(r.Title),
558
+ agency: txt(r.AgencyDept),
559
+ status: txt(r.Status),
560
+ categories: splitList(r.Categories),
561
+ applicant_types: splitList(r.ApplicantType),
562
+ est_available_funds: txt(r.EstAvailFunds),
563
+ est_award_amounts: txt(r.EstAmounts),
564
+ open_date: txt(r.OpenDate),
565
+ application_deadline: txt(r.ApplicationDeadline),
566
+ deadline_kind: d.kind,
567
+ deadline_iso: d.iso,
568
+ grant_url: txt(r.GrantURL),
569
+ };
570
+ }
571
+
572
+ /** Empty string and null both mean "no value" in this dataset. */
573
+ function txt(v: unknown): string | null {
574
+ if (v === null || v === undefined) return null;
575
+ const s = String(v).trim();
576
+ return s ? s : null;
577
+ }
578
+
579
+ function splitList(v: unknown): string[] {
580
+ const s = txt(v);
581
+ if (!s) return [];
582
+ return s
583
+ .split(';')
584
+ .map((p) => p.trim())
585
+ .filter((p) => p.length > 0);
586
+ }
587
+
588
+ /** Case-insensitive match of `needle` against one atom of a semicolon-joined list. */
589
+ function listHas(v: unknown, needle: string): boolean {
590
+ const n = needle.trim().toLowerCase();
591
+ if (!n) return true;
592
+ return splitList(v).some((atom) => {
593
+ const a = atom.toLowerCase();
594
+ return a === n || a.includes(n);
595
+ });
596
+ }
597
+
598
+ type Deadline = { kind: 'date' | 'ongoing' | 'unknown'; ms: number | null; iso: string | null };
599
+
600
+ /**
601
+ * `ApplicationDeadline` is text. Usual form is "YYYY-MM-DD HH:MM:SS" (interpreted as
602
+ * Pacific-portal local time but stored without a zone, so treated as UTC — accurate to
603
+ * within a day, which is all a "closing soon" window needs). 127 of 177 active rows say
604
+ * "Ongoing"; one row is null. Anything else is reported as unknown rather than guessed at.
605
+ */
606
+ function parseDeadline(v: unknown): Deadline {
607
+ const s = txt(v);
608
+ if (!s) return { kind: 'unknown', ms: null, iso: null };
609
+ if (/^ongoing$/i.test(s)) return { kind: 'ongoing', ms: null, iso: null };
610
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s);
611
+ if (!m) return { kind: 'unknown', ms: null, iso: null };
612
+ const ms = Date.UTC(
613
+ Number(m[1]),
614
+ Number(m[2]) - 1,
615
+ Number(m[3]),
616
+ Number(m[4] ?? '0'),
617
+ Number(m[5] ?? '0'),
618
+ Number(m[6] ?? '0'),
619
+ );
620
+ if (!Number.isFinite(ms)) return { kind: 'unknown', ms: null, iso: null };
621
+ return { kind: 'date', ms, iso: new Date(ms).toISOString() };
622
+ }
623
+
624
+ /** Sort key: dated deadlines ascending, then rolling "Ongoing", then unknown. */
625
+ function deadlineRank(v: unknown): number {
626
+ const d = parseDeadline(v);
627
+ if (d.kind === 'date' && d.ms != null) return d.ms;
628
+ return d.kind === 'ongoing' ? Number.MAX_SAFE_INTEGER - 1 : Number.MAX_SAFE_INTEGER;
629
+ }
630
+
631
+ /**
632
+ * Approximate dollars from a display string like "$750,000". Ranges such as
633
+ * "Between $1 and $375,000" yield the FIRST figure only, so this is advisory context
634
+ * next to the verbatim text — it is never used for filtering.
635
+ */
636
+ function parseMoney(v: unknown): number | null {
637
+ const s = txt(v);
638
+ if (!s) return null;
639
+ const m = /\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)/.exec(s);
640
+ if (!m) return null;
641
+ const n = Number(m[1].replace(/,/g, ''));
642
+ return Number.isFinite(n) ? n : null;
643
+ }
644
+
645
+ function hintForEmpty(f: { query?: string; status?: string; category?: string; applicantType?: string }): string {
646
+ const parts: string[] = [];
647
+ if (f.category) parts.push(`category "${f.category}"`);
648
+ if (f.applicantType) parts.push(`applicant_type "${f.applicantType}"`);
649
+ if (parts.length) {
650
+ return `No California state grant matched ${parts.join(' and ')}${f.query ? ` together with query "${f.query}"` : ''}. Call ca_list_grant_categories to see the exact category and applicant-type values present in the data, then retry with one of those.`;
651
+ }
652
+ if (f.query) {
653
+ return `No California state grant matched "${f.query}"${f.status === 'active' ? ' among currently-open opportunities — retry with status "all" to include closed and forecasted grants' : ''}. Try a broader single keyword such as "water", "housing", or "education".`;
654
+ }
655
+ return 'No rows returned. Retry with status "all", or call ca_list_grant_categories to confirm the dataset is reachable.';
656
+ }
657
+
658
+ // ── argument coercion ──────────────────────────────────────────────────────
659
+
660
+ function strArg(v: unknown): string | undefined {
661
+ if (typeof v === 'string') {
662
+ const t = v.trim();
663
+ return t ? t : undefined;
664
+ }
665
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
666
+ return undefined;
667
+ }
668
+
669
+ /** Returns the Status value to filter on, or undefined for "all". */
670
+ function statusArg(v: unknown, def: 'active' | 'all'): string | undefined {
671
+ const s = strArg(v)?.toLowerCase();
672
+ if (!s) return def === 'all' ? undefined : def;
673
+ if (s === 'all' || s === 'any') return undefined;
674
+ const hit = STATUS_VALUES.find((k) => k === s);
675
+ if (!hit) {
676
+ throw new Error(`Unknown status "${s}". Use one of: ${STATUS_VALUES.join(', ')}, or "all".`);
677
+ }
678
+ return hit;
679
+ }
680
+
681
+ function clampInt(v: unknown, def: number, min: number, max: number): number {
682
+ let n: number;
683
+ if (typeof v === 'number' && Number.isFinite(v)) n = Math.floor(v);
684
+ else if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) n = Math.floor(Number(v));
685
+ else return def;
686
+ return Math.min(max, Math.max(min, n));
687
+ }
688
+
689
+ 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
+ }