@cliwant/mcp-sam-gov 0.2.1 → 1.0.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.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
package/dist/server.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @cliwant/mcp-sam-gov — Model Context Protocol server for SAM.gov
4
- * + USAspending + Federal Register + eCFR + Grants.gov.
4
+ * + USAspending + Federal Register + eCFR + Grants.gov + GAO + wage/pricing.
5
5
  *
6
- * 34 keyless tools wrapping every public federal-contracting data
6
+ * 52 keyless tools wrapping every public federal-contracting data
7
7
  * source that doesn't require an API key. Compatible with:
8
8
  * - Claude Desktop (claude_desktop_config.json)
9
9
  * - Claude Code (.mcp.json or `claude mcp add`)
@@ -20,13 +20,48 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
21
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
22
22
  import { z } from "zod";
23
- import { SamGovClient } from "./sam-gov/index.js";
23
+ import { SamGovClient, daysUntilResponse, applyResponseDeadlineWindow, } from "./sam-gov/index.js";
24
24
  import * as usas from "./usaspending.js";
25
25
  import * as fedreg from "./federal-register.js";
26
26
  import * as ecfr from "./ecfr.js";
27
+ import * as far from "./far.js";
27
28
  import * as grants from "./grants.js";
29
+ import * as pricing from "./pricing.js";
30
+ import * as integrity from "./integrity.js";
31
+ import * as gao from "./gao.js";
32
+ import * as gsaCsv from "./gsa-csv.js";
33
+ import * as sba from "./sba.js";
34
+ import * as treasury from "./treasury.js";
35
+ import * as edgar from "./edgar.js";
36
+ import * as socrata from "./socrata.js";
37
+ import * as ckan from "./ckan.js";
38
+ import * as echo from "./echo.js";
39
+ import * as datagov from "./datagov.js";
40
+ import * as datagovCatalog from "./datagov-catalog.js";
41
+ import * as govinfo from "./govinfo.js";
42
+ import * as fpds from "./fpds.js";
43
+ import * as nih from "./nih.js";
44
+ import * as nsf from "./nsf.js";
45
+ import * as clinicaltrials from "./clinicaltrials.js";
46
+ import * as census from "./census.js";
47
+ import * as fema from "./fema.js";
48
+ import * as fdic from "./fdic.js";
49
+ import * as bls from "./bls.js";
50
+ import * as ofac from "./ofac.js";
51
+ import * as nvd from "./nvd.js";
52
+ import * as nppes from "./nppes.js";
53
+ import * as cms from "./cms.js";
54
+ import * as fac from "./fac.js";
55
+ import * as usitc from "./usitc.js";
56
+ import { fetchAttachmentText } from "./attachments.js";
57
+ import { toToolError, ToolErrorCarrier, errorFromResponse } from "./errors.js";
58
+ import { buildMeta, isMetaBundle, withMeta, } from "./meta.js";
59
+ import { pathToFileURL, fileURLToPath } from "node:url";
60
+ import { realpathSync } from "node:fs";
28
61
  const SERVER_NAME = "mcp-sam-gov";
29
- const SERVER_VERSION = "0.2.1";
62
+ // Kept in lockstep with package.json / manifest.json / server.json.
63
+ // Keep in sync with package.json "version" (asserted at release; see CHANGELOG).
64
+ const SERVER_VERSION = "1.0.0";
30
65
  // ─── Tool input schemas (Zod) ────────────────────────────────────
31
66
  const SamSearchInput = z.object({
32
67
  query: z.string().optional().describe("Free-text title query"),
@@ -42,6 +77,39 @@ const SamSearchInput = z.object({
42
77
  setAside: z.array(z.string()).optional().describe("Set-aside codes: SBA, 8A, HZS, SDVOSBC, WOSB, EDWOSB, VSA, VSS"),
43
78
  limit: z.number().min(1).max(50).optional(),
44
79
  });
80
+ // Pre-solicitation shaping radar (doc 06 §3.1). Surfaces Sources Sought /
81
+ // Presolicitation / Special Notices BEFORE the RFP exists — the free, real-time
82
+ // analogue of paid agency-forecast feeds. Keyless via the notice_type facet.
83
+ const SamSearchShapingInput = z.object({
84
+ query: z.string().optional().describe("Free-text title query"),
85
+ ncode: z.string().optional().describe("NAICS code, e.g. '541512'"),
86
+ organizationName: z
87
+ .string()
88
+ .optional()
89
+ .describe("Issuing agency canonical name (e.g. 'Department of Veterans Affairs'). NOTE: the keyless endpoint has NO organization-name filter — it is sent best-effort and flagged in _meta.filtersDropped; filter client-side on the returned `agency`."),
90
+ state: z
91
+ .string()
92
+ .optional()
93
+ .describe("Place-of-performance state, 2-letter, e.g. 'MD'"),
94
+ setAside: z.array(z.string()).optional().describe("Set-aside codes: SBA, 8A, HZS, SDVOSBC, WOSB, EDWOSB, VSA, VSS"),
95
+ noticeType: z
96
+ .array(z.enum(["r", "p", "s", "k", "i", "u"]))
97
+ .optional()
98
+ .describe("Pre-solicitation notice-type codes to include. r=Sources Sought, p=Presolicitation, s=Special Notice (the DEFAULT shaping window = ['r','p','s']); k=Combined Synopsis/Solicitation, i=Intent to Bundle, u=Justification (J&A) are opt-in adjacency/incumbent tells. Ranked r/p over s via noticeTypeCode."),
99
+ responseDeadlineFrom: z
100
+ .string()
101
+ .optional()
102
+ .describe("ISO date lower bound for responseDeadline. APPLIED CLIENT-SIDE over the fetched page (the keyless feed ignores rdlfrom/rdlto) — disclosed in _meta.filtersDropped. A notice with no deadline is excluded from a windowed query."),
103
+ responseDeadlineTo: z
104
+ .string()
105
+ .optional()
106
+ .describe("ISO date upper bound for responseDeadline. APPLIED CLIENT-SIDE over the fetched page (see responseDeadlineFrom)."),
107
+ activeOnly: z
108
+ .boolean()
109
+ .optional()
110
+ .describe("Only currently-active notices (default true)."),
111
+ limit: z.number().min(1).max(50).optional().describe("Page size (default 25, max 50)."),
112
+ });
45
113
  const SamGetOpportunityInput = z.object({
46
114
  noticeId: z.string().describe("32-char hex notice id"),
47
115
  });
@@ -53,6 +121,32 @@ const SamAttachmentUrlInput = z.object({
53
121
  .string()
54
122
  .describe("Resource id from sam_get_opportunity → resourceLinks (URL-tail hex)"),
55
123
  });
124
+ const SamFetchAttachmentTextInput = z.object({
125
+ url: z
126
+ .string()
127
+ .url()
128
+ .refine((u) => {
129
+ try {
130
+ const p = new URL(u);
131
+ const h = p.hostname.toLowerCase();
132
+ return (p.protocol === "https:" &&
133
+ (h === "sam.gov" || h === "api.sam.gov" || h.endsWith(".sam.gov")));
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }, {
139
+ message: "url must be an https:// SAM attachment download URL on sam.gov / api.sam.gov (from sam_get_opportunity's attachments[].url). Arbitrary hosts are refused (SSRF hygiene).",
140
+ })
141
+ .describe("SAM attachment download URL from sam_get_opportunity → attachments[].url / resourceLinks (https://sam.gov/api/prod/opps/v3/opportunities/resources/files/{id}/download). Must be a sam.gov / api.sam.gov host."),
142
+ maxChars: z
143
+ .number()
144
+ .int()
145
+ .min(1000)
146
+ .max(500_000)
147
+ .optional()
148
+ .describe("Cap on returned text characters (default 200000, max 500000). Truncation is disclosed in _meta (truncated:true)."),
149
+ });
56
150
  // USAspending — awards & recipients
57
151
  const UsasFiltersBase = z.object({
58
152
  agency: z.string().optional().describe("Canonical agency name"),
@@ -89,16 +183,93 @@ const UsasSubawardsInput = z.object({
89
183
  const UsasExpiringInput = z.object({
90
184
  agency: z.string().optional(),
91
185
  naics: z.string().optional(),
92
- fiscalYear: z.number().int().min(2007).optional(),
186
+ // M2 (W3-1 honesty): `fiscalYear` removed. The recompete radar windows on the
187
+ // current PoP end date around TODAY, not an obligation FY, so it was
188
+ // inapplicable — advertised here, then validated and silently discarded by
189
+ // searchExpiringContracts (never forwarded) with empty filtersDropped. Dropping
190
+ // it from the schema stops the validated-then-discarded arg at the door.
93
191
  monthsUntilExpiry: z.number().min(1).max(36).optional(),
94
192
  minAwardValue: z.number().optional(),
95
193
  limit: z.number().min(1).max(20).optional(),
96
194
  });
195
+ const UsasRecompetesInput = z.object({
196
+ agency: z
197
+ .string()
198
+ .optional()
199
+ .describe("Canonical awarding toptier agency name (use usas_lookup_agency)"),
200
+ naics: z.string().optional().describe("6-digit NAICS code, e.g. '541512'"),
201
+ pscCodes: z
202
+ .array(z.string())
203
+ .optional()
204
+ .describe("Product/Service Codes to filter on, e.g. ['DA01','R425']"),
205
+ setAside: z
206
+ .enum(["SBA", "8A", "HZS", "SDVOSBC", "WOSB", "EDWOSB", "VSA", "VSS"])
207
+ .optional()
208
+ .describe("USAspending set_aside_type_code (honored server-side)"),
209
+ windowStartDays: z
210
+ .number()
211
+ .int()
212
+ .optional()
213
+ .describe("Lower edge of the recompete window in days from today (default -90 = include contracts that ended up to 90 days ago)."),
214
+ windowEndDays: z
215
+ .number()
216
+ .int()
217
+ .min(1)
218
+ .optional()
219
+ .describe("Upper edge of the window in days from today (default 548 ≈ 18 months)."),
220
+ minAwardValue: z
221
+ .number()
222
+ .min(0)
223
+ .optional()
224
+ .describe("Minimum Award Amount ($) to include (default 0)."),
225
+ includePotentialEnd: z
226
+ .boolean()
227
+ .optional()
228
+ .describe("Also return the potential (option-inclusive) PoP end date + extendableDays (default false)."),
229
+ actionDateLookbackYears: z
230
+ .number()
231
+ .int()
232
+ .min(1)
233
+ .max(10)
234
+ .optional()
235
+ .describe("action_date lower bound in years (default 3). Contracts with no recorded action in this span are excluded — this bound makes the End-Date sort reach the window."),
236
+ page: z.number().int().min(1).optional().describe("1-based page (default 1)."),
237
+ pageSize: z
238
+ .number()
239
+ .int()
240
+ .min(1)
241
+ .max(100)
242
+ .optional()
243
+ .describe("Rows per page (default 25, max 100)."),
244
+ scanBudgetPages: z
245
+ .number()
246
+ .int()
247
+ .min(1)
248
+ .max(20)
249
+ .optional()
250
+ .describe("Max 100-row pages to scan before giving up (default 8). If exhausted before the window ends, results are a lower bound and totalAvailable is null."),
251
+ });
97
252
  const UsasAwardDetailInput = z.object({
98
253
  generatedInternalId: z
99
254
  .string()
100
255
  .describe("From spending_by_award results — e.g. CONT_AWD_*"),
101
256
  });
257
+ const UsasAnalyzeIncumbentInput = z.object({
258
+ generatedInternalId: z
259
+ .string()
260
+ .describe("The ONE award to analyze — generatedInternalId from usas_search_individual_awards / usas_search_awards_by_recipient / usas_search_recompetes (e.g. CONT_AWD_*)."),
261
+ includeOtherAwards: z
262
+ .boolean()
263
+ .optional()
264
+ .describe("Also return the incumbent's other awards in the same agency×NAICS via one bounded recipient search (default true)."),
265
+ otherAwardsLimit: z
266
+ .number()
267
+ .int()
268
+ .min(1)
269
+ .max(50)
270
+ .optional()
271
+ .describe("Cap on incumbentOtherAwards (default 15, max 50)."),
272
+ });
102
273
  const UsasSpendingOverTimeInput = z.object({
103
274
  group: z.enum(["fiscal_year", "quarter", "month"]).optional(),
104
275
  agency: z.string().optional(),
@@ -176,242 +347,3708 @@ const FedRegSearchInput = z.object({
176
347
  type: z
177
348
  .enum(["RULE", "PRORULE", "NOTICE", "PRESDOCU"])
178
349
  .optional()
179
- .describe("Document type"),
180
- publicationDateFrom: z.string().optional().describe("YYYY-MM-DD"),
181
- publicationDateTo: z.string().optional().describe("YYYY-MM-DD"),
182
- effectiveDateFrom: z.string().optional().describe("YYYY-MM-DD"),
183
- perPage: z.number().min(1).max(100).optional(),
350
+ .describe("Document type"),
351
+ publicationDateFrom: z.string().optional().describe("YYYY-MM-DD"),
352
+ publicationDateTo: z.string().optional().describe("YYYY-MM-DD"),
353
+ effectiveDateFrom: z.string().optional().describe("YYYY-MM-DD"),
354
+ perPage: z.number().min(1).max(100).optional(),
355
+ });
356
+ const FedRegGetDocInput = z.object({
357
+ documentNumber: z
358
+ .string()
359
+ .describe("Federal Register document number, e.g. '2026-08333'"),
360
+ });
361
+ const FedRegListAgenciesInput = z.object({
362
+ perPage: z.number().min(1).max(500).optional(),
363
+ });
364
+ const FedRegPublicInspectionInput = z.object({
365
+ mode: z
366
+ .enum(["current", "date", "search"])
367
+ .optional()
368
+ .describe("Retrieval surface (default current): 'current' = all documents on public inspection now; 'date' = a specific inspection day (requires `date`); 'search' = server-side full-text over the on-inspection set (via `term`)."),
369
+ date: z
370
+ .string()
371
+ .optional()
372
+ .describe("YYYY-MM-DD; REQUIRED iff mode='date'. Rides conditions[available_on] as a query param (never a path segment). Validated (real calendar date, 1994..currentYear+1) before any fetch."),
373
+ term: z
374
+ .string()
375
+ .optional()
376
+ .describe("Full-text query; VALID only in mode='search'. Rides conditions[term] (server-side)."),
377
+ type: z
378
+ .enum(["RULE", "PRORULE", "NOTICE", "PRESDOCU"])
379
+ .optional()
380
+ .describe("Client-side document-type filter (applied in all modes)."),
381
+ agency: z
382
+ .string()
383
+ .regex(/^[a-z0-9-]+$/)
384
+ .optional()
385
+ .describe("Client-side agency-slug filter; matches ANY of a doc's agencies[].slug. Resolve slugs via fed_register_list_agencies."),
386
+ specialOnly: z
387
+ .boolean()
388
+ .optional()
389
+ .describe("Client-side filter keeping only filing_type='special' (off-cycle/emergency — a stronger, sooner signal)."),
390
+ limit: z.number().min(1).max(200).optional().describe("Page size (default 20)."),
391
+ offset: z.number().min(0).optional().describe("Page offset (default 0)."),
392
+ });
393
+ // eCFR
394
+ const EcfrSearchInput = z.object({
395
+ query: z.string(),
396
+ titleNumber: z
397
+ .number()
398
+ .optional()
399
+ .describe("CFR title (1-50). e.g. 48 = FAR (Federal Acquisition Regulation), 2 = Federal financial assistance."),
400
+ perPage: z.number().min(1).max(20).optional(),
401
+ });
402
+ const EcfrListTitlesInput = z.object({});
403
+ // FAR / DFARS clause lookup (eCFR versioner full endpoint)
404
+ const FarClauseLookupInput = z.object({
405
+ clauseNumber: z
406
+ .string()
407
+ .regex(
408
+ // Accept an optional FAR/DFARS prefix + the NN.NNN-N / NNN.NNN-NNNN core.
409
+ /^\s*(?:d?far[s]?\b[\s.:#-]*)?\d{1,3}\.\d{3,4}-\d{1,4}\s*$/i, "clauseNumber must be a FAR/DFARS clause like '52.212-4', '252.204-7012', or '52.204-25' (an optional 'FAR '/'DFARS ' prefix is allowed).")
410
+ .describe("FAR or DFARS clause/provision number, e.g. '52.212-4', '252.204-7012', '52.204-25'. An optional 'FAR '/'DFARS ' prefix is stripped."),
411
+ includePrescription: z
412
+ .boolean()
413
+ .optional()
414
+ .describe("Also fetch the prescribing section parsed from the clause's 'As prescribed in …' opener (the rule for WHEN the clause applies). Default true."),
415
+ asOfDate: z
416
+ .string()
417
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "asOfDate must be YYYY-MM-DD.")
418
+ .optional()
419
+ .describe("Point-in-time codification date (YYYY-MM-DD). Defaults to Title 48's current up_to_date_as_of."),
420
+ });
421
+ // FAR compliance matrix (composes far_clause_lookup over a cited-clause list)
422
+ const FarComplianceMatrixInput = z.object({
423
+ clauses: z
424
+ .array(z
425
+ .string()
426
+ .regex(
427
+ // Same clause grammar as FarClauseLookupInput (optional FAR/DFARS prefix).
428
+ /^\s*(?:d?far[s]?\b[\s.:#-]*)?\d{1,3}\.\d{3,4}-\d{1,4}\s*$/i, "each clause must be a FAR/DFARS clause like '52.212-4', '252.204-7012', or '52.204-25' (an optional 'FAR '/'DFARS ' prefix is allowed)."))
429
+ .min(1)
430
+ .max(25)
431
+ .describe("The FAR/DFARS clause numbers a solicitation cites (e.g. from its 52.252-2 'Clauses Incorporated by Reference' list), 1–25. Deduped case-insensitively. e.g. ['52.212-4','52.204-25','252.204-7012']."),
432
+ asOfDate: z
433
+ .string()
434
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "asOfDate must be YYYY-MM-DD.")
435
+ .optional()
436
+ .describe("Point-in-time codification date (YYYY-MM-DD) — typically the solicitation issue date. Defaults to Title 48's current up_to_date_as_of."),
437
+ includePrescription: z
438
+ .boolean()
439
+ .optional()
440
+ .describe("Also fetch each clause's prescribing section (the 'As prescribed in …' rule for WHEN it applies). Default true."),
441
+ flagGates: z
442
+ .boolean()
443
+ .optional()
444
+ .describe("Tag resolved rows that are pass/fail award-eligibility gates (Section 889, CMMC, limitations on subcontracting) with a gate label; others get gate:null. Default true. false ⇒ all gate:null."),
445
+ });
446
+ // FAR/DFARS-scoped search (composes ecfr_search, filtered to FAR/DFARS + deduped)
447
+ const FarSearchInput = z.object({
448
+ query: z
449
+ .string()
450
+ .min(1, "query must be a non-empty search string.")
451
+ .describe("What to search FAR/DFARS text for, e.g. 'limitations on subcontracting', 'covered defense information', 'commercial item'."),
452
+ scope: z
453
+ .enum(["far", "dfars", "both"])
454
+ .optional()
455
+ .describe("Which corpus to search: 'far' (Title 48 chapter 1, the default), 'dfars' (chapter 2), or 'both'. Excludes GSAM/agency supplements."),
456
+ dedupeVersions: z
457
+ .boolean()
458
+ .optional()
459
+ .describe("Collapse each section's historical versions to the current (in-force) one. Default true. false ⇒ return all raw rows incl. historical."),
460
+ partsOnly: z
461
+ .array(z.number().int())
462
+ .optional()
463
+ .describe("Restrict results to these FAR/DFARS parts, e.g. [52] for clause text only, [12] for commercial-item policy."),
464
+ perPage: z
465
+ .number()
466
+ .int()
467
+ .min(1)
468
+ .max(20)
469
+ .optional()
470
+ .describe("Number of DISTINCT sections to return (1–20). Default 5."),
471
+ });
472
+ // SBA size standards
473
+ const SbaSizeStandardInput = z.object({
474
+ naics: z
475
+ .string()
476
+ .regex(/^\d{6}$/, "naics must be a 6-digit NAICS code (e.g. '541512').")
477
+ .describe("6-digit NAICS code to look up the SBA small-business size standard for (e.g. '541512')."),
478
+ });
479
+ // Grants.gov
480
+ const GrantsSearchInput = z.object({
481
+ keyword: z.string().optional(),
482
+ cfda: z.string().optional().describe("CFDA program number, e.g. '10.500'"),
483
+ agency: z
484
+ .string()
485
+ .optional()
486
+ .describe("Grants.gov agency code, e.g. 'DHS-FEMA'"),
487
+ oppNum: z.string().optional().describe("Specific opportunity number"),
488
+ oppStatuses: z
489
+ .array(z.enum(["forecasted", "posted", "closed", "archived"]))
490
+ .optional()
491
+ .describe("Defaults to forecasted+posted"),
492
+ rows: z.number().min(1).max(50).optional(),
493
+ });
494
+ const GrantsGetInput = z.object({
495
+ opportunityId: z.string().describe("Grants.gov opportunity id (numeric string)"),
496
+ });
497
+ // SAM.gov organization lookup (federal hierarchy)
498
+ const SamLookupOrgInput = z.object({
499
+ organizationId: z.string().describe("SAM.gov federal-organization id (numeric)"),
500
+ });
501
+ // Pricing tier — wage determinations + GSA CALC labor-rate benchmarks
502
+ const WageSearchInput = z.object({
503
+ coverage: z
504
+ .enum(["sca", "dba"])
505
+ .describe("Which wage-determination law: 'sca' (Service Contract Act — services) or 'dba' (Davis-Bacon Act — construction). 'dba' is normalized to the API's 'dbra' index."),
506
+ state: z
507
+ .string()
508
+ .optional()
509
+ .describe("2-letter USPS state code (e.g. 'VA'), applied SERVER-SIDE. A full name is applied client-side instead."),
510
+ county: z
511
+ .string()
512
+ .optional()
513
+ .describe("County name (substring match), applied CLIENT-SIDE over the fetched page only (the API has no county filter)."),
514
+ query: z
515
+ .string()
516
+ .optional()
517
+ .describe("Matches the WD NUMBER/TITLE only — NOT occupation/job title (q=guard returns 0)."),
518
+ activeOnly: z.boolean().optional().describe("Only currently-active WDs (default true)."),
519
+ standardOnly: z
520
+ .boolean()
521
+ .optional()
522
+ .describe("Only standard (non-non-standard) WDs (default true)."),
523
+ limit: z.number().min(1).max(50).optional().describe("Page size (default 20, max 50)."),
524
+ page: z.number().min(0).optional().describe("0-based page index (default 0)."),
525
+ });
526
+ const WageRatesInput = z.object({
527
+ reference: z
528
+ .string()
529
+ .describe("fullReferenceNumber of the wage determination (e.g. '2015-4093' for SCA, 'IA20260028' for DBA) from sam_search_wage_determinations."),
530
+ revision: z
531
+ .number()
532
+ .optional()
533
+ .describe("Revision number. Omit to resolve the latest ACTIVE revision via /history."),
534
+ coverage: z
535
+ .enum(["sca", "dba"])
536
+ .optional()
537
+ .describe("Optional hint (sca|dba) to disambiguate the parser; inferred otherwise."),
538
+ format: z
539
+ .enum(["parsed", "raw", "both"])
540
+ .optional()
541
+ .describe("'parsed' (structured rates, default), 'raw' (the full document text), or 'both'. Use 'raw'/'both' when parseConfidence is low."),
542
+ });
543
+ const BenchmarkLaborInput = z.object({
544
+ laborCategory: z
545
+ .string()
546
+ .describe("Labor category to benchmark (e.g. 'Program Manager', 'Software Engineer'). Matched exactly against CALC's labor_category."),
547
+ businessSize: z
548
+ .enum(["S", "O"])
549
+ .optional()
550
+ .describe("Business size filter: 'S' (small) or 'O' (other-than-small)."),
551
+ educationLevel: z
552
+ .string()
553
+ .optional()
554
+ .describe("Education filter — use CALC's SHORT CODES (e.g. 'HS','AA','BA','MA','PHD'); the displayed education_level field may show full words."),
555
+ minYearsExperience: z
556
+ .number()
557
+ .optional()
558
+ .describe("Minimum years of experience filter."),
559
+ experienceRange: z
560
+ .string()
561
+ .optional()
562
+ .describe("Experience range as 'min,max' (e.g. '5,10')."),
563
+ sin: z.string().optional().describe("Schedule SIN filter (e.g. '54151S')."),
564
+ priceRange: z
565
+ .string()
566
+ .optional()
567
+ .describe("Ceiling-price range as 'min,max' (e.g. '50,150')."),
568
+ maxSamplePages: z
569
+ .number()
570
+ .min(1)
571
+ .max(10)
572
+ .optional()
573
+ .describe("How many 20-row pages to sample for the distribution (default 3, max 10)."),
574
+ });
575
+ // Integrity / teaming
576
+ const CheckExclusionsInput = z.object({
577
+ query: z
578
+ .string()
579
+ .optional()
580
+ .describe("Firm or individual name to screen (drives the server-side exclusions text search). Provide at least one of query/uei/cage."),
581
+ uei: z
582
+ .string()
583
+ .optional()
584
+ .describe("SAM UEI to match. Used as the text query when it is the sole selector; post-filtered against results when combined with a name query."),
585
+ cage: z
586
+ .string()
587
+ .optional()
588
+ .describe("CAGE code to match (post-filtered against results, or used as the text query when sole)."),
589
+ activeOnly: z
590
+ .boolean()
591
+ .optional()
592
+ .describe("Only currently-active exclusions (default true). false includes terminated exclusions."),
593
+ classification: z
594
+ .enum(["Firm", "Individual", "Special Entity Designation", "any"])
595
+ .optional()
596
+ .describe("Filter by excluded-party classification (default 'any')."),
597
+ page: z.number().min(0).optional().describe("0-based page index (default 0)."),
598
+ size: z.number().min(1).max(100).optional().describe("Page size (default 25, max 100)."),
599
+ });
600
+ const IntegrityLookupInput = z.object({
601
+ uei: z
602
+ .string()
603
+ .optional()
604
+ .describe("SAM UEI of the entity to screen (PREFERRED — most precise). Provide at least one of uei/cage/name."),
605
+ cage: z
606
+ .string()
607
+ .optional()
608
+ .describe("CAGE code of the entity to screen."),
609
+ name: z
610
+ .string()
611
+ .optional()
612
+ .describe("Legal entity name to screen (drives the keyless exclusions text search; normalized-name gated). Provide at least one of uei/cage/name."),
613
+ });
614
+ const TeamingPartnersInput = z.object({
615
+ // ENUM-VALIDATED: a bogus recipient_type_names value is SILENTLY accepted by
616
+ // USAspending (HTTP 200, 0 results), so this enum is the guardrail — only the
617
+ // spellings LIVE-VERIFIED to narrow a populated NAICS are accepted.
618
+ cert: z
619
+ .enum([
620
+ "small_business",
621
+ "8a_program_participant",
622
+ "woman_owned_business",
623
+ "women_owned_small_business",
624
+ "economically_disadvantaged_women_owned_small_business",
625
+ "service_disabled_veteran_owned_business",
626
+ "veteran_owned_business",
627
+ "historically_underutilized_business_firm",
628
+ ])
629
+ .describe("Socioeconomic certification (award-derived, NOT the SBA registry of record). One of: small_business, 8a_program_participant, woman_owned_business, women_owned_small_business, economically_disadvantaged_women_owned_small_business, service_disabled_veteran_owned_business, veteran_owned_business, historically_underutilized_business_firm (HUBZone)."),
630
+ naics: z.string().optional().describe("NAICS code to scope the search (e.g. '541512')."),
631
+ agency: z
632
+ .string()
633
+ .optional()
634
+ .describe("Awarding agency canonical toptier name (e.g. 'Department of Veterans Affairs'). Use usas_lookup_agency to resolve abbreviations."),
635
+ subagency: z
636
+ .string()
637
+ .optional()
638
+ .describe("Awarding sub-agency name. Requires `agency` to also be set (a subagency alone is dropped)."),
639
+ lookbackYears: z
640
+ .number()
641
+ .min(1)
642
+ .max(20)
643
+ .optional()
644
+ .describe("Action-date lookback window in years (default 3)."),
645
+ excludeDebarred: z
646
+ .boolean()
647
+ .optional()
648
+ .describe("Screen the top-ranked candidates via sam_check_exclusions and drop active exclusions (default true; bounded + disclosed in _meta)."),
649
+ minAwards: z
650
+ .number()
651
+ .min(1)
652
+ .optional()
653
+ .describe("Minimum scanned award count for a firm to be listed (default 1)."),
654
+ limit: z.number().min(1).max(50).optional().describe("Candidates per page (default 25, max 50)."),
655
+ page: z.number().min(1).optional().describe("1-based page index (default 1)."),
656
+ screenCap: z
657
+ .number()
658
+ .min(1)
659
+ .max(25)
660
+ .optional()
661
+ .describe("Max candidates to exclusion-screen per page (default 10, max 25)."),
662
+ scanPages: z
663
+ .number()
664
+ .min(1)
665
+ .max(10)
666
+ .optional()
667
+ .describe("Award-value-sorted pages (100 rows each) to scan before aggregating by recipient (default 4, max 10)."),
668
+ });
669
+ // OFAC denied-party sanctions screening (keyless bulk SDN + Consolidated lists)
670
+ const OfacScreenInput = z.object({
671
+ name: z
672
+ .string()
673
+ .describe("REQUIRED. The entity / individual / vessel / aircraft name to screen against OFAC's published SDN + Consolidated lists. Trimmed; empty is rejected (invalid_input) — never a no-op empty screen."),
674
+ type: z
675
+ .enum(["individual", "entity", "vessel", "aircraft"])
676
+ .optional()
677
+ .describe("Optional post-filter on the matched party's OFAC type. A blank OFAC type is inferred as 'entity' (disclosed). Omit to screen all types. Only trims the returned matches — it never turns a real name hit into no_name_match."),
678
+ program: z
679
+ .string()
680
+ .optional()
681
+ .describe("Optional case-insensitive filter to one sanctions PROGRAM code (e.g. 'CUBA', 'IRAN', 'SDGT'). Applied LOCALLY to matched rows (never on the wire); only trims returned matches (a hit under another program still yields potential_matches)."),
682
+ list: z
683
+ .enum(["sdn", "consolidated", "all"])
684
+ .optional()
685
+ .describe("Which OFAC list(s) to screen: 'sdn' (SDN + its AKAs), 'consolidated' (non-SDN programs + AKAs), or 'all' (default — the correct default for a real screen). Every list required for the scope loads-or-throws (a partial set is never screened)."),
686
+ minMatchQuality: z
687
+ .enum(["exact", "strong", "weak"])
688
+ .optional()
689
+ .describe("Floor of match quality to RETURN (default 'weak'). This ONLY trims the returned matches[]; existence is computed at the lowest quality FIRST, so result is 'potential_matches' whenever ANY match exists regardless of this value (suppressed matches are disclosed)."),
690
+ limit: z
691
+ .number()
692
+ .min(1)
693
+ .max(200)
694
+ .optional()
695
+ .describe("Max matches returned (default 50, max 200). Over-limit truncation is disclosed, never silent."),
696
+ });
697
+ // ━━━ NVD + CISA KEV — the IT/cyber-compliance lane (2) ━━━ ADR-0035
698
+ // cve_lookup: NVD CVE detail/search JOINED with CISA KEV status.
699
+ const CveLookupInput = z.object({
700
+ cveId: z
701
+ .string()
702
+ .optional()
703
+ .describe("Exact CVE identifier CVE-YYYY-NNNN (^CVE-\\d{4}-\\d+$, validated client-side). Exact-lookup mode; a malformed cveId is rejected (invalid_input) — a malformed cveId 404s upstream. At least one of cveId/keyword/cpeName/cvssV3Severity/a date range is REQUIRED."),
704
+ keyword: z
705
+ .string()
706
+ .optional()
707
+ .describe("Free-text keyword search (NVD keywordSearch) over CVE descriptions (e.g. 'log4j', 'apache struts'). Control chars stripped, length-capped; rides only as a query param (SSRF-safe)."),
708
+ cpeName: z
709
+ .string()
710
+ .optional()
711
+ .describe("A CPE 2.3 formatted string to match affected products (cpe:2.3:[aho]:… — e.g. cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*). Non-CPE input is rejected (invalid_input)."),
712
+ cvssV3Severity: z
713
+ .enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"])
714
+ .optional()
715
+ .describe("Filter to a CVSS v3 base severity band (LOW|MEDIUM|HIGH|CRITICAL)."),
716
+ pubStartDate: z
717
+ .string()
718
+ .optional()
719
+ .describe("Publication-date window START (ISO YYYY-MM-DD). PAIRED with pubEndDate (both required together — NVD 404s a lone bound). A span >120 days is clamped forward to 120 days BEFORE the request and disclosed."),
720
+ pubEndDate: z
721
+ .string()
722
+ .optional()
723
+ .describe("Publication-date window END (ISO YYYY-MM-DD). Paired with pubStartDate."),
724
+ lastModStartDate: z
725
+ .string()
726
+ .optional()
727
+ .describe("Last-modified window START (ISO YYYY-MM-DD). PAIRED with lastModEndDate (both required together). A span >120 days is clamped + disclosed."),
728
+ lastModEndDate: z
729
+ .string()
730
+ .optional()
731
+ .describe("Last-modified window END (ISO YYYY-MM-DD). Paired with lastModStartDate."),
732
+ kevOnly: z
733
+ .boolean()
734
+ .optional()
735
+ .describe("When true, return ONLY rows listed in the CISA KEV catalog. ★If the KEV catalog cannot be loaded, this THROWS (a KEV-membership filter is unanswerable without a loaded catalog) — it NEVER returns a silently-empty set (which would falsely read as 'none on the mandatory-remediation list')."),
736
+ resultsPerPage: z
737
+ .number()
738
+ .min(1)
739
+ .max(2000)
740
+ .optional()
741
+ .describe("Rows per page (default 50, max 2000 — NVD's cap). Over-cap is refused, never silently clamped."),
742
+ startIndex: z
743
+ .number()
744
+ .min(0)
745
+ .optional()
746
+ .describe("Zero-based page offset (default 0). Pagination derives from NVD's exact totalResults, never the page length."),
747
+ });
748
+ // cisa_kev_lookup: filter the KEV catalog standalone.
749
+ const CisaKevLookupInput = z.object({
750
+ cveId: z
751
+ .string()
752
+ .optional()
753
+ .describe("Exact CVE identifier CVE-YYYY-NNNN to check for KEV membership. A miss returns found:false + the not-in-KEV≠safe caveat (absence is NOT a safety clearance)."),
754
+ vendorProject: z
755
+ .string()
756
+ .optional()
757
+ .describe("Case-insensitive substring filter on the vendor/project (e.g. 'Microsoft', 'Apache')."),
758
+ product: z
759
+ .string()
760
+ .optional()
761
+ .describe("Case-insensitive substring filter on the product (e.g. 'Log4j', 'Exchange Server')."),
762
+ ransomwareOnly: z
763
+ .boolean()
764
+ .optional()
765
+ .describe("When true, keep only entries with knownRansomwareCampaignUse === 'Known'."),
766
+ addedSince: z
767
+ .string()
768
+ .optional()
769
+ .describe("Keep only entries with dateAdded >= this ISO date (YYYY-MM-DD)."),
770
+ dueBefore: z
771
+ .string()
772
+ .optional()
773
+ .describe("Keep only entries with dueDate < this ISO date (YYYY-MM-DD) — the CISA-mandated remediation deadline."),
774
+ limit: z
775
+ .number()
776
+ .min(1)
777
+ .max(1000)
778
+ .optional()
779
+ .describe("Max matches returned (default 100, max 1000)."),
780
+ offset: z.number().min(0).optional().describe("Zero-based page offset (default 0)."),
781
+ });
782
+ // ━━━ NPPES NPI Registry — the healthcare-provider identity/credentialing lane (1) ━━━ ADR-0036
783
+ // nppes_lookup_provider: exact NPI detail OR search over CMS/HHS's keyless public
784
+ // registry of every US healthcare provider (npiregistry.cms.hhs.gov/api, version=2.1
785
+ // REQUIRED). Mode is inferred from `number` (no mode flag). ★M1: an exact NPI is
786
+ // looked up by number ALONE on the wire (a co-supplied filter AND-combines and would
787
+ // falsely zero a real active provider → found:false); co-filters are checked
788
+ // client-side and disclosed via data.filterMatch. ★S2: the required-one gate is
789
+ // {number, first_name, last_name, organization_name, taxonomy_description, city,
790
+ // postal_code}; state + enumeration_type are REFINERS ONLY (rejected alone). The
791
+ // grammars (10-digit NPI + CMS Luhn, USPS state enum, NPI-1/NPI-2 enum, trailing-`*`
792
+ // wildcard ≥2 leading chars) are the SSRF + silent-foot-gun guards.
793
+ const NppesLookupInput = z.object({
794
+ number: z
795
+ .string()
796
+ .regex(/^\d{10}$/)
797
+ .optional()
798
+ .describe("Exact NPI — 10 digits (^\\d{10}$). Triggers EXACT-NPI mode: the wire query carries number (+version) ALONE (any co-supplied filter is DROPPED from the wire and checked client-side, disclosed in data.filterMatch — NPPES AND-combines a number with filters, so a mismatched filter would falsely zero a real active provider). Also client-side CMS-Luhn-validated (Luhn over 80840+first-9): a typo'd NPI ⇒ invalid_input, NEVER a fake 'does not exist'. e.g. '1104130236'."),
799
+ enumeration_type: z
800
+ .enum(["NPI-1", "NPI-2"])
801
+ .optional()
802
+ .describe("REFINER only (NPI-1 = individual, NPI-2 = organization). Never sufficient alone (⇒ invalid_input) — must accompany a required criterion."),
803
+ first_name: z
804
+ .string()
805
+ .min(1)
806
+ .max(100)
807
+ .optional()
808
+ .describe("Individual provider first name (a required-one criterion). A trailing '*' wildcard needs ≥2 leading literal chars. e.g. 'John'."),
809
+ last_name: z
810
+ .string()
811
+ .min(1)
812
+ .max(100)
813
+ .optional()
814
+ .describe("Individual provider last name (a required-one criterion). Trailing '*' wildcard: ≥2 leading chars. e.g. 'Smith'."),
815
+ organization_name: z
816
+ .string()
817
+ .min(1)
818
+ .max(100)
819
+ .optional()
820
+ .describe("Organization (NPI-2) name (a required-one criterion). Trailing '*' wildcard: ≥2 leading chars. e.g. 'Mayo Clinic'."),
821
+ taxonomy_description: z
822
+ .string()
823
+ .min(1)
824
+ .max(100)
825
+ .optional()
826
+ .describe("Provider taxonomy/specialty description (a required-one criterion). e.g. 'Internal Medicine'."),
827
+ city: z
828
+ .string()
829
+ .min(1)
830
+ .max(100)
831
+ .optional()
832
+ .describe("Address city (a required-one criterion). e.g. 'Baltimore'."),
833
+ postal_code: z
834
+ .string()
835
+ .min(1)
836
+ .max(100)
837
+ .optional()
838
+ .describe("Address postal/ZIP code (a required-one criterion; a prefix like '212' is allowed). e.g. '21218'."),
839
+ state: z
840
+ .enum(nppes.NPPES_STATES)
841
+ .optional()
842
+ .describe("US state/territory 2-letter USPS code — a REFINER only (never sufficient alone ⇒ invalid_input; NPPES rejects 'state' as the sole criterion). e.g. 'MD'."),
843
+ limit: z
844
+ .number()
845
+ .int()
846
+ .min(1)
847
+ .max(200)
848
+ .optional()
849
+ .describe("Providers per page, 1..200, default 10. NPPES silently clamps >200; this tool rejects it loudly. Search mode only."),
850
+ skip: z
851
+ .number()
852
+ .int()
853
+ .min(0)
854
+ .max(1000)
855
+ .optional()
856
+ .describe("0-based pagination offset, 0..1000 (default 0). ★POLICY cap: this vetting tool reaches at most the first ~1,200 matches/query (a deliberate targeted-lookup boundary — NPPES itself no longer enforces a skip ceiling); skip > 1000 ⇒ invalid_input. Search mode only."),
857
+ });
858
+ // ━━━ CMS Open Payments — the healthcare spend/transparency lane (2) ━━━ ADR-0037
859
+ // A NEW keyless DKAN 2.x datastore adapter (a sibling of ckan/socrata) over
860
+ // openpaymentsdata.cms.gov. ★M2: the DCAT metastore ignores limit/offset — the
861
+ // whole catalog is sliced CLIENT-SIDE (totalAvailable is the exact post-q size).
862
+ // ★M1: the results-array drift guard is conditioned on the effective `results` mode
863
+ // (results:false omits rows). ★S2: `count` is NOT a caller toggle (always count=true
864
+ // on the wire). ★S3: offset ≤ 2000 reach cap (PII boundary, mirrors NPPES).
865
+ // SSRF (load-bearing): datasetId (36-char lowercase UUID) + index interpolate into
866
+ // the URL PATH — the Zod grammars are the primary path-injection guard.
867
+ const CmsSearchDatasetsInput = z.object({
868
+ q: z
869
+ .string()
870
+ .max(200)
871
+ .optional()
872
+ .describe("Client-side case-insensitive substring filter over each dataset's title + description (the DKAN metastore returns the ENTIRE catalog in one response; q is applied client-side and totalAvailable is the exact post-filter catalog size). e.g. 'research payment'."),
873
+ limit: z
874
+ .number()
875
+ .int()
876
+ .min(1)
877
+ .max(100)
878
+ .optional()
879
+ .describe("Datasets per page, 1..100, default 20 (client-side slice of the full catalog)."),
880
+ offset: z
881
+ .number()
882
+ .int()
883
+ .min(0)
884
+ .optional()
885
+ .describe("0-based pagination offset (default 0), applied client-side against the known catalog length (never a server offset)."),
886
+ });
887
+ const CmsQueryDatasetInput = z.object({
888
+ datasetId: z
889
+ .string()
890
+ .length(36)
891
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
892
+ .describe("REQUIRED — the DKAN datasetId, a 36-char LOWERCASE UUID. ★SSRF: it interpolates into the URL PATH, so this strict grammar (no uppercase, no %2F/../, no trailing newline) is the load-bearing path-injection guard. e.g. 'f0d1de67-6852-4093-a036-c9328c256a05' (2025 Research Payment Data)."),
893
+ index: z
894
+ .number()
895
+ .int()
896
+ .min(0)
897
+ .max(50)
898
+ .optional()
899
+ .describe("Distribution index (default 0 = the primary CSV). Also interpolates into the URL path (int 0..50)."),
900
+ conditions: z
901
+ .array(z.object({
902
+ property: z
903
+ .string()
904
+ .min(1)
905
+ .max(128)
906
+ .regex(/^[a-z0-9_]+$/)
907
+ .describe("Column name (snake_case lowercase alnum). A bad column ⇒ HTTP 400 ⇒ invalid_input (never a silent drop). e.g. 'recipient_state'."),
908
+ value: z
909
+ .union([z.string().max(200), z.number()])
910
+ .describe("Filter value. e.g. 'CA'."),
911
+ operator: z
912
+ .enum(["=", "<>", "<", ">", "<=", ">=", "like", "in"])
913
+ .optional()
914
+ .describe("Comparison operator (default '='). AND-combined across conditions."),
915
+ }))
916
+ .max(10)
917
+ .optional()
918
+ .describe("Server-side filters (≤10, AND-combined) that provably narrow the EXACT count. Each either applies or the call errors — filtersDropped is always empty."),
919
+ properties: z
920
+ .array(z.string().min(1).max(128).regex(/^[a-z0-9_]+$/))
921
+ .optional()
922
+ .describe("Optional column projection (snake_case column names). Omit for all columns."),
923
+ limit: z
924
+ .number()
925
+ .int()
926
+ .min(1)
927
+ .max(500)
928
+ .optional()
929
+ .describe("Rows per page, 1..500, default 100. 500 is the HARD DKAN cap (the API 400s over it; this tool rejects >500 loudly)."),
930
+ offset: z
931
+ .number()
932
+ .int()
933
+ .min(0)
934
+ .max(2000)
935
+ .optional()
936
+ .describe("0-based row offset (default 0). ★POLICY reach cap ≤ 2000 (a deliberate targeted-lookup boundary — Open Payments names physicians + amounts); offset > 2000 ⇒ invalid_input."),
937
+ results: z
938
+ .boolean()
939
+ .optional()
940
+ .describe("Default true (return rows). Set false for COUNT/SCHEMA-discovery mode: no rows, pagination disabled, but the EXACT count + every column's schema are returned. (`count` is NOT a toggle — count=true is always on the wire.)"),
941
+ });
942
+ // ─── FAC (Federal Audit Clearinghouse) Single Audit — ADR-0038 ──────
943
+ // ★ PII crux: the tools expose ONLY structured, validated filters — NO caller
944
+ // `select`/`order`/free-column param (that is why v1 uses purpose-built tools,
945
+ // not a generic fac_query). The select-allowlist is a hardcoded module constant
946
+ // in fac.ts; no caller value can name a column (PostgREST would project a
947
+ // personal-contact column the instant `select=` named it).
948
+ const FacSearchAuditsInput = z.object({
949
+ auditeeUei: z
950
+ .string()
951
+ .regex(/^[A-Z0-9]{12}$/)
952
+ .optional()
953
+ .describe("Filter by 12-char SAM UEI (^[A-Z0-9]{12}$; → auditee_uei=eq. — the PRIMARY join key to SAM/USAspending/EDGAR). e.g. 'ZQGGHJH74DW7'."),
954
+ auditeeState: z
955
+ .string()
956
+ .regex(/^[A-Z]{2}$/)
957
+ .optional()
958
+ .describe("Filter by 2-letter US state code (uppercase; → auditee_state=eq.). e.g. 'CA'."),
959
+ auditYear: z
960
+ .number()
961
+ .int()
962
+ .min(2016)
963
+ .max(2100)
964
+ .optional()
965
+ .describe("Filter by audit year (int, → audit_year=eq.). e.g. 2024."),
966
+ totalExpendedMin: z
967
+ .number()
968
+ .finite()
969
+ .optional()
970
+ .describe("Minimum total federal awards expended (USD, → total_amount_expended=gte.)."),
971
+ totalExpendedMax: z
972
+ .number()
973
+ .finite()
974
+ .optional()
975
+ .describe("Maximum total federal awards expended (USD, → total_amount_expended=lte.)."),
976
+ limit: z
977
+ .number()
978
+ .int()
979
+ .min(1)
980
+ .max(100)
981
+ .default(25)
982
+ .describe("Rows per page, 1..100, default 25."),
983
+ offset: z
984
+ .number()
985
+ .int()
986
+ .min(0)
987
+ .default(0)
988
+ .describe("0-based row offset for pagination (default 0)."),
989
+ });
990
+ const FacGetFindingsInput = z
991
+ .object({
992
+ auditeeUei: z
993
+ .string()
994
+ .regex(/^[A-Z0-9]{12}$/)
995
+ .optional()
996
+ .describe("Filter by 12-char SAM UEI (^[A-Z0-9]{12}$; → auditee_uei=eq.)."),
997
+ reportId: z
998
+ .string()
999
+ .regex(/^[0-9A-Za-z-]+$/)
1000
+ .max(64)
1001
+ .optional()
1002
+ .describe("Filter by FAC report_id (^[0-9A-Za-z-]+$; → report_id=eq. — from a fac_search_audits row)."),
1003
+ auditYear: z
1004
+ .number()
1005
+ .int()
1006
+ .min(2016)
1007
+ .max(2100)
1008
+ .optional()
1009
+ .describe("Filter by audit year (int, → audit_year=eq.)."),
1010
+ limit: z
1011
+ .number()
1012
+ .int()
1013
+ .min(1)
1014
+ .max(100)
1015
+ .default(50)
1016
+ .describe("Rows per page, 1..100, default 50."),
1017
+ offset: z
1018
+ .number()
1019
+ .int()
1020
+ .min(0)
1021
+ .default(0)
1022
+ .describe("0-based row offset for pagination (default 0)."),
1023
+ })
1024
+ .refine((v) => v.auditeeUei !== undefined || v.reportId !== undefined, {
1025
+ message: "fac_get_findings requires at least one of `auditeeUei` or `reportId` (an empty query would scan the whole 670K-row findings table).",
1026
+ path: ["auditeeUei"],
1027
+ });
1028
+ // GAO bid-protest lookup (keyless RSS + decision-page parse)
1029
+ const GaoProtestInput = z.object({
1030
+ agency: z
1031
+ .string()
1032
+ .optional()
1033
+ .describe("Client-side substring filter on the recent-protest feed (matched against the decision title + description). NOTE: filters the RECENT feed window only — not a historical agency search."),
1034
+ protester: z
1035
+ .string()
1036
+ .optional()
1037
+ .describe("Client-side substring filter on the protester name (feed title/description)."),
1038
+ solicitationNumber: z
1039
+ .string()
1040
+ .optional()
1041
+ .describe("Client-side substring filter on the solicitation number (matched in the feed description)."),
1042
+ outcome: z
1043
+ .enum(["sustained", "denied", "dismissed", "withdrawn", "any"])
1044
+ .optional()
1045
+ .describe("Filter by protest disposition (default 'any'). Determined from each decision page, so it applies only when enrich is true."),
1046
+ bNumber: z
1047
+ .string()
1048
+ .optional()
1049
+ .describe("Fetch ONE specific decision directly by GAO B-number (e.g. 'B-424377' or 'b-424249.2'), bypassing the feed. Use to pull a decision that has aged out of the recent feed window."),
1050
+ limit: z
1051
+ .number()
1052
+ .min(1)
1053
+ .max(50)
1054
+ .optional()
1055
+ .describe("Max decisions to return (default 20, max 50). The feed itself carries ~25 recent legal products."),
1056
+ enrich: z
1057
+ .boolean()
1058
+ .optional()
1059
+ .describe("Fetch each decision's page to fill agency/outcome/solicitation/PDF (default true). Set false for a fast feed-only list (those fields will be null)."),
1060
+ });
1061
+ // GSA daily-CSV keyless backbone — batch page-completing enrichment
1062
+ const SamLookupNoticeFieldsInput = z.object({
1063
+ noticeIds: z
1064
+ .array(z.string())
1065
+ .min(1)
1066
+ .max(100)
1067
+ .describe("1..100 32-char hex noticeIds (the ids returned by sam_search_opportunities) to enrich in ONE batch. Completes a whole search page's null naics/setAside/place-of-performance/deadline/type from the cached GSA daily CSV. OFF BY DEFAULT — enable by setting SAM_GOV_CSV_CACHE (a cache dir) or SAM_GOV_ENABLE_CSV=1."),
1068
+ });
1069
+ // ─── US Treasury Fiscal Data (keyless) — input schemas ───────────
1070
+ // ADR-0002. `dataset` is an ENUM of the 5 live-confirmed paths (F5: no free
1071
+ // path → no SSRF surface). pageSize max 500 (upstream page[size] ceiling).
1072
+ const TreasuryDatasetEnum = z
1073
+ .enum([
1074
+ "debt_to_penny",
1075
+ "avg_interest_rates",
1076
+ "mts_table_1",
1077
+ "rates_of_exchange",
1078
+ "debt_outstanding",
1079
+ ])
1080
+ .describe("Which confirmed Treasury Fiscal Data dataset to query: debt_to_penny (daily total public debt), avg_interest_rates (avg rate by security type), mts_table_1 (Monthly Treasury Statement receipts/outlays/deficit), rates_of_exchange (quarterly FX by currency), debt_outstanding (historical fiscal-year-end debt).");
1081
+ const TreasuryQueryDatasetInput = z.object({
1082
+ dataset: TreasuryDatasetEnum,
1083
+ fields: z
1084
+ .string()
1085
+ .optional()
1086
+ .describe("Optional CSV column projection (e.g. 'record_date,exchange_rate'). An unknown column ⇒ upstream HTTP 400 ⇒ invalid_input (surfaced as an error, never silently dropped)."),
1087
+ filter: z
1088
+ .string()
1089
+ .optional()
1090
+ .describe("Optional CSV of upstream filters 'col:op:val' (ops: lt|lte|gt|gte|eq|in), AND-combined — e.g. 'record_date:gte:2024-01-01,country_currency_desc:eq:Canada-Dollar'."),
1091
+ sort: z
1092
+ .string()
1093
+ .optional()
1094
+ .describe("Optional CSV sort columns; prefix '-' for descending (e.g. '-record_date')."),
1095
+ pageSize: z
1096
+ .number()
1097
+ .int()
1098
+ .min(1)
1099
+ .max(500)
1100
+ .default(100)
1101
+ .describe("Rows per page (upstream page[size]); 1..500, default 100."),
1102
+ pageNumber: z
1103
+ .number()
1104
+ .int()
1105
+ .min(1)
1106
+ .default(1)
1107
+ .describe("1-based page number (upstream page[number]); default 1."),
1108
+ });
1109
+ const TreasuryDebtToPennyInput = z.object({
1110
+ latest: z
1111
+ .boolean()
1112
+ .default(true)
1113
+ .describe("true (default) ⇒ only the single most-recent day (page[size]=1). false ⇒ the startDate/endDate range, newest-first."),
1114
+ startDate: z
1115
+ .string()
1116
+ .optional()
1117
+ .describe("Range mode only: ISO YYYY-MM-DD lower bound on record_date (inclusive)."),
1118
+ endDate: z
1119
+ .string()
1120
+ .optional()
1121
+ .describe("Range mode only: ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1122
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Range mode: rows per page, 1..500, default 100."),
1123
+ pageNumber: z.number().int().min(1).default(1).describe("Range mode: 1-based page number, default 1."),
1124
+ });
1125
+ const TreasuryMonthlyStatementInput = z.object({
1126
+ startDate: z
1127
+ .string()
1128
+ .optional()
1129
+ .describe("ISO YYYY-MM-DD lower bound on record_date (inclusive). Default: trailing ~12 months."),
1130
+ endDate: z
1131
+ .string()
1132
+ .optional()
1133
+ .describe("ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1134
+ excludeSummaryRows: z
1135
+ .boolean()
1136
+ .default(true)
1137
+ .describe("true (default) excludes fiscal-year PARENT/SUMMARY rows (parent_id/amounts all null) via the server-side filter current_month_gross_outly_amt:gt:0, so only real child line-items (and totalAvailable) remain. false includes the null-amount summary rows."),
1138
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Rows per page, 1..500, default 100."),
1139
+ pageNumber: z.number().int().min(1).default(1).describe("1-based page number, default 1."),
1140
+ });
1141
+ const TreasuryAvgInterestRatesInput = z.object({
1142
+ securityType: z
1143
+ .string()
1144
+ .optional()
1145
+ .describe("Optional exact security_type_desc filter (e.g. 'Marketable', 'Non-marketable', 'Interest-bearing Debt')."),
1146
+ latest: z
1147
+ .boolean()
1148
+ .default(true)
1149
+ .describe("true (default) ⇒ the most-recent month's full breakdown across security types (pinned to the latest record_date, memoized). false ⇒ the startDate/endDate range."),
1150
+ startDate: z.string().optional().describe("Range mode only: ISO YYYY-MM-DD lower bound on record_date (inclusive)."),
1151
+ endDate: z.string().optional().describe("Range mode only: ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1152
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Range mode: rows per page, 1..500, default 100."),
1153
+ pageNumber: z.number().int().min(1).default(1).describe("Range mode: 1-based page number, default 1."),
1154
+ });
1155
+ // ─── SEC EDGAR (keyless) — input schemas ─────────────────────────
1156
+ // ADR-0003. Keyless capital-markets source over data.sec.gov / efts.sec.gov.
1157
+ // The join key is the 10-digit SEC CIK (NOT SAM UEI/DUNS — see edgar.ts caveat).
1158
+ const EdgarLookupCikInput = z.object({
1159
+ query: z
1160
+ .string()
1161
+ .min(1)
1162
+ .describe("Company ticker (exact, case-insensitive) or a company-name substring to resolve to a 10-digit SEC CIK via company_tickers.json. e.g. 'AAPL' or 'apple'. Returns up to 50 matches (found:false on none)."),
1163
+ });
1164
+ const EdgarCompanyFilingsInput = z.object({
1165
+ cikOrTicker: z
1166
+ .string()
1167
+ .min(1)
1168
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'CIK0000320193', 'AAPL')."),
1169
+ forms: z
1170
+ .array(z.string())
1171
+ .optional()
1172
+ .describe("Optional form-type filter (e.g. ['10-K','10-Q','8-K']); case-insensitive exact match on the filing's form. Omit for all forms."),
1173
+ limit: z
1174
+ .number()
1175
+ .int()
1176
+ .min(1)
1177
+ .max(100)
1178
+ .default(20)
1179
+ .describe("Max filings to return, 1..100, default 20 (offset pagination over the recent window)."),
1180
+ offset: z
1181
+ .number()
1182
+ .int()
1183
+ .min(0)
1184
+ .default(0)
1185
+ .describe("0-based offset into the (form-filtered) recent filings, default 0."),
1186
+ fullHistory: z
1187
+ .boolean()
1188
+ .optional()
1189
+ .describe("When true, ALSO fetch the older filings.files[] submission shards (newest-first, up to maxShards) and assemble the COMPLETE filing history (recent ++ shard001..N, descending order preserved). Default false ⇒ recent window only (byte-identical to omitting it). A capped/failed fan-out is disclosed as PARTIAL — never a capped set claimed complete."),
1190
+ maxShards: z
1191
+ .number()
1192
+ .int()
1193
+ .min(1)
1194
+ .max(100)
1195
+ .optional()
1196
+ .describe("Fan-out cap when fullHistory is true: at most this many older shards are fetched (newest-first), 1..100, default 10. Bounds wall-time (each shard is one throttle-gated GET, serialized through the SHARED edgar gate) + payload. When totalShards > maxShards the response is PARTIAL-BY-CAP (hasMore:true; older un-fetched shards reached by RAISING maxShards, not by nextOffset). Ignored when fullHistory is false."),
1197
+ });
1198
+ const EdgarCompanyFactsInput = z.object({
1199
+ cikOrTicker: z
1200
+ .string()
1201
+ .min(1)
1202
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'AAPL')."),
1203
+ concepts: z
1204
+ .array(z.string())
1205
+ .optional()
1206
+ .describe("Optional XBRL us-gaap/dei concept tags to extract (e.g. ['Assets','NetIncomeLoss']). Default: the 6 curated USD concepts (Revenues/RevenueFromContractWithCustomerExcludingAssessedTax, Assets, Liabilities, StockholdersEquity, NetIncomeLoss, CashAndCashEquivalentsAtCarryingValue). A concept absent for the filer is OMITTED (never 0)."),
1207
+ unit: z
1208
+ .string()
1209
+ .default("USD")
1210
+ .describe("XBRL unit to extract, default 'USD'. A concept present only in another unit (e.g. EarningsPerShareBasic in 'USD/shares') is reported under wrongUnit with a note — never a silent 0."),
1211
+ latest: z
1212
+ .boolean()
1213
+ .default(false)
1214
+ .describe("true ⇒ reduce each concept to its single most-recent data point (by period end). false (default) ⇒ the full reported time series."),
1215
+ });
1216
+ // ADR-0017. Keyless cross-filer XBRL cross-section over data.sec.gov/api/xbrl/
1217
+ // frames. taxonomy/tag/unit/period are RAW PATH SEGMENTS, so the enum + the three
1218
+ // regexes below ARE the SSRF guard (re-run belt-and-suspenders in the handler's
1219
+ // URL builder). `limit`/`offset` window the already-fully-fetched cross-section
1220
+ // CLIENT-SIDE (they do NOT reduce the fetch). taxonomy enum = live-confirmed only.
1221
+ const EdgarXbrlFramesInput = z.object({
1222
+ tag: z
1223
+ .string()
1224
+ .regex(/^[A-Za-z0-9]+$/, "tag must be alphanumeric only — an EXACT XBRL concept tag (e.g. 'Assets', 'Revenues'). Slash/dot/space/percent/'..' are rejected (path-segment injection guard).")
1225
+ .describe("XBRL concept tag — EXACT, alphanumeric only (e.g. 'Assets', 'Revenues', 'NetIncomeLoss', 'EarningsPerShareBasic'). A non-matching tag ⇒ upstream 404 ⇒ found:false (never a fabricated 0)."),
1226
+ period: z
1227
+ .string()
1228
+ .regex(/^CY\d{4}(Q[1-4]I?)?$/, "period must be CY{yyyy} (annual flow, e.g. CY2023), CY{yyyy}Q{n} (quarterly flow, e.g. CY2023Q1), or CY{yyyy}Q{n}I (instant, trailing I, e.g. CY2023Q4I).")
1229
+ .describe("Calendar period frame: CY2023 (annual flow) · CY2023Q1 (quarterly flow, no I) · CY2023Q4I (instant / balance-sheet, trailing I). Instant concepts (e.g. Assets) REQUIRE the trailing I; a mismatch ⇒ 404 ⇒ found:false."),
1230
+ taxonomy: z
1231
+ .enum(edgar.FRAMES_TAXONOMIES)
1232
+ .default("us-gaap")
1233
+ .describe("XBRL taxonomy namespace (a fixed enum — the SSRF guard for this segment): 'us-gaap' (financial statements, default) or 'dei' (entity/document info, e.g. EntityCommonStockSharesOutstanding, EntityPublicFloat). Live-confirmed members only."),
1234
+ unit: z
1235
+ .string()
1236
+ .regex(/^[A-Za-z0-9-]+$/, "unit must match ^[A-Za-z0-9-]+$ — hyphen allowed (e.g. 'USD-per-shares'); slash/dot/percent forbidden (never the 'USD/shares' companyfacts key form).")
1237
+ .default("USD")
1238
+ .describe("XBRL unit of measure, as a path segment: 'USD' (default), 'shares', 'USD-per-shares' (EPS — HYPHEN, never 'USD/shares'), 'pure'. A valid-shaped but wrong unit ⇒ 404 ⇒ found:false."),
1239
+ limit: z
1240
+ .number()
1241
+ .int()
1242
+ .min(1)
1243
+ .max(1000)
1244
+ .default(100)
1245
+ .describe("CLIENT-SIDE page size over the already-fully-fetched cross-section (1..1000, default 100). Does NOT reduce the upstream fetch — the whole frame is fetched in one call; this only windows the returned rows (page via _meta.pagination.nextOffset)."),
1246
+ offset: z
1247
+ .number()
1248
+ .int()
1249
+ .min(0)
1250
+ .default(0)
1251
+ .describe("0-based client-side offset into the fetched cross-section (default 0). Page via _meta.pagination.nextOffset to reach every filer."),
1252
+ includeStats: z
1253
+ .boolean()
1254
+ .default(false)
1255
+ .describe("When true, compute a summary distribution { count, min, max, sum, mean, median, p25, p75, nonFiniteExcluded } over the FULL cross-section (ALL rows, BEFORE the client-side slice), using linear-interpolated percentiles over the FINITE vals only. count===0 (no finite vals) ⇒ every stat is null (never 0/NaN/Infinity)."),
1256
+ });
1257
+ const EdgarFullTextSearchInput = z.object({
1258
+ q: z
1259
+ .string()
1260
+ .min(1)
1261
+ .describe("Full-text query over EDGAR filings (2001-present). Wrap a phrase in double quotes for an exact match (e.g. '\"climate risk\"')."),
1262
+ forms: z
1263
+ .array(z.string())
1264
+ .optional()
1265
+ .describe("Optional form-type filter (e.g. ['10-K','8-K'])."),
1266
+ startdt: z
1267
+ .string()
1268
+ .optional()
1269
+ .describe("Optional ISO YYYY-MM-DD filing-date lower bound (sets dateRange=custom)."),
1270
+ enddt: z
1271
+ .string()
1272
+ .optional()
1273
+ .describe("Optional ISO YYYY-MM-DD filing-date upper bound (sets dateRange=custom)."),
1274
+ ciks: z
1275
+ .array(z.string())
1276
+ .max(50)
1277
+ .optional()
1278
+ .describe("Optional: pin filings BY one or more entities, by NUMERIC SEC CIK (each is zero-padded to 10 digits — an EXACT-entity match). Multiple CIKs are AND-of-OR (any of the listed entities). A ticker/company name / CIK-0 entry is rejected as invalid_input — use `entityName` or resolve the CIK first with edgar_lookup_cik."),
1279
+ entityName: z
1280
+ .string()
1281
+ .min(1)
1282
+ .max(200)
1283
+ .optional()
1284
+ .describe("Optional FUZZY filer-name narrowing (matches the filer's display name; NOT CIK-exact — can match related entities, e.g. multiple 'Apple*' filers). Combine with `ciks` for an exact-entity result."),
1285
+ from: z
1286
+ .number()
1287
+ .int()
1288
+ .min(0)
1289
+ .default(0)
1290
+ .describe("0-based result offset for pagination; page size is FIXED at 100 (there is no size param). Must be <= 9900 (from+100 ≤ 10000 upstream window); a larger from is rejected as invalid_input."),
1291
+ });
1292
+ // ADR-0026. Keyless BULK cross-filer capability on the EXISTING edgar source: the
1293
+ // SEC EDGAR quarterly full-index (www.sec.gov/Archives/edgar/full-index/<year>/
1294
+ // QTR<n>/master.idx). year/quarter are the ONLY path segments (bounded integers —
1295
+ // the SSRF guard, re-checked belt-and-suspenders pre-fetch in edgar's
1296
+ // buildFullIndexUrl). ALL other params are CLIENT-SIDE filters over the whole
1297
+ // downloaded body (ZERO query string reaches the wire). The year UPPER bound
1298
+ // (≤ current UTC year) is enforced at CALL time in the handler, NOT baked into Zod,
1299
+ // so the tools/list snapshot stays deterministic across a year rollover.
1300
+ const EdgarFilingIndexInput = z.object({
1301
+ year: z
1302
+ .number()
1303
+ .int()
1304
+ .min(1993)
1305
+ .describe("Filing year (>= 1993 — EDGAR full-index begins 1993 Q1). Must be <= the current year; a future year is rejected as invalid_input with 0 fetch. Path segment."),
1306
+ quarter: z
1307
+ .number()
1308
+ .int()
1309
+ .min(1)
1310
+ .max(4)
1311
+ .describe("Calendar quarter 1..4 (path segment QTR<quarter>). A same-year FUTURE quarter returns a well-formed EMPTY result (genuine-empty, complete:true), NOT an error."),
1312
+ formType: z
1313
+ .string()
1314
+ .min(1)
1315
+ .max(30)
1316
+ .optional()
1317
+ .describe("Optional CLIENT-SIDE filter: case-insensitive EXACT match on the Form Type column (e.g. '8-K', '10-K'). '8-K' does NOT match '8-K/A' — pass each amendment variant separately."),
1318
+ cik: z
1319
+ .union([z.string().regex(/^\d{1,10}$/), z.number().int().nonnegative()])
1320
+ .optional()
1321
+ .describe("Optional CLIENT-SIDE filter: numeric SEC CIK (1-10 digits or a number), matched leading-zero-safe via padCik on both sides (so '320193' and '0000320193' match the same filer)."),
1322
+ companyContains: z
1323
+ .string()
1324
+ .min(1)
1325
+ .max(200)
1326
+ .optional()
1327
+ .describe("Optional CLIENT-SIDE filter: case-insensitive LITERAL substring on the Company Name column. A multi-word value matches as ONE contiguous string (NOT AND/OR-tokenized)."),
1328
+ dateFrom: z
1329
+ .string()
1330
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1331
+ .optional()
1332
+ .describe("Optional CLIENT-SIDE filter: keep filings whose Date Filed >= this ISO YYYY-MM-DD (string compare; the column is already YYYY-MM-DD)."),
1333
+ dateTo: z
1334
+ .string()
1335
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1336
+ .optional()
1337
+ .describe("Optional CLIENT-SIDE filter: keep filings whose Date Filed <= this ISO YYYY-MM-DD."),
1338
+ limit: z
1339
+ .number()
1340
+ .int()
1341
+ .min(1)
1342
+ .max(1000)
1343
+ .default(100)
1344
+ .describe("Page size over the FILTERED, full-scanned matches (1..1000, default 100). Does NOT reduce the download — the whole quarter is scanned; this only windows the returned rows (page via _meta.pagination.nextOffset)."),
1345
+ offset: z
1346
+ .number()
1347
+ .int()
1348
+ .min(0)
1349
+ .default(0)
1350
+ .describe("0-based offset into the filtered matches (default 0)."),
1351
+ });
1352
+ // ─── EDGAR daily-index (ADR-0027) — input schema ─────────────────
1353
+ // The per-DAY sibling of edgar_filing_index. A single required ISO `date`
1354
+ // (YYYY-MM-DD) is the ONLY caller value that shapes the URL — the handler derives
1355
+ // year/quarter/yyyymmdd from it and re-validates them belt-and-suspenders in
1356
+ // edgar's buildDailyIndexUrl. dateFrom/dateTo are DROPPED (a single-day file has one
1357
+ // date). ALL other params are CLIENT-SIDE filters over the whole downloaded body
1358
+ // (ZERO query string reaches the wire). The date's EXACT calendar-day round-trip +
1359
+ // the future-date rejection are enforced at CALL time in the handler (M2), so the
1360
+ // tools/list snapshot stays deterministic across a day rollover.
1361
+ const EdgarDailyFilingIndexInput = z.object({
1362
+ date: z
1363
+ .string()
1364
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1365
+ .describe("Required calendar day ISO YYYY-MM-DD (>= 1994-01-01 — EDGAR daily-index begins 1994 Q1). The handler derives year/quarter/yyyymmdd. A malformed / non-real day (2024-02-30, non-leap 2023-02-29) or a FUTURE date is rejected as invalid_input with 0 fetch. TODAY is allowed (its index may not be posted until ~22:00 US-Eastern)."),
1366
+ formType: z
1367
+ .string()
1368
+ .min(1)
1369
+ .max(30)
1370
+ .optional()
1371
+ .describe("Optional CLIENT-SIDE filter: case-insensitive EXACT match on the Form Type column (e.g. '8-K', '10-K'). '8-K' does NOT match '8-K/A' — pass each amendment variant separately."),
1372
+ cik: z
1373
+ .union([z.string().regex(/^\d{1,10}$/), z.number().int().nonnegative()])
1374
+ .optional()
1375
+ .describe("Optional CLIENT-SIDE filter: numeric SEC CIK (1-10 digits or a number), matched leading-zero-safe via padCik on both sides (so '320193' and '0000320193' match the same filer)."),
1376
+ companyContains: z
1377
+ .string()
1378
+ .min(1)
1379
+ .max(200)
1380
+ .optional()
1381
+ .describe("Optional CLIENT-SIDE filter: case-insensitive LITERAL substring on the Company Name column. A multi-word value matches as ONE contiguous string (NOT AND/OR-tokenized)."),
1382
+ limit: z
1383
+ .number()
1384
+ .int()
1385
+ .min(1)
1386
+ .max(1000)
1387
+ .default(100)
1388
+ .describe("Page size over the FILTERED, full-scanned matches (1..1000, default 100). Does NOT reduce the download — the whole day is scanned; this only windows the returned rows (page via _meta.pagination.nextOffset)."),
1389
+ offset: z
1390
+ .number()
1391
+ .int()
1392
+ .min(0)
1393
+ .default(0)
1394
+ .describe("0-based offset into the filtered matches (default 0)."),
1395
+ });
1396
+ // ADR-0041. Keyless WITHIN-SOURCE DEPTH on the existing edgar source: one filer ×
1397
+ // one XBRL concept × the COMPLETE reported time-series (data.sec.gov/api/xbrl/
1398
+ // companyconcept/CIK{cik10}/{taxonomy}/{Concept}.json). cik (via resolveCik→padCik) +
1399
+ // taxonomy (enum) + concept (alnum regex) are the THREE validated PATH SEGMENTS (the
1400
+ // SSRF guard, re-checked belt-and-suspenders pre-fetch in edgar's buildConceptUrl).
1401
+ // `unit` is a BODY key filtered CLIENT-SIDE (NOT a path segment — no unit regex);
1402
+ // unit/form/fy/canonicalOnly/limit/offset are all client-side over the fully-fetched set.
1403
+ const EdgarCompanyConceptInput = z.object({
1404
+ cikOrTicker: z
1405
+ .string()
1406
+ .min(1)
1407
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'CIK0000320193', 'AAPL')."),
1408
+ concept: z
1409
+ .string()
1410
+ .regex(/^[A-Za-z0-9]+$/, "concept must be alphanumeric only — an EXACT XBRL tag (e.g. 'Assets', 'Revenues', 'NetIncomeLoss'). Slash/dot/space/percent/'..' are rejected (path-segment injection guard).")
1411
+ .describe("XBRL concept tag — EXACT, alphanumeric CamelCase (e.g. 'Assets', 'Revenues', 'NetIncomeLoss', 'Liabilities'). A tag the filer never reported ⇒ upstream 404 ⇒ found:false (never a fabricated 0)."),
1412
+ taxonomy: z
1413
+ .enum(edgar.CONCEPT_TAXONOMIES)
1414
+ .default("us-gaap")
1415
+ .describe("XBRL taxonomy namespace (a fixed enum — the SSRF guard for this segment): 'us-gaap' (financial statements, default), 'dei' (entity/document info, e.g. EntityCommonStockSharesOutstanding), or 'ifrs-full' (IFRS filers, e.g. a foreign private issuer). Live-confirmed members only."),
1416
+ unit: z
1417
+ .string()
1418
+ .min(1)
1419
+ .max(40)
1420
+ .optional()
1421
+ .describe("Optional CLIENT-SIDE filter on the returned units{} keys (NOT a path segment — 'USD', 'shares', 'USD/shares', 'EUR', 'pure'). Restricts rows to that unit but STILL discloses the other units via unitsAvailable + a note. A unit not present ⇒ 0 rows + the available-units note (never a fabricated pick)."),
1422
+ form: z
1423
+ .string()
1424
+ .min(1)
1425
+ .max(30)
1426
+ .optional()
1427
+ .describe("Optional CLIENT-SIDE filter: case-insensitive EXACT match on a row's `form` (e.g. '10-K' for annual values only, '10-Q' for quarterly)."),
1428
+ fy: z
1429
+ .number()
1430
+ .int()
1431
+ .optional()
1432
+ .describe("Optional CLIENT-SIDE filter: keep only rows whose fiscal year `fy` equals this integer (e.g. 2023)."),
1433
+ canonicalOnly: z
1434
+ .boolean()
1435
+ .default(false)
1436
+ .describe("When true, reduce to ONE row per distinct (unit,start,end) period — the frame-tagged canonical value, or (for a not-yet-consolidated period) the latest-filed row (marked canonical:false). SUPERSEDED/amendment rows are REMOVED (fully disclosed via a note). Default false ⇒ ALL rows incl. the amendment/restatement history. A same-`end` different-`start` pair is a DIFFERENT period (both kept), NOT a duplicate."),
1437
+ limit: z
1438
+ .number()
1439
+ .int()
1440
+ .min(1)
1441
+ .max(1000)
1442
+ .default(100)
1443
+ .describe("CLIENT-SIDE page size over the already-fully-fetched, (unit,start,end)-keyed time-series (1..1000, default 100). Does NOT reduce the upstream fetch (SEC does not paginate companyconcept); page via _meta.pagination.nextOffset."),
1444
+ offset: z
1445
+ .number()
1446
+ .int()
1447
+ .min(0)
1448
+ .default(0)
1449
+ .describe("0-based client-side offset into the filtered time-series (default 0)."),
1450
+ });
1451
+ // ─── Socrata / SODA (keyless SLED + E-rate) — input schemas ──────
1452
+ // ADR-0004. First SLED source. `domain` is a curated allowlist ENUM (the SSRF
1453
+ // core — no free host); `datasetId` is a strict 4x4 with .length(9) (M2 — blocks
1454
+ // a trailing-newline the regex `$` would admit). SoQL params are raw upstream-
1455
+ // validated strings (a bad column ⇒ upstream 400 ⇒ invalid_input, surfaced).
1456
+ const SocrataDomainEnum = z
1457
+ .enum(socrata.SOCRATA_DOMAINS)
1458
+ .describe("Which allowlisted Socrata portal to query (curated .gov hosts + USAC E-rate .org; the SSRF host allowlist — no free host). e.g. data.ny.gov, data.texas.gov, data.wa.gov, opendata.usac.org.");
1459
+ const SocrataQueryInput = z.object({
1460
+ domain: SocrataDomainEnum,
1461
+ datasetId: z
1462
+ // M2 — a strict 4x4. NOTE: deliberately NO .trim(): Zod applies .trim()
1463
+ // BEFORE .length(9), so `.trim().length(9)` (the ADR's literal wording)
1464
+ // would STRIP a trailing "\n" to a valid 9-char id and ACCEPT it —
1465
+ // empirically confirmed — defeating the very newline rejection M2 wants.
1466
+ // Dropping .trim() makes .length(9) see the raw string, so any trailing
1467
+ // char (incl. "\n", which the regex `$` alone would admit) is rejected.
1468
+ .string()
1469
+ .length(9)
1470
+ .regex(/^[a-z0-9]{4}-[a-z0-9]{4}$/)
1471
+ .describe("The dataset's Socrata 4x4 id, e.g. 'kwxv-fwze' (from socrata_discover_datasets). Exactly [a-z0-9]{4}-[a-z0-9]{4} (9 chars; no surrounding whitespace)."),
1472
+ select: z
1473
+ .string()
1474
+ .optional()
1475
+ .describe("Optional SoQL $select (column projection / aggregate), e.g. 'agency,SUM(amount)'."),
1476
+ where: z
1477
+ .string()
1478
+ .optional()
1479
+ .describe("Optional SoQL $where filter, e.g. \"fiscal_year='2024' AND amount>1000\". A bad column ⇒ upstream HTTP 400 ⇒ invalid_input (surfaced, never silent)."),
1480
+ order: z
1481
+ .string()
1482
+ .optional()
1483
+ .describe("Optional SoQL $order, e.g. 'amount DESC'."),
1484
+ q: z
1485
+ .string()
1486
+ .optional()
1487
+ .describe("Optional SoQL $q full-text search across the row."),
1488
+ limit: z
1489
+ .number()
1490
+ .int()
1491
+ .min(1)
1492
+ .max(1000)
1493
+ .default(100)
1494
+ .describe("Rows per page ($limit), 1..1000, default 100."),
1495
+ offset: z
1496
+ .number()
1497
+ .int()
1498
+ .min(0)
1499
+ .default(0)
1500
+ .describe("0-based row offset ($offset) for pagination, default 0."),
1501
+ withTotal: z
1502
+ .boolean()
1503
+ .default(true)
1504
+ .describe("true (default) ⇒ issue a count(*) companion query so totalAvailable is exact. false ⇒ skip it (one fewer request); totalAvailable is null and a note discloses results may be truncated at $limit."),
1505
+ });
1506
+ const SocrataDiscoverDatasetsInput = z.object({
1507
+ q: z
1508
+ .string()
1509
+ .min(1)
1510
+ .describe("Keyword(s) to find datasets, e.g. 'procurement', 'vendor payments', 'checkbook'."),
1511
+ domain: SocrataDomainEnum.optional().describe("Optional: scope discovery to ONE allowlisted portal. Omit to search the whole allowlist. NOTE: the federated catalog does not index every host (e.g. USAC E-rate returns 0) — those remain queryable via socrata_query with a known 4x4."),
1512
+ limit: z
1513
+ .number()
1514
+ .int()
1515
+ .min(1)
1516
+ .max(100)
1517
+ .default(20)
1518
+ .describe("Max datasets to return, 1..100, default 20."),
1519
+ });
1520
+ // ─── CKAN datastore_search (keyless SLED) — input schemas ───────
1521
+ // ADR-0006. Second SLED source; FIRST on the R2 DataSource port. `host` is a
1522
+ // curated allowlist ENUM (the SSRF core — no free host); `resourceId` is a strict
1523
+ // 36-char lowercase UUID with .length(36) (m1 — the regex `$` already blocks a
1524
+ // trailing "\n"; length is belt-and-suspenders; NO .trim(), NO `i` flag). `filters`
1525
+ // is a CONSTRAINED object we JSON.stringify (Q3 — never a raw string). A bad
1526
+ // filter field / sort ⇒ upstream 409 ⇒ invalid_input (surfaced, never silent).
1527
+ const CkanHostEnum = z
1528
+ .enum(ckan.CKAN_HOSTS)
1529
+ .describe("Which allowlisted CKAN portal to query (curated .gov hosts — the SSRF host allowlist, no free host): data.ca.gov (CA), data.virginia.gov (VA — eVA), data.boston.gov (City of Boston Checkbook).");
1530
+ const CkanQueryInput = z.object({
1531
+ host: CkanHostEnum,
1532
+ resourceId: z
1533
+ // m1 — a strict 36-char lowercase UUID. NO .trim() (Zod trims BEFORE
1534
+ // .length, which would strip a trailing "\n" to a valid id and defeat the
1535
+ // guard); the regex `$` alone rejects a trailing newline in JS, and
1536
+ // .length(36) is belt-and-suspenders. Lowercase-only ⇒ no `i` flag.
1537
+ .string()
1538
+ .length(36)
1539
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1540
+ .describe("The datastore resource_id, a 36-char lowercase UUID e.g. 'bb82edc5-9c78-44e2-8947-68ece26197c5' (from ckan_discover_datasets, a datastoreActive:true resource)."),
1541
+ q: z
1542
+ .string()
1543
+ .optional()
1544
+ .describe("Optional full-text search across the record (CKAN `q`)."),
1545
+ filters: z
1546
+ .record(z.string(), z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]))
1547
+ .optional()
1548
+ .describe("Optional structured field filters, e.g. {\"Fiscal Year\":\"2013-2014\"}. A constrained object (string/number/array values only) that we JSON.stringify; a bad field ⇒ upstream HTTP 409 ⇒ invalid_input (surfaced, never silent)."),
1549
+ sort: z
1550
+ .string()
1551
+ .optional()
1552
+ .describe("Optional sort, e.g. '_id asc' or 'amount desc'. A bad field ⇒ 409 ⇒ invalid_input."),
1553
+ limit: z
1554
+ .number()
1555
+ .int()
1556
+ .min(1)
1557
+ .max(1000)
1558
+ .default(100)
1559
+ .describe("Rows per page, 1..1000, default 100."),
1560
+ offset: z
1561
+ .number()
1562
+ .int()
1563
+ .min(0)
1564
+ .default(0)
1565
+ .describe("0-based row offset for pagination, default 0."),
1566
+ });
1567
+ const CkanDiscoverDatasetsInput = z.object({
1568
+ host: CkanHostEnum,
1569
+ q: z
1570
+ .string()
1571
+ .min(1)
1572
+ .describe("Keyword(s) to find datasets, e.g. 'procurement', 'checkbook', 'vendor'."),
1573
+ limit: z
1574
+ .number()
1575
+ .int()
1576
+ .min(1)
1577
+ .max(100)
1578
+ .default(20)
1579
+ .describe("Max datasets (packages) to return, 1..100, default 20."),
1580
+ });
1581
+ // ─── FDIC BankFind Suite (keyless institution directory + financials) ──────────
1582
+ // ADR-0028. First OFF-EDGAR entity source. KEYLESS, fixed host api.fdic.gov +
1583
+ // FIXED endpoint constants (the SSRF core — no free host/path). Structured
1584
+ // inputs → a server-side filters/search builder (NAME/CITY route through FDIC's
1585
+ // full-text `search`, NOT `filters` — M1). name/city char-class is a sanity
1586
+ // boundary; the injection defense is the module's backslash-first phrase-escape
1587
+ // (M2). sortBy is a Zod enum + a Set.has recheck in the builder (an unknown sort
1588
+ // field is invalid_input before fetch).
1589
+ // name/city char-class — includes `( ) #` so real bank names like 'Mizuho Bank
1590
+ // (USA)' are NOT false-rejected (S2); these are literal inside the phrase-quoted
1591
+ // + escaped `search` value, never ES metachars there.
1592
+ const FdicNameCity = z
1593
+ .string()
1594
+ .regex(/^[A-Za-z0-9 .,&'\/()#\-]+$/)
1595
+ .describe("Free-text bank name / city fragment (letters, digits, spaces and . , & ' / ( ) # -). Matched by FDIC's case-insensitive full-text `search` (token match; a multi-word value is matched per-token, may be broader than a literal substring).");
1596
+ const FdicSearchInstitutionsInput = z.object({
1597
+ state: z
1598
+ .string()
1599
+ .regex(/^[A-Z]{2}$/)
1600
+ .optional()
1601
+ .describe("Filter by 2-letter US state code (uppercase; → STALP filter). e.g. 'VA'."),
1602
+ activeOnly: z
1603
+ .boolean()
1604
+ .optional()
1605
+ .describe("Filter to active (true → ACTIVE:1) or inactive (false → ACTIVE:0) institutions; omit for both."),
1606
+ cert: z
1607
+ .number()
1608
+ .int()
1609
+ .min(1)
1610
+ .optional()
1611
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter)."),
1612
+ name: FdicNameCity.optional().describe("Filter by institution NAME via FDIC full-text `search` (case-insensitive token match; NOT case-sensitive exact-keyword — that is why we route to `search`, not `filters`)."),
1613
+ city: FdicNameCity.optional().describe("Filter by CITY via FDIC full-text `search` (case-insensitive token match)."),
1614
+ limit: z
1615
+ .number()
1616
+ .int()
1617
+ .min(1)
1618
+ .max(1000)
1619
+ .default(100)
1620
+ .describe("Rows per page, 1..1000, default 100."),
1621
+ offset: z
1622
+ .number()
1623
+ .int()
1624
+ .min(0)
1625
+ .max(100000)
1626
+ .default(0)
1627
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1628
+ sortBy: z
1629
+ .enum(["NAME", "CERT", "ASSET", "ESTYMD", "STALP", "CITY", "ACTIVE"])
1630
+ .optional()
1631
+ .describe("Optional sort field (an allowlisted enum; an unknown field is rejected before fetch)."),
1632
+ sortOrder: z
1633
+ .enum(["ASC", "DESC"])
1634
+ .default("ASC")
1635
+ .describe("Sort direction when sortBy is set, ASC (default) or DESC."),
1636
+ });
1637
+ const FdicInstitutionFinancialsInput = z.object({
1638
+ cert: z
1639
+ .number()
1640
+ .int()
1641
+ .min(1)
1642
+ .describe("REQUIRED FDIC certificate number of the institution (→ CERT filter). From fdic_search_institutions."),
1643
+ limit: z
1644
+ .number()
1645
+ .int()
1646
+ .min(1)
1647
+ .max(1000)
1648
+ .default(100)
1649
+ .describe("Rows per page, 1..1000, default 100."),
1650
+ offset: z
1651
+ .number()
1652
+ .int()
1653
+ .min(0)
1654
+ .max(100000)
1655
+ .default(0)
1656
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1657
+ sortBy: z
1658
+ .enum(["REPDTE", "ASSET", "DEP", "NETINC"])
1659
+ .default("REPDTE")
1660
+ .describe("Sort field (allowlisted enum; default REPDTE = report date)."),
1661
+ sortOrder: z
1662
+ .enum(["ASC", "DESC"])
1663
+ .default("DESC")
1664
+ .describe("Sort direction, default DESC (newest quarter first)."),
1665
+ });
1666
+ // ADR-0029 — the 3rd FDIC tool (source 23 unchanged; snapshot 89→90) reading
1667
+ // /banks/failures. v2 (cycle-33) live review: the state field is PSTALP (NOT
1668
+ // STALP — a false-empty landmine), and /failures IGNORES the `search` param (a
1669
+ // name/city query floods the whole dataset) → NO name/city filter here; name
1670
+ // lookup is the 2-step CERT linkage via fdic_search_institutions.
1671
+ const FdicBankFailuresInput = z.object({
1672
+ state: z
1673
+ .string()
1674
+ .regex(/^[A-Z]{2}$/)
1675
+ .optional()
1676
+ .describe("Filter by 2-letter US state code (uppercase; → PSTALP filter — the /failures state field is PSTALP, NOT STALP). e.g. 'CA'."),
1677
+ failYear: z
1678
+ .number()
1679
+ .int()
1680
+ .min(1934)
1681
+ .max(new Date().getUTCFullYear())
1682
+ .optional()
1683
+ .describe("Filter by year of failure (→ FAILYR filter). 1934..current UTC year. e.g. 2023 → the 5 real 2023 failures (Silicon Valley Bank, Signature Bank, First Republic Bank, Heartland Tri-State Bank, Citizens Bank)."),
1684
+ cert: z
1685
+ .number()
1686
+ .int()
1687
+ .min(1)
1688
+ .optional()
1689
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter). Resolve a bank's CERT via fdic_search_institutions."),
1690
+ limit: z
1691
+ .number()
1692
+ .int()
1693
+ .min(1)
1694
+ .max(1000)
1695
+ .default(100)
1696
+ .describe("Rows per page, 1..1000, default 100."),
1697
+ offset: z
1698
+ .number()
1699
+ .int()
1700
+ .min(0)
1701
+ .max(100000)
1702
+ .default(0)
1703
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1704
+ sortBy: z
1705
+ .enum(["FAILDATE", "COST", "QBFASSET", "QBFDEP", "NAME", "FAILYR"])
1706
+ .default("FAILDATE")
1707
+ .describe("Sort field (allowlisted enum; default FAILDATE = failure date). An unknown field is rejected before fetch."),
1708
+ sortOrder: z
1709
+ .enum(["ASC", "DESC"])
1710
+ .default("DESC")
1711
+ .describe("Sort direction, default DESC (most-recent failures first)."),
1712
+ });
1713
+ // ADR-0030 — the 4th FDIC tool (source 23 unchanged; snapshot 90→91) reading
1714
+ // /banks/history, the institution-level STRUCTURAL-CHANGE event log (mergers,
1715
+ // absorptions, failures, name/location/charter/regulator changes, branch open/close,
1716
+ // etc.) — completing the FDIC entity cluster. Live review: the state field is PSTALP
1717
+ // (NOT STALP — a false-empty landmine), and /history's `search` param returns 0 for
1718
+ // INSTNAME (a false-empty) → NO name/city filter; name lookup is the 2-step CERT
1719
+ // linkage via fdic_search_institutions. All inputs optional, AND-combined; `cert` is
1720
+ // the primary path.
1721
+ const FdicInstitutionHistoryInput = z.object({
1722
+ cert: z
1723
+ .number()
1724
+ .int()
1725
+ .min(1)
1726
+ .optional()
1727
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter — the PRIMARY lookup). Resolve a bank's CERT via fdic_search_institutions. e.g. 3510 → Bank of America's 13,794-row structural-change history."),
1728
+ changeCode: z
1729
+ .number()
1730
+ .int()
1731
+ .min(1)
1732
+ .optional()
1733
+ .describe("Filter by FDIC structural-change code (→ CHANGECODE filter). e.g. 223 = Merger (Without Assistance), 211 = Failure (Whole Institution), 721 = Branch Closing, 520 = Change in Physical Location, 110 = New Institution. Each row also carries FDIC's own changeDescription (CHANGECODE_DESC)."),
1734
+ effYear: z
1735
+ .number()
1736
+ .int()
1737
+ .min(1782)
1738
+ .max(new Date().getUTCFullYear())
1739
+ .optional()
1740
+ .describe("Filter by the year the structural change took effect (→ EFFYEAR filter). 1782..current UTC year (1782 = the oldest observed EFFYEAR)."),
1741
+ state: z
1742
+ .string()
1743
+ .regex(/^[A-Z]{2}$/)
1744
+ .optional()
1745
+ .describe("Filter by 2-letter US state code (uppercase; → PSTALP filter — the /history state field is PSTALP, NOT STALP). e.g. 'CA'."),
1746
+ limit: z
1747
+ .number()
1748
+ .int()
1749
+ .min(1)
1750
+ .max(1000)
1751
+ .default(100)
1752
+ .describe("Rows per page, 1..1000, default 100."),
1753
+ offset: z
1754
+ .number()
1755
+ .int()
1756
+ .min(0)
1757
+ .max(100000)
1758
+ .default(0)
1759
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1760
+ sortBy: z
1761
+ .enum(["EFFDATE", "PROCDATE", "CHANGECODE", "TRANSNUM"])
1762
+ .default("EFFDATE")
1763
+ .describe("Sort field (allowlisted enum; default EFFDATE = effective date). An unknown field is rejected before fetch."),
1764
+ sortOrder: z
1765
+ .enum(["ASC", "DESC"])
1766
+ .default("DESC")
1767
+ .describe("Sort direction, default DESC (newest structural change first)."),
1768
+ });
1769
+ // ADR-0031 — the 5th FDIC tool (source 23 unchanged; snapshot 91→92) reading
1770
+ // /banks/summary, the FDIC's OWN aggregate/statistical roll-ups (the FIRST
1771
+ // AGGREGATE tool on this source; the 4 existing are per-ENTITY on CERT). Live
1772
+ // review: the state field is STALP (NOT PSTALP — a per-endpoint difference), the
1773
+ // number-of-institutions field is BANKS (NOT NUMINST), /summary serves NO ratio
1774
+ // fields, and NIM is net interest INCOME in $thousands (NOT the margin ratio). Each
1775
+ // row crosses charter (CB_SI) × geography (STALP), where STALP ∈ {USA,US,OT,PI} are
1776
+ // geographic ROLL-UPS surfaced via a derived scope/isRollup so a roll-up never
1777
+ // masquerades as a state. NO name/city filter (the `search` param is a no-op). A
1778
+ // non-int year is rejected pre-fetch (a malformed year is a live HTTP-200 total:0
1779
+ // false-empty). All inputs optional, AND-combined.
1780
+ const FdicIndustrySummaryInput = z.object({
1781
+ year: z
1782
+ .number()
1783
+ .int()
1784
+ .min(1934)
1785
+ .max(new Date().getUTCFullYear())
1786
+ .optional()
1787
+ .describe("Filter by aggregate YEAR (→ YEAR filter). 1934..current UTC year. e.g. 2023 → the 121 (charter × geography) aggregate rows for 2023. A non-int is rejected pre-fetch (a malformed year is a live HTTP-200 total:0 false-empty)."),
1788
+ state: z
1789
+ .string()
1790
+ .regex(/^[A-Z]{2,3}$/)
1791
+ .optional()
1792
+ .describe("Filter by geography via the STALP code (uppercase 2-or-3 letters; → STALP filter — the /summary state field is STALP, NOT PSTALP). Accepts a jurisdiction USPS code (TX, CA, DC, GU, PR…) OR a ROLL-UP code: USA (all states+territories), US (states+DC), OT (all territories), PI (Pacific Islands). The output scope/isRollup disambiguates every returned row."),
1793
+ charterClass: z
1794
+ .enum(["CB", "SI"])
1795
+ .optional()
1796
+ .describe("Filter by charter class (→ CB_SI filter): CB = commercial banks, SI = savings institutions. Omit to return BOTH charter rows for the geography — there is NO pre-combined 'all institutions' row (a geography's total = its CB row + its SI row)."),
1797
+ limit: z
1798
+ .number()
1799
+ .int()
1800
+ .min(1)
1801
+ .max(1000)
1802
+ .default(100)
1803
+ .describe("Rows per page, 1..1000, default 100."),
1804
+ offset: z
1805
+ .number()
1806
+ .int()
1807
+ .min(0)
1808
+ .max(100000)
1809
+ .default(0)
1810
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1811
+ sortBy: z
1812
+ .enum(["YEAR", "ASSET", "DEP", "NETINC", "BANKS"])
1813
+ .default("YEAR")
1814
+ .describe("Sort field (allowlisted enum; default YEAR = aggregate year). An unknown field is rejected before fetch."),
1815
+ sortOrder: z
1816
+ .enum(["ASC", "DESC"])
1817
+ .default("DESC")
1818
+ .describe("Sort direction, default DESC (newest year / largest first)."),
1819
+ });
1820
+ // ADR-0040 — the 6th & 7th FDIC tools (source 30 unchanged; snapshot 103→105) —
1821
+ // WITHIN-SOURCE DEPTH on the already-wired src/fdic.ts adapter. (6) fdic_risk_ratios
1822
+ // projects the curated counterparty RISK-RATIO catalog on the ALREADY-wired
1823
+ // /banks/financials endpoint (per-field units in the output key; percent ratios
1824
+ // verbatim via num, tier-1 capital ×1000; ★M1 the CBLR RBCRWAJ=0 sentinel → null via
1825
+ // FDIC's CBLRIND flag + a per-row cblrFramework, never a false 0% capital). (7)
1826
+ // fdic_branch_deposits reads /banks/sod (Summary of Deposits) — ONE new fixed endpoint
1827
+ // constant. Both keyed on the numeric CERT; C118-quoted state; NO name/city search.
1828
+ const FdicRiskRatiosInput = z.object({
1829
+ cert: z
1830
+ .number()
1831
+ .int()
1832
+ .min(1)
1833
+ .describe("REQUIRED FDIC certificate number of the institution (→ CERT filter). From fdic_search_institutions."),
1834
+ reportDate: z
1835
+ .number()
1836
+ .int()
1837
+ .min(19000101)
1838
+ .max(29991231)
1839
+ .optional()
1840
+ .describe("Optional report date (→ REPDTE filter), a quarter-end as a YYYYMMDD integer (e.g. 20240630). Omit for the full quarterly ratio time-series."),
1841
+ limit: z
1842
+ .number()
1843
+ .int()
1844
+ .min(1)
1845
+ .max(1000)
1846
+ .default(100)
1847
+ .describe("Rows per page, 1..1000, default 100."),
1848
+ offset: z
1849
+ .number()
1850
+ .int()
1851
+ .min(0)
1852
+ .max(100000)
1853
+ .default(0)
1854
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1855
+ sortBy: z
1856
+ .enum(["REPDTE", "ROA", "ROE", "RBCRWAJ", "EEFFR"])
1857
+ .default("REPDTE")
1858
+ .describe("Sort field (allowlisted enum; default REPDTE = report date). An unknown field is rejected before fetch."),
1859
+ sortOrder: z
1860
+ .enum(["ASC", "DESC"])
1861
+ .default("DESC")
1862
+ .describe("Sort direction, default DESC (newest quarter first)."),
1863
+ });
1864
+ const FdicBranchDepositsInput = z.object({
1865
+ cert: z
1866
+ .number()
1867
+ .int()
1868
+ .min(1)
1869
+ .optional()
1870
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter). Resolve a bank's CERT via fdic_search_institutions."),
1871
+ state: z
1872
+ .string()
1873
+ .regex(/^[A-Z]{2}$/)
1874
+ .optional()
1875
+ .describe("Filter by 2-letter branch state code (uppercase; → STALPBR filter — the SOD branch-state field). e.g. 'OR'. C118-quoted so Oregon is Lucene-operator-safe."),
1876
+ year: z
1877
+ .number()
1878
+ .int()
1879
+ .min(1934)
1880
+ .max(new Date().getUTCFullYear())
1881
+ .optional()
1882
+ .describe("Filter by Summary-of-Deposits survey YEAR (→ YEAR filter), the annual June-30 snapshot year. 1934..current UTC year."),
1883
+ limit: z
1884
+ .number()
1885
+ .int()
1886
+ .min(1)
1887
+ .max(1000)
1888
+ .default(100)
1889
+ .describe("Rows per page, 1..1000, default 100."),
1890
+ offset: z
1891
+ .number()
1892
+ .int()
1893
+ .min(0)
1894
+ .max(100000)
1895
+ .default(0)
1896
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1897
+ sortBy: z
1898
+ .enum(["YEAR", "DEPSUMBR"])
1899
+ .default("YEAR")
1900
+ .describe("Sort field (allowlisted enum; default YEAR = snapshot year). An unknown field is rejected before fetch."),
1901
+ sortOrder: z
1902
+ .enum(["ASC", "DESC"])
1903
+ .default("DESC")
1904
+ .describe("Sort direction, default DESC (newest snapshot / largest deposits first)."),
1905
+ });
1906
+ // ─── OpenFEMA (keyless disaster declarations + emergency-assistance spend) ──────
1907
+ // ADR-0016. KEYLESS, fixed host www.fema.gov + a PINNED dataset registry
1908
+ // {entityName, version} (the SSRF core — no free host/path/version). Filters are
1909
+ // MODULE-BUILT from a per-tool, LIVE-VERIFIED field whitelist into an OData
1910
+ // `$filter` (NO raw `$filter` arg exists — no injection surface); the module
1911
+ // ALWAYS sends `$inlinecount=allpages` so metadata.count is the EXACT filtered
1912
+ // total. Per-dataset field names DIFFER (M1): the PA tool's `state` maps to
1913
+ // `stateAbbreviation`, the declarations tool's `state` maps to `state` — a shared
1914
+ // user-facing arg over two different real OData fields. limit → $top (≤1000),
1915
+ // offset → $skip.
1916
+ // A 2-letter US state/territory code (uppercase; e.g. CA, LA, PR, DC). A bad value
1917
+ // is NOT an SSRF vector (the fixed host + dataset registry are) — it just returns
1918
+ // an honest genuine-empty (metadata.count:0), never a silent unfiltered set.
1919
+ const FemaStateSchema = z
1920
+ .string()
1921
+ .regex(/^[A-Za-z]{2}$/)
1922
+ .describe("2-letter US state / territory code (e.g. 'CA', 'LA', 'PR', 'DC'). Uppercase per FEMA; a bad code returns an honest empty (count 0).");
1923
+ // ISO date 'YYYY-MM-DD' or a full ISO datetime. A bare date = midnight-UTC start.
1924
+ const FemaDateSchema = z
1925
+ .string()
1926
+ .regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z?)?$/)
1927
+ .describe("ISO date 'YYYY-MM-DD' or full ISO datetime; a bare date is midnight-UTC start of that day.");
1928
+ const FemaSearchPublicAssistanceInput = z.object({
1929
+ state: FemaStateSchema.optional().describe("Filter by applicant state (→ stateAbbreviation eq 'XX'). 2-letter code."),
1930
+ disasterNumber: z
1931
+ .number()
1932
+ .int()
1933
+ .positive()
1934
+ .optional()
1935
+ .describe("Filter by FEMA disaster number (→ disasterNumber eq N)."),
1936
+ applicantId: z
1937
+ .string()
1938
+ .min(1)
1939
+ .optional()
1940
+ .describe("Filter by applicant id (→ applicantId eq '...'). e.g. '015-UF5E0-00'."),
1941
+ damageCategoryCode: z
1942
+ .string()
1943
+ .min(1)
1944
+ .max(2)
1945
+ .optional()
1946
+ .describe("Filter by PA damage category code (→ damageCategoryCode eq 'X'). e.g. 'B' = Emergency Protective Measures, 'C'–'G' = permanent work."),
1947
+ incidentType: z
1948
+ .string()
1949
+ .min(1)
1950
+ .optional()
1951
+ .describe("Filter by incident type (→ incidentType eq '...'). e.g. 'Flood', 'Hurricane', 'Severe Storm'."),
1952
+ minProjectAmount: z
1953
+ .number()
1954
+ .optional()
1955
+ .describe("Minimum project amount (→ projectAmount ge N)."),
1956
+ maxProjectAmount: z
1957
+ .number()
1958
+ .optional()
1959
+ .describe("Maximum project amount (→ projectAmount le N)."),
1960
+ declaredDateFrom: FemaDateSchema.optional().describe("Earliest declaration date, inclusive (→ declarationDate ge 'ISO')."),
1961
+ declaredDateTo: FemaDateSchema.optional().describe("Latest declaration date, inclusive (→ declarationDate le 'ISO')."),
1962
+ limit: z
1963
+ .number()
1964
+ .int()
1965
+ .min(1)
1966
+ .max(1000)
1967
+ .default(100)
1968
+ .describe("Rows per page ($top), 1..1000, default 100."),
1969
+ offset: z
1970
+ .number()
1971
+ .int()
1972
+ .min(0)
1973
+ .default(0)
1974
+ .describe("0-based row offset ($skip) for pagination, default 0."),
1975
+ });
1976
+ const FemaDisasterDeclarationsInput = z.object({
1977
+ state: FemaStateSchema.optional().describe("Filter by state (→ state eq 'XX'). 2-letter code."),
1978
+ incidentType: z
1979
+ .string()
1980
+ .min(1)
1981
+ .optional()
1982
+ .describe("Filter by incident type (→ incidentType eq '...'). e.g. 'Flood', 'Hurricane', 'Winter Storm'."),
1983
+ declarationType: z
1984
+ .enum(["DR", "EM", "FM"])
1985
+ .optional()
1986
+ .describe("Filter by declaration type (→ declarationType eq 'XX'): DR (major disaster), EM (emergency), FM (fire management)."),
1987
+ fyDeclared: z
1988
+ .number()
1989
+ .int()
1990
+ .optional()
1991
+ .describe("Filter by fiscal year declared (→ fyDeclared eq N). e.g. 2024."),
1992
+ disasterNumber: z
1993
+ .number()
1994
+ .int()
1995
+ .positive()
1996
+ .optional()
1997
+ .describe("Filter by FEMA disaster number (→ disasterNumber eq N)."),
1998
+ declaredDateFrom: FemaDateSchema.optional().describe("Earliest declaration date, inclusive (→ declarationDate ge 'ISO')."),
1999
+ declaredDateTo: FemaDateSchema.optional().describe("Latest declaration date, inclusive (→ declarationDate le 'ISO')."),
2000
+ paProgramDeclared: z
2001
+ .boolean()
2002
+ .optional()
2003
+ .describe("Filter to declarations where the Public Assistance program was declared (→ paProgramDeclared eq true/false)."),
2004
+ iaProgramDeclared: z
2005
+ .boolean()
2006
+ .optional()
2007
+ .describe("Filter to declarations where the Individual Assistance program was declared (→ iaProgramDeclared eq true/false)."),
2008
+ limit: z
2009
+ .number()
2010
+ .int()
2011
+ .min(1)
2012
+ .max(1000)
2013
+ .default(100)
2014
+ .describe("Rows per page ($top), 1..1000, default 100."),
2015
+ offset: z
2016
+ .number()
2017
+ .int()
2018
+ .min(0)
2019
+ .default(0)
2020
+ .describe("0-based row offset ($skip) for pagination, default 0."),
2021
+ });
2022
+ // ─── EPA ECHO REST (keyless facility compliance/enforcement) — input schemas ──
2023
+ // ADR-0009. KEYLESS, single fixed host (echodata.epa.gov) + three fixed service
2024
+ // paths (the SSRF core — no free host/path). `state` is a curated US state/
2025
+ // territory ENUM: it scopes the query (national is ~5.6M rows) AND is the
2026
+ // silent-zero guard (ECHO does NOT validate filter VALUES, so an unknown value
2027
+ // returns QueryRows:0 — indistinguishable from a genuine-empty). M2 (LIVE-verified
2028
+ // 2026-07-12): `sic` DOES narrow (a real filter); `naics` is DROPPED upstream —
2029
+ // exposed as BEST-EFFORT, disclosed in _meta.filtersDropped + notes, never
2030
+ // silently presented as filtered.
2031
+ const EchoStateEnum = z
2032
+ .enum(echo.ECHO_STATES)
2033
+ .describe("US state / territory 2-letter code to scope the search (REQUIRED — an unscoped national query is ~5.6M rows; the enum is also the SSRF value guard + the silent-zero guard). e.g. 'DC', 'TX', 'CA', 'PR'.");
2034
+ const EchoSearchFacilitiesInput = z.object({
2035
+ state: EchoStateEnum,
2036
+ naics: z
2037
+ .string()
2038
+ .regex(/^[0-9]{2,6}$/)
2039
+ .optional()
2040
+ .describe("BEST-EFFORT industry filter (2–6 digit NAICS). WARNING: ECHO DROPS the NAICS filter upstream (live-verified 2026-07-12) — the returned facilities are NOT guaranteed to match this code; it is reported in _meta.filtersDropped + a note. Use `sic` (which DOES narrow) to scope by industry."),
2041
+ sic: z
2042
+ .string()
2043
+ .regex(/^[0-9]{2,4}$/)
2044
+ .optional()
2045
+ .describe("Industry filter (2–4 digit SIC code). A REAL filter — ECHO narrows by SIC (live-verified). A code with no facilities returns 0 (silent-zero — verify the code)."),
2046
+ facilityName: z
2047
+ .string()
2048
+ .min(1)
2049
+ .optional()
2050
+ .describe("Facility-name substring filter (p_fn). NOTE: not validated by ECHO — a typo silently returns 0 results, not an error."),
2051
+ majorOnly: z
2052
+ .boolean()
2053
+ .optional()
2054
+ .describe("true ⇒ only EPA 'major' facilities (p_maj=Y)."),
2055
+ federalOnly: z
2056
+ .boolean()
2057
+ .optional()
2058
+ .describe("true ⇒ only federal facilities (p_ff=Y)."),
2059
+ limit: z
2060
+ .number()
2061
+ .int()
2062
+ .min(1)
2063
+ .max(1000)
2064
+ .default(100)
2065
+ .describe("Facilities per page (→ responseset), 1..1000, default 100."),
2066
+ offset: z
2067
+ .number()
2068
+ .int()
2069
+ .min(0)
2070
+ .default(0)
2071
+ .describe("0-based offset for pagination, default 0. MUST be an exact multiple of `limit` (ECHO pages on fixed boundaries; a non-multiple ⇒ invalid_input)."),
2072
+ });
2073
+ const EchoFacilityReportInput = z.object({
2074
+ registryId: z
2075
+ .string()
2076
+ .regex(/^[0-9]{9,12}$/)
2077
+ .describe("The facility's FRS RegistryID (from echo_search_facilities rows' RegistryID) — an all-digit id, 9–12 digits (e.g. '110059768461'). A bad/unknown id ⇒ not_found (never a fabricated report)."),
2078
+ });
2079
+ // ─── api.data.gov keyed trio (Regulations.gov + Congress.gov) — input schemas ──
2080
+ // ADR-0007. The project's FIRST KEYED source. The key is read from env
2081
+ // (DATA_GOV_API_KEY, else the public DEMO_KEY) and travels ONLY in the X-Api-Key
2082
+ // header — NEVER in a param here. M4 (Zod-first): page/limit bounds + sort/type
2083
+ // enums + ISO date-time formats are validated LOCALLY so bad params fail as
2084
+ // invalid_input BEFORE any fetch (Regulations.gov's page[number] is HARD-capped at
2085
+ // 40 = a 10,000-record ceiling; page[size] must be 5..250).
2086
+ const RegulationsSearchInput = z.object({
2087
+ searchTerm: z
2088
+ .string()
2089
+ .optional()
2090
+ .describe("Full-text search term (filter[searchTerm]), e.g. 'artificial intelligence'."),
2091
+ query: z
2092
+ .string()
2093
+ .optional()
2094
+ .describe("Alias for `searchTerm` (either is accepted; both feed filter[searchTerm])."),
2095
+ agencyId: z
2096
+ .string()
2097
+ .optional()
2098
+ .describe("Filter by posting agency acronym (filter[agencyId]), e.g. 'EPA', 'FDA'."),
2099
+ docketId: z
2100
+ .string()
2101
+ .optional()
2102
+ .describe("Filter by docket id (filter[docketId]), e.g. 'EPA-HQ-OAR-2021-0257'."),
2103
+ documentType: z
2104
+ .enum(datagov.REGULATIONS_DOCUMENT_TYPES)
2105
+ .optional()
2106
+ .describe("Filter by document type (documents only): Rule / Proposed Rule / Notice / Supporting & Related Material / Other."),
2107
+ withinCommentPeriod: z
2108
+ .boolean()
2109
+ .optional()
2110
+ .describe("true ⇒ only documents currently open for comment (documents only; filter[withinCommentPeriod])."),
2111
+ postedDateGe: z
2112
+ .string()
2113
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2114
+ .optional()
2115
+ .describe("Posted on/after this date, YYYY-MM-DD (filter[postedDate][ge])."),
2116
+ postedDateLe: z
2117
+ .string()
2118
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2119
+ .optional()
2120
+ .describe("Posted on/before this date, YYYY-MM-DD (filter[postedDate][le])."),
2121
+ sort: z
2122
+ .enum(datagov.REGULATIONS_SORTS)
2123
+ .optional()
2124
+ .describe("Sort order (default '-postedDate'). Live-verified set: -postedDate/postedDate/-lastModifiedDate/lastModifiedDate/-commentEndDate (non-exhaustive)."),
2125
+ pageNumber: z
2126
+ .number()
2127
+ .int()
2128
+ .min(1)
2129
+ .max(40)
2130
+ .default(1)
2131
+ .describe("1-based page number, 1..40 (HARD cap — page[number] max is 40; the reachable window is 40×pageSize ≤ 10,000 records)."),
2132
+ pageSize: z
2133
+ .number()
2134
+ .int()
2135
+ .min(5)
2136
+ .max(250)
2137
+ .default(25)
2138
+ .describe("Records per page (page[size]), 5..250, default 25."),
2139
+ });
2140
+ // ADR-0044 — Regulations.gov DOCKETS (the rulemaking/nonrulemaking CONTAINER +
2141
+ // its cross-source `rin` join key). Within-source depth on the SAME api.data.gov
2142
+ // keyed adapter. The docketType enum + ISO date formats fail LOCALLY as
2143
+ // invalid_input BEFORE any fetch; `limit` exposes a friendly count while the wire
2144
+ // page[size] floor of 5 is handled by the handler (a limit<5 is client-sliced).
2145
+ const RegulationsSearchDocketsInput = z.object({
2146
+ searchTerm: z
2147
+ .string()
2148
+ .optional()
2149
+ .describe("Full-text search term (filter[searchTerm]) over docket title/abstract, e.g. 'endangered species'."),
2150
+ query: z
2151
+ .string()
2152
+ .optional()
2153
+ .describe("Alias for `searchTerm` (either is accepted; both feed filter[searchTerm])."),
2154
+ agencyId: z
2155
+ .string()
2156
+ .optional()
2157
+ .describe("Filter by owning agency acronym (filter[agencyId]), e.g. 'EPA', 'BLM', 'TREAS-FINCEN'."),
2158
+ docketType: z
2159
+ .enum(datagov.REGULATIONS_DOCKET_TYPES)
2160
+ .optional()
2161
+ .describe("Filter by docket type: Rulemaking / Nonrulemaking (filter[docketType])."),
2162
+ lastModifiedDateGe: z
2163
+ .string()
2164
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2165
+ .optional()
2166
+ .describe("Docket last modified on/after this date, YYYY-MM-DD (filter[lastModifiedDate][ge])."),
2167
+ lastModifiedDateLe: z
2168
+ .string()
2169
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2170
+ .optional()
2171
+ .describe("Docket last modified on/before this date, YYYY-MM-DD (filter[lastModifiedDate][le])."),
2172
+ sort: z
2173
+ .enum(datagov.REGULATIONS_DOCKET_SORTS)
2174
+ .optional()
2175
+ .describe("Sort order (default '-lastModifiedDate'). Set: -lastModifiedDate/lastModifiedDate/title/-title (first two DEMO_KEY-verified)."),
2176
+ limit: z
2177
+ .number()
2178
+ .int()
2179
+ .min(1)
2180
+ .max(250)
2181
+ .default(20)
2182
+ .describe("Requested rows, 1..250, default 20. NOTE: the API's page[size] floor is 5 — a limit<5 fetches page[size]=5 upstream and returns the first `limit` rows client-side (disclosed in _meta.notes); totalAvailable stays the EXACT server total."),
2183
+ pageNumber: z
2184
+ .number()
2185
+ .int()
2186
+ .min(1)
2187
+ .max(40)
2188
+ .default(1)
2189
+ .describe("1-based page number, 1..40 (HARD cap — page[number] max is 40; the reachable window is 40×page[size] ≤ 10,000 records)."),
2190
+ });
2191
+ const RegulationsGetDocketInput = z.object({
2192
+ docketId: z
2193
+ .string()
2194
+ .regex(/^[A-Za-z0-9_.-]+$/, "docketId may contain only letters, digits, '_', '.', '-' (no slashes/spaces/%).")
2195
+ .refine((v) => /[A-Za-z0-9]/.test(v), "docketId must contain an alphanumeric")
2196
+ .describe("The docket id — the ONLY path-segment value, charclass-validated (rejects '../', '%2F', spaces, pure-dot) — e.g. 'BLM-2026-0001', 'TREAS-FINCEN-2008-0008'. A bad id ⇒ invalid_input (0 fetch); a nonexistent id ⇒ not_found (never a fabricated docket)."),
2197
+ });
2198
+ const CongressSearchBillsInput = z.object({
2199
+ query: z
2200
+ .string()
2201
+ .optional()
2202
+ .describe("Keyword — NOTE: Congress.gov /v3/bill has NO keyword search, so this is NOT applied (disclosed in _meta.filtersDropped). Use congress/billType/date filters instead."),
2203
+ congress: z
2204
+ .number()
2205
+ .int()
2206
+ .min(1)
2207
+ .max(999)
2208
+ .optional()
2209
+ .describe("Congress number, e.g. 118 (scopes the path to /v3/bill/{congress})."),
2210
+ billType: z
2211
+ .enum(datagov.CONGRESS_BILL_TYPES)
2212
+ .optional()
2213
+ .describe("Bill type: hr/s/hjres/sjres/hconres/sconres/hres/sres. Requires `congress` (path /v3/bill/{congress}/{billType})."),
2214
+ fromDateTime: z
2215
+ .string()
2216
+ .datetime({ offset: true })
2217
+ .optional()
2218
+ .describe("Filter to bills updated at/after this ISO-8601 date-time with offset, e.g. '2024-01-01T00:00:00Z'."),
2219
+ toDateTime: z
2220
+ .string()
2221
+ .datetime({ offset: true })
2222
+ .optional()
2223
+ .describe("Filter to bills updated at/before this ISO-8601 date-time with offset, e.g. '2024-12-31T23:59:59Z'."),
2224
+ offset: z
2225
+ .number()
2226
+ .int()
2227
+ .min(0)
2228
+ .default(0)
2229
+ .describe("0-based record offset for pagination, default 0."),
2230
+ limit: z
2231
+ .number()
2232
+ .int()
2233
+ .min(1)
2234
+ .max(250)
2235
+ .default(20)
2236
+ .describe("Records per page, 1..250, default 20."),
2237
+ });
2238
+ const CongressGetBillInput = z.object({
2239
+ congress: z
2240
+ .number()
2241
+ .int()
2242
+ .min(1)
2243
+ .max(999)
2244
+ .describe("Congress number, e.g. 117."),
2245
+ billType: z
2246
+ .enum(datagov.CONGRESS_BILL_TYPES)
2247
+ .describe("Bill type: hr/s/hjres/sjres/hconres/sconres/hres/sres."),
2248
+ billNumber: z
2249
+ .number()
2250
+ .int()
2251
+ .min(1)
2252
+ .describe("Bill number, e.g. 3076 (for H.R.3076)."),
2253
+ });
2254
+ // ─── data.gov v4 Catalog API (api.gsa.gov — CKAN-retirement replacement) ─
2255
+ // ADR-0046, resilience Phase 3. Federal dataset DISCOVERY. Same DATA_GOV_API_KEY/
2256
+ // DEMO_KEY/X-Api-Key discipline as the datagov trio (shared datagovKey.ts seam),
2257
+ // but a DIFFERENT host (api.gsa.gov). The opaque `cursor` is charclass-validated
2258
+ // here AND re-guarded in the handler (a bad token ⇒ invalid_input, 0 fetch).
2259
+ const DatagovSearchDatasetsInput = z.object({
2260
+ query: z
2261
+ .string()
2262
+ .min(1)
2263
+ .max(500)
2264
+ .optional()
2265
+ .describe("Free-text search over the dataset catalog (→ _q), e.g. 'wildfire'. LIVE-CONFIRMED to narrow."),
2266
+ organization: z
2267
+ .string()
2268
+ .min(1)
2269
+ .max(200)
2270
+ .optional()
2271
+ .describe("Publisher organization SLUG filter (→ organization), e.g. 'epa-gov', 'noaa-gov'. An org catalog lists that agency's published datasets."),
2272
+ limit: z
2273
+ .number()
2274
+ .int()
2275
+ .min(1)
2276
+ .max(100)
2277
+ .default(20)
2278
+ .describe("Datasets per page (→ _size), 1..100, default 20."),
2279
+ cursor: z
2280
+ .string()
2281
+ .min(1)
2282
+ .max(4096)
2283
+ .regex(datagovCatalog.DATAGOV_CURSOR_RE)
2284
+ .optional()
2285
+ .describe("Opaque continuation cursor (→ after) — pass back the _meta.nextCursor from the previous page. Pagination is a cursor, NOT a numeric offset (offset/nextOffset are null); nextCursor:null means the last page. A bad token (spaces/'../'/'%') ⇒ invalid_input pre-fetch."),
2286
+ });
2287
+ // ─── GovInfo (api.govinfo.gov — the api.data.gov keyed trio's 3rd API) ─
2288
+ // ADR-0010. Same DATA_GOV_API_KEY/DEMO_KEY/X-Api-Key discipline as the datagov
2289
+ // trio (shared datagovKey.ts seam). `collection` is grammar-checked here AND
2290
+ // validated against the live /collections catalog in the handler (the silent-empty
2291
+ // guard). The novel piece is the OPAQUE `offsetMark` cursor: continuation rides in
2292
+ // _meta.nextCursor (passed back as pageMark), never a numeric offset.
2293
+ const GovinfoListCollectionsInput = z.object({});
2294
+ const GovinfoSearchPackagesInput = z.object({
2295
+ collection: z
2296
+ .string()
2297
+ .regex(govinfo.GOVINFO_COLLECTION_RE, "collection must be an uppercase alpha GovInfo code like BILLS/CFR/FR/PLAW ([A-Z]{2,10}).")
2298
+ .describe("GovInfo collection code (uppercase alpha), e.g. BILLS, PLAW, CREC, USCODE, CFR, FR, BUDGET, GAOREPORTS. Validated against the live /collections catalog — an unknown code returns invalid_input listing valid codes (never a misleading empty). Use govinfo_list_collections to discover codes."),
2299
+ startDate: z
2300
+ .string()
2301
+ .regex(govinfo.GOVINFO_DATE_RE, "startDate must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.")
2302
+ .describe("Lower bound on lastModified (the record's last-update date, NOT dateIssued), YYYY-MM-DD (normalized to T00:00:00Z) or a full ISO datetime. e.g. '2024-01-01'."),
2303
+ endDate: z
2304
+ .string()
2305
+ .regex(govinfo.GOVINFO_DATE_RE, "endDate must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.")
2306
+ .optional()
2307
+ .describe("Optional upper bound on lastModified (same format as startDate)."),
2308
+ pageSize: z
2309
+ .number()
2310
+ .int()
2311
+ .min(1)
2312
+ .max(1000)
2313
+ .default(100)
2314
+ .describe("Rows per page (upstream pageSize), 1..1000, default 100."),
2315
+ pageMark: z
2316
+ .string()
2317
+ .regex(govinfo.GOVINFO_PAGE_MARK_RE, 'pageMark must be "*" or the opaque _meta.nextCursor from the previous page (≤4096 base64/URL-safe chars).')
2318
+ .default("*")
2319
+ .describe('Opaque continuation cursor. Default "*" (first page). To page, pass back the previous response\'s _meta.nextCursor (NOT a numeric offset — GovInfo uses an opaque cursor).'),
2320
+ });
2321
+ const GovinfoGetPackageInput = z.object({
2322
+ packageId: z
2323
+ .string()
2324
+ .regex(govinfo.GOVINFO_PACKAGE_ID_RE, "packageId must be a GovInfo id like BILLS-118hr1enr / CFR-2023-title1-vol1 ([A-Za-z0-9][A-Za-z0-9._-]{2,}).")
2325
+ .describe("GovInfo packageId from govinfo_search_packages (e.g. 'BILLS-118hr1enr', 'PLAW-117publ58', 'CFR-2023-title1-vol1', 'GAOREPORTS-GAO-24-106221')."),
2326
+ });
2327
+ // ─── FPDS-NG (www.fpds.gov ezSearch ATOM — keyless XML/ATOM) ─── ADR-0012
2328
+ // The FIRST XML/ATOM source. Structured filters ONLY (NO raw-q — a typo'd field
2329
+ // name is a SILENT ZERO in FPDS, so the module builds the fielded `q`). At least
2330
+ // one filter is REQUIRED (refuse a bare unbounded scan). Dates are ISO
2331
+ // YYYY-MM-DD (reformatted to YYYY/MM/DD internally). `offset` is the 0-indexed
2332
+ // page start (page size fixed at 10); keyless deep-paging past ~200K is
2333
+ // unreliable so it is capped at 600000.
2334
+ const FPDS_ISO_DATE = z
2335
+ .string()
2336
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be ISO YYYY-MM-DD.");
2337
+ const FpdsSearchAwardsInput = z
2338
+ .object({
2339
+ naics: z
2340
+ .string()
2341
+ .optional()
2342
+ .describe("Principal NAICS code (→ PRINCIPAL_NAICS_CODE), e.g. '541511'."),
2343
+ vendorName: z
2344
+ .string()
2345
+ .optional()
2346
+ .describe("Vendor/contractor name phrase (→ VENDOR_NAME), e.g. 'LOCKHEED MARTIN'."),
2347
+ piid: z
2348
+ .string()
2349
+ .optional()
2350
+ .describe("Contract/order PIID (→ PIID) — returns that action's full base+mod chain."),
2351
+ departmentId: z
2352
+ .string()
2353
+ .optional()
2354
+ .describe("4-digit contracting DEPARTMENT_ID, e.g. '9700' (DoD), '4700' (GSA)."),
2355
+ contractingAgencyName: z
2356
+ .string()
2357
+ .optional()
2358
+ .describe("Contracting agency name phrase (→ CONTRACTING_AGENCY_NAME), e.g. 'DEPT OF DEFENSE'."),
2359
+ signedDateFrom: FPDS_ISO_DATE.optional().describe("Signed-date range START (ISO YYYY-MM-DD); pair with signedDateTo (→ SIGNED_DATE:[from,to])."),
2360
+ signedDateTo: FPDS_ISO_DATE.optional().describe("Signed-date range END (ISO YYYY-MM-DD); pair with signedDateFrom."),
2361
+ lastModifiedFrom: FPDS_ISO_DATE.optional().describe("Last-modified range START (ISO YYYY-MM-DD); pair with lastModifiedTo (→ LAST_MOD_DATE:[from,to])."),
2362
+ lastModifiedTo: FPDS_ISO_DATE.optional().describe("Last-modified range END (ISO YYYY-MM-DD); pair with lastModifiedFrom."),
2363
+ keyword: z
2364
+ .string()
2365
+ .optional()
2366
+ .describe("Free-text keyword (bare full-text term; FPDS FIELD: operators are stripped for safety)."),
2367
+ offset: z
2368
+ .number()
2369
+ .int()
2370
+ .min(0)
2371
+ .max(600_000)
2372
+ .default(0)
2373
+ .describe("0-indexed page start (page size fixed at 10). Keyless deep-paging past ~200K is unreliable."),
2374
+ })
2375
+ .refine((a) => a.naics !== undefined ||
2376
+ a.vendorName !== undefined ||
2377
+ a.piid !== undefined ||
2378
+ a.departmentId !== undefined ||
2379
+ a.contractingAgencyName !== undefined ||
2380
+ (a.signedDateFrom !== undefined && a.signedDateTo !== undefined) ||
2381
+ (a.lastModifiedFrom !== undefined && a.lastModifiedTo !== undefined) ||
2382
+ a.keyword !== undefined, {
2383
+ message: "Provide at least one filter (naics, vendorName, piid, departmentId, contractingAgencyName, a signedDate range, a lastModified range, or keyword) — a bare unbounded FPDS scan is refused.",
2384
+ });
2385
+ // ─── NIH RePORTER v2 (api.reporter.nih.gov — keyless POST/JSON) ─── ADR-0014
2386
+ // The R2 getJson port's FIRST non-GET consumer. A NEW capability axis: federal
2387
+ // research-GRANT funding footprint by organization / UEI / state (recipient
2388
+ // enrichment, joinable to SAM/USAspending via primary_uei). The SSRF surface is
2389
+ // a compile-time-CONSTANT URL; all filters ride in the MODULE-BUILT POST body.
2390
+ // Only LIVE-CONFIRMED-narrowing criteria are exposed (M1): orgStates / orgNames /
2391
+ // fiscalYears. agency_ic_codes is EXCLUDED (it silently no-ops upstream). The
2392
+ // 15,000-record retrieval window is enforced by offset .max(14_999) (offset ≥
2393
+ // 15,000 ⇒ invalid_input, 0 fetch) + limit .max(500) — a cap on RETRIEVAL, not
2394
+ // on the exact meta.total count.
2395
+ const NIH_CURRENT_YEAR = new Date().getUTCFullYear();
2396
+ const NihSearchProjectsInput = z.object({
2397
+ orgStates: z
2398
+ .array(z.enum(nih.NIH_ORG_STATES))
2399
+ .max(20)
2400
+ .optional()
2401
+ .describe("Recipient-organization US state/territory 2-letter USPS codes (UPPERCASE — the enum is the SSRF value guard + the silent-zero guard: a lowercase 'ca' or an unknown 'ZZ' silently returns zeros, so a typo is an invalid_input, never read as 'no NIH funding'). LIVE-CONFIRMED to narrow. e.g. ['CA','MA']. Max 20."),
2402
+ orgNames: z
2403
+ .array(z.string().min(1).max(512))
2404
+ .max(20)
2405
+ .optional()
2406
+ .describe("Recipient-organization name filter values (each ≤512 chars, max 20). LIVE-CONFIRMED to narrow. e.g. ['MASSACHUSETTS INSTITUTE OF TECHNOLOGY']. A value matching no org returns a genuine total:0."),
2407
+ fiscalYears: z
2408
+ .array(z.number().int().min(1985).max(NIH_CURRENT_YEAR + 1))
2409
+ .max(20)
2410
+ .optional()
2411
+ .describe(`NIH fiscal years to include (int array, ${1985}..${NIH_CURRENT_YEAR + 1}, max 20). LIVE-CONFIRMED to narrow. e.g. [2023,2024].`),
2412
+ limit: z
2413
+ .number()
2414
+ .int()
2415
+ .min(1)
2416
+ .max(500)
2417
+ .default(50)
2418
+ .describe("Projects per page (upstream hard cap 500), 1..500, default 50."),
2419
+ offset: z
2420
+ .number()
2421
+ .int()
2422
+ .min(0)
2423
+ .max(14_999)
2424
+ .default(0)
2425
+ .describe("0-based offset into the result set. HARD-CAPPED at 14,999: NIH caps keyless retrieval at the first 15,000 records (offset 0..14,999), so offset ≥ 15,000 is refused (invalid_input) — narrow criteria to reach records beyond the window. The count (totalAvailable) stays EXACT past the window."),
2426
+ });
2427
+ // ─── NSF Awards API (api.nsf.gov — keyless GET) ─── ADR-0020 (source #20)
2428
+ // The grant-SIBLING of NIH RePORTER on a DIFFERENT agency: federal research-GRANT
2429
+ // award records with recipient / PI / UEI enrichment, strengthening the WEAK
2430
+ // entity/recipient layer (ueiNumber/parentUeiNumber join to SAM/USAspending). The
2431
+ // SSRF surface is a compile-time-CONSTANT host+path; all filters ride in a
2432
+ // module-built URLSearchParams from a validated whitelist. Only LIVE-CONFIRMED-
2433
+ // narrowing filters ship (M1); agency + printFields are EXCLUDED (agency = the
2434
+ // NSF-only-corpus zeros-out foot-gun; printFields = a proven no-op). The 10,000-
2435
+ // record retrieval window (offset+rpp ≤ 10,000) is enforced by offset .max(9999)
2436
+ // + a module-side outgoing-rpp clamp. Dates are STRICT mm/dd/yyyy (a wrong format
2437
+ // is silently mis-parsed by NSF); a multi-word keyword is OR-tokenized (disclosed).
2438
+ const NSF_UEI_RE = /^[A-Za-z0-9]{12}$/;
2439
+ const NSF_MMDDYYYY_RE = /^(0[1-9]|1[0-2])\/(0[1-9]|[12]\d|3[01])\/\d{4}$/;
2440
+ const NsfSearchAwardsInput = z.object({
2441
+ keyword: z
2442
+ .string()
2443
+ .min(1)
2444
+ .max(200)
2445
+ .optional()
2446
+ .describe("Free-text search over title/abstract. NOTE: NSF OR-tokenizes a MULTI-WORD keyword (matches ANY word, not the phrase — 'machine learning' = machine OR learning, a far broader set; disclosed in _meta.notes). Use a single distinctive word or add a scoping filter for a precise set."),
2447
+ awardeeStateCode: z
2448
+ .enum(nsf.NSF_STATES)
2449
+ .optional()
2450
+ .describe("Awardee-organization US state/territory 2-letter USPS code (UPPERCASE — the enum is the SSRF value guard + the silent-zero guard: a non-state typo silently returns 0 awards on NSF, indistinguishable from 'no NSF funding', so it is an invalid_input). LIVE-CONFIRMED to narrow. e.g. 'CA'."),
2451
+ awardeeName: z
2452
+ .string()
2453
+ .min(2)
2454
+ .max(200)
2455
+ .optional()
2456
+ .describe("Awardee-organization name filter (2..200 chars). LIVE-CONFIRMED to narrow (a top recipient like 'Johns Hopkins University' may still saturate at the 10,000 count cap)."),
2457
+ ueiNumber: z
2458
+ .string()
2459
+ .regex(NSF_UEI_RE)
2460
+ .optional()
2461
+ .describe("Awardee UEI — a 12-char alphanumeric SAM/USAspending Unique Entity ID (uppercase-normalized before sending). LIVE-CONFIRMED an EXACT recipient-graph filter (the clean SAM/USAspending join). e.g. 'FTMTDMBR29C7' (Johns Hopkins)."),
2462
+ parentUeiNumber: z
2463
+ .string()
2464
+ .regex(NSF_UEI_RE)
2465
+ .optional()
2466
+ .describe("Parent-organization UEI — a 12-char alphanumeric UEI for the awardee's parent entity (uppercase-normalized). LIVE-CONFIRMED an EXACT narrow (the parent-org roll-up join). e.g. 'GS4PNKTRNKL3'."),
2467
+ pdPIName: z
2468
+ .string()
2469
+ .min(2)
2470
+ .max(120)
2471
+ .optional()
2472
+ .describe("Principal-investigator name filter (2..120 chars). LIVE-CONFIRMED to narrow. e.g. 'Bell'."),
2473
+ dateStart: z
2474
+ .string()
2475
+ .regex(NSF_MMDDYYYY_RE)
2476
+ .optional()
2477
+ .describe("Award ACTION-date lower bound (the initial award/obligation date, NOT the project startDate — live-verified). STRICT mm/dd/yyyy; a wrong format (yyyy-mm-dd) is silently mis-parsed by NSF (not an error), so it is rejected. e.g. '01/01/2024'."),
2478
+ dateEnd: z
2479
+ .string()
2480
+ .regex(NSF_MMDDYYYY_RE)
2481
+ .optional()
2482
+ .describe("Award ACTION-date upper bound. STRICT mm/dd/yyyy (same semantics/foot-gun as dateStart). e.g. '12/31/2024'."),
2483
+ limit: z
2484
+ .number()
2485
+ .int()
2486
+ .min(1)
2487
+ .max(100)
2488
+ .default(25)
2489
+ .describe("Awards per page (→ NSF rpp), 1..100, default 25. The OUTGOING page size is clamped so offset+rpp ≤ 10,000 (crossing NSF's retrieval window triggers a FATAL)."),
2490
+ offset: z
2491
+ .number()
2492
+ .int()
2493
+ .min(0)
2494
+ .max(9999)
2495
+ .default(0)
2496
+ .describe("0-based offset. HARD-CAPPED at 9,999: NSF caps keyless retrieval at the first 10,000 records (offset+rpp ≤ 10,000), so offset ≥ 10,000 is refused (invalid_input) — narrow criteria to bring the set under 10,000."),
2497
+ });
2498
+ const NsfGetAwardInput = z.object({
2499
+ awardId: z
2500
+ .string()
2501
+ .regex(/^\d{5,9}$/)
2502
+ .describe("NSF award id — an all-digit id (5..9 digits; NSF ids are 7-digit numeric, live-verified). Returns the ONE full award record INCLUDING abstractText; a nonexistent id ⇒ found:false (never a fabricated record). e.g. '2545697'."),
2503
+ });
2504
+ // ─── ClinicalTrials.gov API v2 (ADR-0021, source #21) ────────────
2505
+ const ClinicaltrialsSearchStudiesInput = z.object({
2506
+ "query.term": z
2507
+ .string()
2508
+ .min(1)
2509
+ .max(200)
2510
+ .optional()
2511
+ .describe("Broad free-text search across the study record. MULTI-WORD is AND-conjunctive — ALL tokens must co-occur ('breast cancer' = breast AND cancer; disclosed in _meta.notes). LIVE-CONFIRMED to narrow. e.g. 'cancer'."),
2512
+ sponsor: z
2513
+ .string()
2514
+ .min(1)
2515
+ .max(200)
2516
+ .optional()
2517
+ .describe("Lead-sponsor / collaborator NAME search (→ query.spons; a fuzzy full-text name search, NOT an exact-entity join — the name is free text, not a UEI). MULTI-WORD is AND-conjunctive. LIVE-CONFIRMED to narrow. e.g. 'Pfizer'."),
2518
+ condition: z
2519
+ .string()
2520
+ .min(1)
2521
+ .max(200)
2522
+ .optional()
2523
+ .describe("Condition / disease filter (→ query.cond). MULTI-WORD is AND-conjunctive. LIVE-CONFIRMED to narrow. e.g. 'diabetes'."),
2524
+ location: z
2525
+ .string()
2526
+ .min(1)
2527
+ .max(200)
2528
+ .optional()
2529
+ .describe("Study-location filter (→ query.locn), e.g. a country or city. LIVE-CONFIRMED to narrow. e.g. 'Germany'."),
2530
+ overallStatus: z
2531
+ .enum(clinicaltrials.CT_STATUSES)
2532
+ .optional()
2533
+ .describe("Recruitment/overall status (→ filter.overallStatus). A frozen 14-value enum (COMPLETED, RECRUITING, TERMINATED, …); an unlisted value LOUD-fails at HTTP 400 upstream, so it is rejected pre-fetch. LIVE-CONFIRMED to narrow. e.g. 'RECRUITING'."),
2534
+ funderType: z
2535
+ .enum(clinicaltrials.CT_FUNDER_TYPES)
2536
+ .optional()
2537
+ .describe("Funding-source facet (→ aggFilters=funderType:<v>) — the FEDERAL-funding axis. A frozen 4-value enum: nih, fed, industry, other (the B2G-relevant nih/fed narrow to federally-sponsored trials). An UNLISTED value silently returns totalCount:0 at HTTP 200 (a fake-empty trap), so it is rejected pre-fetch (invalid_input). funderType is an OVERLAPPING facet — counts MUST NOT be summed into a total."),
2538
+ pageSize: z
2539
+ .number()
2540
+ .int()
2541
+ .min(1)
2542
+ .max(1000)
2543
+ .default(20)
2544
+ .describe("Studies per page, 1..1000, default 20 (ClinicalTrials.gov clamps a larger request to 1000)."),
2545
+ pageToken: z
2546
+ .string()
2547
+ .min(1)
2548
+ .max(4096)
2549
+ .regex(clinicaltrials.CT_TOKEN_RE)
2550
+ .optional()
2551
+ .describe("Opaque continuation cursor — pass back the _meta.nextCursor from the previous page. Pagination is a cursor, NOT a numeric offset (offset/nextOffset are null); nextCursor:null means the last page. A bad token loud-fails at HTTP 400."),
184
2552
  });
185
- const FedRegGetDocInput = z.object({
186
- documentNumber: z
2553
+ const ClinicaltrialsGetStudyInput = z.object({
2554
+ nctId: z
187
2555
  .string()
188
- .describe("Federal Register document number, e.g. '2026-08333'"),
2556
+ .regex(clinicaltrials.CT_NCT_RE)
2557
+ .describe("NCT id — the form NCT followed by exactly 8 digits (e.g. NCT02403869). Returns the ONE full study record INCLUDING briefSummary; a nonexistent id ⇒ found:false (never a fabricated record). Injection-safe (validated before the path is built)."),
189
2558
  });
190
- const FedRegListAgenciesInput = z.object({
191
- perPage: z.number().min(1).max(500).optional(),
2559
+ // ADR-0024 the facet-counts field enum is DERIVED from clinicaltrials.CT_FACET_FIELDS
2560
+ // (single source of truth; the handler re-validates each element against the frozen
2561
+ // Set INLINE — the [ssrf] re-guard). 1..11 whitelisted ENUM fields, comma-joined
2562
+ // module-built into fields=<v1,v2,…> (NO raw passthrough); a non-member ⇒ invalid_input
2563
+ // pre-fetch (0 fetch). No filter/scope/page param (they HTTP-400 here).
2564
+ const ClinicaltrialsFacetCountsInput = z.object({
2565
+ fields: z
2566
+ .array(z.enum(clinicaltrials.CT_FACET_FIELDS))
2567
+ .min(1)
2568
+ .max(clinicaltrials.CT_FACET_FIELDS.length)
2569
+ .describe("1..11 ClinicalTrials.gov ENUM facet fields (deduped in-handler): OverallStatus, StudyType, Phase, LeadSponsorClass (★ the funding-SOURCE-class distribution — NIH/FED/OTHER_GOV/INDUSTRY/…, distinct from the search tool's 4-value funderType filter), Sex, DesignAllocation, DesignPrimaryPurpose, DesignInterventionModel, DesignMasking, DesignObservationalModel, DesignTimePerspective. Each returns the EXACT whole-registry per-value study-count distribution. An unlisted field ⇒ invalid_input pre-fetch (0 fetch). Phase is ARRAY-valued (counts OVERLAP — see _meta)."),
192
2570
  });
193
- // eCFR
194
- const EcfrSearchInput = z.object({
195
- query: z.string(),
196
- titleNumber: z
2571
+ // ─── USITC Harmonized Tariff Schedule (hts.usitc.gov — keyless REST) ─── ADR-0039
2572
+ // The IMPORT-TARIFF / supply-chain PRICE lane: a good's HTS classification + its
2573
+ // Column-1 General / Special (preferential/FTA) / Column-2 duty-rate TEXT + the
2574
+ // Chapter-99 additional-duty provisions. Fixed host `hts.usitc.gov` + FIXED path
2575
+ // `/reststop/search` (the SSRF core — no free host/path); the single `query` rides
2576
+ // `keyword=` via URLSearchParams (percent-encoded). ★M2 — a MINIMUM query floor
2577
+ // (≥3 non-whitespace chars) is enforced at BOTH the Zod boundary (below) and a
2578
+ // handler belt, so a 1–2 char query is rejected before the fetch (a single char can
2579
+ // serve 10,000–16,000+ rows / several MB). The full array is fetched once and paged
2580
+ // CLIENT-SIDE (the endpoint serves no total and IGNORES offset).
2581
+ const HtsLookupInput = z.object({
2582
+ query: z
2583
+ .string()
2584
+ .trim()
2585
+ .min(3)
2586
+ .max(100)
2587
+ .describe("REQUIRED — a KEYWORD (e.g. 'laptop', 'cotton shirt') OR an HTS number (e.g. '8471.30' or '8471.30.01.00'); both ride the `keyword=` search. Must be ≥3 non-whitespace chars (a 1–2 char/single-char fragment can make USITC serve 10,000–16,000+ rows / several MB). Returns the matching classification rows across the HTS hierarchy with the Column-1 General / Special / Column-2 duty-rate TEXT + Chapter-99 additional-duty provisions."),
2588
+ limit: z
2589
+ .number()
2590
+ .int()
2591
+ .min(1)
2592
+ .max(200)
2593
+ .default(50)
2594
+ .describe("Rows per page (CLIENT-SIDE slice over the served array), 1..200, default 50."),
2595
+ offset: z
197
2596
  .number()
2597
+ .int()
2598
+ .min(0)
2599
+ .default(0)
2600
+ .describe("0-based row offset for CLIENT-SIDE pagination over the served array (the endpoint has no server-side pagination), default 0."),
2601
+ });
2602
+ // ─── BLS Public Data API v1/v2 (api.bls.gov — keyless POST/JSON) ─── ADR-0032
2603
+ // A NEW capability axis: the PRICING / ESCALATION layer (CPI-U & ECI drive EPA-
2604
+ // clause escalation; PPI benchmarks materials; CES gives labor-rate context). The
2605
+ // SECOND POST-batch getJson-port consumer (after NIH). SSRF surface = a compile-
2606
+ // time-CONSTANT host+path; seriesids ride in the module-built POST body. `series`
2607
+ // is a FROZEN 9-key curated enum (the SSRF value guard + the units-label source);
2608
+ // `seriesId` is the raw passthrough, charclass-validated ^[A-Z0-9]{1,20}$. Years
2609
+ // are bounded ints (1900..currentYear+1); the span is clamped to the tier cap
2610
+ // (v1 ~10y) BEFORE the fetch + disclosed. An OPTIONAL free BLS_API_KEY rides ONLY
2611
+ // in the POST body (v2, ~500/day) — never a URL/header/label/_meta/log.
2612
+ const BlsTimeseriesInput = z.object({
2613
+ series: z
2614
+ .array(z.enum(bls.BLS_SERIES_KEYS))
2615
+ .max(bls.BLS_SERIES_KEYS.length)
198
2616
  .optional()
199
- .describe("CFR title (1-50). e.g. 48 = FAR (Federal Acquisition Regulation), 2 = Federal financial assistance."),
200
- perPage: z.number().min(1).max(20).optional(),
2617
+ .describe("One or more CURATED series enum keys (typo-proof; each carries a meaning + units label): cpi_u_all (CPI-U all items NSA, index), cpi_u_core (CPI-U core NSA, index), ppi_final_demand (PPI final demand NSA, index), eci_total_comp (ECI total comp — ★12-MO % CHANGE, not an index), eci_wages (ECI wages — ★12-MO % CHANGE), unemployment_rate (SA, percent), labor_force_participation (SA, percent), employment_total_nonfarm (SA, thousands of persons), avg_hourly_earnings (SA, dollars/hour). NSA CPI-U is the escalation/EPA-clause reference. At least one of series/seriesId is required; both may be combined."),
2618
+ seriesId: z
2619
+ .array(z.string().regex(/^[A-Z0-9]{1,20}$/))
2620
+ .max(bls.BLS_SERIES_KEYS.length + 50)
2621
+ .optional()
2622
+ .describe("One or more RAW BLS series IDs (power-user passthrough for the un-curatable space — OEWS area×occupation, local-area unemployment LAUCN…, SA/regional CPI variants). Charclass ^[A-Z0-9]{1,20}$ (uppercase alnum; punctuation/whitespace/lowercase rejected — SSRF + 'verify the ID' honesty). A raw ID has units:null (consult BLS). A nonexistent/typo'd ID returns BLS success + empty data (the ambiguity is disclosed, not asserted as 'no data'). At least one of series/seriesId is required."),
2623
+ startYear: z
2624
+ .number()
2625
+ .int()
2626
+ .min(1900)
2627
+ .max(bls.YEAR_MAX)
2628
+ .optional()
2629
+ .describe(`Inclusive start year (1900..${bls.YEAR_MAX}). Default: endYear − 9 (a ~10-year window). The span is CLAMPED to the active tier's cap (v1 ~10 years/query) BEFORE the request and disclosed in _meta.notes (never a silently truncated range).`),
2630
+ endYear: z
2631
+ .number()
2632
+ .int()
2633
+ .min(1900)
2634
+ .max(bls.YEAR_MAX)
2635
+ .optional()
2636
+ .describe(`Inclusive end year (1900..${bls.YEAR_MAX}). Default: the current year. Must be ≥ startYear.`),
201
2637
  });
202
- const EcfrListTitlesInput = z.object({});
203
- // Grants.gov
204
- const GrantsSearchInput = z.object({
205
- keyword: z.string().optional(),
206
- cfda: z.string().optional().describe("CFDA program number, e.g. '10.500'"),
207
- agency: z
2638
+ // ─── BLS OEWS — occupational wage benchmarking (2nd tool on api.bls.gov) ── ADR-0033
2639
+ // The LEVEL layer next to bls_timeseries's ESCALATION layer: mean/median annual &
2640
+ // hourly wages + employment by SOC occupation × geography. OEWS series IDs are 25
2641
+ // chars — they EXCEED the bls_timeseries raw-seriesId cap (^[A-Z0-9]{1,20}$), so
2642
+ // this tool BUILDS the 25-char ID INTERNALLY from validated structured inputs
2643
+ // (area/occupation/datatype), reusing the same POST/JSON transport + honesty
2644
+ // layer. NO year input (OEWS serves only the latest annual release). The module
2645
+ // re-validates every component (belt-and-suspenders behind these schemas).
2646
+ const BlsOewsWagesInput = z.object({
2647
+ occupation: z
2648
+ .array(z.enum(bls.BLS_OEWS_OCCUPATION_KEYS))
2649
+ .max(bls.BLS_OEWS_OCCUPATION_KEYS.length)
2650
+ .optional()
2651
+ .describe("One or more CURATED occupation enum keys (typo-proof; each carries an SOC + official label): all_occupations, software_developer (15-1252), computer_systems_analyst, info_security_analyst, management_analyst, project_mgmt_specialist, logistician, accountant_auditor, general_ops_manager, civil_engineer, electrical_engineer, mechanical_engineer, industrial_engineer, lawyer, technical_writer, admin_assistant. The ~830-SOC long tail is reachable via `soc`. At least one of occupation/soc is required."),
2652
+ soc: z
2653
+ .array(z.string().regex(/^\d{6}$/))
2654
+ .max(50)
2655
+ .optional()
2656
+ .describe("One or more RAW 6-digit SOC codes (the long-tail passthrough) — HYPHENLESS (use 151252, not 15-1252; the hyphen is rejected). A raw soc that matches a curated occupation is auto-labeled; otherwise key/label are null. At least one of occupation/soc is required."),
2657
+ area: z
2658
+ .array(z.string())
2659
+ .max(51)
2660
+ .optional()
2661
+ .describe('One or more geographies (default ["national"]). Each element is "national", a 2-letter USPS state code (e.g. CA, TX, DC — the curated state enum), OR a 5-digit CBSA metropolitan code (^\\d{5}$, e.g. 19100 for Dallas-Fort Worth). Resolved internally to the OEWS areatype + zero-padded area code; an unknown token is rejected (invalid_input, never a malformed series ID on the wire).'),
2662
+ datatype: z
2663
+ .array(z.enum(bls.BLS_OEWS_DATATYPE_KEYS))
2664
+ .max(bls.BLS_OEWS_DATATYPE_KEYS.length)
2665
+ .optional()
2666
+ .describe('One or more measures (default ["annual_mean"]): annual_mean (dollars/year), annual_median (dollars/year), hourly_mean (dollars/hour), hourly_median (dollars/hour), employment (count jobs). Each row carries measure.units from this map (H3 — never mislabel).'),
2667
+ });
2668
+ // ─── BLS QCEW — county×NAICS market-size / wages / location-quotient ── ADR-0042
2669
+ // A THIRD BLS tool but a SECOND, DIFFERENT, keyless, un-rate-limited BLS DOMAIN:
2670
+ // the QCEW Open Data Access CSV files on data.bls.gov/cew (NOT the rate-limited
2671
+ // api.bls.gov/publicAPI timeseries API the two tools above share). SSRF surface =
2672
+ // a compile-time-CONSTANT host + charclass-validated path segments (year `^\d{4}$`,
2673
+ // quarter `^[1-4]$`, mode enum {area,industry}, area `^[0-9A-Za-z]{1,6}$`, industry
2674
+ // DIGIT-ONLY `^[0-9]{1,6}$` — a hyphenated NAICS 31-33 404s). Client-side filters
2675
+ // (ownership/aggregationLevel/sizeCode/narrow) NEVER touch the URL. Honesty crux:
2676
+ // the block/code/field-scoped disclosure→null (never 0). NO BLS_API_KEY on this
2677
+ // keyless path; a NEW self-throttle gate key ("bls_qcew"), NOT "bls".
2678
+ const BlsQcewInput = z.object({
2679
+ mode: z
2680
+ .enum(["area", "industry"])
2681
+ .describe("REQUIRED — the slice shape: 'area' (all industries × ownership × aggregation levels for ONE area_fips) or 'industry' (all areas for ONE NAICS). A fixed enum interpolated as a LITERAL path segment."),
2682
+ area: z
208
2683
  .string()
2684
+ .regex(/^[0-9A-Za-z]{1,6}$/)
209
2685
  .optional()
210
- .describe("Grants.gov agency code, e.g. 'DHS-FEMA'"),
211
- oppNum: z.string().optional().describe("Specific opportunity number"),
212
- oppStatuses: z
213
- .array(z.enum(["forecasted", "posted", "closed", "archived"]))
2686
+ .describe("The area_fips (^[0-9A-Za-z]{1,6}$): county 01005, statewide 01000, national US000, MSA C1018, CSA CS122. REQUIRED when mode=area (the path segment). When mode=industry it is an OPTIONAL client-side narrow (keep only rows for this area_fips)."),
2687
+ industry: z
2688
+ .string()
2689
+ .regex(/^[0-9]{1,6}$/)
214
2690
  .optional()
215
- .describe("Defaults to forecasted+posted"),
216
- rows: z.number().min(1).max(50).optional(),
2691
+ .describe("The NAICS code (DIGIT-ONLY ^[0-9]{1,6}$): 5415, or the aggregate 10. REQUIRED when mode=industry (the path segment). When mode=area it is an OPTIONAL client-side narrow (keep only rows for this NAICS). A hyphenated NAICS supersector (31-33, 44-45) 404s on QCEW — pass its digit aggregate code, never the hyphenated form."),
2692
+ year: z
2693
+ .number()
2694
+ .int()
2695
+ .min(1990)
2696
+ .max(bls.YEAR_MAX)
2697
+ .describe(`REQUIRED — the 4-digit year (1990..${bls.YEAR_MAX}). QCEW Open Data coverage begins ~1990; a pre-coverage or future year is an honest per-tuple HTTP 404 (found:false), NOT zero establishments.`),
2698
+ quarter: z
2699
+ .enum(["1", "2", "3", "4"])
2700
+ .describe("REQUIRED — the quarter '1'|'2'|'3'|'4' (all four live-servable). The annual 'a' is not enabled this build."),
2701
+ ownership: z
2702
+ .string()
2703
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2704
+ .optional()
2705
+ .describe("Optional CLIENT-SIDE filter on own_code (e.g. 0=Total, 1=Federal, 2=State, 3=Local, 5=Private). Never on the URL (no SSRF surface)."),
2706
+ aggregationLevel: z
2707
+ .string()
2708
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2709
+ .optional()
2710
+ .describe("Optional CLIENT-SIDE filter on agglvl_code (e.g. 70=total-all-industries, 78=6-digit-NAICS-by-ownership). Filter to ONE agglvl_code for a coherent, non-double-counted total."),
2711
+ sizeCode: z
2712
+ .string()
2713
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2714
+ .optional()
2715
+ .describe("Optional CLIENT-SIDE filter on size_code."),
2716
+ limit: z
2717
+ .number()
2718
+ .int()
2719
+ .min(1)
2720
+ .max(1000)
2721
+ .default(50)
2722
+ .describe("Rows per page (CLIENT-SIDE window over the fetched-once slice), 1..1000, default 50."),
2723
+ offset: z
2724
+ .number()
2725
+ .int()
2726
+ .min(0)
2727
+ .default(0)
2728
+ .describe("0-based row offset for CLIENT-SIDE pagination over the filtered set (QCEW has no server-side pagination), default 0."),
217
2729
  });
218
- const GrantsGetInput = z.object({
219
- opportunityId: z.string().describe("Grants.gov opportunity id (numeric string)"),
2730
+ // ─── US Census Geocoder (keyless source #22) — input schemas ──────
2731
+ // ADR-0023. KEYLESS, single fixed host (geocoding.geo.census.gov) + two fixed
2732
+ // endpoint paths (the SSRF core — no free host/path; NO id in the path). benchmark /
2733
+ // vintage are frozen enums (the Zod source of truth = census.CENSUS_BENCHMARKS /
2734
+ // census.CENSUS_VINTAGES). [M2] CENSUS_VINTAGES is the UNION of live-valid vintages
2735
+ // across the four benchmarks (25 distinct) so a VALID non-default pair is not Zod-
2736
+ // rejected; an INVALID (benchmark,vintage) pair fails-closed at HTTP 400. GEOIDs are
2737
+ // strings (leading zeros survive). Coordinate finiteness is re-guarded in the handler.
2738
+ const CensusBenchmarkEnum = z
2739
+ .enum(census.CENSUS_BENCHMARKS)
2740
+ .optional()
2741
+ .describe("Address-range benchmark (default Public_AR_Current — a MOVING benchmark). One of Public_AR_Current / Public_AR_ACS2025 / Public_AR_LUCA / Public_AR_Census2020. vintage MUST be compatible with this benchmark (a matrix); an incompatible pair fails-closed with an HTTP 400.");
2742
+ const CensusVintageEnum = z
2743
+ .enum(census.CENSUS_VINTAGES)
2744
+ .optional()
2745
+ .describe("Geography vintage (default Current_Current — a MOVING vintage; the same address may return a different tract/CD across cycles). The valid vintage set DEPENDS on the benchmark (a matrix — this enum is the UNION across all four benchmarks); an incompatible (benchmark, vintage) pair fails-closed with an HTTP 400 (invalid_input), never a silent mis-resolution. e.g. Census2020_Census2020 (with Public_AR_Census2020), Census2010_Current.");
2746
+ const CensusGeocodeAddressInput = z.object({
2747
+ address: z
2748
+ .string()
2749
+ .min(1)
2750
+ .max(500)
2751
+ .describe("A one-line US address, e.g. '600 Dexter Ave, Montgomery, AL 36104'. An unmatched/under-specified address is NOT an error — it returns matches:[] / matchCount:0 (a genuine empty; add city, state, ZIP). An ambiguous address may return MULTIPLE matches, each with its own matchedAddress + geographies."),
2752
+ benchmark: CensusBenchmarkEnum,
2753
+ vintage: CensusVintageEnum,
220
2754
  });
221
- // SAM.gov organization lookup (federal hierarchy)
222
- const SamLookupOrgInput = z.object({
223
- organizationId: z.string().describe("SAM.gov federal-organization id (numeric)"),
2755
+ const CensusGeographiesByCoordinatesInput = z
2756
+ .object({
2757
+ longitude: z
2758
+ .number()
2759
+ .min(-180)
2760
+ .max(180)
2761
+ .optional()
2762
+ .describe("Longitude (x), a finite number in [-180, 180]. Alias of `x`. e.g. -86.301883."),
2763
+ latitude: z
2764
+ .number()
2765
+ .min(-90)
2766
+ .max(90)
2767
+ .optional()
2768
+ .describe("Latitude (y), a finite number in [-90, 90]. Alias of `y`. e.g. 32.377612."),
2769
+ x: z
2770
+ .number()
2771
+ .min(-180)
2772
+ .max(180)
2773
+ .optional()
2774
+ .describe("Longitude — the Census API's own name for longitude (alias of `longitude`)."),
2775
+ y: z
2776
+ .number()
2777
+ .min(-90)
2778
+ .max(90)
2779
+ .optional()
2780
+ .describe("Latitude — the Census API's own name for latitude (alias of `latitude`)."),
2781
+ benchmark: CensusBenchmarkEnum,
2782
+ vintage: CensusVintageEnum,
2783
+ })
2784
+ .refine((v) => v.longitude !== undefined || v.x !== undefined, {
2785
+ message: "longitude (or its alias x) is required.",
2786
+ path: ["longitude"],
2787
+ })
2788
+ .refine((v) => v.latitude !== undefined || v.y !== undefined, {
2789
+ message: "latitude (or its alias y) is required.",
2790
+ path: ["latitude"],
224
2791
  });
225
- const TOOLS = [
226
- // ━━━ SAM.gov (5) ━━━
227
- {
2792
+ // Build a ToolDef whose `handler` is type-checked against the schema's inferred
2793
+ // input `I` at the call site (e.g. `input.searchText` is known-present). The
2794
+ // `I` binding is erased to `any` in the ToolDef[] array, so entries without a
2795
+ // handler need not use this helper.
2796
+ function defineTool(d) {
2797
+ return d;
2798
+ }
2799
+ // Exported for the offline registry-introspection fault fixtures (W3-1): the
2800
+ // harness asserts a tool's advertised inputSchema/description directly (e.g.
2801
+ // usas_search_expiring_contracts no longer carries `fiscalYear`; usas_search_awards
2802
+ // no longer promises "+ count"). Export-only — does NOT change tools/list output
2803
+ // or any dispatch behavior (main() stays entry-point-gated).
2804
+ export const TOOLS = [
2805
+ // ━━━ SAM.gov (8) ━━━
2806
+ defineTool({
228
2807
  name: "sam_search_opportunities",
229
2808
  description: "Search SAM.gov federal contracting opportunities (keyless HAL). Returns up to 50 active notices with title, agency, NAICS, noticeId. Use for discovery — narrow with NAICS / agency / set-aside / state.",
230
2809
  inputSchema: SamSearchInput,
231
- },
232
- {
2810
+ handler: async (input, { sam }) => {
2811
+ const r = await sam.searchOpportunities({
2812
+ ...input,
2813
+ setAside: input.setAside,
2814
+ });
2815
+ // OUTAGE HONESTY (C19). r.degraded is set ONLY when EVERY access tier
2816
+ // threw (HAL down / network / 5xx-after-retry) — a total outage, NOT a
2817
+ // confirmed zero. searchOpportunities otherwise returns the real result
2818
+ // (incl. a genuine 0). Emit an explicitly-incomplete `_meta`: we do NOT
2819
+ // know the count (totalAvailable:null, NEVER 0), the source is flagged
2820
+ // degraded, the data count is null (not a fake 0 that reads as a real
2821
+ // count), and a note tells the AI to retry rather than conclude "no
2822
+ // matching notices". The genuine-zero + healthy paths below are UNCHANGED.
2823
+ if (r.degraded) {
2824
+ return withMeta({
2825
+ totalRecords: null,
2826
+ returned: 0,
2827
+ opportunities: [],
2828
+ }, {
2829
+ source: "sam.gov/sgs/v1 (keyless HAL) (DEGRADED — search backend unavailable)",
2830
+ keylessMode: sam.isKeyless,
2831
+ complete: false,
2832
+ totalAvailable: null,
2833
+ returned: 0,
2834
+ filtersApplied: [],
2835
+ filtersDropped: [],
2836
+ fieldsUnavailable: [],
2837
+ notes: [
2838
+ r.degraded.reason +
2839
+ " This is a service outage, not a confirmed zero — retry.",
2840
+ ],
2841
+ });
2842
+ }
2843
+ const data = {
2844
+ totalRecords: r.totalRecords,
2845
+ returned: r.opportunitiesData.length,
2846
+ opportunities: r.opportunitiesData.map((o) => ({
2847
+ noticeId: o.noticeId,
2848
+ title: o.title,
2849
+ agency: o.fullParentPathName,
2850
+ solicitationNumber: o.solicitationNumber,
2851
+ responseDeadline: o.responseDeadLine,
2852
+ naics: o.naicsCode,
2853
+ setAside: o.typeOfSetAside,
2854
+ uiLink: o.uiLink,
2855
+ })),
2856
+ };
2857
+ // A1 — filter honesty. Contrary to the earlier assumption, the keyless
2858
+ // HAL list endpoint DOES honor the structured facets server-side —
2859
+ // VERIFIED LIVE (2026-07): `naics`, `set_aside`, `pop_state` and `q` each
2860
+ // narrow the result set AND every returned notice's detail matches the
2861
+ // filter (the earlier "ignores facets" reading tested the WRONG param
2862
+ // name `ncode`, which is silently dropped; the real param is `naics`).
2863
+ // The one facet with no keyless param is organization-name (ignored).
2864
+ // Separately, the list PAYLOAD still omits each notice's
2865
+ // naics/set-aside/place-of-performance VALUES (null even when the filter
2866
+ // applied), so filtering is real but reading those field values needs
2867
+ // sam_get_opportunity. See spec §1.2 A1, §2.4.
2868
+ if (sam.isKeyless) {
2869
+ // NOTE: these truthiness checks must mirror EXACTLY the conditions under
2870
+ // which client.searchPublic() actually appends each param (it uses
2871
+ // `if (filters.x)` / `filters.setAside?.length`). Using `!== undefined`
2872
+ // here would over-report: an empty-string facet is not sent by the
2873
+ // client, so it must not appear in filtersApplied.
2874
+ const filtersApplied = [];
2875
+ if (input.query)
2876
+ filtersApplied.push("query");
2877
+ if (input.ncode)
2878
+ filtersApplied.push("ncode");
2879
+ if ((input.setAside?.length ?? 0) > 0)
2880
+ filtersApplied.push("setAside");
2881
+ if (input.state)
2882
+ filtersApplied.push("state");
2883
+ // organization-name is the only requested facet the keyless endpoint
2884
+ // cannot honor — flag it dropped so results aren't read as org-filtered.
2885
+ const filtersDropped = input.organizationName
2886
+ ? ["organizationName"]
2887
+ : [];
2888
+ // ── GSA-CSV inline enrichment (opt-in, non-blocking) ──────────────
2889
+ // The keyless HAL list payload nulls each row's naics/setAside/PoP/
2890
+ // deadline/type. When the GSA-CSV backbone is ENABLED and its index is
2891
+ // already warm, fill those nulls from today's snapshot in ONE lookup —
2892
+ // instead of N sam_get_opportunity detail calls. HARD guarantees:
2893
+ // - DISABLED (default) → resolveCsvConfig().enabled is false, so we
2894
+ // never enter this branch: `data`, fieldsUnavailable and notes are
2895
+ // byte-for-byte the pre-enrichment behavior, and ZERO network hits
2896
+ // the CSV.
2897
+ // - NON-BLOCKING → tryGetReadyIndex returns the already-loaded index
2898
+ // or null immediately (kicking a background warm); a cold CSV NEVER
2899
+ // stalls the search on a 225 MB download.
2900
+ // - A CSV error can't fail the search: tryGetReadyIndex swallows and
2901
+ // returns null → we degrade to the un-enriched page + a note.
2902
+ const csvCfg = gsaCsv.resolveCsvConfig();
2903
+ // Widen the opportunities element type so the enriched rows (which may
2904
+ // carry the added type/placeOfPerformance keys) are assignable; the
2905
+ // original `data` (narrower) widens into this cleanly.
2906
+ let enrichedData = data;
2907
+ // The fields still null after enrichment (rebuilt truthfully below).
2908
+ let fieldsUnavailable = ["naics", "setAside", "placeOfPerformance"];
2909
+ const enrichmentNotes = [];
2910
+ let source = "sam.gov/sgs/v1 (keyless HAL)";
2911
+ let freshness = undefined;
2912
+ if (csvCfg.enabled) {
2913
+ const ready = data.returned > 0 ? gsaCsv.tryGetReadyIndex(csvCfg) : null;
2914
+ if (ready) {
2915
+ const outcome = gsaCsv.enrichSearchOpportunities(enrichedData.opportunities, ready);
2916
+ enrichedData = { ...data, opportunities: outcome.opportunities };
2917
+ freshness = outcome.freshness;
2918
+ source = "sam.gov/sgs/v1 (keyless HAL) + gsa-csv (daily bulk CSV snapshot)";
2919
+ // Rebuild fieldsUnavailable: a field is only "unavailable" if it was
2920
+ // NOT filled on the whole page. Fields filled from the CSV drop off.
2921
+ // (naics/setAside/placeOfPerformance are the originally-null trio.)
2922
+ fieldsUnavailable = ["naics", "setAside", "placeOfPerformance"].filter((f) => !outcome.fieldsFilled.has(f));
2923
+ const filledList = [...outcome.fieldsFilled];
2924
+ if (filledList.length > 0) {
2925
+ enrichmentNotes.push(`naics/set-aside/place-of-performance for results present in today's GSA CSV snapshot were enriched from the GSA daily bulk CSV (source: gsa-csv) — filled fields this page: ${filledList.join(", ")}. set-aside here is the CSV short code (e.g. 'SBA') that matches sam_get_opportunity's setAside. Confirm real-time values (e.g. a just-amended deadline) with sam_get_opportunity.`);
2926
+ }
2927
+ else {
2928
+ enrichmentNotes.push("GSA-CSV enrichment ran but filled no fields on this page (the matched snapshot rows carried no non-empty naics/set-aside/place-of-performance) — values remain null; fetch sam_get_opportunity.");
2929
+ }
2930
+ if (outcome.missingCount > 0) {
2931
+ enrichmentNotes.push(`${outcome.missingCount} of ${data.returned} results were not in the current CSV snapshot (too new or archived) — their naics/set-aside/PoP remain null; fetch sam_get_opportunity for those noticeIds.`);
2932
+ }
2933
+ enrichmentNotes.push(`GSA CSV freshness — snapshot last-modified: ${outcome.freshness.csvLastModified ?? "unknown"}; index built: ${outcome.freshness.indexBuiltAt}; index age: ${outcome.freshness.indexAgeHours ?? "unknown"}h. The snapshot can lag the live HAL by up to ~24h.`);
2934
+ }
2935
+ else if (data.returned > 0) {
2936
+ // Enabled, rows exist that COULD be enriched, but the index isn't
2937
+ // warm yet (cold cache / background refresh in flight). Return the
2938
+ // normal HAL page un-enriched and disclose the pending warm — never
2939
+ // block on the download. Gated on returned>0: on a genuinely-empty
2940
+ // (returned===0) page there are NO rows to enrich, so a "retry for an
2941
+ // enriched page" note would be misleading — a retry cannot add rows.
2942
+ // That case falls through with the plain un-enriched source/notes
2943
+ // (the empty page is a complete, honest result).
2944
+ source = "sam.gov/sgs/v1 (keyless HAL) + gsa-csv (index warming)";
2945
+ enrichmentNotes.push("GSA-CSV enrichment pending — the CSV index is warming (a background download/build was kicked off); naics/set-aside/place-of-performance were NOT enriched this call. Retry shortly for an enriched page, or fetch sam_get_opportunity now.");
2946
+ }
2947
+ }
2948
+ const notes = [];
2949
+ if (filtersApplied.length > 0) {
2950
+ notes.push("Keyless SAM search filtered server-side by the applied facets (NAICS/set-aside/place-of-performance state/keyword) — the result count reflects them. But the keyless list payload OMITS each notice's naics/setAside/placeOfPerformance VALUES (null here); call sam_get_opportunity on a noticeId to read those values.");
2951
+ }
2952
+ else {
2953
+ notes.push("naics/setAside/placeOfPerformance are null because the keyless list endpoint omits those values — call sam_get_opportunity for a notice to obtain them.");
2954
+ }
2955
+ if (filtersDropped.length > 0) {
2956
+ notes.push("The organization-name filter is NOT supported by the keyless endpoint and was ignored (results are unfiltered on organization). Set SAM_GOV_API_KEY to filter by organization, or filter client-side on the returned `agency` field.");
2957
+ }
2958
+ notes.push(...enrichmentNotes);
2959
+ // freshness is surfaced structurally in `data` (the ResponseMeta type
2960
+ // has no typed freshness field, mirroring sam_lookup_notice_fields) —
2961
+ // present only when enrichment actually ran.
2962
+ const dataOut = freshness !== undefined
2963
+ ? { ...enrichedData, freshness }
2964
+ : enrichedData;
2965
+ return withMeta(dataOut, {
2966
+ source,
2967
+ keylessMode: true,
2968
+ truncated: r.totalRecords > data.returned,
2969
+ returned: data.returned,
2970
+ totalAvailable: r.totalRecords,
2971
+ filtersApplied,
2972
+ filtersDropped,
2973
+ fieldsUnavailable,
2974
+ notes,
2975
+ });
2976
+ }
2977
+ // Keyed path: api.sam.gov honors the structured filters and populates
2978
+ // the fields, so nothing is dropped or unavailable.
2979
+ return withMeta(data, {
2980
+ source: "api.sam.gov/opportunities/v2 (keyed)",
2981
+ keylessMode: false,
2982
+ truncated: r.totalRecords > data.returned,
2983
+ returned: data.returned,
2984
+ totalAvailable: r.totalRecords,
2985
+ filtersApplied: [],
2986
+ filtersDropped: [],
2987
+ fieldsUnavailable: [],
2988
+ });
2989
+ },
2990
+ }),
2991
+ defineTool({
2992
+ name: "sam_search_shaping",
2993
+ description: "PRE-SOLICITATION shaping radar (keyless HAL). Surfaces Sources Sought / Presolicitation / Special Notices BEFORE the RFP exists — the free, real-time analogue of paid agency-forecast feeds. Closes the pre-solicitation lifecycle gap: catch a requirement while it's still shapeable (submit capabilities, influence NAICS/set-aside/PWS). Defaults to noticeType ['r','p','s']; opt into k/i/u for combined-synopsis / intent-to-bundle / J&A tells. Each notice carries noticeTypeCode (rank r/p over s), postedDate, responseDeadline + daysUntilResponse (null when no deadline — counted, not hidden), and a uiLink. HONEST KEYLESS LIMITS: naics/setAside/placeOfPerformance are null in the list rows (call sam_get_opportunity(noticeId) for those); and a responseDeadlineFrom/To window is applied CLIENT-SIDE over the fetched page (the feed ignores rdlfrom/rdlto) and disclosed in _meta. data.totalRecords is the TRUE server-side count for the type+facet filter.",
2994
+ inputSchema: SamSearchShapingInput,
2995
+ handler: async (input, { sam }) => {
2996
+ // Default shaping window = Sources Sought + Presolicitation + Special
2997
+ // Notice. These are the notice types that exist BEFORE an RFP — the
2998
+ // whole point of the radar.
2999
+ const noticeType = input.noticeType ?? ["r", "p", "s"];
3000
+ const wantWindow = input.responseDeadlineFrom !== undefined ||
3001
+ input.responseDeadlineTo !== undefined;
3002
+ // Map noticeType → filters.ptype so the client sends the keyless
3003
+ // `notice_type` facet (server-side filter, VERIFIED LIVE). We deliberately
3004
+ // do NOT pass responseDeadlineFrom/To to the client — the keyless feed
3005
+ // IGNORES rdlfrom/rdlto, so the window is applied client-side below and
3006
+ // disclosed. activeOnly is honored by searchPublic's is_active=true.
3007
+ const r = await sam.searchOpportunities({
3008
+ query: input.query,
3009
+ ncode: input.ncode,
3010
+ organizationName: input.organizationName,
3011
+ state: input.state,
3012
+ setAside: input.setAside,
3013
+ ptype: noticeType,
3014
+ limit: input.limit ?? 25,
3015
+ });
3016
+ // OUTAGE HONESTY (C19). r.degraded ⇒ the keyless feed was totally down
3017
+ // (all tiers threw), NOT a genuine "no shaping notices". Emit an
3018
+ // explicitly-incomplete `_meta` (complete:false, totalAvailable:null,
3019
+ // degraded source, retry note) and a null data count instead of the
3020
+ // silent "0 pre-solicitation notices, complete" lie. noticeTypesRequested
3021
+ // is still echoed so the caller knows what was attempted. The genuine-zero
3022
+ // path + the client-side response-deadline window disclosure below are
3023
+ // UNCHANGED.
3024
+ if (r.degraded) {
3025
+ return withMeta({
3026
+ totalRecords: null,
3027
+ returned: 0,
3028
+ noticeTypesRequested: noticeType,
3029
+ notices: [],
3030
+ }, {
3031
+ source: "sam.gov/api/prod/sgs/v1/search (keyless HAL, notice_type filter) (DEGRADED — search backend unavailable)",
3032
+ keylessMode: true,
3033
+ complete: false,
3034
+ totalAvailable: null,
3035
+ returned: 0,
3036
+ filtersApplied: [],
3037
+ filtersDropped: [],
3038
+ fieldsUnavailable: [],
3039
+ notes: [
3040
+ r.degraded.reason +
3041
+ " This is a service outage, not a confirmed zero — retry.",
3042
+ ],
3043
+ });
3044
+ }
3045
+ // Shape each keyless list row. naics/setAside/PoP are NULL in the keyless
3046
+ // list payload (fieldsUnavailable) — NOT fabricated. noticeTypeCode
3047
+ // (type.code) lets the AI rank r/p over s; daysUntilResponse is a whole-day
3048
+ // count (null when no deadline — counted, not hidden).
3049
+ const now = new Date();
3050
+ const allNotices = r.opportunitiesData.map((o) => ({
3051
+ noticeId: o.noticeId,
3052
+ title: o.title,
3053
+ noticeType: o.type ?? null, // type.value (human label)
3054
+ noticeTypeCode: o.baseType ?? null, // type.code (r/p/s/…)
3055
+ agency: o.fullParentPathName,
3056
+ solicitationNumber: o.solicitationNumber,
3057
+ postedDate: o.postedDate,
3058
+ responseDeadline: o.responseDeadLine ?? null,
3059
+ daysUntilResponse: daysUntilResponse(o.responseDeadLine, now),
3060
+ naics: o.naicsCode, // null in keyless list rows
3061
+ setAside: o.typeOfSetAside, // null in keyless list rows
3062
+ uiLink: o.uiLink,
3063
+ }));
3064
+ // Response-deadline WINDOW — CLIENT-SIDE over the fetched page (the feed
3065
+ // ignores rdlfrom/rdlto). A notice with no deadline is excluded from a
3066
+ // windowed query. Disclosed via filtersDropped + a note below.
3067
+ const notices = wantWindow
3068
+ ? applyResponseDeadlineWindow(allNotices, input.responseDeadlineFrom, input.responseDeadlineTo)
3069
+ : allNotices;
3070
+ const data = {
3071
+ totalRecords: r.totalRecords, // TRUE server-side count for type+facets
3072
+ returned: notices.length,
3073
+ noticeTypesRequested: noticeType,
3074
+ notices,
3075
+ };
3076
+ // _meta honesty. filtersApplied lists what the FEED honored server-side
3077
+ // (mirror EXACTLY searchPublic's append conditions). Always: noticeType.
3078
+ const filtersApplied = ["noticeType"];
3079
+ if (input.query)
3080
+ filtersApplied.push("query");
3081
+ if (input.ncode)
3082
+ filtersApplied.push("ncode");
3083
+ if ((input.setAside?.length ?? 0) > 0)
3084
+ filtersApplied.push("setAside");
3085
+ if (input.state)
3086
+ filtersApplied.push("state");
3087
+ // filtersDropped: organization-name has NO keyless param (ignored), and a
3088
+ // requested response-deadline window is applied client-side (feed ignores
3089
+ // rdlfrom/rdlto) — both must be disclosed so the AI never treats the page
3090
+ // as server-filtered on them.
3091
+ const filtersDropped = [];
3092
+ if (input.organizationName)
3093
+ filtersDropped.push("organizationName");
3094
+ if (wantWindow)
3095
+ filtersDropped.push("responseDeadline");
3096
+ const notes = [
3097
+ "Pre-solicitation shaping radar: notice_type is filtered SERVER-SIDE by the keyless feed (r=Sources Sought, p=Presolicitation, s=Special Notice by default; k/i/u opt-in). totalRecords is the TRUE server-side count for the type+facet filter.",
3098
+ ];
3099
+ if (wantWindow) {
3100
+ notes.push("response-deadline window applied client-side over the fetched page (the keyless feed ignores rdlfrom/rdlto); widen limit or narrow via NAICS/agency for completeness. Notices with no deadline are excluded from a windowed query.");
3101
+ }
3102
+ if (input.organizationName) {
3103
+ notes.push("The organization-name filter is NOT supported by the keyless endpoint and was ignored (results are unfiltered on organization). Filter client-side on the returned `agency`, or set SAM_GOV_API_KEY.");
3104
+ }
3105
+ notes.push("naics/setAside/placeOfPerformance are null in the keyless list rows — call sam_get_opportunity(noticeId) for per-notice NAICS/set-aside/place-of-performance.");
3106
+ // truncated when the server has more than we returned OR a client-side
3107
+ // deadline window trimmed the page (either way the caller isn't seeing the
3108
+ // complete in-scope set).
3109
+ const truncated = r.totalRecords > data.returned ||
3110
+ (wantWindow && allNotices.length !== notices.length);
3111
+ return withMeta(data, {
3112
+ source: "sam.gov/api/prod/sgs/v1/search (keyless HAL, notice_type filter)",
3113
+ keylessMode: true,
3114
+ truncated,
3115
+ returned: data.returned,
3116
+ totalAvailable: r.totalRecords,
3117
+ filtersApplied,
3118
+ filtersDropped,
3119
+ fieldsUnavailable: ["naics", "setAside", "placeOfPerformance"],
3120
+ notes,
3121
+ });
3122
+ },
3123
+ }),
3124
+ defineTool({
233
3125
  name: "sam_get_opportunity",
234
3126
  description: "Fetch full detail for a single SAM.gov notice by 32-char hex noticeId. Returns title, agency, solicitation #, POCs, response deadline, attachments (with download URLs), inline description body. Call BEFORE drafting bid/no-bid or compliance work.",
235
3127
  inputSchema: SamGetOpportunityInput,
236
- },
237
- {
3128
+ handler: async (input, { sam }) => {
3129
+ const { noticeId } = input;
3130
+ const o = await sam.getOpportunity(noticeId);
3131
+ if (!o)
3132
+ return { found: false, noticeId };
3133
+ const data = {
3134
+ found: true,
3135
+ noticeId: o.noticeId,
3136
+ title: o.title,
3137
+ agency: o.fullParentPathName,
3138
+ solicitationNumber: o.solicitationNumber,
3139
+ responseDeadline: o.responseDeadLine,
3140
+ type: o.type,
3141
+ naics: o.naicsCode,
3142
+ setAside: o.typeOfSetAside,
3143
+ placeOfPerformance: o.placeOfPerformance,
3144
+ pointsOfContact: o.pointOfContact ?? [],
3145
+ description: o.description,
3146
+ attachments: (o.resourceLinks ?? []).map((url, idx) => ({
3147
+ index: idx,
3148
+ url,
3149
+ })),
3150
+ uiLink: o.uiLink,
3151
+ };
3152
+ // A HEALTHY notice returns EXACTLY as before (plain object → the server
3153
+ // synthesizes a default complete:true `_meta`) — no crying wolf. ONLY
3154
+ // when an enrichment sub-fetch DEGRADED (an outage, not a genuine empty)
3155
+ // do we attach a degraded `_meta` disclosing that the empty field is
3156
+ // UNKNOWN, not confirmed-absent. One failing sub-fetch does not flag the
3157
+ // other: notes carry exactly one entry per degraded bucket.
3158
+ if (o.enrichmentDegraded?.length) {
3159
+ const notes = o.enrichmentDegraded.map((bucket) => bucket === "attachments"
3160
+ ? "The attachment list could not be fetched (a service issue) — this notice MAY have attachments not shown here; retry. This is NOT a confirmation it has none."
3161
+ : "The awarding-organization path could not be resolved (a service issue) — it is unavailable here, not absent.");
3162
+ return withMeta(data, {
3163
+ source: sam.isKeyless ? "sam.gov (keyless)" : "api.sam.gov (keyed)",
3164
+ keylessMode: sam.isKeyless,
3165
+ complete: false,
3166
+ degraded: {
3167
+ attempted: o.enrichmentDegraded.length,
3168
+ succeeded: 0,
3169
+ failed: o.enrichmentDegraded.length,
3170
+ },
3171
+ notes,
3172
+ });
3173
+ }
3174
+ return data;
3175
+ },
3176
+ }),
3177
+ defineTool({
238
3178
  name: "sam_fetch_description",
239
3179
  description: "Return the full description / RFP body text for a notice as plain text. Useful when sam_get_opportunity returned a description URL instead of inline body, or for an LLM-friendly text dump.",
240
3180
  inputSchema: SamFetchDescriptionInput,
241
- },
242
- {
3181
+ handler: async (input, { sam }) => {
3182
+ const { noticeId } = input;
3183
+ const o = await sam.getOpportunity(noticeId);
3184
+ if (!o)
3185
+ return { found: false, noticeId };
3186
+ const text = o.description
3187
+ ? await sam.fetchOpportunityDescription(o.description)
3188
+ : "";
3189
+ return {
3190
+ found: true,
3191
+ noticeId,
3192
+ descriptionLength: text.length,
3193
+ description: text || "(no description body available)",
3194
+ };
3195
+ },
3196
+ }),
3197
+ defineTool({
243
3198
  name: "sam_attachment_url",
244
3199
  description: "Build the public download URL for an attachment resourceId. The URL returns a 303 redirect to a signed S3 URL — fetch with redirect:'follow' to get the file bytes.",
245
3200
  inputSchema: SamAttachmentUrlInput,
246
- },
247
- {
3201
+ handler: async (input, { sam }) => ({
3202
+ downloadUrl: sam.publicDownloadUrl(input.resourceId),
3203
+ }),
3204
+ }),
3205
+ defineTool({
3206
+ name: "sam_fetch_attachment_text",
3207
+ description: "Extract the TEXT of a SAM notice attachment (the actual RFP / SOW / Q&A / wage tables) by its download URL — so an AI can read the real solicitation, not just its metadata. Give it a sam_get_opportunity attachments[].url (resourceLinks). Keyless. Handles PDF (via pdfjs) + text/HTML; returns { format, text, pages, filename, sizeBytes, truncated, extracted }. HONEST: a DOCX / binary that can't be read keyless returns text:null + a note (never fabricated); a corrupt/encrypted PDF returns text:null + an extractionError note (never a crash); a DOWN fetch throws a retryable upstream_unavailable (never empty text); a 404 throws not_found. Only sam.gov / api.sam.gov URLs are fetched (SSRF hygiene). maxChars caps the text (default 200000) and truncation is disclosed.",
3208
+ inputSchema: SamFetchAttachmentTextInput,
3209
+ handler: (input) => fetchAttachmentText(input),
3210
+ }),
3211
+ defineTool({
248
3212
  name: "sam_lookup_organization",
249
3213
  description: "Resolve a SAM.gov federal-organization id to its canonical fullParentPathName (e.g. 'VETERANS AFFAIRS, DEPARTMENT OF.VETERANS AFFAIRS, DEPARTMENT OF.245-NETWORK CONTRACT OFFICE 5'). Use when sam_get_opportunity returned only an organizationId.",
250
3214
  inputSchema: SamLookupOrgInput,
251
- },
252
- // ━━━ USAspending Awards & Recipients (8) ━━━
253
- {
3215
+ handler: async (input, { sam }) => {
3216
+ const { organizationId } = input;
3217
+ // SamGovClient internal method — exposed via direct fetch since
3218
+ // it's not on the public surface. Use the public sam.gov endpoint
3219
+ // directly (already keyless).
3220
+ const orgUrl = `https://sam.gov/api/prod/federalorganizations/v1/organizations/${encodeURIComponent(organizationId)}`;
3221
+ let r;
3222
+ try {
3223
+ r = await fetch(orgUrl, {
3224
+ headers: { Accept: "application/hal+json" },
3225
+ signal: AbortSignal.timeout(10_000),
3226
+ });
3227
+ }
3228
+ catch (e) {
3229
+ // A network-level fault (DNS, connection reset, timeout) is an OUTAGE, not
3230
+ // absence — classify as retryable rather than letting it surface as the
3231
+ // generic `unknown` (which an agent won't retry).
3232
+ if (e instanceof ToolErrorCarrier)
3233
+ throw e;
3234
+ throw new ToolErrorCarrier({
3235
+ kind: "upstream_unavailable",
3236
+ message: `SAM federalorganizations lookup for '${organizationId}' failed: ${e.message}. This is an outage, not a missing organization. Retry.`,
3237
+ retryable: true,
3238
+ retryAfterSeconds: 30,
3239
+ upstreamEndpoint: "sam:federalorganizations",
3240
+ });
3241
+ }
3242
+ if (!r.ok) {
3243
+ // 404 = the organization genuinely does not exist → a real negative (the
3244
+ // tool's found:false contract), NOT an error.
3245
+ if (r.status === 404) {
3246
+ return { found: false, organizationId, status: 404 };
3247
+ }
3248
+ // Every OTHER non-2xx is an upstream fault, not absence — classify via the
3249
+ // shared errorFromResponse matrix (400/403→invalid_input non-retryable,
3250
+ // 429→rate_limited with Retry-After, 5xx→upstream_unavailable retryable;
3251
+ // carries upstreamStatus) so a down/blocking service is NEVER read as "org
3252
+ // not found" (the fetch-failure-as-absent masquerade).
3253
+ throw new ToolErrorCarrier(errorFromResponse(r, "sam:federalorganizations"));
3254
+ }
3255
+ // This endpoint signals a NONEXISTENT org id with a 200 + EMPTY body
3256
+ // (live-verified 2026-07-06) — a genuine absence. Read text first so an
3257
+ // empty/degraded body never crashes `r.json()` into a mislabeled `unknown`.
3258
+ const orgText = await r.text();
3259
+ if (!orgText.trim()) {
3260
+ return { found: false, organizationId, status: 200 };
3261
+ }
3262
+ let orgJson;
3263
+ try {
3264
+ orgJson = JSON.parse(orgText);
3265
+ }
3266
+ catch {
3267
+ // A non-empty, non-JSON 200 (e.g. an HTML error/interstitial page from the
3268
+ // CDN/WAF) is a DEGRADED response — do NOT fabricate found:false on garbage;
3269
+ // surface it as schema_drift so the caller knows it's unconfirmed, not absent.
3270
+ throw new ToolErrorCarrier({
3271
+ kind: "schema_drift",
3272
+ message: `SAM federalorganizations returned a 200 with a non-JSON body for '${organizationId}' — unexpected shape; cannot confirm whether the organization exists.`,
3273
+ retryable: false,
3274
+ upstreamEndpoint: "sam:federalorganizations",
3275
+ });
3276
+ }
3277
+ const org = orgJson._embedded?.[0]?.org;
3278
+ return {
3279
+ found: !!org,
3280
+ organizationId,
3281
+ fullParentPathName: org?.fullParentPathName ?? "",
3282
+ agencyName: org?.agencyName ?? "",
3283
+ name: org?.name ?? "",
3284
+ type: org?.type,
3285
+ level: org?.level,
3286
+ };
3287
+ },
3288
+ }),
3289
+ defineTool({
3290
+ name: "sam_lookup_notice_fields",
3291
+ description: "BATCH-complete a sam_search_opportunities page in ONE call from the GSA daily bulk CSV (keyless). The keyless HAL list endpoint NULLS each result's naics/setAside/place-of-performance/responseDeadline/type; this tool returns those fields for 1..100 noticeIds at once (naicsCode, setAside + setAsideCode, popState/popCity/popZip/popCountry, responseDeadline, type, active, title) from a cached on-disk CSV index, instead of one sam_get_opportunity detail call per notice. OFF BY DEFAULT (no forced 226 MB download): enable by setting SAM_GOV_CSV_CACHE (a cache dir) or SAM_GOV_ENABLE_CSV=1 — when disabled the tool returns data.enabled:false + a structured 'how to enable' note (never fake data, no network). HONEST: _meta carries the CSV last-modified + index build time (freshness), a noticeId absent from the current snapshot returns found:false + nulls with an explicit 'not in current CSV snapshot' disclosure (never faked), a cold first call discloses 'index warming', and a download/parse failure is a structured retryable error (never a silent empty). setAsideCode (e.g. 'SBA') matches sam_get_opportunity's setAside; the snapshot can lag live by up to ~24h — confirm real-time-critical fields with sam_get_opportunity.",
3292
+ inputSchema: SamLookupNoticeFieldsInput,
3293
+ handler: (input) => gsaCsv.lookupNoticeFields(input),
3294
+ }),
3295
+ // ━━━ USAspending — Awards & Recipients (10) ━━━
3296
+ defineTool({
254
3297
  name: "usas_search_awards",
255
- description: "Aggregate share-of-wallet on USAspending. Given an agency × NAICS × fiscal year, returns top recipients by total $ + count. Use for competitive landscape ('who wins at VA in 541512?').",
3298
+ description: "Aggregate share-of-wallet on USAspending. Given an agency × NAICS × fiscal year, returns top recipients by total obligated $ ONLY — per-recipient award COUNTS are NOT returned by this endpoint (`awards`/`totalAwards` are null, not 0); for real per-recipient contract counts use usas_search_awards_by_recipient (its _meta.totalAvailable) or usas_get_recipient_profile. Use for competitive landscape ('who wins at VA in 541512?').",
256
3299
  inputSchema: UsasFiltersBase,
257
- },
258
- {
3300
+ handler: (input) => usas.searchAwards(input),
3301
+ }),
3302
+ defineTool({
259
3303
  name: "usas_search_individual_awards",
260
3304
  description: "Line-item federal contracts on USAspending. Returns specific awards (recipient + $ + sub-agency + state + description). Use AFTER usas_search_awards when the user wants 'show me the actual contracts'. Each result includes a generatedInternalId for usas_get_award_detail follow-ups.",
261
3305
  inputSchema: UsasIndividualAwardsInput,
262
- },
263
- {
3306
+ handler: (input) => usas.searchIndividualAwards(input),
3307
+ }),
3308
+ defineTool({
264
3309
  name: "usas_search_subagency_spending",
265
3310
  description: "Break down a parent agency's spending by sub-agency / office. Surfaces which office holds the budget (e.g. VA OI&T vs VHA, DoD vs Army vs DISA).",
266
3311
  inputSchema: UsasSubAgencyInput,
267
- },
268
- {
3312
+ handler: (input) => usas.searchSubAgencySpending(input),
3313
+ }),
3314
+ defineTool({
269
3315
  name: "usas_lookup_agency",
270
3316
  description: "Resolve a user-friendly agency reference ('VA', 'Veterans Affairs', 'DHS') to USAspending's canonical toptier name + 4-digit code. ALWAYS call this FIRST if the user uses an abbreviation — other USAspending tools require the canonical name.",
271
3317
  inputSchema: UsasLookupAgencyInput,
272
- },
273
- {
3318
+ handler: (input) => usas.lookupAgency(input.searchText),
3319
+ }),
3320
+ defineTool({
274
3321
  name: "usas_search_awards_by_recipient",
275
3322
  description: "Pull every contract a specific recipient has won within an agency × NAICS slice. Use when the user asks 'show me Booz Allen wins at VA last year' — returns line items + naicsCode + description, not aggregates.",
276
3323
  inputSchema: UsasRecipientAwardsInput,
277
- },
278
- {
3324
+ handler: (input) => usas.searchAwardsByRecipient(input),
3325
+ }),
3326
+ defineTool({
279
3327
  name: "usas_search_subawards",
280
3328
  description: "Enumerate subcontracts on prime awards. Use for 'who teams with Leidos at DISA' or 'show small-business subs on Accenture's DHS contracts' — surfaces the prime/sub network for teaming-map artifacts.",
281
3329
  inputSchema: UsasSubawardsInput,
282
- },
283
- {
3330
+ handler: (input) => usas.searchSubawards(input),
3331
+ }),
3332
+ defineTool({
3333
+ name: "usas_search_recompetes",
3334
+ description: "Recompete radar — federal contracts whose CURRENT period of performance ends inside a window around today (default -90d .. +18mo), sorted soonest-first. Use for 'what VA 541512 contracts are up for recompete in the next 18 months'. Reads the current PoP end date directly from spending_by_award (no per-award enrichment), counts (never drops) rows with missing end dates, and flags in _meta when the scan budget truncates the window (totalAvailable becomes null). Filter by agency/naics/pscCodes/setAside/minAwardValue; set includePotentialEnd for option-inclusive end dates. Public signals only — no CPARS/protest/option-intent, no composite vulnerability score.",
3335
+ inputSchema: UsasRecompetesInput,
3336
+ handler: (input) => usas.searchRecompetes(input),
3337
+ }),
3338
+ defineTool({
284
3339
  name: "usas_search_expiring_contracts",
285
- description: "Find federal contracts at agency × NAICS that expire within N months. Recompete radar end-date sorted, top 10 by value. Use for 'what VA cloud contracts are up for recompete' or 'show 541512 contracts expiring in 6 months'.",
3340
+ description: "DEPRECATED use usas_search_recompetes. Thin backward-compatible alias: finds contracts at agency × NAICS expiring within N months and returns the legacy { contracts, searchedCount } shape. New callers should use usas_search_recompetes for the full window/pagination controls and truthful completeness metadata.",
286
3341
  inputSchema: UsasExpiringInput,
287
- },
288
- {
3342
+ handler: (input) => usas.searchExpiringContracts(input),
3343
+ }),
3344
+ defineTool({
289
3345
  name: "usas_get_award_detail",
290
3346
  description: "Fetch full detail for a single award by generatedInternalId (from usas_search_individual_awards). Returns period_of_performance (start/end/potential_end), base_and_all_options, set-aside type, competition extent, number_of_offers — the per-award fields the search endpoint omits.",
291
3347
  inputSchema: UsasAwardDetailInput,
292
- },
3348
+ handler: (input) => usas.getAwardDetail(input.generatedInternalId),
3349
+ }),
3350
+ defineTool({
3351
+ name: "usas_analyze_incumbent",
3352
+ description: "Per-award incumbent + PUBLIC recompete-pressure analysis for ONE award (generatedInternalId). Assembles the incumbent identity, the vehicle/IDV linkage, and individual PUBLIC pressure SIGNALS — obligated-vs-ceiling consumption (pctConsumed), modification count (lower-bounded), competition extent + number of offers, set-aside, days to the current PoP end, and option-extendable days — plus, optionally, the incumbent's other awards in the same agency×NAICS. Bounded & keyless: at most 3 upstream calls (detail + 1 transactions page + 1 recipient search), no per-record fan-out. Emits pressureHints ('single_offer', 'ceiling_nearly_exhausted', 'hard_stop_no_options') as HINTS, NEVER a composite vulnerability score — CPARS/past-performance, protest history, and option-exercise intent are not public (declared in _meta.fieldsUnavailable).",
3353
+ inputSchema: UsasAnalyzeIncumbentInput,
3354
+ handler: (input) => usas.analyzeIncumbent(input),
3355
+ }),
293
3356
  // ━━━ USAspending — Aggregate Analysis (6) ━━━
294
- {
3357
+ defineTool({
295
3358
  name: "usas_spending_over_time",
296
- description: "Time-series aggregation of federal spending. Group by fiscal_year / quarter / month, filter by agency / NAICS / set-aside. Use for 'how has VA 541512 spending trended over the past 5 years' — returns yearly/quarterly/monthly $ rollups.",
3359
+ description: "Time-series aggregation of federal CONTRACT spending (award types A/B/C/D — grants, IDVs, loans, and other obligation types are EXCLUDED, matching the other usas_search_*_spending tools; disclosed in _meta). Group by fiscal_year / quarter / month, filter by agency / NAICS / set-aside. Use for 'how has VA 541512 contract spending trended over the past 5 years' — returns yearly/quarterly/monthly $ rollups of contract obligations (grantObligations/idvObligations are null, NOT 0, to avoid implying an agency has no grant/IDV spending).",
297
3360
  inputSchema: UsasSpendingOverTimeInput,
298
- },
299
- {
3361
+ handler: (input) => usas.spendingOverTime(input),
3362
+ }),
3363
+ defineTool({
300
3364
  name: "usas_search_psc_spending",
301
3365
  description: "Spending broken down by Product Service Code (PSC). Use for 'what PSC categories see the most $ at DoD' — surfaces market structure beyond NAICS (e.g. PSC R425 = engineering support services).",
302
3366
  inputSchema: UsasCategorySpendingInput,
303
- },
304
- {
3367
+ handler: (input) => usas.searchPscSpending(input),
3368
+ }),
3369
+ defineTool({
305
3370
  name: "usas_search_state_spending",
306
3371
  description: "Spending broken down by state / territory. Use for 'where is the most federal $ flowing for NAICS 541512' — answers like 'VA $128B, MD $66B, DC $58B'.",
307
3372
  inputSchema: UsasCategorySpendingInput,
308
- },
309
- {
3373
+ handler: (input) => usas.searchStateSpending(input),
3374
+ }),
3375
+ defineTool({
310
3376
  name: "usas_search_cfda_spending",
311
3377
  description: "Spending broken down by CFDA grant program code. Use for grant analysis — 'top federal grant programs by $'. Note: CFDA is grants (award_type 02-05), not contracts. Use usas_search_psc_spending for contract market analysis.",
312
3378
  inputSchema: UsasCfdaInput,
313
- },
314
- {
3379
+ handler: (input) => usas.searchCfdaSpending(input),
3380
+ }),
3381
+ defineTool({
315
3382
  name: "usas_search_federal_account_spending",
316
3383
  description: "Spending broken down by federal account / Treasury Account Symbol (TAS). Use to map money to the actual budget line item (e.g. '036-0167 = Information Technology Systems, VA').",
317
3384
  inputSchema: UsasCategorySpendingInput,
318
- },
319
- {
3385
+ handler: (input) => usas.searchFederalAccountSpending(input),
3386
+ }),
3387
+ defineTool({
320
3388
  name: "usas_search_agency_spending",
321
3389
  description: "Spending broken down by awarding agency. Use for 'which agencies spend the most on NAICS 541512' — top buyers by $.",
322
3390
  inputSchema: UsasAgencySpendingInput,
323
- },
3391
+ handler: (input) => usas.searchAgencySpending(input),
3392
+ }),
324
3393
  // ━━━ USAspending — Agency Profile (3) ━━━
325
- {
3394
+ defineTool({
326
3395
  name: "usas_get_agency_profile",
327
3396
  description: "Get full agency profile by toptier code (3-4 digits, from usas_lookup_agency). Returns mission, abbreviation, website, subtier_agency_count, congressional_justification_url.",
328
3397
  inputSchema: UsasAgencyProfileInput,
329
- },
330
- {
3398
+ handler: (input) => usas.getAgencyProfile(input.toptierCode),
3399
+ }),
3400
+ defineTool({
331
3401
  name: "usas_get_agency_awards_summary",
332
- description: "High-level award activity for a fiscal year — transaction_count + obligations + latest_action_date. Snapshot of agency volume.",
3402
+ description: "High-level award activity for a fiscal year — transaction_count + obligations + latest_action_date. SCOPE: obligations/transaction_count span ALL award types (contracts, grants, direct payments incl. benefits, loans) — NOT prime contracts only. For benefit-heavy agencies (VA/SSA/HHS) this is dominated by direct benefit payments (e.g. VA FY2024 ~$238B all-awards vs ~$67B prime contracts), so do NOT read it as the contract/procurement market; for procurement-heavy agencies (DoD/DHS) it closely tracks contract spending. For contracts-only obligations use usas_spending_over_time (contractObligations) — it takes the agency canonical NAME, so resolve it from this toptierCode via usas_get_agency_profile first.",
333
3403
  inputSchema: UsasAgencyAwardsInput,
334
- },
335
- {
3404
+ handler: (input) => usas.getAgencyAwardsSummary(input),
3405
+ }),
3406
+ defineTool({
336
3407
  name: "usas_get_agency_budget_function",
337
3408
  description: "Budget function breakdown for an agency × fiscal year. Returns the agency's spending by program area (e.g. VA: 'Income security for veterans' $204B, 'Hospital and medical care for veterans' $126B).",
338
3409
  inputSchema: UsasAgencyBudgetInput,
339
- },
3410
+ handler: (input) => usas.getAgencyBudgetFunction(input),
3411
+ }),
340
3412
  // ━━━ USAspending — Recipient Profile (2) ━━━
341
- {
3413
+ defineTool({
342
3414
  name: "usas_search_recipients",
343
3415
  description: "Search USAspending recipient list with parent/child/recipient hierarchy. Returns recipients with id, duns, uei, level (P=parent, C=child, R=recipient), total_amount. Use for 'find the recipient_id for Booz Allen' before usas_get_recipient_profile.",
344
3416
  inputSchema: UsasSearchRecipientsInput,
345
- },
346
- {
3417
+ handler: (input) => usas.searchRecipients(input),
3418
+ }),
3419
+ defineTool({
347
3420
  name: "usas_get_recipient_profile",
348
3421
  description: "Full recipient detail by recipient_id (from usas_search_recipients). Returns alternate_names (M&A history), DUNS, UEI, parent linkage, business_types, location, total_amount, total_transactions.",
349
3422
  inputSchema: UsasGetRecipientInput,
350
- },
351
- // ━━━ USAspending — Reference / Autocomplete (4) ━━━
352
- {
3423
+ handler: (input) => usas.getRecipientProfile(input.recipientId),
3424
+ }),
3425
+ // ━━━ USAspending — Reference / Autocomplete (5) ━━━
3426
+ defineTool({
353
3427
  name: "usas_autocomplete_naics",
354
3428
  description: "Autocomplete NAICS codes by free-text. ANTI-HALLUCINATION GUARD — call this when the user mentions a NAICS theme but no specific code (e.g. 'computer systems design' → 541512). Avoids inventing NAICS codes.",
355
3429
  inputSchema: UsasAutocompleteInput,
356
- },
357
- {
3430
+ handler: (input) => usas.autocompleteNaics(input),
3431
+ }),
3432
+ defineTool({
358
3433
  name: "usas_autocomplete_recipient",
359
3434
  description: "Autocomplete recipient names. ANTI-HALLUCINATION — confirm a recipient's exact USAspending-canonical legal name before searching by name. Returns up to 10 fuzzy matches with UEI/DUNS where available.",
360
3435
  inputSchema: UsasAutocompleteInput,
361
- },
362
- {
3436
+ handler: (input) => usas.autocompleteRecipient(input),
3437
+ }),
3438
+ defineTool({
363
3439
  name: "usas_naics_hierarchy",
364
- description: "Navigate the NAICS hierarchy (2-digit 4-digit 6-digit). Returns parent/child relationships + active-contract count per code. Use to explore market scope ('what's under NAICS 541' = 'Professional, Scientific, and Technical Services').",
3440
+ description: "Navigate the NAICS hierarchy (2→4→6 digit) + active-contract count per code. No naicsFilter ⇒ the top-level 2-digit sectors. With naicsFilter=<code> ⇒ that node is in `parent` and its DIRECT children are in `hierarchy` (drill into any row where hasChildren:true by re-calling with its code). A 6-digit leaf returns hierarchy:[] with the node in `parent` (found:true); a nonexistent code returns hierarchy:[] with parent:null (found:false). Use to explore market scope (e.g. what's under NAICS 54 = Professional, Scientific, and Technical Services).",
365
3441
  inputSchema: UsasNaicsHierarchyInput,
366
- },
367
- {
3442
+ handler: (input) => usas.naicsHierarchy(input),
3443
+ }),
3444
+ defineTool({
368
3445
  name: "usas_glossary",
369
3446
  description: "USAspending glossary of 151 federal-spending terms. Use to confirm terminology ('what's a TAS?', 'what's an obligation vs outlay?') before answering compliance/budget questions.",
370
3447
  inputSchema: UsasGlossaryInput,
371
- },
372
- {
3448
+ handler: (input) => usas.glossary(input),
3449
+ }),
3450
+ defineTool({
373
3451
  name: "usas_list_toptier_agencies",
374
3452
  description: "List all toptier federal agencies with toptier_code, abbreviation, slug, current-FY obligations. Use for 'show me every cabinet department + their FY26 spending' or to find a toptier_code for usas_get_agency_*.",
375
3453
  inputSchema: UsasListAgenciesInput,
376
- },
377
- // ━━━ Federal Register (3) ━━━
378
- {
3454
+ handler: (input) => usas.listToptierAgencies(input),
3455
+ }),
3456
+ // ━━━ Federal Register (4) ━━━
3457
+ defineTool({
379
3458
  name: "fed_register_search_documents",
380
3459
  description: "Search Federal Register documents (proposed rules, final rules, notices, presidential documents) by query / agency / type / date range. Use for regulatory-context queries ('what new VA cybersecurity rules came out this quarter?').",
381
3460
  inputSchema: FedRegSearchInput,
382
- },
383
- {
3461
+ handler: (input) => fedreg.searchDocuments(input),
3462
+ }),
3463
+ defineTool({
384
3464
  name: "fed_register_get_document",
385
3465
  description: "Fetch full detail for a Federal Register document by number. Returns title, abstract, citation, publication_date, effective_on, raw_text_url (for the full body), CFR references — everything needed to ground a regulation citation.",
386
3466
  inputSchema: FedRegGetDocInput,
387
- },
388
- {
3467
+ handler: (input) => fedreg.getDocument(input.documentNumber),
3468
+ }),
3469
+ defineTool({
389
3470
  name: "fed_register_list_agencies",
390
3471
  description: "List all Federal Register agencies with slugs (needed for fed_register_search_documents). Use to resolve 'what's the FedReg slug for Veterans Affairs?'",
391
3472
  inputSchema: FedRegListAgenciesInput,
392
- },
393
- // ━━━ eCFR (2) ━━━
394
- {
3473
+ handler: (input) => fedreg.listAgencies(input),
3474
+ }),
3475
+ defineTool({
3476
+ name: "fed_register_public_inspection",
3477
+ description: "Federal Register PUBLIC INSPECTION desk — documents FILED with the Office of the Federal Register but NOT YET published (a pre-publication LEADING INDICATOR, ~1-to-several days ahead of the official publication_date). mode: 'current' (all on inspection now), 'date' (a specific available_on day), 'search' (full-text over the on-inspection set). Returns per-doc leadDays (pre-publication head-start), filing_type special-vs-regular, and unflattened agencies. NOTE: a public-inspection doc is NOT the authoritative published rule (no FR citation/page yet; may change or be withdrawn) — after publication_date cross-check fed_register_get_document.",
3478
+ inputSchema: FedRegPublicInspectionInput,
3479
+ handler: (input) => fedreg.publicInspection(input),
3480
+ }),
3481
+ // ━━━ eCFR (5) ━━━
3482
+ defineTool({
395
3483
  name: "ecfr_search",
396
3484
  description: "Full-text search across the entire CFR (Code of Federal Regulations). Use for compliance questions — pass titleNumber=48 for FAR (Federal Acquisition Regulation), titleNumber=2 for federal financial assistance, etc. Returns excerpt + section path + ecfrUrl.",
397
3485
  inputSchema: EcfrSearchInput,
398
- },
399
- {
3486
+ handler: (input) => ecfr.search(input),
3487
+ }),
3488
+ defineTool({
400
3489
  name: "ecfr_list_titles",
401
3490
  description: "List all 50 CFR titles with name + last_amended_on date. Use to discover what's in each title (Title 48 = FAR, Title 32 = National Defense, Title 14 = Aeronautics, etc.).",
402
3491
  inputSchema: EcfrListTitlesInput,
403
- },
3492
+ handler: () => ecfr.listTitles(),
3493
+ }),
3494
+ defineTool({
3495
+ name: "far_clause_lookup",
3496
+ description: "Authoritative FAR/DFARS clause text + its PRESCRIPTION (the 'As prescribed in …' rule for when the clause applies), from the eCFR versioner-full endpoint (Title 48). Use this — NOT ecfr_search — for an EXACT clause number: full-text search mis-ranks '52.212-4' (returns GSAM 552.212-4 above the real FAR clause). Returns heading, revision date, clause/provision kind, regulation (FAR/DFARS/GSAM), full text, the prescribing section, and ecfrUrl. Every response carries farOverhaulRisk — a structural currency caveat that eCFR reflects only the CODIFIED FAR, so a clause may be superseded by a Revolutionary-FAR-Overhaul agency class deviation not shown here. A genuinely-absent clause returns a not_found error (never a fake empty clause). Keyless.",
3497
+ inputSchema: FarClauseLookupInput,
3498
+ handler: (input) => far.farClauseLookup(input),
3499
+ }),
3500
+ defineTool({
3501
+ name: "far_compliance_matrix",
3502
+ description: "Turn a solicitation's cited FAR/DFARS clause list into a proposal-ready compliance matrix (for a Section L/M response). COMPOSES far_clause_lookup over 1–25 clauses (deduped case-insensitively): each resolved row carries the clause text + prescription + regulation + a gate flag marking pass/fail award-eligibility GATES (Section 889 52.204-24/25/26, limitations on subcontracting 52.219-14, DFARS cyber 252.204-7012/7020/7021 incl. CMMC) + the farOverhaulRisk currency caveat. TRUTHFUL by construction: a clause that genuinely isn't in Title 48 (HTTP 404) goes to `unresolved`, while a clause that couldn't be fetched (eCFR down/5xx/rate-limited) goes to a SEPARATE `errored` bucket — a DOWN service is never reported as 'clause doesn't exist'; `summary.total` proves no clause is dropped. Does NOT parse the PDF solicitation to extract the clause list, and gives NO legal advice or compliance verdict. Keyless.",
3503
+ inputSchema: FarComplianceMatrixInput,
3504
+ handler: (input) => far.farComplianceMatrix(input),
3505
+ }),
3506
+ defineTool({
3507
+ name: "far_search",
3508
+ description: "FAR/DFARS-scoped semantic search — the 'which clauses touch topic X' front-door that feeds far_clause_lookup. COMPOSES ecfr_search but fixes its two compliance flaws: (1) it filters to FAR (Title 48 chapter 1) or DFARS (chapter 2), EXCLUDING GSAM/agency supplements (so 'limitations on subcontracting' no longer mis-ranks GSAM 552.x over FAR 52.x), and (2) it collapses eCFR's ~5-versions-per-section HISTORICAL duplicates to the CURRENT in-force version (endsOn==null). scope: far (default) | dfars | both. dedupeVersions (default true; false shows all historical rows). partsOnly restricts to given parts (e.g. [52] clause text). Returns distinct sections with regulation/section/headingPath/excerpt/score/ecfrUrl/effectiveOn/endsOn/isCurrent, distinctSections, and the farOverhaulRisk caveat. TRUTHFUL: dedupe never drops a distinct section (the raw→distinct collapse is disclosed); a kept-historical row is marked isCurrent:false; a search-endpoint outage THROWS (never a fake 0 results); totalAvailable is null (a deduped view has no clean upstream count). Keyless.",
3509
+ inputSchema: FarSearchInput,
3510
+ handler: (input) => far.farSearch(input),
3511
+ }),
3512
+ // ━━━ SBA — Size Standards (1) ━━━
3513
+ defineTool({
3514
+ name: "sba_size_standard",
3515
+ description: "SBA small-business size standard for a 6-digit NAICS (keyless sba.gov naics.json). Answers 'is a firm SMALL for this NAICS?' — the gate for set-aside eligibility and for vetting a usas_search_teaming_partners candidate. Returns standardType (receipts | employees | assets [financial institutions] | receipts+assets), a normalized threshold (receipts/assets in DOLLARS — the dataset's $millions figure ×1,000,000; employees as a count), the unit, and any SBA footnote. HONESTY: the dataset carries no effective-date field, so the value is 'as published as of retrieval' (asOf) and _meta.notes flags that SBA adjusts standards periodically — re-verify at sba.gov for high-stakes eligibility. An unknown NAICS returns found:false (never a fabricated standard).",
3516
+ inputSchema: SbaSizeStandardInput,
3517
+ handler: (input) => sba.sizeStandard(input),
3518
+ }),
404
3519
  // ━━━ Grants.gov (2) ━━━
405
- {
3520
+ defineTool({
406
3521
  name: "grants_search",
407
- description: "Search Grants.gov federal grant opportunities (financial assistance, distinct from contracts on SAM.gov). Filter by keyword / CFDA / agency / opportunity number. Default status = forecasted + posted.",
3522
+ description: "Search Grants.gov federal grant opportunities (financial assistance, distinct from contracts on SAM.gov). Filter by keyword / CFDA / agency / opportunity number. Default status = forecasted + posted. KEYWORD: Grants.gov OR-matches multi-word keywords (returns grants containing ANY word), so a multi-word keyword BROADENS results — pass ONE specific term for relevance (phrase quoting returns 0); narrow with cfda/agency/oppStatuses.",
408
3523
  inputSchema: GrantsSearchInput,
409
- },
410
- {
3524
+ handler: (input) => grants.searchGrants(input),
3525
+ }),
3526
+ defineTool({
411
3527
  name: "grants_get_opportunity",
412
- description: "Fetch full detail for a single grant opportunity by id. Returns description, agency, posting/response/archive dates, award_ceiling, award_floor, estimated_funding, expected_number_of_awards, applicant_types, funding_instruments, CFDA programs.",
3528
+ description: "Fetch full detail for a single grant opportunity by id. Returns found:true with description, agency, posting/response/archive dates, award_ceiling, award_floor, estimated_funding, expected_number_of_awards, applicant_types, funding_instruments, CFDA programs. `agency` is { code, name (the REAL posting/sub-tier agency, e.g. 'Food and Nutrition Service'), department (the top-tier agency, e.g. 'Department of Agriculture'), contactName (the program officer — NOT the agency) } — Grants.gov's raw `agencyName` field is actually the contact person, so this tool sources the real agency from agencyDetails; `name` may be null if the record carries no structured agency. A NONEXISTENT id returns { found:false, opportunityId } — never a fabricated grant with empty fields (Grants.gov answers a bad id with a hollow 200, which this tool detects). Check `found` before reading the other fields.",
413
3529
  inputSchema: GrantsGetInput,
414
- },
3530
+ handler: (input) => grants.getGrant(input),
3531
+ }),
3532
+ // ━━━ Pricing / Wage (3) ━━━
3533
+ defineTool({
3534
+ name: "sam_search_wage_determinations",
3535
+ description: "Find the Service Contract Act (SCA) or Davis-Bacon (DBA) wage determination(s) governing a locality (keyless SAM SGS). Filter by coverage (sca|dba), state (2-letter, server-side), county (client-side), or WD number/title. Returns the structured WD list; follow with sam_get_wage_rates to read the rate table. NOTE: `query` matches WD number/title only, NOT occupation.",
3536
+ inputSchema: WageSearchInput,
3537
+ handler: (input) => pricing.searchWageDeterminations(input),
3538
+ }),
3539
+ defineTool({
3540
+ name: "sam_get_wage_rates",
3541
+ description: "Return the prevailing-wage + fringe/H&W rate table for a specific wage determination, PARSED from its plain-text document (SAM exposes no structured rate JSON), plus the Executive-Order minimum-wage floor. Distinguishes SCA (WD-wide Health & Welfare) vs DBA (per-craft fringe). Always returns parseConfidence and supports format:'parsed'|'raw'|'both' so you can read the raw text when parsing is low-confidence. Resolves the latest active revision via /history when `revision` is omitted.",
3542
+ inputSchema: WageRatesInput,
3543
+ handler: (input) => pricing.getWageRates(input),
3544
+ }),
3545
+ defineTool({
3546
+ name: "gsa_benchmark_labor_rates",
3547
+ description: "GSA CALC awarded ceiling-rate market band for a labor category (keyless). Returns a DISTRIBUTION (currentRate min/median/max + escalated medians) over a fetched sample, NOT a single price. CALC rates are CEILING/catalog and FULLY BURDENED (do not re-add wrap); the match count SATURATES at 10000 for broad queries (totalAvailable null then). Filter by businessSize/educationLevel(code)/experience/sin to narrow.",
3548
+ inputSchema: BenchmarkLaborInput,
3549
+ handler: (input) => pricing.benchmarkLaborRates(input),
3550
+ }),
3551
+ // ━━━ Integrity / Teaming (3) ━━━
3552
+ defineTool({
3553
+ name: "sam_check_exclusions",
3554
+ description: "Keyless SAM debarment/exclusion screening. Screen a firm or individual by name (query) and/or UEI/CAGE against the SAM exclusions index (FAPIIS). Returns excluded (true iff ≥1 ACTIVE matching record), matchCount, and per-record { name, classification, uei, cage, excludingAgency, exclusionType, exclusionProgram, isActive, activation/terminationDate, samFapiisUrl }. CRITICAL: an EMPTY result means 'no matching exclusion under these terms' — it is NOT proof of general responsibility (stated in _meta.notes). A name match is not identity-proof; verify the UEI/CAGE + dates against the FAPIIS record. Requires at least one of query/uei/cage.",
3555
+ inputSchema: CheckExclusionsInput,
3556
+ handler: (input) => integrity.checkExclusions(input),
3557
+ }),
3558
+ defineTool({
3559
+ name: "sam_integrity_lookup",
3560
+ description: "Keyless ONE-CALL integrity screen — 'any integrity red flags on this entity?'. Composes the keyless government-wide EXCLUSION verdict (via sam_check_exclusions) with an honest pointer to the FAPIIS / Responsibility-Qualification record. Requires at least one of uei/cage/name (uei preferred; name maps to the exclusions text search). Returns { entity, exclusions:{excluded,activeCount,records}, fapiisRecords, fapiisUrl, integrityFlag }. integrityFlag is 'excluded' when ≥1 ACTIVE matching exclusion is found, else 'review_fapiis' — it NEVER returns 'clear' keylessly, because FAPIIS records (terminations for default/cause, non-responsibility determinations, self-reported criminal/civil/administrative proceedings) have NO keyless machine API, so absence of an exclusion is NOT proof of integrity. fapiisRecords is ALWAYS null (never faked; record-level retrieval needs an optional SAM Entity key) with _meta.fieldsUnavailable:['fapiisRecords']; fapiisUrl deep-links the viewable SAM page. An upstream exclusions failure surfaces as the classified error, never a fake clearance.",
3561
+ inputSchema: IntegrityLookupInput,
3562
+ handler: (input) => integrity.integrityLookup(input),
3563
+ }),
3564
+ defineTool({
3565
+ name: "usas_search_teaming_partners",
3566
+ description: "Small-business teaming-partner discovery by socioeconomic certification + NAICS + agency award history (keyless USAspending proxy), integrity-screened. Given a cert (enum-validated), optional naics/agency/subagency, and a lookback window, aggregates federal awardees by recipient and returns candidates ranked by agencyObligated with agencyAwardCount, mostRecentAwardDate, and sampleAwards; optionally screens the top candidates via sam_check_exclusions and drops active exclusions (excludeDebarred, default true). HONESTY: cert is AWARD-DERIVED (recorded on the firm's federal awards), NOT the SBA certification of record (which needs a keyed SAM Entity call) — verify active certification in SAM/SBS before teaming (stated in _meta). A bogus cert is rejected as invalid_input (the endpoint would silently return 0).",
3567
+ inputSchema: TeamingPartnersInput,
3568
+ handler: (input) => integrity.searchTeamingPartners(input),
3569
+ }),
3570
+ // ━━━ OFAC — Denied-Party Sanctions Screening (1) ━━━ ADR-0034
3571
+ defineTool({
3572
+ name: "ofac_screen_entity",
3573
+ description: "Keyless OFAC denied-party sanctions screening — the legally-required leg that SAM exclusions does NOT cover (31 CFR ch. V, strict-liability). Screens a `name` against OFAC's published SDN + Consolidated bulk lists (primary names AND AKAs from ALT.CSV joined by ent_num AND a.k.a./f.k.a./n.k.a. aliases mined from SDN/CONS Remarks — so an alias-only party like 'BNC' for BANCO NACIONAL DE CUBA is caught). Optional post-filters: type (individual|entity|vessel|aircraft), program (e.g. CUBA/IRAN/SDGT), list (sdn|consolidated|all, default all), minMatchQuality (exact|strong|weak, default weak), limit. Returns result ('potential_matches' | 'no_name_match' — NEVER 'clear'), matchCount, and per-match { name, matchedVia (primary|aka(alt)|aka(remarks)), akaType, matchQuality, list, programs, type, entNum, ofacSearchUrl }. ★SAFETY: this is a NAME SCREEN, NOT a legal determination — a no_name_match is NOT a clearance (transliterations/variants can miss a real hit) and a weak/strong hit is a REVIEW CANDIDATE requiring human adjudication against OFAC's Sanctions List Search. Every fetch failure / SSRF reject / parse drift / floor-fail THROWS (a download failure is NEVER read as a clear). minMatchQuality/type/program only trim returned matches — result reflects existence at any quality. Snapshot freshness (publish date + cache age) rides in _meta.",
3574
+ inputSchema: OfacScreenInput,
3575
+ handler: (input) => ofac.screenEntity(input),
3576
+ }),
3577
+ // ━━━ NVD + CISA KEV — the IT/CYBER-COMPLIANCE lane (2) ━━━ ADR-0035
3578
+ // Opens the FedRAMP/CMMC/SBOM IT-compliance lane the server lacked: NIST NVD
3579
+ // CVE/CVSS severity JOINED with the CISA KEV mandatory-remediation catalog.
3580
+ // Keyless (an OPTIONAL free NVD_API_KEY lifts the rate; header-only, never
3581
+ // logged). Never-fake: a genuine totalResults:0/found:false is honest, but any
3582
+ // 403/429/404/5xx/timeout/redirect-off-host THROWS; a KEV outage degrades
3583
+ // kev.listed to null (never false), and a kevOnly filter during an outage THROWS.
3584
+ defineTool({
3585
+ name: "cve_lookup",
3586
+ description: "Look up NIST NVD CVE records (keyless; services.nvd.nist.gov CVE API 2.0) — exact by `cveId` (CVE-YYYY-NNNN) OR search by `keyword`/`cpeName`/`cvssV3Severity`/a publication or last-modified date range — each row JOINED with its CISA KEV (Known Exploited Vulnerabilities) status. THE B2G unlock for FedRAMP/CMMC/SBOM IT-compliance: CVSS severity AND whether CISA mandates remediation by a date, in one row. Returns { results:[{ cveId, vulnStatus, rejected, published, lastModified, description, cvssMetrics:[{version,source,type,baseScore,baseSeverity,vectorString,exploitabilityScore,impactScore}], primaryCvss:{version,baseScore,baseSeverity,type}|null, cwes, references, kev }] } + honest _meta. Optional `kevOnly` (KEV-listed rows only), `resultsPerPage` (≤2000, def 50), `startIndex`. CVSS HONESTY: every metrics key matching ^cvssMetric (V2/V30/V31/V40) is surfaced as its own cvssMetrics[] element — versions are NEVER conflated and ssvcV203/non-CVSS keys are excluded; V2 baseSeverity reads from the metric level; primaryCvss is the highest-version metric, preferring type:'Primary' but FALLING BACK to the highest Secondary (a real CNA score is never dropped), null ONLY when no CVSS exists (Rejected/Awaiting) — base scores are null-never-0. KEV HONESTY: kev is {listed:true,dateAdded,dueDate,ransomware,requiredAction,catalogVersion} | {listed:false,note} | {listed:null,status:'unavailable'}; a not-listed result carries the not-in-KEV≠safe caveat (absence is NOT a clearance); if the KEV catalog cannot load, kev.listed degrades to NULL (never false) with fieldsUnavailable:['kev'], and a kevOnly filter during that outage THROWS (a KEV-membership filter is unanswerable without the catalog). PAGINATION is from NVD's EXACT totalResults, never page length. A genuine totalResults:0 is an honest found:false; a 403/429 rate breach THROWS rate_limited with the NVD_API_KEY tier disclosure; 404/5xx/timeout/off-host-redirect THROW (never a fake-empty). An OPTIONAL free NVD_API_KEY (env; https://nvd.nist.gov/developers/request-an-api-key) lifts the rate and is sent ONLY in the apiKey header — never a URL/label/_meta/log.",
3587
+ inputSchema: CveLookupInput,
3588
+ handler: (input) => nvd.cveLookup(input),
3589
+ }),
3590
+ defineTool({
3591
+ name: "cisa_kev_lookup",
3592
+ description: "Filter the CISA Known Exploited Vulnerabilities (KEV) catalog standalone (keyless; www.cisa.gov feed, cached) — the mandatory-remediation list carrying BINDING due-dates under BOD 22-01 / its 2026 successor BOD 26-04. Works even when NVD is rate-limited (a separate host, no key). Filters (all optional, AND-combined, client-side): `cveId` (exact KEV membership check), `vendorProject`/`product` (case-insensitive substring), `ransomwareOnly` (knownRansomwareCampaignUse === 'Known'), `addedSince`/`dueBefore` (ISO YYYY-MM-DD); `limit` (≤1000, def 100), `offset`. Returns { catalogVersion, dateReleased, count, found?, matches:[{ cveID, vendorProject, product, vulnerabilityName, dateAdded, dueDate, knownRansomwareCampaignUse, shortDescription, requiredAction, cwes, nvdUrl }] } + honest _meta. ★HONESTY: knownRansomwareCampaignUse and requiredAction are surfaced VERBATIM (never defaulted); dueDate is the CISA-mandated remediation deadline. A cveId NOT in the catalog ⇒ found:false — but the not-in-KEV≠safe caveat rides on EVERY response: KEV is a CURATED SUBSET of confirmed in-the-wild exploitation, so absence means CISA has not catalogued it, NOT that the component is unexploited/safe. A catalog download failure / floor-fail / count-drift THROWS (a truncated/near-empty catalog must never read as 'nothing is exploited') — never a fake-empty. The snapshot freshness (catalogVersion + release date + cache age) is disclosed.",
3593
+ inputSchema: CisaKevLookupInput,
3594
+ handler: (input) => nvd.cisaKevLookup(input),
3595
+ }),
3596
+ // ━━━ NPPES NPI Registry — Healthcare-Provider Vetting (1) ━━━ ADR-0036
3597
+ defineTool({
3598
+ name: "nppes_lookup_provider",
3599
+ description: "Keyless CMS/HHS NPPES NPI Registry lookup — the authoritative PUBLIC registry of every US healthcare provider (individual NPI-1 + organization NPI-2), for VA/HHS/CMS subcontractor/provider/teaming due-diligence (validate an NPI, confirm taxonomy/specialty, enumeration status, practice state, org/name match). Host npiregistry.cms.hhs.gov/api (version=2.1). Mode is inferred from `number` (no mode flag). EXACT-NPI mode (`number` given): the NPI is CMS-Luhn-validated client-side (Luhn over 80840+first-9) ⇒ a typo'd NPI is invalid_input, NEVER a fake 'does not exist'; ★the wire query carries `number` (+version) ALONE — any co-supplied filter (last_name/state/…) is DROPPED from the wire and checked CLIENT-SIDE (disclosed in data.filterMatch:{field:bool} + data.filtersDropped), because NPPES AND-combines a number with filters and a mismatch would falsely zero a real active provider into found:false. SEARCH mode: required-one of { first_name, last_name, organization_name, taxonomy_description, city, postal_code } (state + enumeration_type are REFINERS ONLY — rejected alone); a trailing '*' wildcard on a name/org field needs ≥2 leading literal chars. Returns EXACT-mode { found, provider:{ number, enumerationType, active, status, basic{…individual OR org fields, null-never-fabricated…}, taxonomies[{code,desc,primary,state,license,taxonomyGroup}], addresses[{purpose,address1,city,state,postalCode,telephone,fax,countryCode}], practiceLocations[…same, SEPARATE from addresses], identifiers[], otherNames[], endpoints[], createdEpoch, lastUpdatedEpoch }, filterMatch? } OR SEARCH-mode { providers:[…] } + honest _meta. HONESTY: active = basic.status==='A' (a deactivated/absent NPI is NOT active); epochs are ms numeric STRINGS → number|null (null-never-0); addresses[] and practiceLocations[] are kept SEPARATE (a provider can practice in a state that appears ONLY in practiceLocations); NPPES exposes NO match total, so a full page ⇒ totalAvailable is a disclosed LOWER BOUND (totalIsLowerBound) + a ~1,200-row-per-query reach cap (limit ≤ 200, skip ≤ 1,000 — OUR policy, a PER-QUERY cap only; cross-query enumeration is not architecturally prevented). A genuine {result_count:0} ⇒ honest found:false/empty; a {Errors:[…]} 200 body (no results key) ⇒ THROWS invalid_input (never a fake empty); any 4xx/5xx/timeout/off-host-redirect ⇒ THROWS; result_count !== results.length ⇒ schema_drift. ★NOT a fitness/exclusion/licensure/sanctions determination — cross-check SAM exclusions + OFAC; individual (NPI-1) records may surface personal/home addresses + phone/fax verbatim with NO enrichment. The caveat + reach-cap disclosure ride EVERY response.",
3600
+ inputSchema: NppesLookupInput,
3601
+ handler: (input) => nppes.lookupProvider(input),
3602
+ }),
3603
+ // ━━━ CMS Open Payments — Healthcare Spend/Transparency (DKAN, keyless) (2) ━━━ ADR-0037
3604
+ defineTool({
3605
+ name: "cms_search_datasets",
3606
+ description: "Discover CMS Open Payments datasets on the keyless DKAN DCAT metastore (openpaymentsdata.cms.gov) — the Physician Payments Sunshine Act transparency catalog (industry→physician/teaching-hospital payments, other transfers of value, ownership interests). Returns { query, results:[{ datasetId, title, description, distributions:[{index, distId, title, mediaType, downloadURL}], keyword, modified }] } + honest _meta. Feed a result's datasetId + a distribution index to cms_query_dataset (use results:false there to enumerate the column schema before pulling rows). Optional `q` (case-insensitive title/description substring), `limit` (≤100, def 20), `offset`. ★HONESTY: the DKAN metastore IGNORES limit/offset/page and returns the ENTIRE catalog in one response, so q/limit/offset are applied CLIENT-SIDE against the in-memory array and totalAvailable is the EXACT post-q catalog size (never fabricated, never null) — hasMore is computed against the KNOWN catalog length (no false-more, no dead-end offset). The flagship targets are '2025 Research Payment Data', the General-Payment, and Ownership datasets. A non-array metastore body / HTML / 5xx / timeout THROWS (never a fake empty). NOT a determination — see cms_query_dataset's caveat.",
3607
+ inputSchema: CmsSearchDatasetsInput,
3608
+ handler: (input) => cms.searchDatasets(input),
3609
+ }),
3610
+ defineTool({
3611
+ name: "cms_query_dataset",
3612
+ description: "Query a CMS Open Payments DKAN datastore distribution by datasetId + index (keyless; openpaymentsdata.cms.gov) — the healthcare industry-financial-relationship / COI-vetting + market-intelligence lane NPPES (provider identity) cannot answer. GET /api/1/datastore/query/{datasetId}/{index} with server-side `conditions` filters, an EXACT `count`, offset/limit pagination, and a `properties` projection. Returns { datasetId, index, results (mode), fields:[{name,type,mysqlType,description}] (from the DKAN schema), rows:[…verbatim…] } + honest _meta. A confirmed target: 2025 Research Payment Data 'f0d1de67-6852-4093-a036-c9328c256a05' index 0 (count 931959; + a recipient_state='CA' condition → 92097). ★HONESTY: `count` is the EXACT grand total (P1) → totalAvailable=count + real offset pagination (NOT a page-length lower bound); `conditions` are server-side and self-policing — a valid column narrows the count, a BAD column ⇒ HTTP 400 ⇒ invalid_input, so filtersDropped is ALWAYS empty (no silent-drop path, P4); limit ≤ 500 is the HARD API cap (a higher limit ⇒ invalid_input, no silent clamp); every column is text, so amounts (total_amount_of_payment_usdollars, …) arrive as STRINGS surfaced verbatim (a missing amount is null-never-0, P3). ★results:false = a COUNT/SCHEMA-discovery mode: no rows, pagination disabled (no livelock), but the EXACT count + every column's schema returned (count=true is ALWAYS on the wire — not a caller toggle). A genuine {count:0} ⇒ honest empty; a 400 (bad column/limit) / 404 (bad datasetId/index) / HTML (SPA/WAF) / 5xx / timeout / a missing schema anchor or non-array results (in results:true) ⇒ THROW (never a fake empty). ★SSRF: datasetId (36-char lowercase UUID) + index interpolate into the URL PATH (validated before interpolation). ★PII: Open Payments is PUBLIC transparency-BY-LAW data (in-scope per the NPPES precedent) naming physicians + amounts verbatim — bounded to targeted vetting (offset ≤ 2000 reach cap), NO enrichment, NO covered_recipient_npi→NPPES auto-join. NOT a conflict-of-interest finding / fitness / exclusion determination — cross-check SAM exclusions + OFAC + the OIG-LEIE. The caveat + reach-cap disclosure ride EVERY response.",
3613
+ inputSchema: CmsQueryDatasetInput,
3614
+ handler: (input) => cms.queryDataset(input),
3615
+ }),
3616
+ // ━━━ FAC Federal Audit Clearinghouse — Single Audit audit-risk vetting (2) ━━━ ADR-0038
3617
+ defineTool({
3618
+ name: "fac_search_audits",
3619
+ description: "Search entity Single Audit summaries from the Federal Audit Clearinghouse (keyless via the api.data.gov DEMO_KEY; api.fac.gov PostgREST `general` table) — the SUBCONTRACTOR / teaming AUDIT-RISK vetting entry point (2 CFR 200 Subpart F / Single Audit Act; every entity expending ≥$750K/yr in federal awards). Structured filters (all optional, AND-combined): `auditeeUei` (12-char SAM UEI — the PRIMARY join key to SAM/USAspending/EDGAR, → auditee_uei), `auditeeState` (2-letter → auditee_state), `auditYear` (int → audit_year), `totalExpendedMin`/`totalExpendedMax` (USD → total_amount_expended gte/lte). `limit` (≤100, def 25), `offset`. Returns { audits:[{ report_id, auditee_uei, audit_year, auditee_name, auditee_ein, auditee_state, auditee_city, total_amount_expended, fac_accepted_date }] } + honest _meta. Feed a row's report_id (or the UEI) to fac_get_findings for the audit-RISK flags. ★PII: a HARDCODED select-allowlist surfaces ONLY entity + audit-summary fields and DELIBERATELY EXCLUDES the auditee's personal-contact columns (email/phone/certifying-official name) — the vetting subject is the ENTITY; there is NO caller `select`/column param. HONESTY: totalAvailable is the EXACT Content-Range total (a response header under Prefer:count=exact; a '*'/absent/non-numeric denominator ⇒ totalAvailable:null + a page-fullness hedge, NEVER 0); total_amount_expended is null-never-0 (a missing amount is null, never 0); a bad column ⇒ PostgREST 400 ⇒ invalid_input (filtersDropped is ALWAYS empty); a genuine [] ⇒ honest empty; 400/403/5xx/timeout/HTML/non-array THROW (206 = success, never a fake empty). NOT a debarment/exclusion/fitness determination — an audit finding is the auditor's opinion; cross-check SAM exclusions + OFAC. Keyless-first via DEMO_KEY (~10 req/hr shared ceiling; set DATA_GOV_API_KEY for production — never logged).",
3620
+ inputSchema: FacSearchAuditsInput,
3621
+ handler: (input) => fac.searchAudits(input),
3622
+ }),
3623
+ defineTool({
3624
+ name: "fac_get_findings",
3625
+ description: "Drill into the audit-RISK findings for an entity from the Federal Audit Clearinghouse (keyless via the api.data.gov DEMO_KEY; api.fac.gov PostgREST `findings` table) — the risk-detail step after fac_search_audits. At least ONE of `auditeeUei` (12-char UEI → auditee_uei) or `reportId` (→ report_id, from a fac_search_audits row) is REQUIRED (an empty query is refused, never a whole-table scan); optional `auditYear` (int), `limit` (≤100, def 50), `offset`. Returns { findings:[{ report_id, auditee_uei, audit_year, award_reference, reference_number, is_material_weakness, is_modified_opinion, is_questioned_costs, is_repeat_finding, is_significant_deficiency, is_other_findings, is_other_matters, type_requirement, prior_finding_ref_numbers, riskFlags:{materialWeakness, modifiedOpinion, questionedCosts, repeatFinding, significantDeficiency, otherFindings, otherMatters} }] } + honest _meta. ★RISK-FLAG HONESTY: the is_* flags are surfaced VERBATIM as the auditor reported them (\"Y\"/\"N\") PLUS a typed riskFlags tri-state (\"Y\"→true / \"N\"→false / blank/absent/other → null=UNKNOWN) — a null flag is NEVER rendered as false/\"no material weakness\" (the false-CLEAR class). ★EMPTY ≠ CLEAN: an empty findings list does NOT confirm a clean audit — the entity may not have filed a Single Audit (below the $750K threshold), the audit may predate FAC coverage, or the UEI may be wrong; a disclosure note fires on any empty result telling you to confirm an ACCEPTED audit exists via fac_search_audits. ★PII: a HARDCODED select-allowlist (NO caller column param) surfaces only entity + audit-risk fields — no personal contact. totalAvailable is the EXACT Content-Range total ('*'/absent ⇒ null + hedge, never 0); a bad column ⇒ 400 ⇒ invalid_input; 400/403/5xx/timeout/HTML/non-array THROW (206 = success). NOT a debarment/determination — cross-check SAM exclusions + OFAC + the specific finding text. Keyless-first via DEMO_KEY (~10 req/hr; set DATA_GOV_API_KEY — never logged).",
3626
+ inputSchema: FacGetFindingsInput,
3627
+ handler: (input) => fac.getFindings(input),
3628
+ }),
3629
+ // ━━━ GAO — Bid Protests (1) ━━━
3630
+ defineTool({
3631
+ name: "gao_protest_lookup",
3632
+ description: "Recent GAO (Comptroller General) bid-protest decisions from the public Legal-Products RSS feed, enriched from each decision page (protester, contracting agency, decision date, outcome sustained/denied/dismissed/withdrawn, solicitation #, decision PDF). Filter client-side by agency/protester/solicitation/outcome, or pull one decision directly by bNumber. HONEST SCOPE: keyless covers only the RECENT feed window (~25 items) — GAO's faceted historical protest search (all years, by protester/agency/outcome/date) is WAF-blocked to bots and available only via a paid third-party API, so results are ALWAYS marked complete:false and are NOT the full protest history (see the accessNote).",
3633
+ inputSchema: GaoProtestInput,
3634
+ handler: (input) => gao.gaoProtestLookup(input),
3635
+ }),
3636
+ // ━━━ US Treasury — Fiscal Data (keyless) (4) ━━━ ADR-0002
3637
+ defineTool({
3638
+ name: "treasury_query_dataset",
3639
+ description: "Escape-hatch query over 5 confirmed US Treasury Fiscal Data datasets (keyless): debt_to_penny, avg_interest_rates, mts_table_1 (Monthly Treasury Statement), rates_of_exchange, debt_outstanding. Choose `dataset` (enum — no free path), and optionally project `fields` (CSV), `filter` (CSV 'col:op:val', ops lt|lte|gt|gte|eq|in, AND-combined), and `sort` (CSV, '-' = desc), with page[size]/page[number] pagination. Returns raw rows plus a truthful `_meta` (totalAvailable = upstream total-count, offset pagination). Value/amount fields are raw upstream strings — the string \"null\"/empty means 'no value', never 0. Covers rates_of_exchange + debt_outstanding without a dedicated tool.",
3640
+ inputSchema: TreasuryQueryDatasetInput,
3641
+ handler: (input) => treasury.queryDataset(input),
3642
+ }),
3643
+ defineTool({
3644
+ name: "treasury_debt_to_penny",
3645
+ description: "Daily total US public debt outstanding ('Debt to the Penny', keyless Treasury Fiscal Data). Returns record_date + totalPublicDebtOutstanding, debtHeldByPublic, intragovernmentalHoldings (USD). `latest` (default true) ⇒ the single most-recent day; set latest=false with startDate/endDate (ISO YYYY-MM-DD) for a date range, newest-first. Amounts are coerced to number|null (a null amount is 'no value reported', never 0).",
3646
+ inputSchema: TreasuryDebtToPennyInput,
3647
+ handler: (input) => treasury.debtToPenny(input),
3648
+ }),
3649
+ defineTool({
3650
+ name: "treasury_monthly_statement",
3651
+ description: "Monthly Treasury Statement (MTS table 1, keyless): federal receipts, outlays, and deficit/surplus by month. Returns record_date, classification, grossReceipts, grossOutlays, deficitSurplus (USD, number|null). `startDate`/`endDate` (ISO YYYY-MM-DD) filter record_date (default: trailing ~12 months). By default excludeSummaryRows=true drops the fiscal-year parent/summary header rows (whose amounts are all null) via a server-side filter, so totalAvailable and rows reflect real child line-items only; set excludeSummaryRows=false to include them. Highest-value budget-analysis tool.",
3652
+ inputSchema: TreasuryMonthlyStatementInput,
3653
+ handler: (input) => treasury.monthlyStatement(input),
3654
+ }),
3655
+ defineTool({
3656
+ name: "treasury_avg_interest_rates",
3657
+ description: "Average interest rate the US Treasury pays by security type/description (keyless Treasury Fiscal Data). Returns record_date, securityType, securityDescription, avgInterestRatePercent (percent, number|null). `latest` (default true) returns the most-recent month's full breakdown across security types (pinned to the latest record_date, memoized 5 min); set latest=false with startDate/endDate for a range. Optional `securityType` narrows by exact security_type_desc (e.g. 'Marketable', 'Non-marketable').",
3658
+ inputSchema: TreasuryAvgInterestRatesInput,
3659
+ handler: (input) => treasury.avgInterestRates(input),
3660
+ }),
3661
+ // ━━━ SEC EDGAR — filings / XBRL facts / CIK / full-text / frames / full-index / daily-index / companyconcept (keyless) (8) ━━━ ADR-0003 / ADR-0017 / ADR-0026 / ADR-0027 / ADR-0041
3662
+ defineTool({
3663
+ name: "edgar_lookup_cik",
3664
+ description: "Resolve a company ticker or name to its 10-digit SEC CIK (keyless, via SEC company_tickers.json). Input `query` (exact ticker or a title substring) ⇒ up to 50 { cik, ticker, title } matches; found:false on none. The CIK is the join key for edgar_company_filings/edgar_company_facts. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS — there is no authoritative CIK↔UEI join.",
3665
+ inputSchema: EdgarLookupCikInput,
3666
+ handler: (input) => edgar.lookupCik(input),
3667
+ }),
3668
+ defineTool({
3669
+ name: "edgar_company_filings",
3670
+ description: "A company's SEC filings (keyless, from data.sec.gov submissions). Input `cikOrTicker` (CIK or resolvable ticker/name), optional `forms` (e.g. ['10-K','8-K']), `limit` (≤100, default 20), `offset`, `fullHistory` (default false), `maxShards` (1..100, default 10). Returns filings with the REAL primary-document archive URL. By default returns the recent window (up to 1 year OR 1000 filings, whichever is more); set `fullHistory:true` to ALSO fetch the older filings.files[] shards (newest-first up to `maxShards`) and assemble the COMPLETE history (recent ++ shard001..N, descending, no re-sort). HONESTY: totalAvailable = recent + Σ ALL older-shard counts (the grand total, incl un-fetched shards — never recomputed down), so a capped/failed fan-out reads complete:false; a note discloses COMPLETE vs PARTIAL-BY-CAP (RAISE maxShards for older un-fetched shards — pagination does NOT reach them) vs PARTIAL-BY-FAILURE (a 404/bad-CIK/transient shard is skipped, missing filings disclosed, never fabricated); fullHistory serializes N shard GETs through the shared EDGAR throttle gate. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS.",
3671
+ inputSchema: EdgarCompanyFilingsInput,
3672
+ handler: (input) => edgar.companyFilings(input),
3673
+ }),
3674
+ defineTool({
3675
+ name: "edgar_company_facts",
3676
+ description: "Curated XBRL financial facts for a filer (keyless, from data.sec.gov companyfacts). Input `cikOrTicker`, optional `concepts` (default: 6 curated USD concepts — Revenues/RevenueFromContractWithCustomerExcludingAssessedTax, Assets, Liabilities, StockholdersEquity, NetIncomeLoss, CashAndCashEquivalentsAtCarryingValue), `unit` (default USD), `latest`. A concept absent for the filer is OMITTED (never 0); a concept present only in another unit (e.g. EPS in USD/shares) is reported under wrongUnit with a note.",
3677
+ inputSchema: EdgarCompanyFactsInput,
3678
+ handler: (input) => edgar.companyFacts(input),
3679
+ }),
3680
+ defineTool({
3681
+ name: "edgar_full_text_search",
3682
+ description: "Full-text search across EDGAR filings, 2001-present (keyless, efts.sec.gov). Input `q` (phrase in double-quotes for exact), optional `forms`, `startdt`/`enddt` (ISO), `ciks` (pin filings BY entities — numeric 10-digit SEC CIKs, zero-padded, exact-entity match), `entityName` (FUZZY filer-name narrowing — can match related filers, e.g. multiple 'Apple*'), `from` (offset; page size FIXED at 100 — no size param). Returns { accession, form, filingDate, entityNames, ciks, filingIndexUrl }. HONESTY: totalAvailable = the true match count, or a LOWER BOUND (totalIsLowerBound:true) when SEC reports ≥10000; a 0-result set with ciks/entityName applied is NOT proof of absence (verify the CIK via edgar_lookup_cik by name/ticker); from > 9900 is rejected (10000-result window). NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS.",
3683
+ inputSchema: EdgarFullTextSearchInput,
3684
+ handler: (input) => edgar.fullTextSearch(input),
3685
+ }),
3686
+ defineTool({
3687
+ name: "edgar_xbrl_frames",
3688
+ description: "Keyless cross-filer XBRL cross-section (SEC EDGAR frames, data.sec.gov). In ONE call, return EVERY filer's reported value for a single us-gaap/dei concept in a single calendar period — the complete cross-section — for peer benchmarking + distribution stats. Input `tag` (EXACT alnum concept, e.g. 'Assets'), `period` (CY2023 annual · CY2023Q1 quarterly · CY2023Q4I instant/trailing-I), optional `taxonomy` (us-gaap|dei), `unit` (default USD; EPS uses 'USD-per-shares'), `limit`/`offset` (CLIENT-SIDE window over the fully-fetched set), `includeStats`. Rows: { accn, cik, entityName, loc, end, val, start? } (start only for duration concepts). HONESTY: totalAvailable = SEC's own pts (asserted === data.length, else schema_drift THROW — no fake completeness); the whole frame is fetched upstream in one call and limit/offset is a disclosed client-side page (never a subset labeled complete); a tag/unit/period mismatch ⇒ 404 ⇒ found:false (NEVER a fabricated val:0); val is null-never-0; includeStats covers the FULL set with linear-interpolated percentiles (count===0 ⇒ all-null, never 0/NaN). taxonomy/tag/unit/period are validated path segments (enum+regex, re-checked pre-fetch) — no injection surface. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS.",
3689
+ inputSchema: EdgarXbrlFramesInput,
3690
+ handler: (input) => edgar.xbrlFrames(input),
3691
+ }),
3692
+ defineTool({
3693
+ name: "edgar_filing_index",
3694
+ description: "Bulk cross-filer SEC filing index for a quarter (keyless, from the www.sec.gov EDGAR full-index master.idx). Reads the WHOLE quarter's index (every filer's every filing — CIK|Company|Form|Date|Filename, ~370K rows), FULL-SCANS it, and returns offset-paginated filings matching CLIENT-SIDE filters with the EXACT total. Input `year` (>=1993, <= current year), `quarter` (1..4); optional `formType` (exact form, e.g. '8-K'), `cik` (numeric, leading-zero-safe), `companyContains` (LITERAL case-insensitive substring), `dateFrom`/`dateTo` (ISO YYYY-MM-DD), `limit` (<=1000, def 100), `offset`. Returns { year, quarter, indexFile, returned, totalAvailable, filings:[{ cik, cikPadded, companyName, formType, dateFiled, filename, filingUrl }] }. This is the BULK-ENUMERATION primitive (the per-filer edgar tools need a CIK you already hold; this sweeps a whole quarter by form/date/company, e.g. 'every 8-K in 2024 Q1'). HONESTY: totalAvailable is the EXACT match count over the full quarter scan — never a page length, never a byte-capped subset (SEC ignores HTTP Range); a 0-match result is a genuine EXACT ZERO (complete:true), NOT a truncation; a bounds-valid but unpublished quarter returns HTTP 403 and is surfaced as an AMBIGUOUS both-causes error (quarter-not-published OR the 10 req/s rate-block), never a bare rate-limit and never a fake-empty; a non-index / all-malformed body is refused as schema_drift; a future year / bad quarter is rejected pre-fetch (invalid_input, 0 fetch). The CURRENT quarter grows daily (totalAvailable is exact AS-OF-snapshot). filingUrl is a resolvable archive URL. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS — there is no authoritative CIK↔UEI join.",
3695
+ inputSchema: EdgarFilingIndexInput,
3696
+ handler: (input) => edgar.filingIndex(input),
3697
+ }),
3698
+ defineTool({
3699
+ name: "edgar_daily_filing_index",
3700
+ description: "Per-DAY cross-filer SEC filing index (keyless, from the www.sec.gov EDGAR daily-index master.YYYYMMDD.idx). The per-day sibling of edgar_filing_index (~30× smaller): reads ONE calendar day's index (every filer's every filing that day — CIK|Company|Form|Date|File Name, ~8K rows), FULL-SCANS it, and returns offset-paginated filings matching CLIENT-SIDE filters with the EXACT total. Answers the monitoring/alerting question the quarterly tool cannot ('every 8-K filed on 2024-01-03', 'watch a CIK day-by-day'). Input `date` (required ISO YYYY-MM-DD, >=1994-01-01, not future); optional `formType` (exact form, e.g. '8-K'), `cik` (numeric, leading-zero-safe), `companyContains` (LITERAL case-insensitive substring), `limit` (<=1000, def 100), `offset`. Returns { found, date, year, quarter, indexFile, returned, totalAvailable, filings:[{ cik, cikPadded, companyName, formType, dateFiled, filename, filingUrl }] }. HONESTY: totalAvailable is the EXACT match count over the full day scan — never a page length, never a byte-capped subset (SEC ignores HTTP Range). The daily-index's pervasive-403 empty model is disambiguated via the quarter's index.json existence oracle, RECENCY-AWARE: a day NEWER than the newest published index (weekend/holiday/not-yet-disseminated recent trading day) ⇒ found:false, complete:FALSE, retryable not-yet-disseminated note (NEVER a confident empty); an unlisted day INSIDE the covered range (a real weekend/holiday) ⇒ found:false, complete:true genuine-absent; a LISTED day whose .idx 403s ⇒ honest rate_limited; the oracle itself inconclusive ⇒ ambiguous both-causes upstream_unavailable. A non-real/future date is rejected pre-fetch (invalid_input, 0 fetch); a non-index / all-malformed body is refused as schema_drift. dateFiled is normalized to ISO from the compact YYYYMMDD column. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS — there is no authoritative CIK↔UEI join.",
3701
+ inputSchema: EdgarDailyFilingIndexInput,
3702
+ handler: (input) => edgar.dailyFilingIndex(input),
3703
+ }),
3704
+ defineTool({
3705
+ name: "edgar_company_concept",
3706
+ description: "One filer × one XBRL concept × the COMPLETE reported time-series (keyless, from data.sec.gov companyconcept). The focused financial-TREND / entity-vetting primitive BETWEEN edgar_company_facts (many curated concepts for one filer) and edgar_xbrl_frames (one concept across ALL filers for one period) — 'track THIS filer's Assets/Revenues/NetIncomeLoss OVER TIME, and was it ever revised?'. Input `cikOrTicker` (CIK or resolvable ticker/name), `concept` (EXACT alnum XBRL tag, e.g. 'Assets'), optional `taxonomy` (us-gaap|dei|ifrs-full, def us-gaap), `unit` (CLIENT-SIDE key filter), `form`/`fy` (client-side), `canonicalOnly` (def false), `limit`/`offset`. Returns { found, cik, entityName, taxonomy, concept, label, description, unitsAvailable:[{unit,count}], rows:[{ unit, start, end, val, accn, fy, fp, form, filed, frame, canonical }] }. HONESTY: (M1) period identity is the (start,end) PAIR — every row carries `start` (null for INSTANT concepts, the ISO date for DURATION/flow concepts); the SAME `end` with a DIFFERENT `start` is a different-duration fact (a 3-month quarter vs the 12-month year), NOT a revision — a revision is only multiple rows sharing the same (start,end) with a differing accn/filed/val. DEFAULT returns ALL rows incl. the amendment/restatement history + a per-row `canonical` (frame-tagged = SEC's consolidated value); `canonicalOnly:true` dedups to one canonical row per (unit,start,end), fully disclosed, never a silent drop. Every row is unit-tagged (a USD amount is NEVER conflated with a share count); unitsAvailable discloses ALL units with their RAW counts even under a unit filter; val is null-never-0. A bad CIK/taxonomy/concept ⇒ upstream 404 ⇒ found:false (NEVER a fabricated val:0); a 5xx/timeout/non-JSON/units-shape-drift THROWS; a `unit` not present ⇒ honest empty + the available-units note (unit is CLIENT-SIDE, not a path segment). cik/taxonomy/concept are validated path segments (regex+enum, re-checked pre-fetch) — no injection surface. NOTE: EDGAR keys on CIK, NOT SAM UEI/DUNS — there is no authoritative CIK↔UEI join.",
3707
+ inputSchema: EdgarCompanyConceptInput,
3708
+ handler: (input) => edgar.companyConcept(input),
3709
+ }),
3710
+ // ━━━ Socrata / SODA — keyless SLED + E-rate open data (2) ━━━ ADR-0004
3711
+ defineTool({
3712
+ name: "socrata_query",
3713
+ description: "Query rows from an allowlisted Socrata/SODA open-data portal (keyless; ~a dozen US state portals + USAC E-rate on one identical API — state spend/checkbook/contract/vendor-payment datasets). Input `domain` (curated allowlist enum — the SSRF host guard), `datasetId` (4x4, from socrata_discover_datasets), optional SoQL `select`/`where`/`order`/`q`, `limit` (≤1000, def 100), `offset`, `withTotal` (def true). HONESTY: SODA's row response has no total, so a count(*) companion supplies an exact totalAvailable; if it fails the rows still return with totalAvailable:null + a note (hasMore is then inferred from page-fill, never a false complete). Genuine-empty ⇒ complete:true/total:0; an outage/400/404 THROWS (never a fake empty). Value fields are strings.",
3714
+ inputSchema: SocrataQueryInput,
3715
+ handler: (input) => socrata.query(input),
3716
+ }),
3717
+ defineTool({
3718
+ name: "socrata_discover_datasets",
3719
+ description: "Find Socrata dataset 4x4 ids by keyword via the Socrata catalog (keyless, api.us.socrata.com). Input `q` (e.g. 'procurement', 'vendor payments'), optional `domain` (scope to one allowlisted portal; omit to search the whole allowlist), `limit` (≤100, def 20). Returns [{ id, name, description, domain, updatedAt, link }] + totalAvailable = the catalog resultSetSize. Feed a result's `id` to socrata_query as `datasetId`. NOTE: the federated catalog does not index every allowlisted host (e.g. USAC E-rate) — those stay queryable via socrata_query with a known 4x4.",
3720
+ inputSchema: SocrataDiscoverDatasetsInput,
3721
+ handler: (input) => socrata.discoverDatasets(input),
3722
+ }),
3723
+ // ━━━ CKAN datastore_search — keyless SLED open data (2) ━━━ ADR-0006
3724
+ defineTool({
3725
+ name: "ckan_query",
3726
+ description: "Query rows from an allowlisted CKAN datastore resource (keyless; the FIRST source on the R2 DataSource port — state/city spend/checkbook/procurement/vendor tables on the identical CKAN Action API). Input `host` (curated allowlist enum — the SSRF host guard: data.ca.gov, data.virginia.gov, data.boston.gov), `resourceId` (36-char lowercase UUID, from ckan_discover_datasets), optional `q` (full-text), `filters` (constrained object {field:value} we JSON.stringify), `sort`, `limit` (≤1000, def 100), `offset`. HONESTY: CKAN's envelope carries a real result.total — the DEFAULT is an EXACT total (exact totalAvailable + hasMore); the rare estimated total (total_was_estimated:true) is disclosed via totalIsEstimated + a note and does NOT drive pagination (it can be above OR below the truth). Genuine-empty ⇒ complete:true/total:0; an outage/404/409 or success:false THROWS (never a fake empty). Values are typed per result.fields[].type.",
3727
+ inputSchema: CkanQueryInput,
3728
+ handler: (input) => ckan.query(input),
3729
+ }),
3730
+ defineTool({
3731
+ name: "ckan_discover_datasets",
3732
+ description: "Find CKAN datastore resource ids by keyword via package_search (keyless). Input `host` (allowlisted enum), `q` (e.g. 'procurement', 'checkbook'), `limit` (≤100, def 20). Returns per-resource rows [{ resourceId, name, datasetTitle, format, datastoreActive }] + totalAvailable = the matching DATASET count. Feed a datastoreActive:true result's `resourceId` to ckan_query (a datastoreActive:false resource is a raw file blob NOT in the datastore, not queryable).",
3733
+ inputSchema: CkanDiscoverDatasetsInput,
3734
+ handler: (input) => ckan.discoverDatasets(input),
3735
+ }),
3736
+ // ━━━ FDIC BankFind Suite — keyless institution directory + financials + failures + history + industry aggregates (5) ━━━ ADR-0028 / ADR-0029 / ADR-0030 / ADR-0031
3737
+ defineTool({
3738
+ name: "fdic_search_institutions",
3739
+ description: "Search the FDIC-insured-institution directory (keyless FDIC BankFind, api.fdic.gov/banks/institutions) — a regulated-entity directory for B2G counterparty / bank due-diligence. Structured filters: `state` (2-letter, → STALP), `activeOnly` (→ ACTIVE 1/0), `cert` (→ CERT, the STABLE entity key), plus `name`/`city` matched via FDIC's case-insensitive full-text `search` param (NOT `filters` — `filters=NAME:\"chase\"` is case-sensitive exact-keyword and returns a false-empty; `search=NAME:chase` finds JPMorgan Chase etc.). `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum NAME/CERT/ASSET/ESTYMD/STALP/CITY/ACTIVE), `sortOrder` (ASC/DESC). Returns { institutions:[{ name, city, state, cert, assetUSD, active, establishedDate, id }] }. HONESTY: totalAvailable is the EXACT meta.total (stable across offset — never the page length); ASSET is published in $thousands and normalized to whole USD ×1000 (null-never-0 — a real 0 stays 0, absent → null); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); a multi-word name/city is matched per-token (disclosed); the point-in-time snapshot build time is disclosed. NOTE: FDIC keys on CERT, not SAM UEI/DUNS.",
3740
+ inputSchema: FdicSearchInstitutionsInput,
3741
+ handler: (input) => fdic.searchInstitutions(input),
3742
+ }),
3743
+ defineTool({
3744
+ name: "fdic_institution_financials",
3745
+ description: "Quarterly financial time-series for ONE FDIC-insured institution by certificate number (keyless FDIC BankFind, api.fdic.gov/banks/financials). Input `cert` (REQUIRED FDIC certificate number, from fdic_search_institutions), `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum REPDTE/ASSET/DEP/NETINC, def REPDTE), `sortOrder` (def DESC → newest quarter first). Returns { cert, financials:[{ cert, reportDate, assetUSD, depositsUSD, netIncomeUSD, id }] } (e.g. CERT 10363 → 169 quarterly rows). HONESTY: totalAvailable is the EXACT meta.total (stable across offset — page via offset for the full history); ASSET/DEP/NETINC are published in $thousands and normalized to whole USD ×1000 (null-never-0); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope THROWS (never a fake empty); the snapshot build time is disclosed.",
3746
+ inputSchema: FdicInstitutionFinancialsInput,
3747
+ handler: (input) => fdic.institutionFinancials(input),
3748
+ }),
3749
+ defineTool({
3750
+ name: "fdic_bank_failures",
3751
+ description: "Historical FDIC-insured bank failures & assistance transactions (keyless FDIC BankFind, api.fdic.gov/banks/failures) — B2G counterparty / entity due-diligence: a failed or FDIC-assisted institution is a red flag, and CERT links a failure back to fdic_search_institutions / fdic_institution_financials. Exact-key filters: `state` (2-letter → PSTALP — NOTE the /failures state field is PSTALP, NOT STALP), `failYear` (→ FAILYR; e.g. 2023 → the 5 real 2023 failures incl. Silicon Valley Bank & First Republic Bank), `cert` (→ CERT, the STABLE entity key). `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum FAILDATE/COST/QBFASSET/QBFDEP/NAME/FAILYR, def FAILDATE), `sortOrder` (def DESC → most-recent first). Returns { failures:[{ name, cert, failDate, failYear, city, state, resolutionType, resolutionFund, estimatedLossUSD, depositsUSD, assetsUSD, id }] }. NO name/city filter — FDIC's /failures `search` param is IGNORED (it returns the whole dataset), so name/city are SHOWN in each row but NOT searchable; to find a specific bank's failure, resolve its CERT via fdic_search_institutions then filter here by `cert`. HONESTY: totalAvailable is the EXACT meta.total (stable across offset — never the page length); failDate is normalized from FDIC's M/D/YYYY to ISO YYYY-MM-DD (an unrecognized value is surfaced raw + disclosed, never nulled/fabricated); COST/QBFDEP/QBFASSET are $thousands normalized to whole USD ×1000 (null-never-0 — a genuine 0 = a fully-assisted no-loss stays 0, a NEGATIVE COST = a net DIF recovery/gain not a loss, absent → null); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); the point-in-time snapshot build time is disclosed. NOTE: FDIC keys on CERT, not SAM UEI/DUNS.",
3752
+ inputSchema: FdicBankFailuresInput,
3753
+ handler: (input) => fdic.bankFailures(input),
3754
+ }),
3755
+ defineTool({
3756
+ name: "fdic_institution_history",
3757
+ description: "Institution-level STRUCTURAL-CHANGE event log for FDIC-insured banks (keyless FDIC BankFind, api.fdic.gov/banks/history) — the full lineage of mergers, absorptions, consolidations, failures, name/location/charter/regulator changes, branch open/close, trust-power grants & FRS-membership changes. Completes the FDIC entity cluster (directory + financials + failures + history). Killer feature: CERT-linked MERGER LINEAGE — a merger/failure row carries the acquiring / outgoing / surviving institution's CERT + name, each linking back to fdic_search_institutions / fdic_institution_financials / fdic_bank_failures. Exact-key filters (all optional, AND-combined): `cert` (→ CERT, the STABLE entity key & PRIMARY lookup; e.g. 3510 → Bank of America's 13,794 rows), `changeCode` (→ CHANGECODE; e.g. 223 = merger, 211 = failure, 721 = branch closing, 520 = location change), `effYear` (→ EFFYEAR), `state` (2-letter → PSTALP — NOTE the /history state field is PSTALP, NOT STALP). `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum EFFDATE/PROCDATE/CHANGECODE/TRANSNUM, def EFFDATE), `sortOrder` (def DESC → newest change first). Returns { history:[{ cert, instName, state, changeCode, changeDescription, effectiveDate, processDate, effYear, transNum, acquirerCert, acquirerName, outgoingCert, outgoingName, survivingCert, survivingName, id }] }. NO name/city filter — FDIC's /history `search` param returns 0 for INSTNAME (a false-empty), so names are SHOWN in each row but NOT searchable; to find a specific bank's history, resolve its CERT via fdic_search_institutions then filter here by `cert`. HONESTY: totalAvailable is the EXACT meta.total (stable across offset — never the page length); changeDescription is FDIC's OWN co-served CHANGECODE_DESC passed through verbatim (the numeric changeCode is authoritative — never a hand-map); effectiveDate/processDate are normalized from FDIC's YYYY-MM-DDT00:00:00 to ISO YYYY-MM-DD (an unrecognized value is surfaced raw + disclosed, never nulled/fabricated); the acquirer/outgoing/surviving CERTs are null on a non-merger event (null-never-0 — a real absence, never a fabricated 0; *_UNINUM's 0 sentinel is NOT surfaced); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); the point-in-time snapshot build time is disclosed. NOTE: FDIC keys on CERT, not SAM UEI/DUNS.",
3758
+ inputSchema: FdicInstitutionHistoryInput,
3759
+ handler: (input) => fdic.institutionHistory(input),
3760
+ }),
3761
+ defineTool({
3762
+ name: "fdic_industry_summary",
3763
+ description: "FDIC industry & state banking-sector ANNUAL AGGREGATES — the FDIC's own roll-ups (keyless FDIC BankFind, api.fdic.gov/banks/summary). The FIRST aggregate/statistical FDIC tool (the other 4 are per-ENTITY, keyed on CERT): total assets, deposits, net income, equity & net interest income + structural counts (institutions, offices, branches, employees) for the whole US banking industry OR one state/territory in one year, split by charter class. Answers 'how big is the US (or a state's) banking industry this year, and how many institutions?' — a question the entity tools cannot express without summing thousands of rows. Exact-key filters (all optional, AND-combined): `year` (→ YEAR; e.g. 2023 → 121 rows), `state` (2-or-3-letter → STALP — NOTE the /summary state field is STALP, NOT PSTALP; accepts a jurisdiction code TX/CA/DC/GU/PR… OR a ROLL-UP code USA/US/OT/PI), `charterClass` (CB = commercial banks, SI = savings institutions; omit for both — there is NO combined row). `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum YEAR/ASSET/DEP/NETINC/BANKS, def YEAR), `sortOrder` (def DESC → newest year / largest first). Returns { summary:[{ year, charterClass, charterClassCode, geography, stateCode, stateFips, scope, isRollup, institutionCount, officeCount, branchCount, employeeCount, totalAssetsUSD, totalDepositsUSD, netIncomeUSD, totalEquityUSD, netInterestIncomeUSD, id }] }. ★ROLL-UP HONESTY: each row crosses charter × geography; STALP ∈ {USA,US,OT,PI} are GEOGRAPHIC AGGREGATES (scope national_total/national_states_dc/territories_total/pacific_islands, isRollup:true), every other STALP is a jurisdiction (isRollup:false) — NEVER sum a roll-up row with jurisdiction rows or across scopes (national_total = national_states_dc + territories_total; a geography's total = its CB row + its SI row), read the national_total (USA) row directly for one national figure; a roll-up is NOT a state. ★NIM is net interest INCOME (a $ sum surfaced as netInterestIncomeUSD), NOT the margin ratio; this endpoint has NO ratio fields (ROA/ROE — derive from netIncomeUSD/totalAssetsUSD/totalEquityUSD). NO name/city filter — FDIC's /summary `search` param is ignored (returns the whole year); drill to institutions via fdic_search_institutions. HONESTY: totalAvailable is the EXACT meta.total (stable across offset — never the page length); money (ASSET/DEP/NETINC/EQ/NIM) is $thousands → whole USD ×1000 (null-never-0 — a genuine 0 like American Samoa's zero commercial banks stays 0, absent → null), counts (BANKS/OFFICES/BRANCHES/employees) pass through un-scaled (a count ×1000 is a fabrication); a non-int year is rejected pre-fetch (a malformed year is a live HTTP-200 total:0 false-empty); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); the point-in-time snapshot build time is disclosed. NOTE: FDIC keys on CERT, not SAM UEI/DUNS.",
3764
+ inputSchema: FdicIndustrySummaryInput,
3765
+ handler: (input) => fdic.industrySummary(input),
3766
+ }),
3767
+ // ━━━ FDIC BankFind Suite — WITHIN-SOURCE DEPTH: counterparty risk ratios + branch deposits (2) ━━━ ADR-0040
3768
+ defineTool({
3769
+ name: "fdic_risk_ratios",
3770
+ description: "FDIC counterparty RISK RATIOS for ONE institution by certificate number (keyless FDIC BankFind, api.fdic.gov/banks/financials) — the SOUNDNESS lane the balance-sheet tools cannot express: profitability (ROA/pretax ROA/ROE), net interest margin, efficiency ratio, asset quality (net charge-offs to loans), capital adequacy (leverage, tier-1 risk-based, total risk-based ratios) + the tier-1 capital LEVEL. Input `cert` (REQUIRED FDIC certificate number, from fdic_search_institutions), `reportDate` (optional YYYYMMDD quarter-end → REPDTE; omit for the full quarterly time-series), `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum REPDTE/ROA/ROE/RBCRWAJ/EEFFR, def REPDTE), `sortOrder` (def DESC → newest quarter first). Returns { cert, ratios:[{ cert, reportDate, cblrFramework, returnOnAssetsPct, preTaxReturnOnAssetsPct, returnOnEquityPct, netInterestMarginPct, efficiencyRatioPct, netChargeOffsToLoansPct, leverageRatioPct, tier1RiskBasedCapitalRatioPct, totalRiskBasedCapitalRatioPct, tier1CapitalUSD, id }] }. ★UNITS-IN-THE-KEY: every *Pct field is an FDIC-published PERCENTAGE surfaced VERBATIM (no scaling, no recompute) — do NOT read it as a dollar amount or ×1000-scale it; tier1CapitalUSD is a DOLLAR amount (FDIC publishes it in $thousands, normalized ×1000). ★NULL-NEVER-0: a not-reported ratio is null (never 0% — a false 'no return / no capital'). ★CBLR (community-bank-leverage) banks (cblrFramework:true) do NOT report the risk-based capital ratios — FDIC returns a literal 0 for the total risk-based ratio, which this tool maps to null for BOTH tier1RiskBasedCapitalRatioPct and totalRiskBasedCapitalRatioPct (a null there is a normal framework artifact, read alongside leverageRatioPct — NOT a 0% capital red flag). No ratio is recomputed; each is exactly FDIC's published Call-Report figure. HONESTY: totalAvailable is the EXACT meta.total (stable across offset); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); the snapshot build time is disclosed. NOTE: reported regulatory metrics, NOT a soundness rating or failure prediction; FDIC keys on CERT, not SAM UEI/DUNS.",
3771
+ inputSchema: FdicRiskRatiosInput,
3772
+ handler: (input) => fdic.riskRatios(input),
3773
+ }),
3774
+ defineTool({
3775
+ name: "fdic_branch_deposits",
3776
+ description: "FDIC branch-deposit footprint — the Summary of Deposits (keyless FDIC BankFind, api.fdic.gov/banks/sod): the annual June-30 branch-office deposit distribution ('where does this bank hold deposits, and how concentrated?'). Exact-key filters (all optional, AND-combined; ≥1 recommended): `cert` (→ CERT, the STABLE entity key), `state` (2-letter → STALPBR, the branch-state field, C118-quoted so Oregon is operator-safe), `year` (→ YEAR, the June-30 snapshot year). `limit` (≤1000, def 100), `offset` (≤100000), `sortBy` (allowlisted enum YEAR/DEPSUMBR, def YEAR), `sortOrder` (def DESC → newest snapshot / largest deposits first). Returns { branches:[{ cert, institutionName, branchNumber, branchName, city, state, zip, address, depositsUSD, year, id }] } (e.g. CERT 10004 → 74 branch-year rows). HONESTY: totalAvailable is the EXACT meta.total (stable across offset — never the page length); depositsUSD is DEPSUMBR published in $thousands, normalized to whole USD ×1000 (null-never-0 — a real 0 stays 0, absent → null); a bad/mistyped filter field can never reach the wire (server-side allowlist by construction — FDIC would otherwise return a silent total:0 false-empty, not an error); the ONLY honest empty is meta.total:0/data:[] ⇒ complete:true/total:0, every other envelope (400 errors[]/404/non-JSON/missing meta or data) THROWS (never a fake empty); the DISTINCT annual snapshot build time is disclosed. Branch facility data only (name/address/city/state/zip/deposits) — no personal/officer PII. NOTE: SOD is an annual June-30 snapshot; FDIC keys on CERT, not SAM UEI/DUNS.",
3777
+ inputSchema: FdicBranchDepositsInput,
3778
+ handler: (input) => fdic.branchDeposits(input),
3779
+ }),
3780
+ // ━━━ USITC Harmonized Tariff Schedule — keyless import-tariff / duty-rate lookup (1) ━━━ ADR-0039
3781
+ defineTool({
3782
+ name: "hts_lookup",
3783
+ description: "Look up US import-tariff classification + duty rates from the USITC Harmonized Tariff Schedule (keyless; hts.usitc.gov/reststop/search) — the IMPORT-TARIFF / supply-chain PRICE lane a product-reseller / supply-chain bidder needs to price a hardware or commodity contract (extends the THIN Price lane with a NON-labor cost input, a sibling of gsa_benchmark_labor_rates). A single `query` serves BOTH modes: a KEYWORD (e.g. 'laptop', 'cotton shirt') OR an HTS number (e.g. '8471.30' / '8471.30.01.00') — both ride the `keyword=` search. Returns { query, lines:[{ htsno, statisticalSuffix, indent, description, units, columnOneGeneral, specialPreferential, columnTwo, additionalDuties, footnotes, quotaQuantity, effectivePeriod, status, isChapter99 }] } + honest _meta. ★DUTY-RATE HONESTY (the crux): columnOneGeneral (Column-1 General), specialPreferential (Special/preferential/FTA), and columnTwo (Column-2) are AUTHORITATIVE VERBATIM TEXT surfaced as strings — 'Free', a percentage ('35%'), a specific rate ('0.47¢/kg'), a compound/range, or null — NEVER coerced to a number (a coerced 0/NaN would fabricate a false 'duty-free'); an empty Special ('') → null = NO special-program rate published (NEVER read as Free). ★HIERARCHY (M1): a lookup returns rows across levels; the rate is stated ONCE at a shallower level (usually the 6/8-digit subheading) and inherits DOWNWARD to the blank statistical-suffix lines — to find a specific line's rate, read UP to the nearest ANCESTOR line (shallower indent, same htsno prefix) with a non-empty rate; a blank deepest line is NOT no/unknown duty. ★ADDITIONAL DUTIES (S1): the per-line `additionalDuties` is frequently null even when Section 301/232 duties apply — the real additional duty rides the Chapter-99 rows (isChapter99:true, htsno beginning '99') returned alongside the base line + the footnotes; they STACK on the base rate. ★COMPLETENESS (M2): the endpoint returns the FULL match array with NO server-side total and NO working pagination (offset is IGNORED) → totalAvailable is the EXACT served array length and paging is CLIENT-SIDE; there is no fixed cap (a single-char/common fragment can return 10,000–16,000+ rows / several MB), so `query` must be ≥3 non-whitespace chars (a 1–2 char query is rejected invalid_input before the fetch). `limit` (≤200, def 50), `offset`. A no-match ⇒ honest empty; a 404/5xx/timeout/non-array/HTML(→schema_drift) ⇒ THROWS (never a fake empty); a transient 400 on the validated query ⇒ upstream_unavailable (retryable). NOT a binding CBP classification ruling and NOT a landed-cost quote — the duty owed depends on country of origin + trade program + Section 301/232 / Chapter-99 additional duties + footnotes; confirm via CBP (CROSS / eRulings). The not-a-ruling caveat rides EVERY response.",
3784
+ inputSchema: HtsLookupInput,
3785
+ handler: (input) => usitc.htsLookup(input),
3786
+ }),
3787
+ // ━━━ BLS Public Data API v1/v2 — keyless US labor/price time series (1) ━━━ ADR-0032
3788
+ // A NEW capability axis: the PRICING / ESCALATION layer (CPI-U & ECI EPA-clause
3789
+ // escalation, PPI materials benchmarking, CES labor-rate context). The SECOND
3790
+ // POST-batch getJson-port consumer (after NIH). SSRF surface = a compile-time-
3791
+ // constant host+path; seriesids ride in the module-built POST body. Honesty crux:
3792
+ // the "-" unavailable marker → null-never-0 with the footnote reason surfaced; a
3793
+ // non-SUCCESS status THROWS (never a fake-empty); per-series units are labeled;
3794
+ // the span is clamped to the tier cap + disclosed. An OPTIONAL free BLS_API_KEY
3795
+ // rides ONLY in the POST body (v2) — never a URL/header/label/_meta/log.
3796
+ defineTool({
3797
+ name: "bls_timeseries",
3798
+ description: "Fetch US Bureau of Labor Statistics time series — the PRICING / ESCALATION layer (keyless; api.bls.gov Public Data API v1, POST/JSON batch). CPI-U & ECI drive federal contract escalation / economic-price-adjustment (EPA) clauses; PPI benchmarks materials pricing; CES employment/wages give labor-rate context (next to gsa_benchmark_labor_rates + sam wage determinations). Inputs (at least one of series/seriesId REQUIRED; both combinable): `series` — a FROZEN 9-key CURATED enum (typo-proof; each carries meaning + units): cpi_u_all/cpi_u_core (CPI-U index, NSA — the escalation reference), ppi_final_demand (PPI index), eci_total_comp/eci_wages (★12-MONTH % CHANGE, NOT an index — a consumer misreads 3.4 as an index level otherwise), unemployment_rate/labor_force_participation (percent, SA), employment_total_nonfarm (thousands of persons, SA), avg_hourly_earnings (dollars/hour, SA). `seriesId` — raw BLS IDs (charclass ^[A-Z0-9]{1,20}$; the OEWS/local-area/regional passthrough; units:null for a raw ID). `startYear`/`endYear` (1900..currentYear+1; default a ~10-year window; span CLAMPED to the tier cap ~10y and disclosed). Returns { series:[{ seriesId, key, meaning, units, observations:[{ year, period, periodName, value, valueUnavailable, footnotes, latest }], observationCount, coveredRange }] } + honest _meta. HONESTY: each `value` is PARSED number|null — the BLS \"-\" unavailable marker (e.g. the 2025 lapse-in-appropriations gap) → null NEVER 0, with valueUnavailable:true + the footnote reason on the observation AND lifted into _meta.notes (a data gap is DISCLOSED, never a silent null and never a fabricated 0); a genuine \"0\" stays 0. A non-SUCCESS status THROWS (never a fake-empty): REQUEST_NOT_PROCESSED (the v1 ~25/day limit) ⇒ rate_limited with the tier disclosure; REQUEST_FAILED ⇒ upstream_unavailable/invalid_input surfacing message[]. A non-JSON 200 or a SUCCESS body missing Results.series ⇒ schema_drift. An empty data[] on SUCCESS ⇒ observations:[] + an ambiguity note (a curated key = a genuine empty range; a raw seriesId = EITHER genuine-empty OR a nonexistent/typo'd ID — verify it). Every response discloses the active tier (v1 keyless ~25/day, 25 series/query, ~10y span | v2 with a free BLS_API_KEY ~500/day) + the per-series units caveat. An OPTIONAL free BLS_API_KEY (env; https://data.bls.gov/registrationEngine/) lifts to v2 and is sent ONLY in the request body — never a URL/header/log.",
3799
+ inputSchema: BlsTimeseriesInput,
3800
+ handler: (input) => bls.timeseries(input),
3801
+ }),
3802
+ // ━━━ BLS OEWS — keyless occupational wage benchmarking (2nd BLS tool) ━━━ ADR-0033
3803
+ // The LEVEL layer next to bls_timeseries's ESCALATION layer: mean/median annual &
3804
+ // hourly wages + employment by SOC occupation × geography — the highest-value B2G
3805
+ // BLS slice (labor-rate benchmarking) that bls_timeseries structurally cannot reach
3806
+ // (OEWS IDs are 25 chars > the raw-seriesId 20-char cap). BUILDS the 25-char series
3807
+ // ID INTERNALLY from validated structured inputs; REUSES the same POST/JSON transport
3808
+ // + parseBlsBody status-throw + mapObservation ("-"→null-never-0) + tier/key seam.
3809
+ defineTool({
3810
+ name: "bls_oews_wages",
3811
+ description: "Benchmark US occupational wages & employment from BLS OEWS (Occupational Employment & Wage Statistics) — the LEVEL layer for labor-rate benchmarking (keyless; api.bls.gov Public Data API, POST/JSON batch). The actual mean/median annual & hourly wage a labor category commands, by area — next to gsa_benchmark_labor_rates (GSA CALC), sam wage determinations, and bls_timeseries (the CPI/ECI escalation layer). OEWS series IDs are 25 chars (area×occupation×industry×datatype), EXCEEDING bls_timeseries's raw-seriesId cap, so this tool BUILDS the ID INTERNALLY from validated structured inputs. Inputs (at least one of occupation/soc REQUIRED; all arrays batch into ONE POST — the cartesian product area×occupation×datatype is capped at the active tier's series cap and refused over-cap WITH THE COUNT NAMED, never silently truncated): `occupation` — a CURATED 16-key SOC enum (typo-proof; e.g. software_developer=15-1252, civil_engineer=17-2051, management_analyst=13-1111); `soc` — raw 6-digit HYPHENLESS SOC codes for the ~830-SOC long tail (use 151252, not 15-1252); `area` — default [\"national\"]; each is \"national\", a 2-letter USPS state code (CA/TX/DC…), or a 5-digit CBSA metro code (19100 = Dallas-Fort Worth); `datatype` — default [\"annual_mean\"]: annual_mean/annual_median (dollars/year), hourly_mean/hourly_median (dollars/hour), employment (count jobs). NO year input — OEWS is ANNUAL and the API serves only the latest release; the tool requests a recent window internally and DISCLOSES the reference year. Returns { results:[{ area:{type,code,label}, occupation:{soc,key,label}, measure:{key,code,units}, value:number|null, valueUnavailable, referenceYear, referencePeriod, footnotes, seriesId }] } + honest _meta. HONESTY: (H1) OEWS is an ANNUAL point-in-time snapshot (reference May <year>, period A01), NOT monthly/current-quarter — disclosed every call; (H2) a built ID that returns empty/absent ⇒ value:null, valueUnavailable:FALSE (the occupation is not surveyed/estimated there OR the cell is suppressed for confidentiality) + the not-published note + the surfaced upstream \"Series does not exist\" message + the ID in fieldsUnavailable — NEVER a fabricated 0; a PRESENT \"-\" in-band value ⇒ null + valueUnavailable:true + footnote; (H3) each row's measure.units labels the datatype (never read an employment count as a wage); (H4) the API returns real numerics (no top-code); a non-SUCCESS status THROWS (REQUEST_NOT_PROCESSED ⇒ rate_limited with the tier disclosure; a non-JSON 200 ⇒ schema_drift). Every response discloses the active tier. An OPTIONAL free BLS_API_KEY lifts to v2 and is sent ONLY in the request body — never a URL/header/log.",
3812
+ inputSchema: BlsOewsWagesInput,
3813
+ handler: (input) => bls.oewsWages(input),
3814
+ }),
3815
+ // ━━━ BLS QCEW — county×NAICS market-size / wages / location-quotient (3rd BLS tool) ━━━ ADR-0042
3816
+ // A SECOND, DIFFERENT, keyless, un-rate-limited BLS DOMAIN (data.bls.gov/cew — the
3817
+ // QCEW Open Data Access CSV, NOT the rate-limited api.bls.gov/publicAPI timeseries
3818
+ // API). Answers the market-size / competition-density question no existing tool can:
3819
+ // establishment COUNT (market size / competitor density), county×NAICS employment,
3820
+ // avg weekly wage (labor cost), and the LOCATION QUOTIENT (concentration vs national).
3821
+ // Honesty crux: a suppressed employment/wage 0-sentinel → null (never 0), block/code/
3822
+ // field-scoped (base/lq/oty each keyed on its OWN *_disclosure_code; qtrly_estabs /
3823
+ // lq_qtrly_estabs / oty_qtrly_estabs_chg stay disclosed under 'N'). Symmetric CSV
3824
+ // column-drift + a POST-parse quoted-header assertion → schema_drift. A per-tuple 404
3825
+ // → honest empty. NEW gate key "bls_qcew"; NO BLS_API_KEY on this keyless path.
3826
+ defineTool({
3827
+ name: "bls_qcew",
3828
+ description: "BLS QCEW (Quarterly Census of Employment & Wages) — county×NAICS MARKET-SIZE / wages / location-quotient (keyless; data.bls.gov/cew Open Data Access CSV, a SECOND un-rate-limited BLS domain — NOT the ~25/day api.bls.gov timeseries API). Answers the market-size / competition-density question no other tool can: for ONE area_fips (county/state/metro/US) OR ONE NAICS × quarter — establishment COUNT (market size / competitor density), county×NAICS employment, average weekly wage (labor cost), and the LOCATION QUOTIENT (lq_* = concentration vs the national average; >1.00 = more concentrated / higher competition density). Inputs: `mode` (REQUIRED {area,industry}); `area` (area_fips ^[0-9A-Za-z]{1,6}$ — REQUIRED path segment for mode=area, else an optional client-side narrow); `industry` (NAICS ^[0-9]{1,6}$ DIGIT-ONLY — REQUIRED path segment for mode=industry, else an optional narrow; a hyphenated 31-33 404s, use the digit aggregate); `year` (REQUIRED 1990..current), `quarter` (REQUIRED 1|2|3|4); client-side `ownership`(own_code)/`aggregationLevel`(agglvl_code)/`sizeCode`; `limit` (≤1000, def 50)/`offset`. Wire: GET data.bls.gov/cew/data/api/{year}/{quarter}/{mode}/{code}.csv. Returns { found, mode, area|industry, year, quarter, rows:[{ area_fips, own_code, industry_code, agglvl_code, size_code, base:{ disclosed, disclosureCode, qtrly_estabs, month1/2/3_emplvl, total_qtrly_wages, taxable_qtrly_wages, qtrly_contributions, avg_wkly_wage }, locationQuotient:{ disclosed, disclosureCode, lq_qtrly_estabs, lq_… }, overTheYear:{ disclosed, disclosureCode, oty_qtrly_estabs_chg, oty_…_pct_chg } }] } + honest _meta. ★DISCLOSURE-SUPPRESSION HONESTY (the crux): each row carries THREE disclosure codes (base/lq/oty), each governing its block. QCEW encodes a SUPPRESSED (confidential) employment/wage value as a literal 0 — so under 'N' the confidential emplvl/wage/avg-wkly fields map to null (WITHHELD, never a fabricated $0), while the establishment COUNT (qtrly_estabs / lq_qtrly_estabs) AND its over-the-year change (oty_qtrly_estabs_chg / _pct_chg) stay DISCLOSED (real); under '-' the WHOLE block incl. the estabs field(s) → null; under blank a genuine reported/NEGATIVE 0 SURVIVES (the disclosed federal taxable=0/contrib=0 and the oty_*_chg=0 'no change'). NEVER a blanket 0→null. A null carries disclosed:false + the raw disclosureCode; a suppression note fires whenever any page row is suppressed. HONESTY: totalAvailable is the EXACT filtered row count (fetch-once + client-side limit/offset — QCEW does not paginate; never the page length); a per-tuple HTTP 404 ⇒ honest empty (found:false, the HTML 404 body NEVER parsed as CSV); a 5xx/timeout ⇒ THROW; a 200 non-CSV / a renamed/±column header / a wrong field-count row ⇒ schema_drift THROW (symmetric drift guard). The file MIXES aggregation levels + ownerships — a do-NOT-sum-across-agglvl/ownership note rides every response. PUBLIC AGGREGATE stats (the suppression mechanism keeps small-cell data non-identifying — no PII). Keyless, un-rate-limited; NO BLS_API_KEY is read.",
3829
+ inputSchema: BlsQcewInput,
3830
+ handler: (input) => bls.qcew(input),
3831
+ }),
3832
+ // ━━━ OpenFEMA — keyless disaster declarations + emergency-assistance spend (2) ━━━ ADR-0016
3833
+ defineTool({
3834
+ name: "fema_search_public_assistance",
3835
+ description: "Search FEMA Public Assistance funded projects — federal emergency-assistance spend to state/local/tribal applicants (keyless OpenFEMA, dataset PublicAssistanceFundedProjectsDetails v2, ~800k rows). Structured filters (module-built into an OData $filter; each LIVE-VERIFIED to narrow): `state` (→ stateAbbreviation), `disasterNumber`, `applicantId`, `damageCategoryCode` (e.g. 'B' = Emergency Protective Measures), `incidentType`, `minProjectAmount`/`maxProjectAmount` (projectAmount ge/le), `declaredDateFrom`/`declaredDateTo` (declarationDate ge/le). `limit` (≤1000, def 100 → $top), `offset` (→ $skip). HONESTY: the module ALWAYS sends $inlinecount=allpages so totalAvailable is the EXACT filtered total (metadata.count), never the page length; amount fields are number|null (a real 0 stays 0, absent → null); genuine-empty ⇒ complete:true/total:0; an outage/400/404 THROWS (never a fake empty).",
3836
+ inputSchema: FemaSearchPublicAssistanceInput,
3837
+ handler: (input) => fema.searchPublicAssistance(input),
3838
+ }),
3839
+ defineTool({
3840
+ name: "fema_disaster_declarations",
3841
+ description: "Look up FEMA disaster / emergency declarations by state, type, incident, year, or date (keyless OpenFEMA, dataset DisasterDeclarationsSummaries v2, ~70k rows). Structured filters (module-built into an OData $filter; each LIVE-VERIFIED to narrow): `state` (→ state), `incidentType` (e.g. 'Flood'), `declarationType` (DR/EM/FM), `fyDeclared`, `disasterNumber`, `declaredDateFrom`/`declaredDateTo` (declarationDate ge/le), `paProgramDeclared`/`iaProgramDeclared` (booleans). `limit` (≤1000, def 100 → $top), `offset` (→ $skip). HONESTY: the module ALWAYS sends $inlinecount=allpages so totalAvailable is the EXACT filtered total (metadata.count), never the page length; genuine-empty ⇒ complete:true/total:0; an outage/400/404 THROWS (never a fake empty). NOTE: per-dataset OData field names differ — 'state' here is the real field, whereas the public-assistance tool maps 'state' to 'stateAbbreviation'.",
3842
+ inputSchema: FemaDisasterDeclarationsInput,
3843
+ handler: (input) => fema.disasterDeclarations(input),
3844
+ }),
3845
+ // ━━━ FPDS-NG — federal contract AWARD ACTIONS (keyless ATOM) (1) ━━━ ADR-0012
3846
+ // The FIRST XML/ATOM source (bounded, ReDoS-safe hand-parser — the far.ts/gao.ts
3847
+ // lineage; NOT the getJson port). FPDS is the system-of-record USAspending
3848
+ // derives from — this closes the action-level / mod-level latest-truth gap.
3849
+ defineTool({
3850
+ name: "fpds_search_awards",
3851
+ description: "Search FPDS-NG federal contract AWARD ACTIONS (keyless ATOM) — the AUTHORITATIVE system-of-record for contract actions (each modification is its own transaction), the source USAspending.gov derives from (and lags 1-2 days). Structured filters ONLY, AND-combined (NO raw query — a typo'd FPDS field name is a SILENT ZERO, so the tool builds the fielded q): naics (PRINCIPAL_NAICS_CODE), vendorName, piid, departmentId, contractingAgencyName, signedDate range (from/to ISO), lastModified range, keyword. At least one filter is REQUIRED. Returns award/IDV rows { piid, modNumber, parentIdvPiid, actionType, signedDate, vendorName, vendorUei, ultimateParentUei, obligatedAmount, totalObligatedAmount, naics, psc, placeOfPerformanceState, extentCompeted, setAside, businessSize, socioeconomic, … } (content root is award OR IDV — both parse). HONESTY: page size is FIXED at 10; for >10 results totalAvailable is a LOWER BOUND (totalIsLowerBound:true; true count ∈ [total, total+9]) and you MUST paginate by pagination.hasMore (page-fullness), NEVER by totalAvailable (keyless deep-paging is capped ~200K far below the advertised total). Genuine-empty (offset 0) ⇒ complete:true/total:0 + a silent-zero disclosure; an empty page at offset>0 ⇒ totalAvailable:null/complete:false (deep-paging ceiling, ambiguous); an HTML/non-feed body or an all-null-piid page ⇒ schema_drift (never a fake empty); an outage/5xx/timeout THROWS. Amounts are number|null (a 0.00 obligation and negative de-obligations are REAL, absent ⇒ null). Prefer usas_* tools for spending rollups / sub-award graphs.",
3852
+ inputSchema: FpdsSearchAwardsInput,
3853
+ handler: (input) => fpds.searchAwards(input),
3854
+ }),
3855
+ // ━━━ NIH RePORTER v2 — keyless federal research-GRANT projects (1) ━━━ ADR-0014
3856
+ // The R2 getJson port's FIRST non-GET consumer (POST + JSON body). A NEW axis:
3857
+ // federal research-funding footprint by organization / UEI / state (recipient
3858
+ // enrichment, joinable to SAM/USAspending via primary_uei). SSRF surface = a
3859
+ // compile-time-constant URL; all filters ride in the module-built POST body.
3860
+ // Only live-confirmed-narrowing criteria ship (M1); agency_ic_codes is excluded
3861
+ // (silent no-op). The 15,000-record retrieval window is disclosed, not hidden.
3862
+ defineTool({
3863
+ name: "nih_reporter_search_projects",
3864
+ description: "Search awarded NIH RePORTER research-GRANT projects (keyless; api.reporter.nih.gov v2, POST/JSON — the FIRST non-GET getJson-port consumer) — the NEW federal research-funding recipient-enrichment axis (who receives NIH research money, by organization / state, joinable to SAM/USAspending via primary_uei). Structured, LIVE-CONFIRMED-narrowing criteria ONLY, AND-combined in a module-built body (NO raw passthrough): orgStates (UPPERCASE 2-letter USPS enum — the SSRF + silent-zero guard; a lowercase/unknown code silently returns zeros), orgNames (≤512 each, ≤20), fiscalYears (int array 1985..currentYear+1, ≤20), limit (1..500, def 50), offset (0..14,999, def 0). Returns { projects:[{ projectNum, projectTitle, fiscalYear, awardAmount, organization:{ name, state, primaryUei, primaryDuns, ueis, duns }, principalInvestigators, contactPiName, fundingIc }] } + honest _meta. HONESTY: (M2) records are RESEARCH GRANTS, NOT procurement contracts — primary_uei joins to SAM/USAspending recipients but the award nature differs (disclosed in every _meta.notes); totalAvailable = the EXACT meta.total (NEVER the page size, NEVER a lower bound); NIH caps keyless retrieval at the first 15,000 records (offset 0..14,999) — offset ≥ 15,000 ⇒ invalid_input, and past the window the count stays exact while records are UNREACHABLE (disclosed in a note; nextOffset is never a dead-end). Disclose-not-refuse: an unscoped query still returns the first page + the exact total + a narrow-your-criteria note. agencyIcCodes is intentionally NOT a filter (NIH silently drops it — it would be a false 'applied'). Genuine-empty (total:0) ⇒ complete:true/total:0; an outage/5xx/timeout THROWS; a 400 (bad offset/limit/type) ⇒ invalid_input; a 200 body that isn't {meta,results} or a non-numeric meta.total ⇒ schema_drift (never a fake empty). awardAmount is number|null (a real $0 award is 0, an absent amount is null).",
3865
+ inputSchema: NihSearchProjectsInput,
3866
+ handler: (input) => nih.searchProjects(input),
3867
+ }),
3868
+ // ━━━ NSF Awards API — keyless federal research-GRANT awards (2) ━━━ ADR-0020
3869
+ // Source #20. The grant-SIBLING of NIH RePORTER on a DIFFERENT agency: NSF
3870
+ // research-grant awards with recipient / PI / UEI enrichment, strengthening the
3871
+ // WEAK entity/recipient layer (ueiNumber/parentUeiNumber join to SAM/USAspending).
3872
+ // SSRF surface = a compile-time-constant host+path; all filters ride in a
3873
+ // module-built URLSearchParams from a validated whitelist. HONESTY: totalCount is
3874
+ // EXACT below 10,000 and SATURATES at 10,000 (ES track_total_hits ⇒ totalIsLower-
3875
+ // Bound + a note); the offset+rpp ≤ 10,000 retrieval window is clamped/disclosed;
3876
+ // a multi-word keyword is OR-tokenized (disclosed, M1); a serviceNotification at
3877
+ // HTTP 200 loud-fails (never a fake empty); grant≠contract in every response.
3878
+ defineTool({
3879
+ name: "nsf_search_awards",
3880
+ description: "Search awarded NSF research-GRANT awards (keyless; api.nsf.gov/services/v1/awards.json) — the NEW federal research-funding recipient-enrichment axis (who receives NSF research money, by organization / UEI / PI / state, joinable to SAM/USAspending via ueiNumber/parentUeiNumber). The grant-SIBLING of nih_reporter_search_projects on a different agency. LIVE-CONFIRMED-narrowing filters ONLY, module-built into a URLSearchParams query (NO raw passthrough): keyword (free text; MULTI-WORD is OR-tokenized — 'machine learning' = machine OR learning, disclosed in _meta.notes), awardeeStateCode (UPPERCASE 2-letter USPS enum — the SSRF + silent-zero guard; a non-state typo silently returns 0), awardeeName, ueiNumber (12-char UEI — an EXACT SAM/USAspending join), parentUeiNumber (parent-org roll-up), pdPIName, dateStart/dateEnd (STRICT mm/dd/yyyy on the award ACTION date — a wrong format is silently mis-parsed), limit (1..100, def 25 → rpp), offset (0..9999). Returns { awards:[{ id, title, agency, cfdaNumber, transType, awardee:{ name, city, stateCode, ueiNumber, parentUeiNumber }, performanceSite, principalInvestigator:{ fullName, firstName, lastName, middleInitial, email, id }, coPrincipalInvestigators, programOfficer, amounts:{ fundsObligatedAmt, estimatedTotalAmt, fundsObligatedByYear }, dates, program, activeAward, historicalAward }] } (abstract EXCLUDED — use nsf_get_award) + honest _meta. HONESTY: NSF Awards are RESEARCH GRANTS, NOT procurement contracts (ueiNumber joins to SAM/USAspending but the award nature differs — disclosed every response); totalAvailable = the EXACT metadata.totalCount below 10,000 and SATURATES at 10,000 (an ES track_total_hits cap ⇒ totalIsLowerBound:true + a note — the true total is ≥10,000 and only the first 10,000 are retrievable); NSF caps keyless retrieval at offset+rpp ≤ 10,000 (offset ≥ 10,000 ⇒ invalid_input; the outgoing rpp is clamped so a page never crosses the window). fundsObligatedAmt/estimatedTotalAmt arrive as STRINGS → number|null (a real $0 is 0, absent is null). Genuine-empty (totalCount:0) ⇒ complete:true/total:0; a serviceNotification at HTTP 200 (bad param / deep offset) ⇒ invalid_input/upstream_unavailable THROWS (never a fake empty); an outage/5xx/timeout THROWS; a 200 body that isn't {response:{award,metadata}} or a non-numeric totalCount ⇒ schema_drift. Feed a row's id to nsf_get_award for the full record + abstractText.",
3881
+ inputSchema: NsfSearchAwardsInput,
3882
+ handler: (input) => nsf.searchAwards(input),
3883
+ }),
3884
+ defineTool({
3885
+ name: "nsf_get_award",
3886
+ description: "Fetch ONE NSF award by its numeric award id (keyless; api.nsf.gov/services/v1/awards.json). Input `awardId` (all-digit, 5..9 digits — NSF ids are 7-digit numeric, live-verified; numeric-only is injection-safe). Returns { found, award:{ …the FULL curated record INCLUDING abstractText… } } + honest _meta. A nonexistent id ⇒ a genuine empty (totalCount:0) ⇒ found:false / award:null (NEVER a fabricated record). HONESTY: NSF Awards are RESEARCH GRANTS, NOT procurement contracts (ueiNumber joins to SAM/USAspending but the award nature differs — disclosed every response); fundsObligatedAmt/estimatedTotalAmt arrive as STRINGS → number|null (a real $0 is 0, absent is null); a serviceNotification at HTTP 200 ⇒ invalid_input/upstream_unavailable THROWS; an outage/5xx ⇒ THROWS; a 200 body that isn't {response:{award,metadata}} ⇒ schema_drift (never a fabricated record).",
3887
+ inputSchema: NsfGetAwardInput,
3888
+ handler: (input) => nsf.getAward(input),
3889
+ }),
3890
+ // ━━━ ClinicalTrials.gov API v2 — keyless clinical-study registrations (2) ━━━ ADR-0021
3891
+ // Source #21. The trial-REGISTRATION sibling of the research-GRANT sources (NIH
3892
+ // RePORTER / NSF Awards): leadSponsor / collaborators / organization are the
3893
+ // pharma/biotech/university/agency entities that ALSO receive federal money.
3894
+ // SSRF surface = a compile-time host literal (CT_BASE) + a single audited getCT
3895
+ // helper ([M2]); the single-study nctId is ^NCT\d{8}$-validated before the path
3896
+ // is built. HONESTY: countTotal=true is ALWAYS sent (the exact filter-respecting
3897
+ // uncapped total — a missing totalCount ⇒ schema_drift, NEVER studies.length);
3898
+ // an OPAQUE nextPageToken cursor (terminal = token absent, passed back verbatim);
3899
+ // funderType is a 4-value enum RE-VALIDATED IN THE HANDLER ([M1] — an invalid
3900
+ // value silently fake-empties at HTTP 200); multi-word term/sponsor/condition is
3901
+ // AND-tokenized (disclosed); trial≠federal-award caveat in every response.
3902
+ defineTool({
3903
+ name: "clinicaltrials_search_studies",
3904
+ description: "Search federally-registered clinical-research studies with LEAD-SPONSOR / COLLABORATOR / ORGANIZATION / FUNDING-SOURCE entity enrichment (keyless; clinicaltrials.gov/api/v2/studies) — the trial-REGISTRATION axis of the research-funding entity layer (the sponsor/collaborator NAMES overlap the pharma/biotech/university/agency entities in NIH RePORTER / NSF Awards / SAM / USAspending). LIVE-CONFIRMED-narrowing filters ONLY, module-built into a URLSearchParams query (NO raw passthrough): query.term (broad free-text), sponsor (→query.spons — a fuzzy sponsor NAME search), condition (→query.cond), location (→query.locn), overallStatus (a frozen 14-value enum → filter.overallStatus), funderType (a frozen 4-value enum nih/fed/industry/other → aggFilters — the FEDERAL-funding axis), pageSize (1..1000, def 20), pageToken (the OPAQUE cursor). Returns { studies:[{ nctId, briefTitle, orgStudyId, organization:{ name, class }, leadSponsor:{ name, class }, collaborators:[{ name, class }], fundingClass, overallStatus, startDate, studyType, phases, conditions }] } (briefSummary EXCLUDED — use clinicaltrials_get_study) + honest _meta. HONESTY: countTotal=true is ALWAYS sent ⇒ totalAvailable = the EXACT filter-respecting UNCAPPED total (NEVER studies.length; a missing/non-number totalCount ⇒ schema_drift; a genuine 0 ⇒ 0, never null); pagination is an OPAQUE cursor (offset/nextOffset null; nextCursor = nextPageToken passed back verbatim as pageToken; terminal = token absent; a bad token ⇒ HTTP 400 THROWS). funderType is re-validated IN the handler — an UNLISTED value silently returns totalCount:0 at HTTP 200 (a fake-empty trap) ⇒ invalid_input pre-fetch (0 fetch); funderType is an OVERLAPPING facet (counts MUST NOT be summed). A MULTI-WORD query.term/sponsor/condition is AND-conjunctive (ALL tokens must co-occur — disclosed). A registered trial is NOT a federal award and leadSponsor.name is FREE TEXT (not a UEI) ⇒ a NOMINAL name match only (disclosed every response). Genuine-empty (totalCount:0, no token) ⇒ complete:true/total:0; a bad overallStatus/pageToken/nctId ⇒ HTTP 400/404 THROWS; an outage/5xx ⇒ THROWS (never a fake empty). Feed a row's nctId to clinicaltrials_get_study for the full record + briefSummary.",
3905
+ inputSchema: ClinicaltrialsSearchStudiesInput,
3906
+ handler: (input) => clinicaltrials.searchStudies(input),
3907
+ }),
3908
+ defineTool({
3909
+ name: "clinicaltrials_get_study",
3910
+ description: "Fetch ONE clinical study by its NCT id (keyless; clinicaltrials.gov/api/v2/studies/{nctId}). Input `nctId` (the form NCT followed by exactly 8 digits, e.g. NCT02403869 — validated before the path is built, injection-safe). Returns { found, nctId, study:{ …the FULL curated entity record INCLUDING briefSummary… } } + honest _meta. A nonexistent id ⇒ HTTP 404 ⇒ found:false / study:null (NEVER a fabricated record). HONESTY: a registered trial is NOT a federal award and leadSponsor.name is FREE TEXT (not a UEI) ⇒ a NOMINAL name match only (disclosed every response); a 200 body missing protocolSection ⇒ schema_drift; an outage/5xx ⇒ THROWS.",
3911
+ inputSchema: ClinicaltrialsGetStudyInput,
3912
+ handler: (input) => clinicaltrials.getStudy(input),
3913
+ }),
3914
+ // ── ADR-0024: the aggregate/statistical SIBLING (+1 tool). EXACT per-value study
3915
+ // counts over the WHOLE registry for whitelisted ENUM fields (/stats/field/values,
3916
+ // the SAME fixed host + audited getCT). HONESTY: [M1] _meta.totalAvailable/returned
3917
+ // count DISTINCT FIELD VALUES (not studies — a mandatory unit note); [M2] the
3918
+ // whole-registry scope note carries NO frozen registry size; the returned<unique⇒
3919
+ // truncated invariant discloses the 250-cap the instant it binds (never for v1
3920
+ // ENUMs); a non-ENUM shape for a whitelisted field ⇒ schema_drift; Phase is
3921
+ // ARRAY-valued (overlap/not-a-partition note); the facet-scoped trial≠award caveat
3922
+ // every response. NO free-text ⇒ no tokenization.
3923
+ defineTool({
3924
+ name: "clinicaltrials_facet_counts",
3925
+ description: "Aggregate/statistical view: EXACT per-value STUDY counts over the WHOLE ClinicalTrials.gov registry for one or more whitelisted ENUM fields (keyless; clinicaltrials.gov/api/v2/stats/field/values) — the DISTRIBUTION sibling of clinicaltrials_search_studies (which gives the exact FILTERED total for a query). Input `fields`: 1..11 ENUM fields (deduped) — OverallStatus, StudyType, Phase, LeadSponsorClass (★ the funding-SOURCE-class distribution: NIH/FED/OTHER_GOV/INDUSTRY/OTHER/NETWORK/INDIV/UNKNOWN/AMBIG — richer than, and distinct from, the search tool's 4-value funderType filter), Sex, DesignAllocation, DesignPrimaryPurpose, DesignInterventionModel, DesignMasking, DesignObservationalModel, DesignTimePerspective. Module-built comma-joined into fields=<…> (NO raw passthrough). Returns { facets:[{ field, fieldPath, valueType, uniqueValuesCount, missingStudiesCount, returned, truncated, overlapping, values:[{ value, studiesCount }] }] } + honest _meta. HONESTY: each studiesCount/uniqueValuesCount is EXACT (typeof-checked to a NUMBER before num() — a non-number ⇒ schema_drift, NEVER a silent 0); a non-ENUM shape for a whitelisted field (e.g. a BOOLEAN {trueCount,falseCount}) ⇒ schema_drift (never read as empty). [M1] _meta.totalAvailable/returned count DISTINCT FIELD VALUES across the requested facet(s), NOT studies (a mandatory unit note points to facets[].values[].studiesCount / clinicaltrials_search_studies for a study count). These counts cover the ENTIRE registry and are NOT filterable — /stats/field/values rejects query.*/filter.*/countTotal/pageSize (HTTP 400) — a scope note cross-links the search tool for filtered totals. The returned<uniqueValuesCount⇒truncated invariant discloses the endpoint's hard 250-value cap the instant it binds (never for these v1 ENUM fields — all complete). Phase is ARRAY-valued (a study can carry several) ⇒ overlapping:true + a not-a-partition note (counts MUST NOT be summed); scalar fields partition the registry minus missingStudiesCount. A high missingStudiesCount ⇒ a note that the shown buckets cover a MINORITY of the registry. MANDATORY CAVEAT every response: a facet count is a distribution over trial REGISTRATIONS, NOT federal awards; LeadSponsorClass is the funding-SOURCE class, not a UEI-keyed award join. An unlisted field ⇒ invalid_input pre-fetch (0 fetch); a 404/400/5xx ⇒ THROWS (never a fake-empty distribution).",
3926
+ inputSchema: ClinicaltrialsFacetCountsInput,
3927
+ handler: (input) => clinicaltrials.facetCounts(input),
3928
+ }),
3929
+ // ━━━ EPA ECHO REST — keyless facility environmental compliance/enforcement (2) ━━━ ADR-0009
3930
+ // A NEW capability axis: facility & competitor environmental compliance-risk
3931
+ // screening / due diligence. KEYLESS (keylessMode:true, byte-clean init), single
3932
+ // fixed host + three fixed service paths (the SSRF core). The two-step QueryID
3933
+ // pagination is HIDDEN in-call (the ephemeral globally-recycled QueryID is never
3934
+ // exposed); the 200-with-error-body failure mode is guarded FIRST (never a fake
3935
+ // empty). M2: sic narrows (real filter), naics is dropped upstream (best-effort +
3936
+ // disclosed in _meta.filtersDropped/notes).
3937
+ defineTool({
3938
+ name: "echo_search_facilities",
3939
+ description: "Search EPA-regulated facilities by US state (+ optional sic / facilityName / majorOnly / federalOnly) with compliance/enforcement screening fields (EPA ECHO, keyless) — the NEW facility environmental compliance-risk / due-diligence axis (CAA/CWA/RCRA/SDWA violation, inspection, penalty, SNC history). Input `state` (REQUIRED enum — the SSRF + silent-zero guard), `sic` (2–4 digits, a REAL filter), `naics` (2–6 digits, BEST-EFFORT — ECHO DROPS it upstream, reported in _meta.filtersDropped + a note), `facilityName` (substring; a typo silently returns 0), `majorOnly`/`federalOnly` (bool), `limit` (≤1000, def 100), `offset` (multiple of limit). Returns { state, facilities:[…verbatim rows incl. RegistryID…], summary:{ queryRows, programCounts, totalPenalties } } + honest _meta. HONESTY: totalAvailable = the EXACT QueryRows total (NEVER the page size); a hidden two-step QueryID pagination fetches the rows (the QueryID is ephemeral/globally-recycled, never exposed); genuine-empty ⇒ complete:true/total:0; a queryset-limit overflow / bad query ⇒ invalid_input; an outage/5xx ⇒ THROWS (never a fake empty). Feed a row's RegistryID to echo_facility_report.",
3940
+ inputSchema: EchoSearchFacilitiesInput,
3941
+ handler: (input) => echo.searchFacilities(input),
3942
+ }),
3943
+ defineTool({
3944
+ name: "echo_facility_report",
3945
+ description: "Fetch the EPA ECHO Detailed Facility Report (DFR) for ONE facility by its FRS RegistryID (keyless) — the per-facility compliance / enforcement / inspection / permit deep-dive for competitor or acquisition-target due diligence. Input `registryId` (all-digit FRS id, 9–12 digits, from echo_search_facilities rows). Returns { registryId, report:{…verbatim compliance/enforcement/permit detail…} } + single-record _meta (complete:true, no pagination). A bad/unknown RegistryID ⇒ not_found (never a fabricated report).",
3946
+ inputSchema: EchoFacilityReportInput,
3947
+ handler: (input) => echo.facilityReport(input),
3948
+ }),
3949
+ // ━━━ api.data.gov keyed trio — Regulations.gov + Congress.gov (6) ━━━ ADR-0007/0044
3950
+ // The project's FIRST KEYED source. The key (DATA_GOV_API_KEY, else the public
3951
+ // DEMO_KEY) travels ONLY in the X-Api-Key header — never the URL/label/_meta.
3952
+ // keylessMode:false (genuinely keyed); a DEMO_KEY note discloses the shared
3953
+ // ~10 req/hr ceiling + the free-key upgrade path. ADR-0044 adds the two docket
3954
+ // tools (the rulemaking CONTAINER + its `rin` cross-source join key).
3955
+ defineTool({
3956
+ name: "regulations_search_documents",
3957
+ description: "Search Regulations.gov rulemaking DOCUMENTS (rules, proposed rules, notices) — the flagship of the api.data.gov keyed source (JSON:API; DATA_GOV_API_KEY or the shared DEMO_KEY). Input `searchTerm`/`query`, filters (agencyId, docketId, documentType, withinCommentPeriod, postedDateGe/Le YYYY-MM-DD), `sort` (def -postedDate), `pageNumber` (1..40 HARD cap), `pageSize` (5..250, def 25). Returns { documents:[{ id, documentType, title, agencyId, docketId, postedDate, commentEndDate, openForComment, withinCommentPeriod, frDocNum, objectId }] } + honest _meta. HONESTY: totalAvailable = meta.totalElements (the EXACT real total, ~millions), NOT the capped totalPages; page[number] is hard-capped at 40 (10,000-record ceiling) — at the ceiling hasMore stays true but nextOffset is null + a note says how to reach the rest (narrow filters / seek by lastModifiedDate). Genuine-empty ⇒ complete:true/total:0; an outage/4xx THROWS (never a fake empty).",
3958
+ inputSchema: RegulationsSearchInput,
3959
+ handler: (input) => datagov.searchDocuments(input),
3960
+ }),
3961
+ defineTool({
3962
+ name: "regulations_search_comments",
3963
+ description: "Search Regulations.gov public COMMENTS on rulemakings — the killer B2G dataset (who is lobbying which rule). Same JSON:API envelope + input shape as regulations_search_documents (searchTerm/query, agencyId, docketId, postedDateGe/Le, sort, pageNumber 1..40, pageSize 5..250) against /v4/comments. Returns { comments:[{ id, documentType, title, agencyId, docketId, postedDate, objectId }] } + honest _meta (same totalElements-exact total + 40-page/10,000-record ceiling handling as documents).",
3964
+ inputSchema: RegulationsSearchInput,
3965
+ handler: (input) => datagov.searchComments(input),
3966
+ }),
3967
+ defineTool({
3968
+ name: "congress_search_bills",
3969
+ description: "Search Congress.gov BILLS/legislation (api.data.gov keyed; DATA_GOV_API_KEY or DEMO_KEY). Input optional `congress` (e.g. 118), `billType` (hr/s/hjres/sjres/hconres/sconres/hres/sres — requires `congress`), `fromDateTime`/`toDateTime` (ISO-8601 with offset), `offset`, `limit` (≤250, def 20). Returns { bills:[{ congress, type, number, title, originChamber, latestAction, updateDate, url }] } + _meta with totalAvailable = pagination.count (EXACT). NOTE: /v3/bill has no keyword search, so a `query` arg is NOT applied and is disclosed in _meta.filtersDropped. Outage/4xx THROWS (never a fake empty).",
3970
+ inputSchema: CongressSearchBillsInput,
3971
+ handler: (input) => datagov.searchBills(input),
3972
+ }),
3973
+ defineTool({
3974
+ name: "congress_get_bill",
3975
+ description: "Fetch ONE Congress.gov bill by id via /v3/bill/{congress}/{billType}/{billNumber} (api.data.gov keyed; DATA_GOV_API_KEY or DEMO_KEY). Input `congress` (int), `billType` (enum), `billNumber` (int). Returns { bill:{…} } + single-record _meta. A nonexistent bill ⇒ not_found (never fabricated).",
3976
+ inputSchema: CongressGetBillInput,
3977
+ handler: (input) => datagov.getBill(input),
3978
+ }),
3979
+ defineTool({
3980
+ name: "regulations_search_dockets",
3981
+ description: "Search Regulations.gov DOCKETS — the rulemaking/nonrulemaking CONTAINER that groups every document + comment under one regulatory action (api.data.gov keyed; DATA_GOV_API_KEY or the shared DEMO_KEY). Input `searchTerm`/`query`, filters (agencyId, docketType Rulemaking/Nonrulemaking, lastModifiedDateGe/Le YYYY-MM-DD), `sort` (def -lastModifiedDate), `limit` (1..250, def 20), `pageNumber` (1..40 HARD cap). Returns { dockets:[{ docketId, title, agencyId, docketType, lastModifiedDate, objectId, id }] } + honest _meta. HONESTY: totalAvailable = meta.totalElements (the EXACT real total, ~277k), NOT the capped totalPages (a 40 sentinel — deriving a total from totalPages lies); page[number] is hard-capped at 40 (10,000-record ceiling) — at the ceiling hasMore stays true but nextOffset is null + a note on how to reach the rest (narrow filters). The API's page[size] floor is 5, so a limit<5 fetches 5 and returns the first `limit` rows client-side (disclosed; totalAvailable stays exact). NOTE: `rin` is NULL in list rows — call regulations_get_docket for a docket's rin. DEMO_KEY ~10 req/hr (every call, incl. errors, decrements) — set DATA_GOV_API_KEY for 1000/hr. Genuine-empty ⇒ complete:true/total:0; outage/4xx/429 THROWS (never a fake empty).",
3982
+ inputSchema: RegulationsSearchDocketsInput,
3983
+ handler: (input) => datagov.searchDockets(input),
3984
+ }),
3985
+ defineTool({
3986
+ name: "regulations_get_docket",
3987
+ description: "Fetch ONE Regulations.gov docket by id via /v4/dockets/{docketId} (api.data.gov keyed; DATA_GOV_API_KEY or DEMO_KEY) — the detail view where `rin` lives. Input `docketId` (e.g. 'BLM-2026-0001'; the ONLY path-segment value, charclass-validated — a bad id ⇒ invalid_input, 0 fetch). Returns { docket:{ docketId, title, agencyId, docketType, rin, dkAbstract, keywords, program, shortTitle, effectiveDate, modifyDate, objectId, id } } + single-record _meta (returned:1, totalAvailable:null, complete:true). HONESTY: `rin` (Regulatory Identifier Number) is the cross-source JOIN KEY to the Federal Register (fed_register_search_documents) and the Unified Agenda — null-when-absent (never '', e.g. many Nonrulemaking dockets have no assigned RIN), which is NOT a join failure. A nonexistent id ⇒ not_found (or schema_drift if the API returns a 200 error-envelope) — never a fabricated docket. DEMO_KEY ~10 req/hr; set DATA_GOV_API_KEY for 1000/hr.",
3988
+ inputSchema: RegulationsGetDocketInput,
3989
+ handler: (input) => datagov.getDocket(input),
3990
+ }),
3991
+ // ━━━ data.gov v4 Catalog API (api.gsa.gov) — CKAN-retirement replacement (1) ━━━ ADR-0046
3992
+ // Resilience Phase 3. data.gov RETIRED the CKAN package_search endpoint in 2025;
3993
+ // the v4 Catalog API restores federal open-dataset DISCOVERY as a NEW keyed source.
3994
+ // A DIFFERENT host (api.gsa.gov) than the datagov trio, but the SAME api.data.gov
3995
+ // key (X-Api-Key header, shared datagovKey.ts seam) — keylessMode:false. The v4
3996
+ // API reports NO match count ⇒ totalAvailable is NULL (P1, never results.length);
3997
+ // pagination is an OPAQUE `after` cursor (nextCursor passed back verbatim); the
3998
+ // dcat.accessLevel openness field is surfaced verbatim.
3999
+ defineTool({
4000
+ name: "datagov_search_datasets",
4001
+ description: "Search the data.gov DATASET CATALOG for federal open datasets across all publishing agencies (api.gsa.gov v4 Catalog API, keyed — DATA_GOV_API_KEY or the shared DEMO_KEY) — the replacement for the CKAN package_search endpoint data.gov RETIRED in 2025, restoring federal dataset DISCOVERY. Input `query` (→_q free-text), `organization` (publisher slug, e.g. 'epa-gov'), `limit` (1..100, def 20 → _size), `cursor` (the OPAQUE continuation → after). Returns { datasets:[{ id (slug), title, organization, description, accessLevel, license, landingPage, modified, lastHarvested, keywords, themes, distributions:[{ title, format }], identifier }] } + honest _meta. HONESTY: the v4 API reports NO total match count ⇒ totalAvailable is NULL (NEVER results.length, NEVER a fabricated total — a note discloses it); pagination is an OPAQUE cursor (offset/nextOffset null; nextCursor = the `after` token passed back verbatim as `cursor`; nextCursor:null / hasMore:false = last page). accessLevel is surfaced VERBATIM (public / restricted public / non-public) — the openness signal, null-when-absent (this tool DISCOVERS datasets; it does not ingest distributions). A genuine no-match (results:[], no cursor) ⇒ complete:true/returned:0; a 429 (DEMO_KEY ~10 req/hr, hit quickly) ⇒ rate_limited THROWS; a 5xx/timeout ⇒ upstream_unavailable THROWS; a 200 non-JSON / a non-array results ⇒ schema_drift (never a fake empty). DEMO_KEY ~10 req/hr shared ceiling — set DATA_GOV_API_KEY (free at api.data.gov/signup) for 1000/hr. The key rides ONLY in the X-Api-Key header (never the URL/_meta).",
4002
+ inputSchema: DatagovSearchDatasetsInput,
4003
+ handler: (input) => datagovCatalog.searchDatasets(input),
4004
+ }),
4005
+ // ━━━ GovInfo (api.govinfo.gov) — the api.data.gov keyed trio's 3rd API (3) ━━━ ADR-0010
4006
+ // GPO-authoritative bulk publications (BILLS/PLAW/USCODE/CREC/CFR-FR editions/
4007
+ // BUDGET/GAOREPORTS) with PDF/XML/MODS downloads + provenance. 2nd consumer of the
4008
+ // shared api.data.gov env-key adapter (datagovKey.ts) — key ONLY in the X-Api-Key
4009
+ // header, keylessMode:false, DEMO_KEY disclosure. The novel piece is the OPAQUE
4010
+ // offsetMark cursor: continuation rides in _meta.nextCursor (passed back as
4011
+ // pageMark); pagination.offset/nextOffset are null (no numeric offset). The raw
4012
+ // upstream nextPage URL (which embeds pageSize+api_key) is NEVER surfaced.
4013
+ defineTool({
4014
+ name: "govinfo_list_collections",
4015
+ description: "List the GovInfo collection catalog (GPO-authoritative publications; api.data.gov keyed — DATA_GOV_API_KEY or the shared DEMO_KEY). No input. Returns { collections:[{ collectionCode, collectionName, packageCount, granuleCount }] } + _meta (complete:true, totalAvailable = collection count). The discovery entry-point: feed a collectionCode to govinfo_search_packages. Memoized ~6h; also the validator source for search_packages' collection arg. packageCount = whole packages; granuleCount = sub-package granules (a missing count is null, never 0).",
4016
+ inputSchema: GovinfoListCollectionsInput,
4017
+ handler: () => govinfo.listCollections(),
4018
+ }),
4019
+ defineTool({
4020
+ name: "govinfo_search_packages",
4021
+ description: "Search GovInfo packages in a collection modified since a date (GPO-authoritative bulk publications; api.data.gov keyed). Input `collection` (uppercase code — validated against the live catalog; an unknown code ⇒ invalid_input listing valid codes, NEVER a misleading empty), `startDate`/`endDate?` (YYYY-MM-DD or ISO datetime; filters by lastModified — the record UPDATE date, NOT dateIssued — disclosed in _meta), `pageSize?` (1..1000, def 100), `pageMark?` (opaque cursor, def '*'). Returns { collection, packages:[{ packageId, title, dateIssued, lastModified, docClass, congress, packageLink }] } + cursor _meta. HONESTY: totalAvailable = count (the EXACT real total, NOT the page size); GovInfo uses an OPAQUE cursor, so pagination.offset/nextOffset are null — continue by passing _meta.nextCursor back as `pageMark` (hasMore:false / nextCursor:null = last page). The raw upstream nextPage URL is never surfaced (it embeds the key). Genuine-empty ⇒ complete:true/total:0; outage/4xx THROWS (never a fake empty). CFR/ECFR/FR collections carry a note routing to the ecfr_*/fed_register_* tools for point lookups.",
4022
+ inputSchema: GovinfoSearchPackagesInput,
4023
+ handler: (input) => govinfo.searchPackages(input),
4024
+ }),
4025
+ defineTool({
4026
+ name: "govinfo_get_package",
4027
+ description: "Fetch ONE GovInfo package's summary (metadata + download links txt/xml/pdf/mods/premis/zip + related links) by packageId (api.data.gov keyed). Input `packageId` (from govinfo_search_packages, e.g. 'BILLS-118hr1enr', 'PLAW-117publ58', 'CFR-2023-title1-vol1'). Returns { found:true, packageId, package:{…} } + single-record _meta (complete:true). A nonexistent packageId ⇒ found:false (HTTP 404, never a fabricated summary). Any api_key embedded in a download link is stripped key-free before the payload is surfaced.",
4028
+ inputSchema: GovinfoGetPackageInput,
4029
+ handler: (input) => govinfo.getPackage(input),
4030
+ }),
4031
+ // ━━━ US Census Geocoder — keyless territory/geospatial (2) ━━━ ADR-0023
4032
+ // A NEW capability domain (territory/geospatial) serving the WEAK set-aside /
4033
+ // place-of-performance layer. KEYLESS (keylessMode:true, byte-clean init), single
4034
+ // fixed host + two fixed endpoint paths (the SSRF core — no id in the path). The
4035
+ // layer mapper resolves each canonical geography by SUFFIX pattern (the key names
4036
+ // ROLL: "119th Congressional Districts") and handles >1 KEY PER SUFFIX ([B1] — a
4037
+ // historical vintage returns 111th+113th CDs with DISTINCT GEOIDs; both surfaced +
4038
+ // a note, never silently dropped). Drift-guard scoped to 4 sentinels ([M1]); the
4039
+ // vintage enum is the (benchmark,vintage) UNION ([M2]); GEOIDs stay strings.
4040
+ defineTool({
4041
+ name: "census_geocode_address",
4042
+ description: "Resolve a one-line US address → its matched address(es) + the Census GEOGRAPHIES that drive set-aside / place-of-performance analysis (US Census Geocoder, keyless; geocoding.geo.census.gov/geocoder/geographies/onelineaddress) — the NEW territory/geospatial domain. Input `address` (≤500 chars), optional `benchmark` (default Public_AR_Current) / `vintage` (default Current_Current). Returns { matches:[{ matchedAddress, coordinates:{x,y}, tigerLineId, addressComponents, geographies:{ state, county, congressionalDistrict, censusTract, censusBlock, place, cbsaOrCsa, stateLegislativeUpper, stateLegislativeLower } }], matchCount, vintageResolved } + honest _meta. Each geography = { layerKey (the RAW vintage-versioned key, e.g. '119th Congressional Districts'), geoid (a STRING — leading zeros survive: '0102'), name }. HONESTY: genuine-empty (addressMatches:[]) ⇒ matchCount:0/complete:true (NOT an error; verify spelling + add city/state/ZIP); MULTIPLE matches are ALL surfaced (each with its own geographies) + a note; a historical vintage can return >1 layer per type (e.g. 111th+113th Congressional Districts with DISTINCT GEOIDs for a redistricted place) ⇒ BOTH surfaced (chosen + alternates[]) + a mandatory note (NEVER silently dropped); the resolved benchmark/vintage is echoed + a 'Current is a MOVING vintage' note; an invalid/missing benchmark/vintage ⇒ HTTP 400 THROWS (never a fake empty); an outage/5xx ⇒ THROWS. MANDATORY CAVEAT every response: these are a NOMINAL input, NOT an authoritative HUBZone / Opportunity-Zone / set-aside determination (those require SBA's HUBZone map / Treasury's OZ-tract list). Feed censusTract.geoid / county.geoid onward to those authoritative sources.",
4043
+ inputSchema: CensusGeocodeAddressInput,
4044
+ handler: (input) => census.geocodeAddress(input),
4045
+ }),
4046
+ defineTool({
4047
+ name: "census_geographies_by_coordinates",
4048
+ description: "Resolve a longitude/latitude point → the Census GEOGRAPHIES at that point, no address parsing (US Census Geocoder, keyless; geocoding.geo.census.gov/geocoder/geographies/coordinates). For a caller that already holds coordinates. Input `longitude`/`x` (required, -180..180) + `latitude`/`y` (required, -90..90) — x=longitude, y=latitude (the Census API's own names; `longitude`/`latitude` are the clearer aliases), optional `benchmark`/`vintage`. Returns { found, coordinates:{x,y}, geographies:{ state, county, congressionalDistrict, censusTract, censusBlock, place, cbsaOrCsa, stateLegislativeUpper, stateLegislativeLower }, vintageResolved } + honest _meta. HONESTY: a point outside any US Census geography (offshore / out-of-US) ⇒ geographies all null / found:false / complete:true (an honest empty geographies:{}, NOT an error); coordinate finiteness is re-guarded PRE-fetch (a non-finite x/y ⇒ invalid_input, 0 fetch); a historical vintage's >1-layer-per-type is surfaced with alternates[] + a note (same [B1] multi-key handling as the address tool); GEOIDs are STRINGS (leading zeros survive); the resolved benchmark/vintage is echoed + a moving-vintage note; a bad benchmark/vintage ⇒ HTTP 400 THROWS; an outage/5xx ⇒ THROWS. MANDATORY CAVEAT every response: these are a NOMINAL input, NOT an authoritative HUBZone / Opportunity-Zone / set-aside determination.",
4049
+ inputSchema: CensusGeographiesByCoordinatesInput,
4050
+ handler: (input) => census.geographiesByCoordinates(input),
4051
+ }),
415
4052
  ];
416
4053
  // ─── Server bootstrap ────────────────────────────────────────────
417
4054
  async function main() {
@@ -436,17 +4073,37 @@ async function main() {
436
4073
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
437
4074
  const { name, arguments: args } = req.params;
438
4075
  try {
439
- const result = await runTool(name, args ?? {}, sam);
4076
+ const raw = await runTool(name, args ?? {}, sam);
4077
+ // A handler may return either its raw domain object OR a MetaBundle
4078
+ // (via withMeta) carrying a partial `_meta`. Unwrap to `data` + finalize
4079
+ // the `_meta` sibling. `data` is byte-identical either way.
4080
+ const data = isMetaBundle(raw) ? raw.data : raw;
4081
+ const _meta = isMetaBundle(raw)
4082
+ ? buildMeta(raw.meta)
4083
+ : synthesizeDefaultMeta(name, sam);
4084
+ // Structured success envelope. Calling agent can rely on
4085
+ // `ok: true` to know the payload is in `data`, and read `_meta`
4086
+ // for completeness / provenance (see meta.ts).
4087
+ const envelope = { ok: true, data, _meta };
440
4088
  return {
441
4089
  content: [
442
- { type: "text", text: JSON.stringify(result, null, 2) },
4090
+ { type: "text", text: JSON.stringify(envelope, null, 2) },
443
4091
  ],
444
4092
  };
445
4093
  }
446
4094
  catch (err) {
447
- const message = err instanceof Error ? err.message : String(err);
4095
+ // Structured error envelope. The agent can read `error.kind`
4096
+ // and `error.retryable` to decide what to do next. Classification is
4097
+ // centralized in `toToolError`, which maps a Zod input-validation failure
4098
+ // (e.g. a value outside an enum, or a limit above the max) to a
4099
+ // NON-retryable `invalid_input` with readable field-level issues — never a
4100
+ // generic `unknown` carrying Zod's raw JSON dump.
4101
+ const error = toToolError(err, name);
4102
+ const envelope = { ok: false, error };
448
4103
  return {
449
- content: [{ type: "text", text: `Tool error: ${message}` }],
4104
+ content: [
4105
+ { type: "text", text: JSON.stringify(envelope, null, 2) },
4106
+ ],
450
4107
  isError: true,
451
4108
  };
452
4109
  }
@@ -455,171 +4112,78 @@ async function main() {
455
4112
  await server.connect(transport);
456
4113
  console.error(`[mcp-sam-gov] v${SERVER_VERSION} listening on stdio (${TOOLS.length} tools).`);
457
4114
  }
458
- async function runTool(name, args, sam) {
459
- switch (name) {
460
- // SAM.gov
461
- case "sam_search_opportunities": {
462
- const input = SamSearchInput.parse(args);
463
- const r = await sam.searchOpportunities({
464
- ...input,
465
- setAside: input.setAside,
466
- });
467
- return {
468
- totalRecords: r.totalRecords,
469
- returned: r.opportunitiesData.length,
470
- opportunities: r.opportunitiesData.map((o) => ({
471
- noticeId: o.noticeId,
472
- title: o.title,
473
- agency: o.fullParentPathName,
474
- solicitationNumber: o.solicitationNumber,
475
- responseDeadline: o.responseDeadLine,
476
- naics: o.naicsCode,
477
- setAside: o.typeOfSetAside,
478
- uiLink: o.uiLink,
479
- })),
480
- };
481
- }
482
- case "sam_get_opportunity": {
483
- const { noticeId } = SamGetOpportunityInput.parse(args);
484
- const o = await sam.getOpportunity(noticeId);
485
- if (!o)
486
- return { found: false, noticeId };
487
- return {
488
- found: true,
489
- noticeId: o.noticeId,
490
- title: o.title,
491
- agency: o.fullParentPathName,
492
- solicitationNumber: o.solicitationNumber,
493
- responseDeadline: o.responseDeadLine,
494
- type: o.type,
495
- naics: o.naicsCode,
496
- setAside: o.typeOfSetAside,
497
- placeOfPerformance: o.placeOfPerformance,
498
- pointsOfContact: o.pointOfContact ?? [],
499
- description: o.description,
500
- attachments: (o.resourceLinks ?? []).map((url, idx) => ({
501
- index: idx,
502
- url,
503
- })),
504
- uiLink: o.uiLink,
505
- };
506
- }
507
- case "sam_fetch_description": {
508
- const { noticeId } = SamFetchDescriptionInput.parse(args);
509
- const o = await sam.getOpportunity(noticeId);
510
- if (!o)
511
- return { found: false, noticeId };
512
- const text = o.description
513
- ? await sam.fetchOpportunityDescription(o.description)
514
- : "";
515
- return {
516
- found: true,
517
- noticeId,
518
- descriptionLength: text.length,
519
- description: text || "(no description body available)",
520
- };
521
- }
522
- case "sam_attachment_url": {
523
- const { resourceId } = SamAttachmentUrlInput.parse(args);
524
- return { downloadUrl: sam.publicDownloadUrl(resourceId) };
525
- }
526
- case "sam_lookup_organization": {
527
- const { organizationId } = SamLookupOrgInput.parse(args);
528
- // SamGovClient internal method — exposed via direct fetch since
529
- // it's not on the public surface. Use the public sam.gov endpoint
530
- // directly (already keyless).
531
- const r = await fetch(`https://sam.gov/api/prod/federalorganizations/v1/organizations/${encodeURIComponent(organizationId)}`, {
532
- headers: { Accept: "application/hal+json" },
533
- signal: AbortSignal.timeout(10_000),
534
- });
535
- if (!r.ok) {
536
- return { found: false, organizationId, status: r.status };
537
- }
538
- const json = (await r.json());
539
- const org = json._embedded?.[0]?.org;
540
- return {
541
- found: !!org,
542
- organizationId,
543
- fullParentPathName: org?.fullParentPathName ?? "",
544
- agencyName: org?.agencyName ?? "",
545
- name: org?.name ?? "",
546
- type: org?.type,
547
- level: org?.level,
548
- };
549
- }
550
- // USAspending — Awards & Recipients
551
- case "usas_search_awards":
552
- return await usas.searchAwards(UsasFiltersBase.parse(args));
553
- case "usas_search_individual_awards":
554
- return await usas.searchIndividualAwards(UsasIndividualAwardsInput.parse(args));
555
- case "usas_search_subagency_spending":
556
- return await usas.searchSubAgencySpending(UsasSubAgencyInput.parse(args));
557
- case "usas_lookup_agency":
558
- return await usas.lookupAgency(UsasLookupAgencyInput.parse(args).searchText);
559
- case "usas_search_awards_by_recipient":
560
- return await usas.searchAwardsByRecipient(UsasRecipientAwardsInput.parse(args));
561
- case "usas_search_subawards":
562
- return await usas.searchSubawards(UsasSubawardsInput.parse(args));
563
- case "usas_search_expiring_contracts":
564
- return await usas.searchExpiringContracts(UsasExpiringInput.parse(args));
565
- case "usas_get_award_detail":
566
- return await usas.getAwardDetail(UsasAwardDetailInput.parse(args).generatedInternalId);
567
- // USAspending — Aggregate
568
- case "usas_spending_over_time":
569
- return await usas.spendingOverTime(UsasSpendingOverTimeInput.parse(args));
570
- case "usas_search_psc_spending":
571
- return await usas.searchPscSpending(UsasCategorySpendingInput.parse(args));
572
- case "usas_search_state_spending":
573
- return await usas.searchStateSpending(UsasCategorySpendingInput.parse(args));
574
- case "usas_search_cfda_spending":
575
- return await usas.searchCfdaSpending(UsasCfdaInput.parse(args));
576
- case "usas_search_federal_account_spending":
577
- return await usas.searchFederalAccountSpending(UsasCategorySpendingInput.parse(args));
578
- case "usas_search_agency_spending":
579
- return await usas.searchAgencySpending(UsasAgencySpendingInput.parse(args));
580
- // USAspending — Agency Profile
581
- case "usas_get_agency_profile":
582
- return await usas.getAgencyProfile(UsasAgencyProfileInput.parse(args).toptierCode);
583
- case "usas_get_agency_awards_summary":
584
- return await usas.getAgencyAwardsSummary(UsasAgencyAwardsInput.parse(args));
585
- case "usas_get_agency_budget_function":
586
- return await usas.getAgencyBudgetFunction(UsasAgencyBudgetInput.parse(args));
587
- // USAspending — Recipient Profile
588
- case "usas_search_recipients":
589
- return await usas.searchRecipients(UsasSearchRecipientsInput.parse(args));
590
- case "usas_get_recipient_profile":
591
- return await usas.getRecipientProfile(UsasGetRecipientInput.parse(args).recipientId);
592
- // USAspending — Reference / Autocomplete
593
- case "usas_autocomplete_naics":
594
- return await usas.autocompleteNaics(UsasAutocompleteInput.parse(args));
595
- case "usas_autocomplete_recipient":
596
- return await usas.autocompleteRecipient(UsasAutocompleteInput.parse(args));
597
- case "usas_naics_hierarchy":
598
- return await usas.naicsHierarchy(UsasNaicsHierarchyInput.parse(args));
599
- case "usas_glossary":
600
- return await usas.glossary(UsasGlossaryInput.parse(args));
601
- case "usas_list_toptier_agencies":
602
- return await usas.listToptierAgencies(UsasListAgenciesInput.parse(args));
603
- // Federal Register
604
- case "fed_register_search_documents":
605
- return await fedreg.searchDocuments(FedRegSearchInput.parse(args));
606
- case "fed_register_get_document":
607
- return await fedreg.getDocument(FedRegGetDocInput.parse(args).documentNumber);
608
- case "fed_register_list_agencies":
609
- return await fedreg.listAgencies(FedRegListAgenciesInput.parse(args));
610
- // eCFR
611
- case "ecfr_search":
612
- return await ecfr.search(EcfrSearchInput.parse(args));
613
- case "ecfr_list_titles":
614
- return await ecfr.listTitles();
615
- // Grants.gov
616
- case "grants_search":
617
- return await grants.searchGrants(GrantsSearchInput.parse(args));
618
- case "grants_get_opportunity":
619
- return await grants.getGrant(GrantsGetInput.parse(args));
620
- default:
621
- throw new Error(`Unknown tool: ${name}`);
4115
+ /**
4116
+ * Minimal truthful `_meta` for handlers that don't attach their own.
4117
+ *
4118
+ * Defaults to `complete:true, truncated:false` — correct for the single-record
4119
+ * and known-complete tools (detail lookups, reference tables). List/search and
4120
+ * two-phase tools that can be capped or drop filters should instead return
4121
+ * `withMeta(...)` with the real completeness signals; those are migrated
4122
+ * incrementally (A1 landed first). The source label is keyless-aware for SAM
4123
+ * tools so provenance is honest from day one.
4124
+ */
4125
+ function synthesizeDefaultMeta(toolName, sam) {
4126
+ // The wage tools carry a `sam_` prefix but hit the keyless SGS/WDOL
4127
+ // subsystems (never the keyed opportunities API), so they are always keyless.
4128
+ const isWage = toolName === "sam_search_wage_determinations" ||
4129
+ toolName === "sam_get_wage_rates";
4130
+ // sam_lookup_notice_fields also carries a `sam_` prefix but is served from the
4131
+ // keyless GSA daily CSV (never the keyed opportunities API) — always keyless.
4132
+ const isGsaCsv = toolName === "sam_lookup_notice_fields";
4133
+ const isSam = toolName.startsWith("sam_") && !isWage && !isGsaCsv;
4134
+ const keylessMode = isSam ? sam.isKeyless : true;
4135
+ let source;
4136
+ if (isWage) {
4137
+ source = "sam.gov wage-determinations (keyless)";
4138
+ }
4139
+ else if (isGsaCsv) {
4140
+ source = "gsa.gov daily bulk CSV (keyless)";
4141
+ }
4142
+ else if (toolName.startsWith("gsa_")) {
4143
+ source = "api.gsa.gov CALC v3 (keyless)";
4144
+ }
4145
+ else if (isSam) {
4146
+ source = sam.isKeyless ? "sam.gov (keyless)" : "api.sam.gov (keyed)";
4147
+ }
4148
+ else if (toolName.startsWith("usas_")) {
4149
+ source = "usaspending.gov/api/v2";
4150
+ }
4151
+ else if (toolName.startsWith("fed_register_")) {
4152
+ source = "federalregister.gov/api/v1";
622
4153
  }
4154
+ else if (toolName.startsWith("ecfr_")) {
4155
+ source = "ecfr.gov/api";
4156
+ }
4157
+ else if (toolName.startsWith("grants_")) {
4158
+ source = "grants.gov/api";
4159
+ }
4160
+ else if (toolName.startsWith("sba_")) {
4161
+ source = "sba.gov naics.json (keyless)";
4162
+ }
4163
+ else if (toolName.startsWith("gao_")) {
4164
+ source = "gao.gov Legal Products RSS + decision pages (keyless)";
4165
+ }
4166
+ else if (toolName.startsWith("fpds_")) {
4167
+ source = "www.fpds.gov ezSearch ATOM (FPDS-NG, keyless)";
4168
+ }
4169
+ else {
4170
+ source = "unknown";
4171
+ }
4172
+ return buildMeta({ source, keylessMode, complete: true, truncated: false });
4173
+ }
4174
+ export async function runTool(name, args, sam) {
4175
+ // R1 (ADR-0001) — registry dispatch. Every tool's TOOLS[] entry carries a
4176
+ // co-located `handler`: route through it by parsing `args` with the entry's
4177
+ // own schema, then calling the handler. Its return value flows into
4178
+ // CallTool's existing envelope logic (isMetaBundle? buildMeta :
4179
+ // synthesizeDefaultMeta) byte-identically. The legacy dispatch `switch` is
4180
+ // gone (all 52 tools migrated) — an unknown name has no entry and throws.
4181
+ const entry = TOOLS.find((t) => t.name === name);
4182
+ if (entry?.handler) {
4183
+ const input = entry.inputSchema.parse(args);
4184
+ return await entry.handler(input, { sam });
4185
+ }
4186
+ throw new Error(`Unknown tool: ${name}`);
623
4187
  }
624
4188
  /**
625
4189
  * Hand-rolled Zod → JSON Schema converter (subset we use).
@@ -678,8 +4242,46 @@ function zodToJsonSchema(schema) {
678
4242
  }
679
4243
  return { type: "string", ...(description ? { description } : {}) };
680
4244
  }
681
- main().catch((err) => {
682
- console.error("[mcp-sam-gov] FATAL:", err);
683
- process.exit(1);
684
- });
4245
+ // Start the stdio server ONLY when run directly (node dist/server.js / the
4246
+ // mcp-sam-gov bin) — NOT when imported (the fault-injection tests import
4247
+ // runTool to exercise the REAL tool-dispatch over a mocked fetch). Preserves
4248
+ // the launch: `node dist/server.js` → argv[1] === this file → main() runs;
4249
+ // smoke-test.mjs's spawn("node", ["dist/server.js"]) is the same (a subprocess
4250
+ // whose argv[1] is dist/server.js); an `import { runTool }` sets argv[1] to the
4251
+ // importing script → no match → main() does NOT run, the server is not spawned.
4252
+ // Was this module run DIRECTLY (node dist/server.js / the mcp-sam-gov bin), or
4253
+ // merely IMPORTED (the fault-injection tests import runTool over a mocked fetch)?
4254
+ // Only the direct case starts the stdio server. Canonicalize BOTH sides through
4255
+ // realpathSync before comparing, so it holds no matter how symlinks land:
4256
+ // - the `mcp-sam-gov` bin is a symlink to dist/server.js on Unix/macOS, and npm
4257
+ // installs the package dir itself via a symlink — argv[1] must be realpath'd;
4258
+ // - under `--preserve-symlinks-main`, Node keeps import.meta.url as the symlink
4259
+ // path, so THAT side must be realpath'd too.
4260
+ // Realpath'ing both and comparing as file:// URLs (pathToFileURL normalizes
4261
+ // Windows drive-casing/slashes) makes the check robust across every real launch.
4262
+ const invokedDirectly = (() => {
4263
+ const argv1 = process.argv[1];
4264
+ if (!argv1)
4265
+ return false;
4266
+ const canonical = (p) => {
4267
+ try {
4268
+ return pathToFileURL(realpathSync(p)).href;
4269
+ }
4270
+ catch {
4271
+ return pathToFileURL(p).href; // not a real path → best-effort raw
4272
+ }
4273
+ };
4274
+ try {
4275
+ return canonical(argv1) === canonical(fileURLToPath(import.meta.url));
4276
+ }
4277
+ catch {
4278
+ return import.meta.url === pathToFileURL(argv1).href; // extreme fallback
4279
+ }
4280
+ })();
4281
+ if (invokedDirectly) {
4282
+ main().catch((err) => {
4283
+ console.error("[mcp-sam-gov] FATAL:", err);
4284
+ process.exit(1);
4285
+ });
4286
+ }
685
4287
  //# sourceMappingURL=server.js.map