@cliwant/mcp-sam-gov 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +49 -16
  3. package/README.ko.md +49 -16
  4. package/README.md +255 -64
  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 +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census-economic.d.ts +93 -0
  18. package/dist/census-economic.d.ts.map +1 -0
  19. package/dist/census-economic.js +355 -0
  20. package/dist/census-economic.js.map +1 -0
  21. package/dist/census.d.ts +182 -0
  22. package/dist/census.d.ts.map +1 -0
  23. package/dist/census.js +576 -0
  24. package/dist/census.js.map +1 -0
  25. package/dist/ckan.d.ts +141 -0
  26. package/dist/ckan.d.ts.map +1 -0
  27. package/dist/ckan.js +393 -0
  28. package/dist/ckan.js.map +1 -0
  29. package/dist/clinicaltrials.d.ts +180 -0
  30. package/dist/clinicaltrials.d.ts.map +1 -0
  31. package/dist/clinicaltrials.js +730 -0
  32. package/dist/clinicaltrials.js.map +1 -0
  33. package/dist/cms.d.ts +140 -0
  34. package/dist/cms.d.ts.map +1 -0
  35. package/dist/cms.js +482 -0
  36. package/dist/cms.js.map +1 -0
  37. package/dist/coerce.d.ts +32 -0
  38. package/dist/coerce.d.ts.map +1 -0
  39. package/dist/coerce.js +50 -0
  40. package/dist/coerce.js.map +1 -0
  41. package/dist/datagov-catalog.d.ts +84 -0
  42. package/dist/datagov-catalog.d.ts.map +1 -0
  43. package/dist/datagov-catalog.js +233 -0
  44. package/dist/datagov-catalog.js.map +1 -0
  45. package/dist/datagov.d.ts +146 -0
  46. package/dist/datagov.d.ts.map +1 -0
  47. package/dist/datagov.js +689 -0
  48. package/dist/datagov.js.map +1 -0
  49. package/dist/datagovKey.d.ts +36 -0
  50. package/dist/datagovKey.d.ts.map +1 -0
  51. package/dist/datagovKey.js +60 -0
  52. package/dist/datagovKey.js.map +1 -0
  53. package/dist/datasource.d.ts +313 -0
  54. package/dist/datasource.d.ts.map +1 -0
  55. package/dist/datasource.js +551 -0
  56. package/dist/datasource.js.map +1 -0
  57. package/dist/disclosure.d.ts +55 -0
  58. package/dist/disclosure.d.ts.map +1 -0
  59. package/dist/disclosure.js +57 -0
  60. package/dist/disclosure.js.map +1 -0
  61. package/dist/ecfr.d.ts +4 -2
  62. package/dist/ecfr.d.ts.map +1 -1
  63. package/dist/ecfr.js +74 -1
  64. package/dist/ecfr.js.map +1 -1
  65. package/dist/echo.d.ts +143 -0
  66. package/dist/echo.d.ts.map +1 -0
  67. package/dist/echo.js +424 -0
  68. package/dist/echo.js.map +1 -0
  69. package/dist/edgar.d.ts +377 -0
  70. package/dist/edgar.d.ts.map +1 -0
  71. package/dist/edgar.js +2418 -0
  72. package/dist/edgar.js.map +1 -0
  73. package/dist/errors.d.ts +26 -0
  74. package/dist/errors.d.ts.map +1 -1
  75. package/dist/errors.js +69 -1
  76. package/dist/errors.js.map +1 -1
  77. package/dist/fac.d.ts +180 -0
  78. package/dist/fac.d.ts.map +1 -0
  79. package/dist/fac.js +416 -0
  80. package/dist/fac.js.map +1 -0
  81. package/dist/far.d.ts +170 -0
  82. package/dist/far.d.ts.map +1 -0
  83. package/dist/far.js +804 -0
  84. package/dist/far.js.map +1 -0
  85. package/dist/fdic.d.ts +599 -0
  86. package/dist/fdic.d.ts.map +1 -0
  87. package/dist/fdic.js +1624 -0
  88. package/dist/fdic.js.map +1 -0
  89. package/dist/federal-register.d.ts +139 -2
  90. package/dist/federal-register.d.ts.map +1 -1
  91. package/dist/federal-register.js +419 -3
  92. package/dist/federal-register.js.map +1 -1
  93. package/dist/fema.d.ts +181 -0
  94. package/dist/fema.d.ts.map +1 -0
  95. package/dist/fema.js +436 -0
  96. package/dist/fema.js.map +1 -0
  97. package/dist/fpds.d.ts +108 -0
  98. package/dist/fpds.d.ts.map +1 -0
  99. package/dist/fpds.js +519 -0
  100. package/dist/fpds.js.map +1 -0
  101. package/dist/fred.d.ts +108 -0
  102. package/dist/fred.d.ts.map +1 -0
  103. package/dist/fred.js +373 -0
  104. package/dist/fred.js.map +1 -0
  105. package/dist/gao.d.ts +64 -0
  106. package/dist/gao.d.ts.map +1 -0
  107. package/dist/gao.js +640 -0
  108. package/dist/gao.js.map +1 -0
  109. package/dist/govinfo.d.ts +111 -0
  110. package/dist/govinfo.d.ts.map +1 -0
  111. package/dist/govinfo.js +422 -0
  112. package/dist/govinfo.js.map +1 -0
  113. package/dist/grants.d.ts +27 -4
  114. package/dist/grants.d.ts.map +1 -1
  115. package/dist/grants.js +111 -6
  116. package/dist/grants.js.map +1 -1
  117. package/dist/gsa-csv.d.ts +249 -0
  118. package/dist/gsa-csv.d.ts.map +1 -0
  119. package/dist/gsa-csv.js +784 -0
  120. package/dist/gsa-csv.js.map +1 -0
  121. package/dist/integrity.d.ts +212 -0
  122. package/dist/integrity.d.ts.map +1 -0
  123. package/dist/integrity.js +707 -0
  124. package/dist/integrity.js.map +1 -0
  125. package/dist/keys.d.ts +83 -0
  126. package/dist/keys.d.ts.map +1 -0
  127. package/dist/keys.js +173 -0
  128. package/dist/keys.js.map +1 -0
  129. package/dist/meta.d.ts +165 -0
  130. package/dist/meta.d.ts.map +1 -0
  131. package/dist/meta.js +162 -0
  132. package/dist/meta.js.map +1 -0
  133. package/dist/nih.d.ts +117 -0
  134. package/dist/nih.d.ts.map +1 -0
  135. package/dist/nih.js +291 -0
  136. package/dist/nih.js.map +1 -0
  137. package/dist/nppes.d.ts +157 -0
  138. package/dist/nppes.d.ts.map +1 -0
  139. package/dist/nppes.js +648 -0
  140. package/dist/nppes.js.map +1 -0
  141. package/dist/nsf.d.ts +176 -0
  142. package/dist/nsf.d.ts.map +1 -0
  143. package/dist/nsf.js +554 -0
  144. package/dist/nsf.js.map +1 -0
  145. package/dist/nvd.d.ts +176 -0
  146. package/dist/nvd.d.ts.map +1 -0
  147. package/dist/nvd.js +912 -0
  148. package/dist/nvd.js.map +1 -0
  149. package/dist/ofac.d.ts +205 -0
  150. package/dist/ofac.d.ts.map +1 -0
  151. package/dist/ofac.js +919 -0
  152. package/dist/ofac.js.map +1 -0
  153. package/dist/pricing.d.ts +110 -0
  154. package/dist/pricing.d.ts.map +1 -0
  155. package/dist/pricing.js +843 -0
  156. package/dist/pricing.js.map +1 -0
  157. package/dist/sam-gov/client.d.ts +60 -2
  158. package/dist/sam-gov/client.d.ts.map +1 -1
  159. package/dist/sam-gov/client.js +320 -54
  160. package/dist/sam-gov/client.js.map +1 -1
  161. package/dist/sam-gov/index.d.ts +1 -1
  162. package/dist/sam-gov/index.d.ts.map +1 -1
  163. package/dist/sam-gov/index.js +1 -1
  164. package/dist/sam-gov/index.js.map +1 -1
  165. package/dist/sam-gov/types.d.ts +24 -0
  166. package/dist/sam-gov/types.d.ts.map +1 -1
  167. package/dist/sba.d.ts +72 -0
  168. package/dist/sba.d.ts.map +1 -0
  169. package/dist/sba.js +281 -0
  170. package/dist/sba.js.map +1 -0
  171. package/dist/server.d.ts +14 -2
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +4032 -297
  174. package/dist/server.js.map +1 -1
  175. package/dist/snapshot.d.ts +115 -0
  176. package/dist/snapshot.d.ts.map +1 -0
  177. package/dist/snapshot.js +175 -0
  178. package/dist/snapshot.js.map +1 -0
  179. package/dist/socrata.d.ts +157 -0
  180. package/dist/socrata.d.ts.map +1 -0
  181. package/dist/socrata.js +448 -0
  182. package/dist/socrata.js.map +1 -0
  183. package/dist/treasury.d.ts +143 -0
  184. package/dist/treasury.d.ts.map +1 -0
  185. package/dist/treasury.js +436 -0
  186. package/dist/treasury.js.map +1 -0
  187. package/dist/usaspending.d.ts +260 -65
  188. package/dist/usaspending.d.ts.map +1 -1
  189. package/dist/usaspending.js +1629 -201
  190. package/dist/usaspending.js.map +1 -1
  191. package/dist/usitc.d.ts +142 -0
  192. package/dist/usitc.d.ts.map +1 -0
  193. package/dist/usitc.js +339 -0
  194. package/dist/usitc.js.map +1 -0
  195. package/package.json +24 -2
  196. package/src/attachments.ts +652 -0
  197. package/src/bls.ts +1943 -0
  198. package/src/cache.ts +5 -0
  199. package/src/census-economic.ts +425 -0
  200. package/src/census.ts +735 -0
  201. package/src/ckan.ts +495 -0
  202. package/src/clinicaltrials.ts +923 -0
  203. package/src/cms.ts +634 -0
  204. package/src/coerce.ts +47 -0
  205. package/src/datagov-catalog.ts +296 -0
  206. package/src/datagov.ts +907 -0
  207. package/src/datagovKey.ts +68 -0
  208. package/src/datasource.ts +721 -0
  209. package/src/disclosure.ts +61 -0
  210. package/src/ecfr.ts +98 -1
  211. package/src/echo.ts +496 -0
  212. package/src/edgar.ts +3014 -0
  213. package/src/errors.ts +84 -1
  214. package/src/fac.ts +529 -0
  215. package/src/far.ts +1007 -0
  216. package/src/fdic.ts +2052 -0
  217. package/src/federal-register.ts +512 -3
  218. package/src/fema.ts +541 -0
  219. package/src/fpds.ts +620 -0
  220. package/src/fred.ts +464 -0
  221. package/src/gao.ts +744 -0
  222. package/src/govinfo.ts +497 -0
  223. package/src/grants.ts +139 -7
  224. package/src/gsa-csv.ts +992 -0
  225. package/src/integrity.ts +928 -0
  226. package/src/keys.ts +216 -0
  227. package/src/meta.ts +292 -0
  228. package/src/nih.ts +375 -0
  229. package/src/nppes.ts +834 -0
  230. package/src/nsf.ts +706 -0
  231. package/src/nvd.ts +1124 -0
  232. package/src/ofac.ts +1166 -0
  233. package/src/pricing.ts +1075 -0
  234. package/src/sam-gov/client.ts +345 -63
  235. package/src/sam-gov/index.ts +5 -1
  236. package/src/sam-gov/types.ts +22 -0
  237. package/src/sba.ts +357 -0
  238. package/src/server.ts +4717 -328
  239. package/src/snapshot.ts +223 -0
  240. package/src/socrata.ts +532 -0
  241. package/src/treasury.ts +575 -0
  242. package/src/usaspending.ts +1946 -214
  243. 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,14 +20,51 @@ 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";
28
- import { toToolError } from "./errors.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 censusEconomic from "./census-economic.js";
48
+ import * as fred from "./fred.js";
49
+ import * as fema from "./fema.js";
50
+ import * as fdic from "./fdic.js";
51
+ import * as bls from "./bls.js";
52
+ import * as ofac from "./ofac.js";
53
+ import * as nvd from "./nvd.js";
54
+ import * as nppes from "./nppes.js";
55
+ import * as cms from "./cms.js";
56
+ import * as fac from "./fac.js";
57
+ import * as usitc from "./usitc.js";
58
+ import { fetchAttachmentText } from "./attachments.js";
59
+ import * as keys from "./keys.js";
60
+ import { toToolError, ToolErrorCarrier, errorFromResponse } from "./errors.js";
61
+ import { buildMeta, isMetaBundle, withMeta, } from "./meta.js";
62
+ import { pathToFileURL, fileURLToPath } from "node:url";
63
+ import { realpathSync } from "node:fs";
29
64
  const SERVER_NAME = "mcp-sam-gov";
30
- const SERVER_VERSION = "0.3.0";
65
+ // Kept in lockstep with package.json / manifest.json / server.json.
66
+ // Keep in sync with package.json "version" (asserted at release; see CHANGELOG).
67
+ const SERVER_VERSION = "1.1.0";
31
68
  // ─── Tool input schemas (Zod) ────────────────────────────────────
32
69
  const SamSearchInput = z.object({
33
70
  query: z.string().optional().describe("Free-text title query"),
@@ -43,6 +80,39 @@ const SamSearchInput = z.object({
43
80
  setAside: z.array(z.string()).optional().describe("Set-aside codes: SBA, 8A, HZS, SDVOSBC, WOSB, EDWOSB, VSA, VSS"),
44
81
  limit: z.number().min(1).max(50).optional(),
45
82
  });
83
+ // Pre-solicitation shaping radar (doc 06 §3.1). Surfaces Sources Sought /
84
+ // Presolicitation / Special Notices BEFORE the RFP exists — the free, real-time
85
+ // analogue of paid agency-forecast feeds. Keyless via the notice_type facet.
86
+ const SamSearchShapingInput = z.object({
87
+ query: z.string().optional().describe("Free-text title query"),
88
+ ncode: z.string().optional().describe("NAICS code, e.g. '541512'"),
89
+ organizationName: z
90
+ .string()
91
+ .optional()
92
+ .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`."),
93
+ state: z
94
+ .string()
95
+ .optional()
96
+ .describe("Place-of-performance state, 2-letter, e.g. 'MD'"),
97
+ setAside: z.array(z.string()).optional().describe("Set-aside codes: SBA, 8A, HZS, SDVOSBC, WOSB, EDWOSB, VSA, VSS"),
98
+ noticeType: z
99
+ .array(z.enum(["r", "p", "s", "k", "i", "u"]))
100
+ .optional()
101
+ .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."),
102
+ responseDeadlineFrom: z
103
+ .string()
104
+ .optional()
105
+ .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."),
106
+ responseDeadlineTo: z
107
+ .string()
108
+ .optional()
109
+ .describe("ISO date upper bound for responseDeadline. APPLIED CLIENT-SIDE over the fetched page (see responseDeadlineFrom)."),
110
+ activeOnly: z
111
+ .boolean()
112
+ .optional()
113
+ .describe("Only currently-active notices (default true)."),
114
+ limit: z.number().min(1).max(50).optional().describe("Page size (default 25, max 50)."),
115
+ });
46
116
  const SamGetOpportunityInput = z.object({
47
117
  noticeId: z.string().describe("32-char hex notice id"),
48
118
  });
@@ -54,6 +124,32 @@ const SamAttachmentUrlInput = z.object({
54
124
  .string()
55
125
  .describe("Resource id from sam_get_opportunity → resourceLinks (URL-tail hex)"),
56
126
  });
127
+ const SamFetchAttachmentTextInput = z.object({
128
+ url: z
129
+ .string()
130
+ .url()
131
+ .refine((u) => {
132
+ try {
133
+ const p = new URL(u);
134
+ const h = p.hostname.toLowerCase();
135
+ return (p.protocol === "https:" &&
136
+ (h === "sam.gov" || h === "api.sam.gov" || h.endsWith(".sam.gov")));
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }, {
142
+ 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).",
143
+ })
144
+ .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."),
145
+ maxChars: z
146
+ .number()
147
+ .int()
148
+ .min(1000)
149
+ .max(500_000)
150
+ .optional()
151
+ .describe("Cap on returned text characters (default 200000, max 500000). Truncation is disclosed in _meta (truncated:true)."),
152
+ });
57
153
  // USAspending — awards & recipients
58
154
  const UsasFiltersBase = z.object({
59
155
  agency: z.string().optional().describe("Canonical agency name"),
@@ -90,16 +186,93 @@ const UsasSubawardsInput = z.object({
90
186
  const UsasExpiringInput = z.object({
91
187
  agency: z.string().optional(),
92
188
  naics: z.string().optional(),
93
- fiscalYear: z.number().int().min(2007).optional(),
189
+ // M2 (W3-1 honesty): `fiscalYear` removed. The recompete radar windows on the
190
+ // current PoP end date around TODAY, not an obligation FY, so it was
191
+ // inapplicable — advertised here, then validated and silently discarded by
192
+ // searchExpiringContracts (never forwarded) with empty filtersDropped. Dropping
193
+ // it from the schema stops the validated-then-discarded arg at the door.
94
194
  monthsUntilExpiry: z.number().min(1).max(36).optional(),
95
195
  minAwardValue: z.number().optional(),
96
196
  limit: z.number().min(1).max(20).optional(),
97
197
  });
198
+ const UsasRecompetesInput = z.object({
199
+ agency: z
200
+ .string()
201
+ .optional()
202
+ .describe("Canonical awarding toptier agency name (use usas_lookup_agency)"),
203
+ naics: z.string().optional().describe("6-digit NAICS code, e.g. '541512'"),
204
+ pscCodes: z
205
+ .array(z.string())
206
+ .optional()
207
+ .describe("Product/Service Codes to filter on, e.g. ['DA01','R425']"),
208
+ setAside: z
209
+ .enum(["SBA", "8A", "HZS", "SDVOSBC", "WOSB", "EDWOSB", "VSA", "VSS"])
210
+ .optional()
211
+ .describe("USAspending set_aside_type_code (honored server-side)"),
212
+ windowStartDays: z
213
+ .number()
214
+ .int()
215
+ .optional()
216
+ .describe("Lower edge of the recompete window in days from today (default -90 = include contracts that ended up to 90 days ago)."),
217
+ windowEndDays: z
218
+ .number()
219
+ .int()
220
+ .min(1)
221
+ .optional()
222
+ .describe("Upper edge of the window in days from today (default 548 ≈ 18 months)."),
223
+ minAwardValue: z
224
+ .number()
225
+ .min(0)
226
+ .optional()
227
+ .describe("Minimum Award Amount ($) to include (default 0)."),
228
+ includePotentialEnd: z
229
+ .boolean()
230
+ .optional()
231
+ .describe("Also return the potential (option-inclusive) PoP end date + extendableDays (default false)."),
232
+ actionDateLookbackYears: z
233
+ .number()
234
+ .int()
235
+ .min(1)
236
+ .max(10)
237
+ .optional()
238
+ .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."),
239
+ page: z.number().int().min(1).optional().describe("1-based page (default 1)."),
240
+ pageSize: z
241
+ .number()
242
+ .int()
243
+ .min(1)
244
+ .max(100)
245
+ .optional()
246
+ .describe("Rows per page (default 25, max 100)."),
247
+ scanBudgetPages: z
248
+ .number()
249
+ .int()
250
+ .min(1)
251
+ .max(20)
252
+ .optional()
253
+ .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."),
254
+ });
98
255
  const UsasAwardDetailInput = z.object({
99
256
  generatedInternalId: z
100
257
  .string()
101
258
  .describe("From spending_by_award results — e.g. CONT_AWD_*"),
102
259
  });
260
+ const UsasAnalyzeIncumbentInput = z.object({
261
+ generatedInternalId: z
262
+ .string()
263
+ .describe("The ONE award to analyze — generatedInternalId from usas_search_individual_awards / usas_search_awards_by_recipient / usas_search_recompetes (e.g. CONT_AWD_*)."),
264
+ includeOtherAwards: z
265
+ .boolean()
266
+ .optional()
267
+ .describe("Also return the incumbent's other awards in the same agency×NAICS via one bounded recipient search (default true)."),
268
+ otherAwardsLimit: z
269
+ .number()
270
+ .int()
271
+ .min(1)
272
+ .max(50)
273
+ .optional()
274
+ .describe("Cap on incumbentOtherAwards (default 15, max 50)."),
275
+ });
103
276
  const UsasSpendingOverTimeInput = z.object({
104
277
  group: z.enum(["fiscal_year", "quarter", "month"]).optional(),
105
278
  agency: z.string().optional(),
@@ -177,245 +350,3847 @@ const FedRegSearchInput = z.object({
177
350
  type: z
178
351
  .enum(["RULE", "PRORULE", "NOTICE", "PRESDOCU"])
179
352
  .optional()
180
- .describe("Document type"),
181
- publicationDateFrom: z.string().optional().describe("YYYY-MM-DD"),
182
- publicationDateTo: z.string().optional().describe("YYYY-MM-DD"),
183
- effectiveDateFrom: z.string().optional().describe("YYYY-MM-DD"),
184
- perPage: z.number().min(1).max(100).optional(),
185
- });
186
- const FedRegGetDocInput = z.object({
187
- documentNumber: z
353
+ .describe("Document type"),
354
+ publicationDateFrom: z.string().optional().describe("YYYY-MM-DD"),
355
+ publicationDateTo: z.string().optional().describe("YYYY-MM-DD"),
356
+ effectiveDateFrom: z.string().optional().describe("YYYY-MM-DD"),
357
+ perPage: z.number().min(1).max(100).optional(),
358
+ });
359
+ const FedRegGetDocInput = z.object({
360
+ documentNumber: z
361
+ .string()
362
+ .describe("Federal Register document number, e.g. '2026-08333'"),
363
+ });
364
+ const FedRegListAgenciesInput = z.object({
365
+ perPage: z.number().min(1).max(500).optional(),
366
+ });
367
+ const FedRegPublicInspectionInput = z.object({
368
+ mode: z
369
+ .enum(["current", "date", "search"])
370
+ .optional()
371
+ .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`)."),
372
+ date: z
373
+ .string()
374
+ .optional()
375
+ .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."),
376
+ term: z
377
+ .string()
378
+ .optional()
379
+ .describe("Full-text query; VALID only in mode='search'. Rides conditions[term] (server-side)."),
380
+ type: z
381
+ .enum(["RULE", "PRORULE", "NOTICE", "PRESDOCU"])
382
+ .optional()
383
+ .describe("Client-side document-type filter (applied in all modes)."),
384
+ agency: z
385
+ .string()
386
+ .regex(/^[a-z0-9-]+$/)
387
+ .optional()
388
+ .describe("Client-side agency-slug filter; matches ANY of a doc's agencies[].slug. Resolve slugs via fed_register_list_agencies."),
389
+ specialOnly: z
390
+ .boolean()
391
+ .optional()
392
+ .describe("Client-side filter keeping only filing_type='special' (off-cycle/emergency — a stronger, sooner signal)."),
393
+ limit: z.number().min(1).max(200).optional().describe("Page size (default 20)."),
394
+ offset: z.number().min(0).optional().describe("Page offset (default 0)."),
395
+ });
396
+ // eCFR
397
+ const EcfrSearchInput = z.object({
398
+ query: z.string(),
399
+ titleNumber: z
400
+ .number()
401
+ .optional()
402
+ .describe("CFR title (1-50). e.g. 48 = FAR (Federal Acquisition Regulation), 2 = Federal financial assistance."),
403
+ perPage: z.number().min(1).max(20).optional(),
404
+ });
405
+ const EcfrListTitlesInput = z.object({});
406
+ // FAR / DFARS clause lookup (eCFR versioner full endpoint)
407
+ const FarClauseLookupInput = z.object({
408
+ clauseNumber: z
409
+ .string()
410
+ .regex(
411
+ // Accept an optional FAR/DFARS prefix + the NN.NNN-N / NNN.NNN-NNNN core.
412
+ /^\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).")
413
+ .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."),
414
+ includePrescription: z
415
+ .boolean()
416
+ .optional()
417
+ .describe("Also fetch the prescribing section parsed from the clause's 'As prescribed in …' opener (the rule for WHEN the clause applies). Default true."),
418
+ asOfDate: z
419
+ .string()
420
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "asOfDate must be YYYY-MM-DD.")
421
+ .optional()
422
+ .describe("Point-in-time codification date (YYYY-MM-DD). Defaults to Title 48's current up_to_date_as_of."),
423
+ });
424
+ // FAR compliance matrix (composes far_clause_lookup over a cited-clause list)
425
+ const FarComplianceMatrixInput = z.object({
426
+ clauses: z
427
+ .array(z
428
+ .string()
429
+ .regex(
430
+ // Same clause grammar as FarClauseLookupInput (optional FAR/DFARS prefix).
431
+ /^\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)."))
432
+ .min(1)
433
+ .max(25)
434
+ .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']."),
435
+ asOfDate: z
436
+ .string()
437
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "asOfDate must be YYYY-MM-DD.")
438
+ .optional()
439
+ .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."),
440
+ includePrescription: z
441
+ .boolean()
442
+ .optional()
443
+ .describe("Also fetch each clause's prescribing section (the 'As prescribed in …' rule for WHEN it applies). Default true."),
444
+ flagGates: z
445
+ .boolean()
446
+ .optional()
447
+ .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."),
448
+ });
449
+ // FAR/DFARS-scoped search (composes ecfr_search, filtered to FAR/DFARS + deduped)
450
+ const FarSearchInput = z.object({
451
+ query: z
452
+ .string()
453
+ .min(1, "query must be a non-empty search string.")
454
+ .describe("What to search FAR/DFARS text for, e.g. 'limitations on subcontracting', 'covered defense information', 'commercial item'."),
455
+ scope: z
456
+ .enum(["far", "dfars", "both"])
457
+ .optional()
458
+ .describe("Which corpus to search: 'far' (Title 48 chapter 1, the default), 'dfars' (chapter 2), or 'both'. Excludes GSAM/agency supplements."),
459
+ dedupeVersions: z
460
+ .boolean()
461
+ .optional()
462
+ .describe("Collapse each section's historical versions to the current (in-force) one. Default true. false ⇒ return all raw rows incl. historical."),
463
+ partsOnly: z
464
+ .array(z.number().int())
465
+ .optional()
466
+ .describe("Restrict results to these FAR/DFARS parts, e.g. [52] for clause text only, [12] for commercial-item policy."),
467
+ perPage: z
468
+ .number()
469
+ .int()
470
+ .min(1)
471
+ .max(20)
472
+ .optional()
473
+ .describe("Number of DISTINCT sections to return (1–20). Default 5."),
474
+ });
475
+ // SBA size standards
476
+ const SbaSizeStandardInput = z.object({
477
+ naics: z
478
+ .string()
479
+ .regex(/^\d{6}$/, "naics must be a 6-digit NAICS code (e.g. '541512').")
480
+ .describe("6-digit NAICS code to look up the SBA small-business size standard for (e.g. '541512')."),
481
+ });
482
+ // Grants.gov
483
+ const GrantsSearchInput = z.object({
484
+ keyword: z.string().optional(),
485
+ cfda: z.string().optional().describe("CFDA program number, e.g. '10.500'"),
486
+ agency: z
487
+ .string()
488
+ .optional()
489
+ .describe("Grants.gov agency code, e.g. 'DHS-FEMA'"),
490
+ oppNum: z.string().optional().describe("Specific opportunity number"),
491
+ oppStatuses: z
492
+ .array(z.enum(["forecasted", "posted", "closed", "archived"]))
493
+ .optional()
494
+ .describe("Defaults to forecasted+posted"),
495
+ rows: z.number().min(1).max(50).optional(),
496
+ });
497
+ const GrantsGetInput = z.object({
498
+ opportunityId: z.string().describe("Grants.gov opportunity id (numeric string)"),
499
+ });
500
+ // SAM.gov organization lookup (federal hierarchy)
501
+ const SamLookupOrgInput = z.object({
502
+ organizationId: z.string().describe("SAM.gov federal-organization id (numeric)"),
503
+ });
504
+ // Pricing tier — wage determinations + GSA CALC labor-rate benchmarks
505
+ const WageSearchInput = z.object({
506
+ coverage: z
507
+ .enum(["sca", "dba"])
508
+ .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."),
509
+ state: z
510
+ .string()
511
+ .optional()
512
+ .describe("2-letter USPS state code (e.g. 'VA'), applied SERVER-SIDE. A full name is applied client-side instead."),
513
+ county: z
514
+ .string()
515
+ .optional()
516
+ .describe("County name (substring match), applied CLIENT-SIDE over the fetched page only (the API has no county filter)."),
517
+ query: z
518
+ .string()
519
+ .optional()
520
+ .describe("Matches the WD NUMBER/TITLE only — NOT occupation/job title (q=guard returns 0)."),
521
+ activeOnly: z.boolean().optional().describe("Only currently-active WDs (default true)."),
522
+ standardOnly: z
523
+ .boolean()
524
+ .optional()
525
+ .describe("Only standard (non-non-standard) WDs (default true)."),
526
+ limit: z.number().min(1).max(50).optional().describe("Page size (default 20, max 50)."),
527
+ page: z.number().min(0).optional().describe("0-based page index (default 0)."),
528
+ });
529
+ const WageRatesInput = z.object({
530
+ reference: z
531
+ .string()
532
+ .describe("fullReferenceNumber of the wage determination (e.g. '2015-4093' for SCA, 'IA20260028' for DBA) from sam_search_wage_determinations."),
533
+ revision: z
534
+ .number()
535
+ .optional()
536
+ .describe("Revision number. Omit to resolve the latest ACTIVE revision via /history."),
537
+ coverage: z
538
+ .enum(["sca", "dba"])
539
+ .optional()
540
+ .describe("Optional hint (sca|dba) to disambiguate the parser; inferred otherwise."),
541
+ format: z
542
+ .enum(["parsed", "raw", "both"])
543
+ .optional()
544
+ .describe("'parsed' (structured rates, default), 'raw' (the full document text), or 'both'. Use 'raw'/'both' when parseConfidence is low."),
545
+ });
546
+ const BenchmarkLaborInput = z.object({
547
+ laborCategory: z
548
+ .string()
549
+ .describe("Labor category to benchmark (e.g. 'Program Manager', 'Software Engineer'). Matched exactly against CALC's labor_category."),
550
+ businessSize: z
551
+ .enum(["S", "O"])
552
+ .optional()
553
+ .describe("Business size filter: 'S' (small) or 'O' (other-than-small)."),
554
+ educationLevel: z
555
+ .string()
556
+ .optional()
557
+ .describe("Education filter — use CALC's SHORT CODES (e.g. 'HS','AA','BA','MA','PHD'); the displayed education_level field may show full words."),
558
+ minYearsExperience: z
559
+ .number()
560
+ .optional()
561
+ .describe("Minimum years of experience filter."),
562
+ experienceRange: z
563
+ .string()
564
+ .optional()
565
+ .describe("Experience range as 'min,max' (e.g. '5,10')."),
566
+ sin: z.string().optional().describe("Schedule SIN filter (e.g. '54151S')."),
567
+ priceRange: z
568
+ .string()
569
+ .optional()
570
+ .describe("Ceiling-price range as 'min,max' (e.g. '50,150')."),
571
+ maxSamplePages: z
572
+ .number()
573
+ .min(1)
574
+ .max(10)
575
+ .optional()
576
+ .describe("How many 20-row pages to sample for the distribution (default 3, max 10)."),
577
+ });
578
+ // Integrity / teaming
579
+ const CheckExclusionsInput = z.object({
580
+ query: z
581
+ .string()
582
+ .optional()
583
+ .describe("Firm or individual name to screen (drives the server-side exclusions text search). Provide at least one of query/uei/cage."),
584
+ uei: z
585
+ .string()
586
+ .optional()
587
+ .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."),
588
+ cage: z
589
+ .string()
590
+ .optional()
591
+ .describe("CAGE code to match (post-filtered against results, or used as the text query when sole)."),
592
+ activeOnly: z
593
+ .boolean()
594
+ .optional()
595
+ .describe("Only currently-active exclusions (default true). false includes terminated exclusions."),
596
+ classification: z
597
+ .enum(["Firm", "Individual", "Special Entity Designation", "any"])
598
+ .optional()
599
+ .describe("Filter by excluded-party classification (default 'any')."),
600
+ page: z.number().min(0).optional().describe("0-based page index (default 0)."),
601
+ size: z.number().min(1).max(100).optional().describe("Page size (default 25, max 100)."),
602
+ });
603
+ const IntegrityLookupInput = z.object({
604
+ uei: z
605
+ .string()
606
+ .optional()
607
+ .describe("SAM UEI of the entity to screen (PREFERRED — most precise). Provide at least one of uei/cage/name."),
608
+ cage: z
609
+ .string()
610
+ .optional()
611
+ .describe("CAGE code of the entity to screen."),
612
+ name: z
613
+ .string()
614
+ .optional()
615
+ .describe("Legal entity name to screen (drives the keyless exclusions text search; normalized-name gated). Provide at least one of uei/cage/name."),
616
+ });
617
+ const TeamingPartnersInput = z.object({
618
+ // ENUM-VALIDATED: a bogus recipient_type_names value is SILENTLY accepted by
619
+ // USAspending (HTTP 200, 0 results), so this enum is the guardrail — only the
620
+ // spellings LIVE-VERIFIED to narrow a populated NAICS are accepted.
621
+ cert: z
622
+ .enum([
623
+ "small_business",
624
+ "8a_program_participant",
625
+ "woman_owned_business",
626
+ "women_owned_small_business",
627
+ "economically_disadvantaged_women_owned_small_business",
628
+ "service_disabled_veteran_owned_business",
629
+ "veteran_owned_business",
630
+ "historically_underutilized_business_firm",
631
+ ])
632
+ .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)."),
633
+ naics: z.string().optional().describe("NAICS code to scope the search (e.g. '541512')."),
634
+ agency: z
635
+ .string()
636
+ .optional()
637
+ .describe("Awarding agency canonical toptier name (e.g. 'Department of Veterans Affairs'). Use usas_lookup_agency to resolve abbreviations."),
638
+ subagency: z
639
+ .string()
640
+ .optional()
641
+ .describe("Awarding sub-agency name. Requires `agency` to also be set (a subagency alone is dropped)."),
642
+ lookbackYears: z
643
+ .number()
644
+ .min(1)
645
+ .max(20)
646
+ .optional()
647
+ .describe("Action-date lookback window in years (default 3)."),
648
+ excludeDebarred: z
649
+ .boolean()
650
+ .optional()
651
+ .describe("Screen the top-ranked candidates via sam_check_exclusions and drop active exclusions (default true; bounded + disclosed in _meta)."),
652
+ minAwards: z
653
+ .number()
654
+ .min(1)
655
+ .optional()
656
+ .describe("Minimum scanned award count for a firm to be listed (default 1)."),
657
+ limit: z.number().min(1).max(50).optional().describe("Candidates per page (default 25, max 50)."),
658
+ page: z.number().min(1).optional().describe("1-based page index (default 1)."),
659
+ screenCap: z
660
+ .number()
661
+ .min(1)
662
+ .max(25)
663
+ .optional()
664
+ .describe("Max candidates to exclusion-screen per page (default 10, max 25)."),
665
+ scanPages: z
666
+ .number()
667
+ .min(1)
668
+ .max(10)
669
+ .optional()
670
+ .describe("Award-value-sorted pages (100 rows each) to scan before aggregating by recipient (default 4, max 10)."),
671
+ });
672
+ // OFAC denied-party sanctions screening (keyless bulk SDN + Consolidated lists)
673
+ const OfacScreenInput = z.object({
674
+ name: z
675
+ .string()
676
+ .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."),
677
+ type: z
678
+ .enum(["individual", "entity", "vessel", "aircraft"])
679
+ .optional()
680
+ .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."),
681
+ program: z
682
+ .string()
683
+ .optional()
684
+ .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)."),
685
+ list: z
686
+ .enum(["sdn", "consolidated", "all"])
687
+ .optional()
688
+ .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)."),
689
+ minMatchQuality: z
690
+ .enum(["exact", "strong", "weak"])
691
+ .optional()
692
+ .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)."),
693
+ limit: z
694
+ .number()
695
+ .min(1)
696
+ .max(200)
697
+ .optional()
698
+ .describe("Max matches returned (default 50, max 200). Over-limit truncation is disclosed, never silent."),
699
+ });
700
+ // ━━━ NVD + CISA KEV — the IT/cyber-compliance lane (2) ━━━ ADR-0035
701
+ // cve_lookup: NVD CVE detail/search JOINED with CISA KEV status.
702
+ const CveLookupInput = z.object({
703
+ cveId: z
704
+ .string()
705
+ .optional()
706
+ .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."),
707
+ keyword: z
708
+ .string()
709
+ .optional()
710
+ .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)."),
711
+ cpeName: z
712
+ .string()
713
+ .optional()
714
+ .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)."),
715
+ cvssV3Severity: z
716
+ .enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"])
717
+ .optional()
718
+ .describe("Filter to a CVSS v3 base severity band (LOW|MEDIUM|HIGH|CRITICAL)."),
719
+ pubStartDate: z
720
+ .string()
721
+ .optional()
722
+ .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."),
723
+ pubEndDate: z
724
+ .string()
725
+ .optional()
726
+ .describe("Publication-date window END (ISO YYYY-MM-DD). Paired with pubStartDate."),
727
+ lastModStartDate: z
728
+ .string()
729
+ .optional()
730
+ .describe("Last-modified window START (ISO YYYY-MM-DD). PAIRED with lastModEndDate (both required together). A span >120 days is clamped + disclosed."),
731
+ lastModEndDate: z
732
+ .string()
733
+ .optional()
734
+ .describe("Last-modified window END (ISO YYYY-MM-DD). Paired with lastModStartDate."),
735
+ kevOnly: z
736
+ .boolean()
737
+ .optional()
738
+ .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')."),
739
+ resultsPerPage: z
740
+ .number()
741
+ .min(1)
742
+ .max(2000)
743
+ .optional()
744
+ .describe("Rows per page (default 50, max 2000 — NVD's cap). Over-cap is refused, never silently clamped."),
745
+ startIndex: z
746
+ .number()
747
+ .min(0)
748
+ .optional()
749
+ .describe("Zero-based page offset (default 0). Pagination derives from NVD's exact totalResults, never the page length."),
750
+ });
751
+ // cisa_kev_lookup: filter the KEV catalog standalone.
752
+ const CisaKevLookupInput = z.object({
753
+ cveId: z
754
+ .string()
755
+ .optional()
756
+ .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)."),
757
+ vendorProject: z
758
+ .string()
759
+ .optional()
760
+ .describe("Case-insensitive substring filter on the vendor/project (e.g. 'Microsoft', 'Apache')."),
761
+ product: z
762
+ .string()
763
+ .optional()
764
+ .describe("Case-insensitive substring filter on the product (e.g. 'Log4j', 'Exchange Server')."),
765
+ ransomwareOnly: z
766
+ .boolean()
767
+ .optional()
768
+ .describe("When true, keep only entries with knownRansomwareCampaignUse === 'Known'."),
769
+ addedSince: z
770
+ .string()
771
+ .optional()
772
+ .describe("Keep only entries with dateAdded >= this ISO date (YYYY-MM-DD)."),
773
+ dueBefore: z
774
+ .string()
775
+ .optional()
776
+ .describe("Keep only entries with dueDate < this ISO date (YYYY-MM-DD) — the CISA-mandated remediation deadline."),
777
+ limit: z
778
+ .number()
779
+ .min(1)
780
+ .max(1000)
781
+ .optional()
782
+ .describe("Max matches returned (default 100, max 1000)."),
783
+ offset: z.number().min(0).optional().describe("Zero-based page offset (default 0)."),
784
+ });
785
+ // ━━━ NPPES NPI Registry — the healthcare-provider identity/credentialing lane (1) ━━━ ADR-0036
786
+ // nppes_lookup_provider: exact NPI detail OR search over CMS/HHS's keyless public
787
+ // registry of every US healthcare provider (npiregistry.cms.hhs.gov/api, version=2.1
788
+ // REQUIRED). Mode is inferred from `number` (no mode flag). ★M1: an exact NPI is
789
+ // looked up by number ALONE on the wire (a co-supplied filter AND-combines and would
790
+ // falsely zero a real active provider → found:false); co-filters are checked
791
+ // client-side and disclosed via data.filterMatch. ★S2: the required-one gate is
792
+ // {number, first_name, last_name, organization_name, taxonomy_description, city,
793
+ // postal_code}; state + enumeration_type are REFINERS ONLY (rejected alone). The
794
+ // grammars (10-digit NPI + CMS Luhn, USPS state enum, NPI-1/NPI-2 enum, trailing-`*`
795
+ // wildcard ≥2 leading chars) are the SSRF + silent-foot-gun guards.
796
+ const NppesLookupInput = z.object({
797
+ number: z
798
+ .string()
799
+ .regex(/^\d{10}$/)
800
+ .optional()
801
+ .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'."),
802
+ enumeration_type: z
803
+ .enum(["NPI-1", "NPI-2"])
804
+ .optional()
805
+ .describe("REFINER only (NPI-1 = individual, NPI-2 = organization). Never sufficient alone (⇒ invalid_input) — must accompany a required criterion."),
806
+ first_name: z
807
+ .string()
808
+ .min(1)
809
+ .max(100)
810
+ .optional()
811
+ .describe("Individual provider first name (a required-one criterion). A trailing '*' wildcard needs ≥2 leading literal chars. e.g. 'John'."),
812
+ last_name: z
813
+ .string()
814
+ .min(1)
815
+ .max(100)
816
+ .optional()
817
+ .describe("Individual provider last name (a required-one criterion). Trailing '*' wildcard: ≥2 leading chars. e.g. 'Smith'."),
818
+ organization_name: z
819
+ .string()
820
+ .min(1)
821
+ .max(100)
822
+ .optional()
823
+ .describe("Organization (NPI-2) name (a required-one criterion). Trailing '*' wildcard: ≥2 leading chars. e.g. 'Mayo Clinic'."),
824
+ taxonomy_description: z
825
+ .string()
826
+ .min(1)
827
+ .max(100)
828
+ .optional()
829
+ .describe("Provider taxonomy/specialty description (a required-one criterion). e.g. 'Internal Medicine'."),
830
+ city: z
831
+ .string()
832
+ .min(1)
833
+ .max(100)
834
+ .optional()
835
+ .describe("Address city (a required-one criterion). e.g. 'Baltimore'."),
836
+ postal_code: z
837
+ .string()
838
+ .min(1)
839
+ .max(100)
840
+ .optional()
841
+ .describe("Address postal/ZIP code (a required-one criterion; a prefix like '212' is allowed). e.g. '21218'."),
842
+ state: z
843
+ .enum(nppes.NPPES_STATES)
844
+ .optional()
845
+ .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'."),
846
+ limit: z
847
+ .number()
848
+ .int()
849
+ .min(1)
850
+ .max(200)
851
+ .optional()
852
+ .describe("Providers per page, 1..200, default 10. NPPES silently clamps >200; this tool rejects it loudly. Search mode only."),
853
+ skip: z
854
+ .number()
855
+ .int()
856
+ .min(0)
857
+ .max(1000)
858
+ .optional()
859
+ .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."),
860
+ });
861
+ // ━━━ CMS Open Payments — the healthcare spend/transparency lane (2) ━━━ ADR-0037
862
+ // A NEW keyless DKAN 2.x datastore adapter (a sibling of ckan/socrata) over
863
+ // openpaymentsdata.cms.gov. ★M2: the DCAT metastore ignores limit/offset — the
864
+ // whole catalog is sliced CLIENT-SIDE (totalAvailable is the exact post-q size).
865
+ // ★M1: the results-array drift guard is conditioned on the effective `results` mode
866
+ // (results:false omits rows). ★S2: `count` is NOT a caller toggle (always count=true
867
+ // on the wire). ★S3: offset ≤ 2000 reach cap (PII boundary, mirrors NPPES).
868
+ // SSRF (load-bearing): datasetId (36-char lowercase UUID) + index interpolate into
869
+ // the URL PATH — the Zod grammars are the primary path-injection guard.
870
+ const CmsSearchDatasetsInput = z.object({
871
+ q: z
872
+ .string()
873
+ .max(200)
874
+ .optional()
875
+ .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'."),
876
+ limit: z
877
+ .number()
878
+ .int()
879
+ .min(1)
880
+ .max(100)
881
+ .optional()
882
+ .describe("Datasets per page, 1..100, default 20 (client-side slice of the full catalog)."),
883
+ offset: z
884
+ .number()
885
+ .int()
886
+ .min(0)
887
+ .optional()
888
+ .describe("0-based pagination offset (default 0), applied client-side against the known catalog length (never a server offset)."),
889
+ });
890
+ const CmsQueryDatasetInput = z.object({
891
+ datasetId: z
892
+ .string()
893
+ .length(36)
894
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
895
+ .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)."),
896
+ index: z
897
+ .number()
898
+ .int()
899
+ .min(0)
900
+ .max(50)
901
+ .optional()
902
+ .describe("Distribution index (default 0 = the primary CSV). Also interpolates into the URL path (int 0..50)."),
903
+ conditions: z
904
+ .array(z.object({
905
+ property: z
906
+ .string()
907
+ .min(1)
908
+ .max(128)
909
+ .regex(/^[a-z0-9_]+$/)
910
+ .describe("Column name (snake_case lowercase alnum). A bad column ⇒ HTTP 400 ⇒ invalid_input (never a silent drop). e.g. 'recipient_state'."),
911
+ value: z
912
+ .union([z.string().max(200), z.number()])
913
+ .describe("Filter value. e.g. 'CA'."),
914
+ operator: z
915
+ .enum(["=", "<>", "<", ">", "<=", ">=", "like", "in"])
916
+ .optional()
917
+ .describe("Comparison operator (default '='). AND-combined across conditions."),
918
+ }))
919
+ .max(10)
920
+ .optional()
921
+ .describe("Server-side filters (≤10, AND-combined) that provably narrow the EXACT count. Each either applies or the call errors — filtersDropped is always empty."),
922
+ properties: z
923
+ .array(z.string().min(1).max(128).regex(/^[a-z0-9_]+$/))
924
+ .optional()
925
+ .describe("Optional column projection (snake_case column names). Omit for all columns."),
926
+ limit: z
927
+ .number()
928
+ .int()
929
+ .min(1)
930
+ .max(500)
931
+ .optional()
932
+ .describe("Rows per page, 1..500, default 100. 500 is the HARD DKAN cap (the API 400s over it; this tool rejects >500 loudly)."),
933
+ offset: z
934
+ .number()
935
+ .int()
936
+ .min(0)
937
+ .max(2000)
938
+ .optional()
939
+ .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."),
940
+ results: z
941
+ .boolean()
942
+ .optional()
943
+ .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.)"),
944
+ });
945
+ // ─── FAC (Federal Audit Clearinghouse) Single Audit — ADR-0038 ──────
946
+ // ★ PII crux: the tools expose ONLY structured, validated filters — NO caller
947
+ // `select`/`order`/free-column param (that is why v1 uses purpose-built tools,
948
+ // not a generic fac_query). The select-allowlist is a hardcoded module constant
949
+ // in fac.ts; no caller value can name a column (PostgREST would project a
950
+ // personal-contact column the instant `select=` named it).
951
+ const FacSearchAuditsInput = z.object({
952
+ auditeeUei: z
953
+ .string()
954
+ .regex(/^[A-Z0-9]{12}$/)
955
+ .optional()
956
+ .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'."),
957
+ auditeeState: z
958
+ .string()
959
+ .regex(/^[A-Z]{2}$/)
960
+ .optional()
961
+ .describe("Filter by 2-letter US state code (uppercase; → auditee_state=eq.). e.g. 'CA'."),
962
+ auditYear: z
963
+ .number()
964
+ .int()
965
+ .min(2016)
966
+ .max(2100)
967
+ .optional()
968
+ .describe("Filter by audit year (int, → audit_year=eq.). e.g. 2024."),
969
+ totalExpendedMin: z
970
+ .number()
971
+ .finite()
972
+ .optional()
973
+ .describe("Minimum total federal awards expended (USD, → total_amount_expended=gte.)."),
974
+ totalExpendedMax: z
975
+ .number()
976
+ .finite()
977
+ .optional()
978
+ .describe("Maximum total federal awards expended (USD, → total_amount_expended=lte.)."),
979
+ limit: z
980
+ .number()
981
+ .int()
982
+ .min(1)
983
+ .max(100)
984
+ .default(25)
985
+ .describe("Rows per page, 1..100, default 25."),
986
+ offset: z
987
+ .number()
988
+ .int()
989
+ .min(0)
990
+ .default(0)
991
+ .describe("0-based row offset for pagination (default 0)."),
992
+ });
993
+ const FacGetFindingsInput = z
994
+ .object({
995
+ auditeeUei: z
996
+ .string()
997
+ .regex(/^[A-Z0-9]{12}$/)
998
+ .optional()
999
+ .describe("Filter by 12-char SAM UEI (^[A-Z0-9]{12}$; → auditee_uei=eq.)."),
1000
+ reportId: z
1001
+ .string()
1002
+ .regex(/^[0-9A-Za-z-]+$/)
1003
+ .max(64)
1004
+ .optional()
1005
+ .describe("Filter by FAC report_id (^[0-9A-Za-z-]+$; → report_id=eq. — from a fac_search_audits row)."),
1006
+ auditYear: z
1007
+ .number()
1008
+ .int()
1009
+ .min(2016)
1010
+ .max(2100)
1011
+ .optional()
1012
+ .describe("Filter by audit year (int, → audit_year=eq.)."),
1013
+ limit: z
1014
+ .number()
1015
+ .int()
1016
+ .min(1)
1017
+ .max(100)
1018
+ .default(50)
1019
+ .describe("Rows per page, 1..100, default 50."),
1020
+ offset: z
1021
+ .number()
1022
+ .int()
1023
+ .min(0)
1024
+ .default(0)
1025
+ .describe("0-based row offset for pagination (default 0)."),
1026
+ })
1027
+ .refine((v) => v.auditeeUei !== undefined || v.reportId !== undefined, {
1028
+ message: "fac_get_findings requires at least one of `auditeeUei` or `reportId` (an empty query would scan the whole 670K-row findings table).",
1029
+ path: ["auditeeUei"],
1030
+ });
1031
+ // GAO bid-protest lookup (keyless RSS + decision-page parse)
1032
+ const GaoProtestInput = z.object({
1033
+ agency: z
1034
+ .string()
1035
+ .optional()
1036
+ .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."),
1037
+ protester: z
1038
+ .string()
1039
+ .optional()
1040
+ .describe("Client-side substring filter on the protester name (feed title/description)."),
1041
+ solicitationNumber: z
1042
+ .string()
1043
+ .optional()
1044
+ .describe("Client-side substring filter on the solicitation number (matched in the feed description)."),
1045
+ outcome: z
1046
+ .enum(["sustained", "denied", "dismissed", "withdrawn", "any"])
1047
+ .optional()
1048
+ .describe("Filter by protest disposition (default 'any'). Determined from each decision page, so it applies only when enrich is true."),
1049
+ bNumber: z
1050
+ .string()
1051
+ .optional()
1052
+ .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."),
1053
+ limit: z
1054
+ .number()
1055
+ .min(1)
1056
+ .max(50)
1057
+ .optional()
1058
+ .describe("Max decisions to return (default 20, max 50). The feed itself carries ~25 recent legal products."),
1059
+ enrich: z
1060
+ .boolean()
1061
+ .optional()
1062
+ .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)."),
1063
+ });
1064
+ // GSA daily-CSV keyless backbone — batch page-completing enrichment
1065
+ const SamLookupNoticeFieldsInput = z.object({
1066
+ noticeIds: z
1067
+ .array(z.string())
1068
+ .min(1)
1069
+ .max(100)
1070
+ .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."),
1071
+ });
1072
+ // ─── US Treasury Fiscal Data (keyless) — input schemas ───────────
1073
+ // ADR-0002. `dataset` is an ENUM of the 5 live-confirmed paths (F5: no free
1074
+ // path → no SSRF surface). pageSize max 500 (upstream page[size] ceiling).
1075
+ const TreasuryDatasetEnum = z
1076
+ .enum([
1077
+ "debt_to_penny",
1078
+ "avg_interest_rates",
1079
+ "mts_table_1",
1080
+ "rates_of_exchange",
1081
+ "debt_outstanding",
1082
+ ])
1083
+ .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).");
1084
+ const TreasuryQueryDatasetInput = z.object({
1085
+ dataset: TreasuryDatasetEnum,
1086
+ fields: z
1087
+ .string()
1088
+ .optional()
1089
+ .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)."),
1090
+ filter: z
1091
+ .string()
1092
+ .optional()
1093
+ .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'."),
1094
+ sort: z
1095
+ .string()
1096
+ .optional()
1097
+ .describe("Optional CSV sort columns; prefix '-' for descending (e.g. '-record_date')."),
1098
+ pageSize: z
1099
+ .number()
1100
+ .int()
1101
+ .min(1)
1102
+ .max(500)
1103
+ .default(100)
1104
+ .describe("Rows per page (upstream page[size]); 1..500, default 100."),
1105
+ pageNumber: z
1106
+ .number()
1107
+ .int()
1108
+ .min(1)
1109
+ .default(1)
1110
+ .describe("1-based page number (upstream page[number]); default 1."),
1111
+ });
1112
+ const TreasuryDebtToPennyInput = z.object({
1113
+ latest: z
1114
+ .boolean()
1115
+ .default(true)
1116
+ .describe("true (default) ⇒ only the single most-recent day (page[size]=1). false ⇒ the startDate/endDate range, newest-first."),
1117
+ startDate: z
1118
+ .string()
1119
+ .optional()
1120
+ .describe("Range mode only: ISO YYYY-MM-DD lower bound on record_date (inclusive)."),
1121
+ endDate: z
1122
+ .string()
1123
+ .optional()
1124
+ .describe("Range mode only: ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1125
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Range mode: rows per page, 1..500, default 100."),
1126
+ pageNumber: z.number().int().min(1).default(1).describe("Range mode: 1-based page number, default 1."),
1127
+ });
1128
+ const TreasuryMonthlyStatementInput = z.object({
1129
+ startDate: z
1130
+ .string()
1131
+ .optional()
1132
+ .describe("ISO YYYY-MM-DD lower bound on record_date (inclusive). Default: trailing ~12 months."),
1133
+ endDate: z
1134
+ .string()
1135
+ .optional()
1136
+ .describe("ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1137
+ excludeSummaryRows: z
1138
+ .boolean()
1139
+ .default(true)
1140
+ .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."),
1141
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Rows per page, 1..500, default 100."),
1142
+ pageNumber: z.number().int().min(1).default(1).describe("1-based page number, default 1."),
1143
+ });
1144
+ const TreasuryAvgInterestRatesInput = z.object({
1145
+ securityType: z
1146
+ .string()
1147
+ .optional()
1148
+ .describe("Optional exact security_type_desc filter (e.g. 'Marketable', 'Non-marketable', 'Interest-bearing Debt')."),
1149
+ latest: z
1150
+ .boolean()
1151
+ .default(true)
1152
+ .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."),
1153
+ startDate: z.string().optional().describe("Range mode only: ISO YYYY-MM-DD lower bound on record_date (inclusive)."),
1154
+ endDate: z.string().optional().describe("Range mode only: ISO YYYY-MM-DD upper bound on record_date (inclusive)."),
1155
+ pageSize: z.number().int().min(1).max(500).default(100).describe("Range mode: rows per page, 1..500, default 100."),
1156
+ pageNumber: z.number().int().min(1).default(1).describe("Range mode: 1-based page number, default 1."),
1157
+ });
1158
+ // ─── SEC EDGAR (keyless) — input schemas ─────────────────────────
1159
+ // ADR-0003. Keyless capital-markets source over data.sec.gov / efts.sec.gov.
1160
+ // The join key is the 10-digit SEC CIK (NOT SAM UEI/DUNS — see edgar.ts caveat).
1161
+ const EdgarLookupCikInput = z.object({
1162
+ query: z
1163
+ .string()
1164
+ .min(1)
1165
+ .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)."),
1166
+ });
1167
+ const EdgarCompanyFilingsInput = z.object({
1168
+ cikOrTicker: z
1169
+ .string()
1170
+ .min(1)
1171
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'CIK0000320193', 'AAPL')."),
1172
+ forms: z
1173
+ .array(z.string())
1174
+ .optional()
1175
+ .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."),
1176
+ limit: z
1177
+ .number()
1178
+ .int()
1179
+ .min(1)
1180
+ .max(100)
1181
+ .default(20)
1182
+ .describe("Max filings to return, 1..100, default 20 (offset pagination over the recent window)."),
1183
+ offset: z
1184
+ .number()
1185
+ .int()
1186
+ .min(0)
1187
+ .default(0)
1188
+ .describe("0-based offset into the (form-filtered) recent filings, default 0."),
1189
+ fullHistory: z
1190
+ .boolean()
1191
+ .optional()
1192
+ .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."),
1193
+ maxShards: z
1194
+ .number()
1195
+ .int()
1196
+ .min(1)
1197
+ .max(100)
1198
+ .optional()
1199
+ .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."),
1200
+ });
1201
+ const EdgarCompanyFactsInput = z.object({
1202
+ cikOrTicker: z
1203
+ .string()
1204
+ .min(1)
1205
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'AAPL')."),
1206
+ concepts: z
1207
+ .array(z.string())
1208
+ .optional()
1209
+ .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)."),
1210
+ unit: z
1211
+ .string()
1212
+ .default("USD")
1213
+ .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."),
1214
+ latest: z
1215
+ .boolean()
1216
+ .default(false)
1217
+ .describe("true ⇒ reduce each concept to its single most-recent data point (by period end). false (default) ⇒ the full reported time series."),
1218
+ });
1219
+ // ADR-0017. Keyless cross-filer XBRL cross-section over data.sec.gov/api/xbrl/
1220
+ // frames. taxonomy/tag/unit/period are RAW PATH SEGMENTS, so the enum + the three
1221
+ // regexes below ARE the SSRF guard (re-run belt-and-suspenders in the handler's
1222
+ // URL builder). `limit`/`offset` window the already-fully-fetched cross-section
1223
+ // CLIENT-SIDE (they do NOT reduce the fetch). taxonomy enum = live-confirmed only.
1224
+ const EdgarXbrlFramesInput = z.object({
1225
+ tag: z
1226
+ .string()
1227
+ .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).")
1228
+ .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)."),
1229
+ period: z
1230
+ .string()
1231
+ .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).")
1232
+ .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."),
1233
+ taxonomy: z
1234
+ .enum(edgar.FRAMES_TAXONOMIES)
1235
+ .default("us-gaap")
1236
+ .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."),
1237
+ unit: z
1238
+ .string()
1239
+ .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).")
1240
+ .default("USD")
1241
+ .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."),
1242
+ limit: z
1243
+ .number()
1244
+ .int()
1245
+ .min(1)
1246
+ .max(1000)
1247
+ .default(100)
1248
+ .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)."),
1249
+ offset: z
1250
+ .number()
1251
+ .int()
1252
+ .min(0)
1253
+ .default(0)
1254
+ .describe("0-based client-side offset into the fetched cross-section (default 0). Page via _meta.pagination.nextOffset to reach every filer."),
1255
+ includeStats: z
1256
+ .boolean()
1257
+ .default(false)
1258
+ .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)."),
1259
+ });
1260
+ const EdgarFullTextSearchInput = z.object({
1261
+ q: z
1262
+ .string()
1263
+ .min(1)
1264
+ .describe("Full-text query over EDGAR filings (2001-present). Wrap a phrase in double quotes for an exact match (e.g. '\"climate risk\"')."),
1265
+ forms: z
1266
+ .array(z.string())
1267
+ .optional()
1268
+ .describe("Optional form-type filter (e.g. ['10-K','8-K'])."),
1269
+ startdt: z
1270
+ .string()
1271
+ .optional()
1272
+ .describe("Optional ISO YYYY-MM-DD filing-date lower bound (sets dateRange=custom)."),
1273
+ enddt: z
1274
+ .string()
1275
+ .optional()
1276
+ .describe("Optional ISO YYYY-MM-DD filing-date upper bound (sets dateRange=custom)."),
1277
+ ciks: z
1278
+ .array(z.string())
1279
+ .max(50)
1280
+ .optional()
1281
+ .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."),
1282
+ entityName: z
1283
+ .string()
1284
+ .min(1)
1285
+ .max(200)
1286
+ .optional()
1287
+ .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."),
1288
+ from: z
1289
+ .number()
1290
+ .int()
1291
+ .min(0)
1292
+ .default(0)
1293
+ .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."),
1294
+ });
1295
+ // ADR-0026. Keyless BULK cross-filer capability on the EXISTING edgar source: the
1296
+ // SEC EDGAR quarterly full-index (www.sec.gov/Archives/edgar/full-index/<year>/
1297
+ // QTR<n>/master.idx). year/quarter are the ONLY path segments (bounded integers —
1298
+ // the SSRF guard, re-checked belt-and-suspenders pre-fetch in edgar's
1299
+ // buildFullIndexUrl). ALL other params are CLIENT-SIDE filters over the whole
1300
+ // downloaded body (ZERO query string reaches the wire). The year UPPER bound
1301
+ // (≤ current UTC year) is enforced at CALL time in the handler, NOT baked into Zod,
1302
+ // so the tools/list snapshot stays deterministic across a year rollover.
1303
+ const EdgarFilingIndexInput = z.object({
1304
+ year: z
1305
+ .number()
1306
+ .int()
1307
+ .min(1993)
1308
+ .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."),
1309
+ quarter: z
1310
+ .number()
1311
+ .int()
1312
+ .min(1)
1313
+ .max(4)
1314
+ .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."),
1315
+ formType: z
1316
+ .string()
1317
+ .min(1)
1318
+ .max(30)
1319
+ .optional()
1320
+ .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."),
1321
+ cik: z
1322
+ .union([z.string().regex(/^\d{1,10}$/), z.number().int().nonnegative()])
1323
+ .optional()
1324
+ .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)."),
1325
+ companyContains: z
1326
+ .string()
1327
+ .min(1)
1328
+ .max(200)
1329
+ .optional()
1330
+ .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)."),
1331
+ dateFrom: z
1332
+ .string()
1333
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1334
+ .optional()
1335
+ .describe("Optional CLIENT-SIDE filter: keep filings whose Date Filed >= this ISO YYYY-MM-DD (string compare; the column is already YYYY-MM-DD)."),
1336
+ dateTo: z
1337
+ .string()
1338
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1339
+ .optional()
1340
+ .describe("Optional CLIENT-SIDE filter: keep filings whose Date Filed <= this ISO YYYY-MM-DD."),
1341
+ limit: z
1342
+ .number()
1343
+ .int()
1344
+ .min(1)
1345
+ .max(1000)
1346
+ .default(100)
1347
+ .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)."),
1348
+ offset: z
1349
+ .number()
1350
+ .int()
1351
+ .min(0)
1352
+ .default(0)
1353
+ .describe("0-based offset into the filtered matches (default 0)."),
1354
+ });
1355
+ // ─── EDGAR daily-index (ADR-0027) — input schema ─────────────────
1356
+ // The per-DAY sibling of edgar_filing_index. A single required ISO `date`
1357
+ // (YYYY-MM-DD) is the ONLY caller value that shapes the URL — the handler derives
1358
+ // year/quarter/yyyymmdd from it and re-validates them belt-and-suspenders in
1359
+ // edgar's buildDailyIndexUrl. dateFrom/dateTo are DROPPED (a single-day file has one
1360
+ // date). ALL other params are CLIENT-SIDE filters over the whole downloaded body
1361
+ // (ZERO query string reaches the wire). The date's EXACT calendar-day round-trip +
1362
+ // the future-date rejection are enforced at CALL time in the handler (M2), so the
1363
+ // tools/list snapshot stays deterministic across a day rollover.
1364
+ const EdgarDailyFilingIndexInput = z.object({
1365
+ date: z
1366
+ .string()
1367
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
1368
+ .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)."),
1369
+ formType: z
1370
+ .string()
1371
+ .min(1)
1372
+ .max(30)
1373
+ .optional()
1374
+ .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."),
1375
+ cik: z
1376
+ .union([z.string().regex(/^\d{1,10}$/), z.number().int().nonnegative()])
1377
+ .optional()
1378
+ .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)."),
1379
+ companyContains: z
1380
+ .string()
1381
+ .min(1)
1382
+ .max(200)
1383
+ .optional()
1384
+ .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)."),
1385
+ limit: z
1386
+ .number()
1387
+ .int()
1388
+ .min(1)
1389
+ .max(1000)
1390
+ .default(100)
1391
+ .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)."),
1392
+ offset: z
1393
+ .number()
1394
+ .int()
1395
+ .min(0)
1396
+ .default(0)
1397
+ .describe("0-based offset into the filtered matches (default 0)."),
1398
+ });
1399
+ // ADR-0041. Keyless WITHIN-SOURCE DEPTH on the existing edgar source: one filer ×
1400
+ // one XBRL concept × the COMPLETE reported time-series (data.sec.gov/api/xbrl/
1401
+ // companyconcept/CIK{cik10}/{taxonomy}/{Concept}.json). cik (via resolveCik→padCik) +
1402
+ // taxonomy (enum) + concept (alnum regex) are the THREE validated PATH SEGMENTS (the
1403
+ // SSRF guard, re-checked belt-and-suspenders pre-fetch in edgar's buildConceptUrl).
1404
+ // `unit` is a BODY key filtered CLIENT-SIDE (NOT a path segment — no unit regex);
1405
+ // unit/form/fy/canonicalOnly/limit/offset are all client-side over the fully-fetched set.
1406
+ const EdgarCompanyConceptInput = z.object({
1407
+ cikOrTicker: z
1408
+ .string()
1409
+ .min(1)
1410
+ .describe("A 10-digit (or unpadded) SEC CIK, or a ticker/company-name resolvable via company_tickers.json (e.g. '320193', 'CIK0000320193', 'AAPL')."),
1411
+ concept: z
1412
+ .string()
1413
+ .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).")
1414
+ .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)."),
1415
+ taxonomy: z
1416
+ .enum(edgar.CONCEPT_TAXONOMIES)
1417
+ .default("us-gaap")
1418
+ .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."),
1419
+ unit: z
1420
+ .string()
1421
+ .min(1)
1422
+ .max(40)
1423
+ .optional()
1424
+ .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)."),
1425
+ form: z
1426
+ .string()
1427
+ .min(1)
1428
+ .max(30)
1429
+ .optional()
1430
+ .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)."),
1431
+ fy: z
1432
+ .number()
1433
+ .int()
1434
+ .optional()
1435
+ .describe("Optional CLIENT-SIDE filter: keep only rows whose fiscal year `fy` equals this integer (e.g. 2023)."),
1436
+ canonicalOnly: z
1437
+ .boolean()
1438
+ .default(false)
1439
+ .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."),
1440
+ limit: z
1441
+ .number()
1442
+ .int()
1443
+ .min(1)
1444
+ .max(1000)
1445
+ .default(100)
1446
+ .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."),
1447
+ offset: z
1448
+ .number()
1449
+ .int()
1450
+ .min(0)
1451
+ .default(0)
1452
+ .describe("0-based client-side offset into the filtered time-series (default 0)."),
1453
+ });
1454
+ // ─── Socrata / SODA (keyless SLED + E-rate) — input schemas ──────
1455
+ // ADR-0004. First SLED source. `domain` is a curated allowlist ENUM (the SSRF
1456
+ // core — no free host); `datasetId` is a strict 4x4 with .length(9) (M2 — blocks
1457
+ // a trailing-newline the regex `$` would admit). SoQL params are raw upstream-
1458
+ // validated strings (a bad column ⇒ upstream 400 ⇒ invalid_input, surfaced).
1459
+ const SocrataDomainEnum = z
1460
+ .enum(socrata.SOCRATA_DOMAINS)
1461
+ .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.");
1462
+ const SocrataQueryInput = z.object({
1463
+ domain: SocrataDomainEnum,
1464
+ datasetId: z
1465
+ // M2 — a strict 4x4. NOTE: deliberately NO .trim(): Zod applies .trim()
1466
+ // BEFORE .length(9), so `.trim().length(9)` (the ADR's literal wording)
1467
+ // would STRIP a trailing "\n" to a valid 9-char id and ACCEPT it —
1468
+ // empirically confirmed — defeating the very newline rejection M2 wants.
1469
+ // Dropping .trim() makes .length(9) see the raw string, so any trailing
1470
+ // char (incl. "\n", which the regex `$` alone would admit) is rejected.
1471
+ .string()
1472
+ .length(9)
1473
+ .regex(/^[a-z0-9]{4}-[a-z0-9]{4}$/)
1474
+ .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)."),
1475
+ select: z
1476
+ .string()
1477
+ .optional()
1478
+ .describe("Optional SoQL $select (column projection / aggregate), e.g. 'agency,SUM(amount)'."),
1479
+ where: z
1480
+ .string()
1481
+ .optional()
1482
+ .describe("Optional SoQL $where filter, e.g. \"fiscal_year='2024' AND amount>1000\". A bad column ⇒ upstream HTTP 400 ⇒ invalid_input (surfaced, never silent)."),
1483
+ order: z
1484
+ .string()
1485
+ .optional()
1486
+ .describe("Optional SoQL $order, e.g. 'amount DESC'."),
1487
+ q: z
1488
+ .string()
1489
+ .optional()
1490
+ .describe("Optional SoQL $q full-text search across the row."),
1491
+ limit: z
1492
+ .number()
1493
+ .int()
1494
+ .min(1)
1495
+ .max(1000)
1496
+ .default(100)
1497
+ .describe("Rows per page ($limit), 1..1000, default 100."),
1498
+ offset: z
1499
+ .number()
1500
+ .int()
1501
+ .min(0)
1502
+ .default(0)
1503
+ .describe("0-based row offset ($offset) for pagination, default 0."),
1504
+ withTotal: z
1505
+ .boolean()
1506
+ .default(true)
1507
+ .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."),
1508
+ });
1509
+ const SocrataDiscoverDatasetsInput = z.object({
1510
+ q: z
1511
+ .string()
1512
+ .min(1)
1513
+ .describe("Keyword(s) to find datasets, e.g. 'procurement', 'vendor payments', 'checkbook'."),
1514
+ 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."),
1515
+ limit: z
1516
+ .number()
1517
+ .int()
1518
+ .min(1)
1519
+ .max(100)
1520
+ .default(20)
1521
+ .describe("Max datasets to return, 1..100, default 20."),
1522
+ });
1523
+ // ─── CKAN datastore_search (keyless SLED) — input schemas ───────
1524
+ // ADR-0006. Second SLED source; FIRST on the R2 DataSource port. `host` is a
1525
+ // curated allowlist ENUM (the SSRF core — no free host); `resourceId` is a strict
1526
+ // 36-char lowercase UUID with .length(36) (m1 — the regex `$` already blocks a
1527
+ // trailing "\n"; length is belt-and-suspenders; NO .trim(), NO `i` flag). `filters`
1528
+ // is a CONSTRAINED object we JSON.stringify (Q3 — never a raw string). A bad
1529
+ // filter field / sort ⇒ upstream 409 ⇒ invalid_input (surfaced, never silent).
1530
+ const CkanHostEnum = z
1531
+ .enum(ckan.CKAN_HOSTS)
1532
+ .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).");
1533
+ const CkanQueryInput = z.object({
1534
+ host: CkanHostEnum,
1535
+ resourceId: z
1536
+ // m1 — a strict 36-char lowercase UUID. NO .trim() (Zod trims BEFORE
1537
+ // .length, which would strip a trailing "\n" to a valid id and defeat the
1538
+ // guard); the regex `$` alone rejects a trailing newline in JS, and
1539
+ // .length(36) is belt-and-suspenders. Lowercase-only ⇒ no `i` flag.
1540
+ .string()
1541
+ .length(36)
1542
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
1543
+ .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)."),
1544
+ q: z
1545
+ .string()
1546
+ .optional()
1547
+ .describe("Optional full-text search across the record (CKAN `q`)."),
1548
+ filters: z
1549
+ .record(z.string(), z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]))
1550
+ .optional()
1551
+ .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)."),
1552
+ sort: z
1553
+ .string()
1554
+ .optional()
1555
+ .describe("Optional sort, e.g. '_id asc' or 'amount desc'. A bad field ⇒ 409 ⇒ invalid_input."),
1556
+ limit: z
1557
+ .number()
1558
+ .int()
1559
+ .min(1)
1560
+ .max(1000)
1561
+ .default(100)
1562
+ .describe("Rows per page, 1..1000, default 100."),
1563
+ offset: z
1564
+ .number()
1565
+ .int()
1566
+ .min(0)
1567
+ .default(0)
1568
+ .describe("0-based row offset for pagination, default 0."),
1569
+ });
1570
+ const CkanDiscoverDatasetsInput = z.object({
1571
+ host: CkanHostEnum,
1572
+ q: z
1573
+ .string()
1574
+ .min(1)
1575
+ .describe("Keyword(s) to find datasets, e.g. 'procurement', 'checkbook', 'vendor'."),
1576
+ limit: z
1577
+ .number()
1578
+ .int()
1579
+ .min(1)
1580
+ .max(100)
1581
+ .default(20)
1582
+ .describe("Max datasets (packages) to return, 1..100, default 20."),
1583
+ });
1584
+ // ─── FDIC BankFind Suite (keyless institution directory + financials) ──────────
1585
+ // ADR-0028. First OFF-EDGAR entity source. KEYLESS, fixed host api.fdic.gov +
1586
+ // FIXED endpoint constants (the SSRF core — no free host/path). Structured
1587
+ // inputs → a server-side filters/search builder (NAME/CITY route through FDIC's
1588
+ // full-text `search`, NOT `filters` — M1). name/city char-class is a sanity
1589
+ // boundary; the injection defense is the module's backslash-first phrase-escape
1590
+ // (M2). sortBy is a Zod enum + a Set.has recheck in the builder (an unknown sort
1591
+ // field is invalid_input before fetch).
1592
+ // name/city char-class — includes `( ) #` so real bank names like 'Mizuho Bank
1593
+ // (USA)' are NOT false-rejected (S2); these are literal inside the phrase-quoted
1594
+ // + escaped `search` value, never ES metachars there.
1595
+ const FdicNameCity = z
1596
+ .string()
1597
+ .regex(/^[A-Za-z0-9 .,&'\/()#\-]+$/)
1598
+ .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).");
1599
+ const FdicSearchInstitutionsInput = z.object({
1600
+ state: z
1601
+ .string()
1602
+ .regex(/^[A-Z]{2}$/)
1603
+ .optional()
1604
+ .describe("Filter by 2-letter US state code (uppercase; → STALP filter). e.g. 'VA'."),
1605
+ activeOnly: z
1606
+ .boolean()
1607
+ .optional()
1608
+ .describe("Filter to active (true → ACTIVE:1) or inactive (false → ACTIVE:0) institutions; omit for both."),
1609
+ cert: z
1610
+ .number()
1611
+ .int()
1612
+ .min(1)
1613
+ .optional()
1614
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter)."),
1615
+ 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`)."),
1616
+ city: FdicNameCity.optional().describe("Filter by CITY via FDIC full-text `search` (case-insensitive token match)."),
1617
+ limit: z
1618
+ .number()
1619
+ .int()
1620
+ .min(1)
1621
+ .max(1000)
1622
+ .default(100)
1623
+ .describe("Rows per page, 1..1000, default 100."),
1624
+ offset: z
1625
+ .number()
1626
+ .int()
1627
+ .min(0)
1628
+ .max(100000)
1629
+ .default(0)
1630
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1631
+ sortBy: z
1632
+ .enum(["NAME", "CERT", "ASSET", "ESTYMD", "STALP", "CITY", "ACTIVE"])
1633
+ .optional()
1634
+ .describe("Optional sort field (an allowlisted enum; an unknown field is rejected before fetch)."),
1635
+ sortOrder: z
1636
+ .enum(["ASC", "DESC"])
1637
+ .default("ASC")
1638
+ .describe("Sort direction when sortBy is set, ASC (default) or DESC."),
1639
+ });
1640
+ const FdicInstitutionFinancialsInput = z.object({
1641
+ cert: z
1642
+ .number()
1643
+ .int()
1644
+ .min(1)
1645
+ .describe("REQUIRED FDIC certificate number of the institution (→ CERT filter). From fdic_search_institutions."),
1646
+ limit: z
1647
+ .number()
1648
+ .int()
1649
+ .min(1)
1650
+ .max(1000)
1651
+ .default(100)
1652
+ .describe("Rows per page, 1..1000, default 100."),
1653
+ offset: z
1654
+ .number()
1655
+ .int()
1656
+ .min(0)
1657
+ .max(100000)
1658
+ .default(0)
1659
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1660
+ sortBy: z
1661
+ .enum(["REPDTE", "ASSET", "DEP", "NETINC"])
1662
+ .default("REPDTE")
1663
+ .describe("Sort field (allowlisted enum; default REPDTE = report date)."),
1664
+ sortOrder: z
1665
+ .enum(["ASC", "DESC"])
1666
+ .default("DESC")
1667
+ .describe("Sort direction, default DESC (newest quarter first)."),
1668
+ });
1669
+ // ADR-0029 — the 3rd FDIC tool (source 23 unchanged; snapshot 89→90) reading
1670
+ // /banks/failures. v2 (cycle-33) live review: the state field is PSTALP (NOT
1671
+ // STALP — a false-empty landmine), and /failures IGNORES the `search` param (a
1672
+ // name/city query floods the whole dataset) → NO name/city filter here; name
1673
+ // lookup is the 2-step CERT linkage via fdic_search_institutions.
1674
+ const FdicBankFailuresInput = z.object({
1675
+ state: z
1676
+ .string()
1677
+ .regex(/^[A-Z]{2}$/)
1678
+ .optional()
1679
+ .describe("Filter by 2-letter US state code (uppercase; → PSTALP filter — the /failures state field is PSTALP, NOT STALP). e.g. 'CA'."),
1680
+ failYear: z
1681
+ .number()
1682
+ .int()
1683
+ .min(1934)
1684
+ .max(new Date().getUTCFullYear())
1685
+ .optional()
1686
+ .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)."),
1687
+ cert: z
1688
+ .number()
1689
+ .int()
1690
+ .min(1)
1691
+ .optional()
1692
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter). Resolve a bank's CERT via fdic_search_institutions."),
1693
+ limit: z
1694
+ .number()
1695
+ .int()
1696
+ .min(1)
1697
+ .max(1000)
1698
+ .default(100)
1699
+ .describe("Rows per page, 1..1000, default 100."),
1700
+ offset: z
1701
+ .number()
1702
+ .int()
1703
+ .min(0)
1704
+ .max(100000)
1705
+ .default(0)
1706
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1707
+ sortBy: z
1708
+ .enum(["FAILDATE", "COST", "QBFASSET", "QBFDEP", "NAME", "FAILYR"])
1709
+ .default("FAILDATE")
1710
+ .describe("Sort field (allowlisted enum; default FAILDATE = failure date). An unknown field is rejected before fetch."),
1711
+ sortOrder: z
1712
+ .enum(["ASC", "DESC"])
1713
+ .default("DESC")
1714
+ .describe("Sort direction, default DESC (most-recent failures first)."),
1715
+ });
1716
+ // ADR-0030 — the 4th FDIC tool (source 23 unchanged; snapshot 90→91) reading
1717
+ // /banks/history, the institution-level STRUCTURAL-CHANGE event log (mergers,
1718
+ // absorptions, failures, name/location/charter/regulator changes, branch open/close,
1719
+ // etc.) — completing the FDIC entity cluster. Live review: the state field is PSTALP
1720
+ // (NOT STALP — a false-empty landmine), and /history's `search` param returns 0 for
1721
+ // INSTNAME (a false-empty) → NO name/city filter; name lookup is the 2-step CERT
1722
+ // linkage via fdic_search_institutions. All inputs optional, AND-combined; `cert` is
1723
+ // the primary path.
1724
+ const FdicInstitutionHistoryInput = z.object({
1725
+ cert: z
1726
+ .number()
1727
+ .int()
1728
+ .min(1)
1729
+ .optional()
1730
+ .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."),
1731
+ changeCode: z
1732
+ .number()
1733
+ .int()
1734
+ .min(1)
1735
+ .optional()
1736
+ .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)."),
1737
+ effYear: z
1738
+ .number()
1739
+ .int()
1740
+ .min(1782)
1741
+ .max(new Date().getUTCFullYear())
1742
+ .optional()
1743
+ .describe("Filter by the year the structural change took effect (→ EFFYEAR filter). 1782..current UTC year (1782 = the oldest observed EFFYEAR)."),
1744
+ state: z
1745
+ .string()
1746
+ .regex(/^[A-Z]{2}$/)
1747
+ .optional()
1748
+ .describe("Filter by 2-letter US state code (uppercase; → PSTALP filter — the /history state field is PSTALP, NOT STALP). e.g. 'CA'."),
1749
+ limit: z
1750
+ .number()
1751
+ .int()
1752
+ .min(1)
1753
+ .max(1000)
1754
+ .default(100)
1755
+ .describe("Rows per page, 1..1000, default 100."),
1756
+ offset: z
1757
+ .number()
1758
+ .int()
1759
+ .min(0)
1760
+ .max(100000)
1761
+ .default(0)
1762
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1763
+ sortBy: z
1764
+ .enum(["EFFDATE", "PROCDATE", "CHANGECODE", "TRANSNUM"])
1765
+ .default("EFFDATE")
1766
+ .describe("Sort field (allowlisted enum; default EFFDATE = effective date). An unknown field is rejected before fetch."),
1767
+ sortOrder: z
1768
+ .enum(["ASC", "DESC"])
1769
+ .default("DESC")
1770
+ .describe("Sort direction, default DESC (newest structural change first)."),
1771
+ });
1772
+ // ADR-0031 — the 5th FDIC tool (source 23 unchanged; snapshot 91→92) reading
1773
+ // /banks/summary, the FDIC's OWN aggregate/statistical roll-ups (the FIRST
1774
+ // AGGREGATE tool on this source; the 4 existing are per-ENTITY on CERT). Live
1775
+ // review: the state field is STALP (NOT PSTALP — a per-endpoint difference), the
1776
+ // number-of-institutions field is BANKS (NOT NUMINST), /summary serves NO ratio
1777
+ // fields, and NIM is net interest INCOME in $thousands (NOT the margin ratio). Each
1778
+ // row crosses charter (CB_SI) × geography (STALP), where STALP ∈ {USA,US,OT,PI} are
1779
+ // geographic ROLL-UPS surfaced via a derived scope/isRollup so a roll-up never
1780
+ // masquerades as a state. NO name/city filter (the `search` param is a no-op). A
1781
+ // non-int year is rejected pre-fetch (a malformed year is a live HTTP-200 total:0
1782
+ // false-empty). All inputs optional, AND-combined.
1783
+ const FdicIndustrySummaryInput = z.object({
1784
+ year: z
1785
+ .number()
1786
+ .int()
1787
+ .min(1934)
1788
+ .max(new Date().getUTCFullYear())
1789
+ .optional()
1790
+ .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)."),
1791
+ state: z
1792
+ .string()
1793
+ .regex(/^[A-Z]{2,3}$/)
1794
+ .optional()
1795
+ .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."),
1796
+ charterClass: z
1797
+ .enum(["CB", "SI"])
1798
+ .optional()
1799
+ .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)."),
1800
+ limit: z
1801
+ .number()
1802
+ .int()
1803
+ .min(1)
1804
+ .max(1000)
1805
+ .default(100)
1806
+ .describe("Rows per page, 1..1000, default 100."),
1807
+ offset: z
1808
+ .number()
1809
+ .int()
1810
+ .min(0)
1811
+ .max(100000)
1812
+ .default(0)
1813
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1814
+ sortBy: z
1815
+ .enum(["YEAR", "ASSET", "DEP", "NETINC", "BANKS"])
1816
+ .default("YEAR")
1817
+ .describe("Sort field (allowlisted enum; default YEAR = aggregate year). An unknown field is rejected before fetch."),
1818
+ sortOrder: z
1819
+ .enum(["ASC", "DESC"])
1820
+ .default("DESC")
1821
+ .describe("Sort direction, default DESC (newest year / largest first)."),
1822
+ });
1823
+ // ADR-0040 — the 6th & 7th FDIC tools (source 30 unchanged; snapshot 103→105) —
1824
+ // WITHIN-SOURCE DEPTH on the already-wired src/fdic.ts adapter. (6) fdic_risk_ratios
1825
+ // projects the curated counterparty RISK-RATIO catalog on the ALREADY-wired
1826
+ // /banks/financials endpoint (per-field units in the output key; percent ratios
1827
+ // verbatim via num, tier-1 capital ×1000; ★M1 the CBLR RBCRWAJ=0 sentinel → null via
1828
+ // FDIC's CBLRIND flag + a per-row cblrFramework, never a false 0% capital). (7)
1829
+ // fdic_branch_deposits reads /banks/sod (Summary of Deposits) — ONE new fixed endpoint
1830
+ // constant. Both keyed on the numeric CERT; C118-quoted state; NO name/city search.
1831
+ const FdicRiskRatiosInput = z.object({
1832
+ cert: z
1833
+ .number()
1834
+ .int()
1835
+ .min(1)
1836
+ .describe("REQUIRED FDIC certificate number of the institution (→ CERT filter). From fdic_search_institutions."),
1837
+ reportDate: z
1838
+ .number()
1839
+ .int()
1840
+ .min(19000101)
1841
+ .max(29991231)
1842
+ .optional()
1843
+ .describe("Optional report date (→ REPDTE filter), a quarter-end as a YYYYMMDD integer (e.g. 20240630). Omit for the full quarterly ratio time-series."),
1844
+ limit: z
1845
+ .number()
1846
+ .int()
1847
+ .min(1)
1848
+ .max(1000)
1849
+ .default(100)
1850
+ .describe("Rows per page, 1..1000, default 100."),
1851
+ offset: z
1852
+ .number()
1853
+ .int()
1854
+ .min(0)
1855
+ .max(100000)
1856
+ .default(0)
1857
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1858
+ sortBy: z
1859
+ .enum(["REPDTE", "ROA", "ROE", "RBCRWAJ", "EEFFR"])
1860
+ .default("REPDTE")
1861
+ .describe("Sort field (allowlisted enum; default REPDTE = report date). An unknown field is rejected before fetch."),
1862
+ sortOrder: z
1863
+ .enum(["ASC", "DESC"])
1864
+ .default("DESC")
1865
+ .describe("Sort direction, default DESC (newest quarter first)."),
1866
+ });
1867
+ const FdicBranchDepositsInput = z.object({
1868
+ cert: z
1869
+ .number()
1870
+ .int()
1871
+ .min(1)
1872
+ .optional()
1873
+ .describe("Filter by FDIC certificate number (the STABLE entity key; → CERT filter). Resolve a bank's CERT via fdic_search_institutions."),
1874
+ state: z
1875
+ .string()
1876
+ .regex(/^[A-Z]{2}$/)
1877
+ .optional()
1878
+ .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."),
1879
+ year: z
1880
+ .number()
1881
+ .int()
1882
+ .min(1934)
1883
+ .max(new Date().getUTCFullYear())
1884
+ .optional()
1885
+ .describe("Filter by Summary-of-Deposits survey YEAR (→ YEAR filter), the annual June-30 snapshot year. 1934..current UTC year."),
1886
+ limit: z
1887
+ .number()
1888
+ .int()
1889
+ .min(1)
1890
+ .max(1000)
1891
+ .default(100)
1892
+ .describe("Rows per page, 1..1000, default 100."),
1893
+ offset: z
1894
+ .number()
1895
+ .int()
1896
+ .min(0)
1897
+ .max(100000)
1898
+ .default(0)
1899
+ .describe("0-based row offset for pagination, 0..100000, default 0."),
1900
+ sortBy: z
1901
+ .enum(["YEAR", "DEPSUMBR"])
1902
+ .default("YEAR")
1903
+ .describe("Sort field (allowlisted enum; default YEAR = snapshot year). An unknown field is rejected before fetch."),
1904
+ sortOrder: z
1905
+ .enum(["ASC", "DESC"])
1906
+ .default("DESC")
1907
+ .describe("Sort direction, default DESC (newest snapshot / largest deposits first)."),
1908
+ });
1909
+ // ─── OpenFEMA (keyless disaster declarations + emergency-assistance spend) ──────
1910
+ // ADR-0016. KEYLESS, fixed host www.fema.gov + a PINNED dataset registry
1911
+ // {entityName, version} (the SSRF core — no free host/path/version). Filters are
1912
+ // MODULE-BUILT from a per-tool, LIVE-VERIFIED field whitelist into an OData
1913
+ // `$filter` (NO raw `$filter` arg exists — no injection surface); the module
1914
+ // ALWAYS sends `$inlinecount=allpages` so metadata.count is the EXACT filtered
1915
+ // total. Per-dataset field names DIFFER (M1): the PA tool's `state` maps to
1916
+ // `stateAbbreviation`, the declarations tool's `state` maps to `state` — a shared
1917
+ // user-facing arg over two different real OData fields. limit → $top (≤1000),
1918
+ // offset → $skip.
1919
+ // A 2-letter US state/territory code (uppercase; e.g. CA, LA, PR, DC). A bad value
1920
+ // is NOT an SSRF vector (the fixed host + dataset registry are) — it just returns
1921
+ // an honest genuine-empty (metadata.count:0), never a silent unfiltered set.
1922
+ const FemaStateSchema = z
1923
+ .string()
1924
+ .regex(/^[A-Za-z]{2}$/)
1925
+ .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).");
1926
+ // ISO date 'YYYY-MM-DD' or a full ISO datetime. A bare date = midnight-UTC start.
1927
+ const FemaDateSchema = z
1928
+ .string()
1929
+ .regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z?)?$/)
1930
+ .describe("ISO date 'YYYY-MM-DD' or full ISO datetime; a bare date is midnight-UTC start of that day.");
1931
+ const FemaSearchPublicAssistanceInput = z.object({
1932
+ state: FemaStateSchema.optional().describe("Filter by applicant state (→ stateAbbreviation eq 'XX'). 2-letter code."),
1933
+ disasterNumber: z
1934
+ .number()
1935
+ .int()
1936
+ .positive()
1937
+ .optional()
1938
+ .describe("Filter by FEMA disaster number (→ disasterNumber eq N)."),
1939
+ applicantId: z
1940
+ .string()
1941
+ .min(1)
1942
+ .optional()
1943
+ .describe("Filter by applicant id (→ applicantId eq '...'). e.g. '015-UF5E0-00'."),
1944
+ damageCategoryCode: z
1945
+ .string()
1946
+ .min(1)
1947
+ .max(2)
1948
+ .optional()
1949
+ .describe("Filter by PA damage category code (→ damageCategoryCode eq 'X'). e.g. 'B' = Emergency Protective Measures, 'C'–'G' = permanent work."),
1950
+ incidentType: z
1951
+ .string()
1952
+ .min(1)
1953
+ .optional()
1954
+ .describe("Filter by incident type (→ incidentType eq '...'). e.g. 'Flood', 'Hurricane', 'Severe Storm'."),
1955
+ minProjectAmount: z
1956
+ .number()
1957
+ .optional()
1958
+ .describe("Minimum project amount (→ projectAmount ge N)."),
1959
+ maxProjectAmount: z
1960
+ .number()
1961
+ .optional()
1962
+ .describe("Maximum project amount (→ projectAmount le N)."),
1963
+ declaredDateFrom: FemaDateSchema.optional().describe("Earliest declaration date, inclusive (→ declarationDate ge 'ISO')."),
1964
+ declaredDateTo: FemaDateSchema.optional().describe("Latest declaration date, inclusive (→ declarationDate le 'ISO')."),
1965
+ limit: z
1966
+ .number()
1967
+ .int()
1968
+ .min(1)
1969
+ .max(1000)
1970
+ .default(100)
1971
+ .describe("Rows per page ($top), 1..1000, default 100."),
1972
+ offset: z
1973
+ .number()
1974
+ .int()
1975
+ .min(0)
1976
+ .default(0)
1977
+ .describe("0-based row offset ($skip) for pagination, default 0."),
1978
+ });
1979
+ const FemaDisasterDeclarationsInput = z.object({
1980
+ state: FemaStateSchema.optional().describe("Filter by state (→ state eq 'XX'). 2-letter code."),
1981
+ incidentType: z
1982
+ .string()
1983
+ .min(1)
1984
+ .optional()
1985
+ .describe("Filter by incident type (→ incidentType eq '...'). e.g. 'Flood', 'Hurricane', 'Winter Storm'."),
1986
+ declarationType: z
1987
+ .enum(["DR", "EM", "FM"])
1988
+ .optional()
1989
+ .describe("Filter by declaration type (→ declarationType eq 'XX'): DR (major disaster), EM (emergency), FM (fire management)."),
1990
+ fyDeclared: z
1991
+ .number()
1992
+ .int()
1993
+ .optional()
1994
+ .describe("Filter by fiscal year declared (→ fyDeclared eq N). e.g. 2024."),
1995
+ disasterNumber: z
1996
+ .number()
1997
+ .int()
1998
+ .positive()
1999
+ .optional()
2000
+ .describe("Filter by FEMA disaster number (→ disasterNumber eq N)."),
2001
+ declaredDateFrom: FemaDateSchema.optional().describe("Earliest declaration date, inclusive (→ declarationDate ge 'ISO')."),
2002
+ declaredDateTo: FemaDateSchema.optional().describe("Latest declaration date, inclusive (→ declarationDate le 'ISO')."),
2003
+ paProgramDeclared: z
2004
+ .boolean()
2005
+ .optional()
2006
+ .describe("Filter to declarations where the Public Assistance program was declared (→ paProgramDeclared eq true/false)."),
2007
+ iaProgramDeclared: z
2008
+ .boolean()
2009
+ .optional()
2010
+ .describe("Filter to declarations where the Individual Assistance program was declared (→ iaProgramDeclared eq true/false)."),
2011
+ limit: z
2012
+ .number()
2013
+ .int()
2014
+ .min(1)
2015
+ .max(1000)
2016
+ .default(100)
2017
+ .describe("Rows per page ($top), 1..1000, default 100."),
2018
+ offset: z
2019
+ .number()
2020
+ .int()
2021
+ .min(0)
2022
+ .default(0)
2023
+ .describe("0-based row offset ($skip) for pagination, default 0."),
2024
+ });
2025
+ // ─── EPA ECHO REST (keyless facility compliance/enforcement) — input schemas ──
2026
+ // ADR-0009. KEYLESS, single fixed host (echodata.epa.gov) + three fixed service
2027
+ // paths (the SSRF core — no free host/path). `state` is a curated US state/
2028
+ // territory ENUM: it scopes the query (national is ~5.6M rows) AND is the
2029
+ // silent-zero guard (ECHO does NOT validate filter VALUES, so an unknown value
2030
+ // returns QueryRows:0 — indistinguishable from a genuine-empty). M2 (LIVE-verified
2031
+ // 2026-07-12): `sic` DOES narrow (a real filter); `naics` is DROPPED upstream —
2032
+ // exposed as BEST-EFFORT, disclosed in _meta.filtersDropped + notes, never
2033
+ // silently presented as filtered.
2034
+ const EchoStateEnum = z
2035
+ .enum(echo.ECHO_STATES)
2036
+ .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'.");
2037
+ const EchoSearchFacilitiesInput = z.object({
2038
+ state: EchoStateEnum,
2039
+ naics: z
2040
+ .string()
2041
+ .regex(/^[0-9]{2,6}$/)
2042
+ .optional()
2043
+ .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."),
2044
+ sic: z
2045
+ .string()
2046
+ .regex(/^[0-9]{2,4}$/)
2047
+ .optional()
2048
+ .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)."),
2049
+ facilityName: z
2050
+ .string()
2051
+ .min(1)
2052
+ .optional()
2053
+ .describe("Facility-name substring filter (p_fn). NOTE: not validated by ECHO — a typo silently returns 0 results, not an error."),
2054
+ majorOnly: z
2055
+ .boolean()
2056
+ .optional()
2057
+ .describe("true ⇒ only EPA 'major' facilities (p_maj=Y)."),
2058
+ federalOnly: z
2059
+ .boolean()
2060
+ .optional()
2061
+ .describe("true ⇒ only federal facilities (p_ff=Y)."),
2062
+ limit: z
2063
+ .number()
2064
+ .int()
2065
+ .min(1)
2066
+ .max(1000)
2067
+ .default(100)
2068
+ .describe("Facilities per page (→ responseset), 1..1000, default 100."),
2069
+ offset: z
2070
+ .number()
2071
+ .int()
2072
+ .min(0)
2073
+ .default(0)
2074
+ .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)."),
2075
+ });
2076
+ const EchoFacilityReportInput = z.object({
2077
+ registryId: z
2078
+ .string()
2079
+ .regex(/^[0-9]{9,12}$/)
2080
+ .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)."),
2081
+ });
2082
+ // ─── api.data.gov keyed trio (Regulations.gov + Congress.gov) — input schemas ──
2083
+ // ADR-0007. The project's FIRST KEYED source. The key is read from env
2084
+ // (DATA_GOV_API_KEY, else the public DEMO_KEY) and travels ONLY in the X-Api-Key
2085
+ // header — NEVER in a param here. M4 (Zod-first): page/limit bounds + sort/type
2086
+ // enums + ISO date-time formats are validated LOCALLY so bad params fail as
2087
+ // invalid_input BEFORE any fetch (Regulations.gov's page[number] is HARD-capped at
2088
+ // 40 = a 10,000-record ceiling; page[size] must be 5..250).
2089
+ const RegulationsSearchInput = z.object({
2090
+ searchTerm: z
2091
+ .string()
2092
+ .optional()
2093
+ .describe("Full-text search term (filter[searchTerm]), e.g. 'artificial intelligence'."),
2094
+ query: z
2095
+ .string()
2096
+ .optional()
2097
+ .describe("Alias for `searchTerm` (either is accepted; both feed filter[searchTerm])."),
2098
+ agencyId: z
2099
+ .string()
2100
+ .optional()
2101
+ .describe("Filter by posting agency acronym (filter[agencyId]), e.g. 'EPA', 'FDA'."),
2102
+ docketId: z
2103
+ .string()
2104
+ .optional()
2105
+ .describe("Filter by docket id (filter[docketId]), e.g. 'EPA-HQ-OAR-2021-0257'."),
2106
+ documentType: z
2107
+ .enum(datagov.REGULATIONS_DOCUMENT_TYPES)
2108
+ .optional()
2109
+ .describe("Filter by document type (documents only): Rule / Proposed Rule / Notice / Supporting & Related Material / Other."),
2110
+ withinCommentPeriod: z
2111
+ .boolean()
2112
+ .optional()
2113
+ .describe("true ⇒ only documents currently open for comment (documents only; filter[withinCommentPeriod])."),
2114
+ postedDateGe: z
2115
+ .string()
2116
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2117
+ .optional()
2118
+ .describe("Posted on/after this date, YYYY-MM-DD (filter[postedDate][ge])."),
2119
+ postedDateLe: z
2120
+ .string()
2121
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2122
+ .optional()
2123
+ .describe("Posted on/before this date, YYYY-MM-DD (filter[postedDate][le])."),
2124
+ sort: z
2125
+ .enum(datagov.REGULATIONS_SORTS)
2126
+ .optional()
2127
+ .describe("Sort order (default '-postedDate'). Live-verified set: -postedDate/postedDate/-lastModifiedDate/lastModifiedDate/-commentEndDate (non-exhaustive)."),
2128
+ pageNumber: z
2129
+ .number()
2130
+ .int()
2131
+ .min(1)
2132
+ .max(40)
2133
+ .default(1)
2134
+ .describe("1-based page number, 1..40 (HARD cap — page[number] max is 40; the reachable window is 40×pageSize ≤ 10,000 records)."),
2135
+ pageSize: z
2136
+ .number()
2137
+ .int()
2138
+ .min(5)
2139
+ .max(250)
2140
+ .default(25)
2141
+ .describe("Records per page (page[size]), 5..250, default 25."),
2142
+ });
2143
+ // ADR-0044 — Regulations.gov DOCKETS (the rulemaking/nonrulemaking CONTAINER +
2144
+ // its cross-source `rin` join key). Within-source depth on the SAME api.data.gov
2145
+ // keyed adapter. The docketType enum + ISO date formats fail LOCALLY as
2146
+ // invalid_input BEFORE any fetch; `limit` exposes a friendly count while the wire
2147
+ // page[size] floor of 5 is handled by the handler (a limit<5 is client-sliced).
2148
+ const RegulationsSearchDocketsInput = z.object({
2149
+ searchTerm: z
2150
+ .string()
2151
+ .optional()
2152
+ .describe("Full-text search term (filter[searchTerm]) over docket title/abstract, e.g. 'endangered species'."),
2153
+ query: z
2154
+ .string()
2155
+ .optional()
2156
+ .describe("Alias for `searchTerm` (either is accepted; both feed filter[searchTerm])."),
2157
+ agencyId: z
2158
+ .string()
2159
+ .optional()
2160
+ .describe("Filter by owning agency acronym (filter[agencyId]), e.g. 'EPA', 'BLM', 'TREAS-FINCEN'."),
2161
+ docketType: z
2162
+ .enum(datagov.REGULATIONS_DOCKET_TYPES)
2163
+ .optional()
2164
+ .describe("Filter by docket type: Rulemaking / Nonrulemaking (filter[docketType])."),
2165
+ lastModifiedDateGe: z
2166
+ .string()
2167
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2168
+ .optional()
2169
+ .describe("Docket last modified on/after this date, YYYY-MM-DD (filter[lastModifiedDate][ge])."),
2170
+ lastModifiedDateLe: z
2171
+ .string()
2172
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2173
+ .optional()
2174
+ .describe("Docket last modified on/before this date, YYYY-MM-DD (filter[lastModifiedDate][le])."),
2175
+ sort: z
2176
+ .enum(datagov.REGULATIONS_DOCKET_SORTS)
2177
+ .optional()
2178
+ .describe("Sort order (default '-lastModifiedDate'). Set: -lastModifiedDate/lastModifiedDate/title/-title (first two DEMO_KEY-verified)."),
2179
+ limit: z
2180
+ .number()
2181
+ .int()
2182
+ .min(1)
2183
+ .max(250)
2184
+ .default(20)
2185
+ .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."),
2186
+ pageNumber: z
2187
+ .number()
2188
+ .int()
2189
+ .min(1)
2190
+ .max(40)
2191
+ .default(1)
2192
+ .describe("1-based page number, 1..40 (HARD cap — page[number] max is 40; the reachable window is 40×page[size] ≤ 10,000 records)."),
2193
+ });
2194
+ const RegulationsGetDocketInput = z.object({
2195
+ docketId: z
2196
+ .string()
2197
+ .regex(/^[A-Za-z0-9_.-]+$/, "docketId may contain only letters, digits, '_', '.', '-' (no slashes/spaces/%).")
2198
+ .refine((v) => /[A-Za-z0-9]/.test(v), "docketId must contain an alphanumeric")
2199
+ .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)."),
2200
+ });
2201
+ const CongressSearchBillsInput = z.object({
2202
+ query: z
2203
+ .string()
2204
+ .optional()
2205
+ .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."),
2206
+ congress: z
2207
+ .number()
2208
+ .int()
2209
+ .min(1)
2210
+ .max(999)
2211
+ .optional()
2212
+ .describe("Congress number, e.g. 118 (scopes the path to /v3/bill/{congress})."),
2213
+ billType: z
2214
+ .enum(datagov.CONGRESS_BILL_TYPES)
2215
+ .optional()
2216
+ .describe("Bill type: hr/s/hjres/sjres/hconres/sconres/hres/sres. Requires `congress` (path /v3/bill/{congress}/{billType})."),
2217
+ fromDateTime: z
2218
+ .string()
2219
+ .datetime({ offset: true })
2220
+ .optional()
2221
+ .describe("Filter to bills updated at/after this ISO-8601 date-time with offset, e.g. '2024-01-01T00:00:00Z'."),
2222
+ toDateTime: z
2223
+ .string()
2224
+ .datetime({ offset: true })
2225
+ .optional()
2226
+ .describe("Filter to bills updated at/before this ISO-8601 date-time with offset, e.g. '2024-12-31T23:59:59Z'."),
2227
+ offset: z
2228
+ .number()
2229
+ .int()
2230
+ .min(0)
2231
+ .default(0)
2232
+ .describe("0-based record offset for pagination, default 0."),
2233
+ limit: z
2234
+ .number()
2235
+ .int()
2236
+ .min(1)
2237
+ .max(250)
2238
+ .default(20)
2239
+ .describe("Records per page, 1..250, default 20."),
2240
+ });
2241
+ const CongressGetBillInput = z.object({
2242
+ congress: z
2243
+ .number()
2244
+ .int()
2245
+ .min(1)
2246
+ .max(999)
2247
+ .describe("Congress number, e.g. 117."),
2248
+ billType: z
2249
+ .enum(datagov.CONGRESS_BILL_TYPES)
2250
+ .describe("Bill type: hr/s/hjres/sjres/hconres/sconres/hres/sres."),
2251
+ billNumber: z
2252
+ .number()
2253
+ .int()
2254
+ .min(1)
2255
+ .describe("Bill number, e.g. 3076 (for H.R.3076)."),
2256
+ });
2257
+ // ─── data.gov v4 Catalog API (api.gsa.gov — CKAN-retirement replacement) ─
2258
+ // ADR-0046, resilience Phase 3. Federal dataset DISCOVERY. Same DATA_GOV_API_KEY/
2259
+ // DEMO_KEY/X-Api-Key discipline as the datagov trio (shared datagovKey.ts seam),
2260
+ // but a DIFFERENT host (api.gsa.gov). The opaque `cursor` is charclass-validated
2261
+ // here AND re-guarded in the handler (a bad token ⇒ invalid_input, 0 fetch).
2262
+ const DatagovSearchDatasetsInput = z.object({
2263
+ query: z
2264
+ .string()
2265
+ .min(1)
2266
+ .max(500)
2267
+ .optional()
2268
+ .describe("Free-text search over the dataset catalog (→ _q), e.g. 'wildfire'. LIVE-CONFIRMED to narrow."),
2269
+ organization: z
2270
+ .string()
2271
+ .min(1)
2272
+ .max(200)
2273
+ .optional()
2274
+ .describe("Publisher organization SLUG filter (→ organization), e.g. 'epa-gov', 'noaa-gov'. An org catalog lists that agency's published datasets."),
2275
+ limit: z
2276
+ .number()
2277
+ .int()
2278
+ .min(1)
2279
+ .max(100)
2280
+ .default(20)
2281
+ .describe("Datasets per page (→ _size), 1..100, default 20."),
2282
+ cursor: z
2283
+ .string()
2284
+ .min(1)
2285
+ .max(4096)
2286
+ .regex(datagovCatalog.DATAGOV_CURSOR_RE)
2287
+ .optional()
2288
+ .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."),
2289
+ });
2290
+ // ─── GovInfo (api.govinfo.gov — the api.data.gov keyed trio's 3rd API) ─
2291
+ // ADR-0010. Same DATA_GOV_API_KEY/DEMO_KEY/X-Api-Key discipline as the datagov
2292
+ // trio (shared datagovKey.ts seam). `collection` is grammar-checked here AND
2293
+ // validated against the live /collections catalog in the handler (the silent-empty
2294
+ // guard). The novel piece is the OPAQUE `offsetMark` cursor: continuation rides in
2295
+ // _meta.nextCursor (passed back as pageMark), never a numeric offset.
2296
+ const GovinfoListCollectionsInput = z.object({});
2297
+ const GovinfoSearchPackagesInput = z.object({
2298
+ collection: z
2299
+ .string()
2300
+ .regex(govinfo.GOVINFO_COLLECTION_RE, "collection must be an uppercase alpha GovInfo code like BILLS/CFR/FR/PLAW ([A-Z]{2,10}).")
2301
+ .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."),
2302
+ startDate: z
2303
+ .string()
2304
+ .regex(govinfo.GOVINFO_DATE_RE, "startDate must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.")
2305
+ .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'."),
2306
+ endDate: z
2307
+ .string()
2308
+ .regex(govinfo.GOVINFO_DATE_RE, "endDate must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.")
2309
+ .optional()
2310
+ .describe("Optional upper bound on lastModified (same format as startDate)."),
2311
+ pageSize: z
2312
+ .number()
2313
+ .int()
2314
+ .min(1)
2315
+ .max(1000)
2316
+ .default(100)
2317
+ .describe("Rows per page (upstream pageSize), 1..1000, default 100."),
2318
+ pageMark: z
2319
+ .string()
2320
+ .regex(govinfo.GOVINFO_PAGE_MARK_RE, 'pageMark must be "*" or the opaque _meta.nextCursor from the previous page (≤4096 base64/URL-safe chars).')
2321
+ .default("*")
2322
+ .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).'),
2323
+ });
2324
+ const GovinfoGetPackageInput = z.object({
2325
+ packageId: z
2326
+ .string()
2327
+ .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,}).")
2328
+ .describe("GovInfo packageId from govinfo_search_packages (e.g. 'BILLS-118hr1enr', 'PLAW-117publ58', 'CFR-2023-title1-vol1', 'GAOREPORTS-GAO-24-106221')."),
2329
+ });
2330
+ // ─── FPDS-NG (www.fpds.gov ezSearch ATOM — keyless XML/ATOM) ─── ADR-0012
2331
+ // The FIRST XML/ATOM source. Structured filters ONLY (NO raw-q — a typo'd field
2332
+ // name is a SILENT ZERO in FPDS, so the module builds the fielded `q`). At least
2333
+ // one filter is REQUIRED (refuse a bare unbounded scan). Dates are ISO
2334
+ // YYYY-MM-DD (reformatted to YYYY/MM/DD internally). `offset` is the 0-indexed
2335
+ // page start (page size fixed at 10); keyless deep-paging past ~200K is
2336
+ // unreliable so it is capped at 600000.
2337
+ const FPDS_ISO_DATE = z
2338
+ .string()
2339
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be ISO YYYY-MM-DD.");
2340
+ const FpdsSearchAwardsInput = z
2341
+ .object({
2342
+ naics: z
2343
+ .string()
2344
+ .optional()
2345
+ .describe("Principal NAICS code (→ PRINCIPAL_NAICS_CODE), e.g. '541511'."),
2346
+ vendorName: z
2347
+ .string()
2348
+ .optional()
2349
+ .describe("Vendor/contractor name phrase (→ VENDOR_NAME), e.g. 'LOCKHEED MARTIN'."),
2350
+ piid: z
2351
+ .string()
2352
+ .optional()
2353
+ .describe("Contract/order PIID (→ PIID) — returns that action's full base+mod chain."),
2354
+ departmentId: z
2355
+ .string()
2356
+ .optional()
2357
+ .describe("4-digit contracting DEPARTMENT_ID, e.g. '9700' (DoD), '4700' (GSA)."),
2358
+ contractingAgencyName: z
2359
+ .string()
2360
+ .optional()
2361
+ .describe("Contracting agency name phrase (→ CONTRACTING_AGENCY_NAME), e.g. 'DEPT OF DEFENSE'."),
2362
+ signedDateFrom: FPDS_ISO_DATE.optional().describe("Signed-date range START (ISO YYYY-MM-DD); pair with signedDateTo (→ SIGNED_DATE:[from,to])."),
2363
+ signedDateTo: FPDS_ISO_DATE.optional().describe("Signed-date range END (ISO YYYY-MM-DD); pair with signedDateFrom."),
2364
+ lastModifiedFrom: FPDS_ISO_DATE.optional().describe("Last-modified range START (ISO YYYY-MM-DD); pair with lastModifiedTo (→ LAST_MOD_DATE:[from,to])."),
2365
+ lastModifiedTo: FPDS_ISO_DATE.optional().describe("Last-modified range END (ISO YYYY-MM-DD); pair with lastModifiedFrom."),
2366
+ keyword: z
2367
+ .string()
2368
+ .optional()
2369
+ .describe("Free-text keyword (bare full-text term; FPDS FIELD: operators are stripped for safety)."),
2370
+ offset: z
2371
+ .number()
2372
+ .int()
2373
+ .min(0)
2374
+ .max(600_000)
2375
+ .default(0)
2376
+ .describe("0-indexed page start (page size fixed at 10). Keyless deep-paging past ~200K is unreliable."),
2377
+ })
2378
+ .refine((a) => a.naics !== undefined ||
2379
+ a.vendorName !== undefined ||
2380
+ a.piid !== undefined ||
2381
+ a.departmentId !== undefined ||
2382
+ a.contractingAgencyName !== undefined ||
2383
+ (a.signedDateFrom !== undefined && a.signedDateTo !== undefined) ||
2384
+ (a.lastModifiedFrom !== undefined && a.lastModifiedTo !== undefined) ||
2385
+ a.keyword !== undefined, {
2386
+ 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.",
2387
+ });
2388
+ // ─── NIH RePORTER v2 (api.reporter.nih.gov — keyless POST/JSON) ─── ADR-0014
2389
+ // The R2 getJson port's FIRST non-GET consumer. A NEW capability axis: federal
2390
+ // research-GRANT funding footprint by organization / UEI / state (recipient
2391
+ // enrichment, joinable to SAM/USAspending via primary_uei). The SSRF surface is
2392
+ // a compile-time-CONSTANT URL; all filters ride in the MODULE-BUILT POST body.
2393
+ // Only LIVE-CONFIRMED-narrowing criteria are exposed (M1): orgStates / orgNames /
2394
+ // fiscalYears. agency_ic_codes is EXCLUDED (it silently no-ops upstream). The
2395
+ // 15,000-record retrieval window is enforced by offset .max(14_999) (offset ≥
2396
+ // 15,000 ⇒ invalid_input, 0 fetch) + limit .max(500) — a cap on RETRIEVAL, not
2397
+ // on the exact meta.total count.
2398
+ const NIH_CURRENT_YEAR = new Date().getUTCFullYear();
2399
+ const NihSearchProjectsInput = z.object({
2400
+ orgStates: z
2401
+ .array(z.enum(nih.NIH_ORG_STATES))
2402
+ .max(20)
2403
+ .optional()
2404
+ .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."),
2405
+ orgNames: z
2406
+ .array(z.string().min(1).max(512))
2407
+ .max(20)
2408
+ .optional()
2409
+ .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."),
2410
+ fiscalYears: z
2411
+ .array(z.number().int().min(1985).max(NIH_CURRENT_YEAR + 1))
2412
+ .max(20)
2413
+ .optional()
2414
+ .describe(`NIH fiscal years to include (int array, ${1985}..${NIH_CURRENT_YEAR + 1}, max 20). LIVE-CONFIRMED to narrow. e.g. [2023,2024].`),
2415
+ limit: z
2416
+ .number()
2417
+ .int()
2418
+ .min(1)
2419
+ .max(500)
2420
+ .default(50)
2421
+ .describe("Projects per page (upstream hard cap 500), 1..500, default 50."),
2422
+ offset: z
2423
+ .number()
2424
+ .int()
2425
+ .min(0)
2426
+ .max(14_999)
2427
+ .default(0)
2428
+ .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."),
2429
+ });
2430
+ // ─── NSF Awards API (api.nsf.gov — keyless GET) ─── ADR-0020 (source #20)
2431
+ // The grant-SIBLING of NIH RePORTER on a DIFFERENT agency: federal research-GRANT
2432
+ // award records with recipient / PI / UEI enrichment, strengthening the WEAK
2433
+ // entity/recipient layer (ueiNumber/parentUeiNumber join to SAM/USAspending). The
2434
+ // SSRF surface is a compile-time-CONSTANT host+path; all filters ride in a
2435
+ // module-built URLSearchParams from a validated whitelist. Only LIVE-CONFIRMED-
2436
+ // narrowing filters ship (M1); agency + printFields are EXCLUDED (agency = the
2437
+ // NSF-only-corpus zeros-out foot-gun; printFields = a proven no-op). The 10,000-
2438
+ // record retrieval window (offset+rpp ≤ 10,000) is enforced by offset .max(9999)
2439
+ // + a module-side outgoing-rpp clamp. Dates are STRICT mm/dd/yyyy (a wrong format
2440
+ // is silently mis-parsed by NSF); a multi-word keyword is OR-tokenized (disclosed).
2441
+ const NSF_UEI_RE = /^[A-Za-z0-9]{12}$/;
2442
+ const NSF_MMDDYYYY_RE = /^(0[1-9]|1[0-2])\/(0[1-9]|[12]\d|3[01])\/\d{4}$/;
2443
+ const NsfSearchAwardsInput = z.object({
2444
+ keyword: z
2445
+ .string()
2446
+ .min(1)
2447
+ .max(200)
2448
+ .optional()
2449
+ .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."),
2450
+ awardeeStateCode: z
2451
+ .enum(nsf.NSF_STATES)
2452
+ .optional()
2453
+ .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'."),
2454
+ awardeeName: z
2455
+ .string()
2456
+ .min(2)
2457
+ .max(200)
2458
+ .optional()
2459
+ .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)."),
2460
+ ueiNumber: z
2461
+ .string()
2462
+ .regex(NSF_UEI_RE)
2463
+ .optional()
2464
+ .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)."),
2465
+ parentUeiNumber: z
2466
+ .string()
2467
+ .regex(NSF_UEI_RE)
2468
+ .optional()
2469
+ .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'."),
2470
+ pdPIName: z
2471
+ .string()
2472
+ .min(2)
2473
+ .max(120)
2474
+ .optional()
2475
+ .describe("Principal-investigator name filter (2..120 chars). LIVE-CONFIRMED to narrow. e.g. 'Bell'."),
2476
+ dateStart: z
2477
+ .string()
2478
+ .regex(NSF_MMDDYYYY_RE)
2479
+ .optional()
2480
+ .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'."),
2481
+ dateEnd: z
2482
+ .string()
2483
+ .regex(NSF_MMDDYYYY_RE)
2484
+ .optional()
2485
+ .describe("Award ACTION-date upper bound. STRICT mm/dd/yyyy (same semantics/foot-gun as dateStart). e.g. '12/31/2024'."),
2486
+ limit: z
2487
+ .number()
2488
+ .int()
2489
+ .min(1)
2490
+ .max(100)
2491
+ .default(25)
2492
+ .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)."),
2493
+ offset: z
2494
+ .number()
2495
+ .int()
2496
+ .min(0)
2497
+ .max(9999)
2498
+ .default(0)
2499
+ .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."),
2500
+ });
2501
+ const NsfGetAwardInput = z.object({
2502
+ awardId: z
2503
+ .string()
2504
+ .regex(/^\d{5,9}$/)
2505
+ .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'."),
2506
+ });
2507
+ // ─── ClinicalTrials.gov API v2 (ADR-0021, source #21) ────────────
2508
+ const ClinicaltrialsSearchStudiesInput = z.object({
2509
+ "query.term": z
2510
+ .string()
2511
+ .min(1)
2512
+ .max(200)
2513
+ .optional()
2514
+ .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'."),
2515
+ sponsor: z
2516
+ .string()
2517
+ .min(1)
2518
+ .max(200)
2519
+ .optional()
2520
+ .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'."),
2521
+ condition: z
2522
+ .string()
2523
+ .min(1)
2524
+ .max(200)
2525
+ .optional()
2526
+ .describe("Condition / disease filter (→ query.cond). MULTI-WORD is AND-conjunctive. LIVE-CONFIRMED to narrow. e.g. 'diabetes'."),
2527
+ location: z
2528
+ .string()
2529
+ .min(1)
2530
+ .max(200)
2531
+ .optional()
2532
+ .describe("Study-location filter (→ query.locn), e.g. a country or city. LIVE-CONFIRMED to narrow. e.g. 'Germany'."),
2533
+ overallStatus: z
2534
+ .enum(clinicaltrials.CT_STATUSES)
2535
+ .optional()
2536
+ .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'."),
2537
+ funderType: z
2538
+ .enum(clinicaltrials.CT_FUNDER_TYPES)
2539
+ .optional()
2540
+ .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."),
2541
+ pageSize: z
2542
+ .number()
2543
+ .int()
2544
+ .min(1)
2545
+ .max(1000)
2546
+ .default(20)
2547
+ .describe("Studies per page, 1..1000, default 20 (ClinicalTrials.gov clamps a larger request to 1000)."),
2548
+ pageToken: z
2549
+ .string()
2550
+ .min(1)
2551
+ .max(4096)
2552
+ .regex(clinicaltrials.CT_TOKEN_RE)
2553
+ .optional()
2554
+ .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."),
2555
+ });
2556
+ const ClinicaltrialsGetStudyInput = z.object({
2557
+ nctId: z
2558
+ .string()
2559
+ .regex(clinicaltrials.CT_NCT_RE)
2560
+ .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)."),
2561
+ });
2562
+ // ADR-0024 — the facet-counts field enum is DERIVED from clinicaltrials.CT_FACET_FIELDS
2563
+ // (single source of truth; the handler re-validates each element against the frozen
2564
+ // Set INLINE — the [ssrf] re-guard). 1..11 whitelisted ENUM fields, comma-joined
2565
+ // module-built into fields=<v1,v2,…> (NO raw passthrough); a non-member ⇒ invalid_input
2566
+ // pre-fetch (0 fetch). No filter/scope/page param (they HTTP-400 here).
2567
+ const ClinicaltrialsFacetCountsInput = z.object({
2568
+ fields: z
2569
+ .array(z.enum(clinicaltrials.CT_FACET_FIELDS))
2570
+ .min(1)
2571
+ .max(clinicaltrials.CT_FACET_FIELDS.length)
2572
+ .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)."),
2573
+ });
2574
+ // ─── USITC Harmonized Tariff Schedule (hts.usitc.gov — keyless REST) ─── ADR-0039
2575
+ // The IMPORT-TARIFF / supply-chain PRICE lane: a good's HTS classification + its
2576
+ // Column-1 General / Special (preferential/FTA) / Column-2 duty-rate TEXT + the
2577
+ // Chapter-99 additional-duty provisions. Fixed host `hts.usitc.gov` + FIXED path
2578
+ // `/reststop/search` (the SSRF core — no free host/path); the single `query` rides
2579
+ // `keyword=` via URLSearchParams (percent-encoded). ★M2 — a MINIMUM query floor
2580
+ // (≥3 non-whitespace chars) is enforced at BOTH the Zod boundary (below) and a
2581
+ // handler belt, so a 1–2 char query is rejected before the fetch (a single char can
2582
+ // serve 10,000–16,000+ rows / several MB). The full array is fetched once and paged
2583
+ // CLIENT-SIDE (the endpoint serves no total and IGNORES offset).
2584
+ const HtsLookupInput = z.object({
2585
+ query: z
2586
+ .string()
2587
+ .trim()
2588
+ .min(3)
2589
+ .max(100)
2590
+ .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."),
2591
+ limit: z
2592
+ .number()
2593
+ .int()
2594
+ .min(1)
2595
+ .max(200)
2596
+ .default(50)
2597
+ .describe("Rows per page (CLIENT-SIDE slice over the served array), 1..200, default 50."),
2598
+ offset: z
2599
+ .number()
2600
+ .int()
2601
+ .min(0)
2602
+ .default(0)
2603
+ .describe("0-based row offset for CLIENT-SIDE pagination over the served array (the endpoint has no server-side pagination), default 0."),
2604
+ });
2605
+ // ─── BLS Public Data API v1/v2 (api.bls.gov — keyless POST/JSON) ─── ADR-0032
2606
+ // A NEW capability axis: the PRICING / ESCALATION layer (CPI-U & ECI drive EPA-
2607
+ // clause escalation; PPI benchmarks materials; CES gives labor-rate context). The
2608
+ // SECOND POST-batch getJson-port consumer (after NIH). SSRF surface = a compile-
2609
+ // time-CONSTANT host+path; seriesids ride in the module-built POST body. `series`
2610
+ // is a FROZEN 9-key curated enum (the SSRF value guard + the units-label source);
2611
+ // `seriesId` is the raw passthrough, charclass-validated ^[A-Z0-9]{1,20}$. Years
2612
+ // are bounded ints (1900..currentYear+1); the span is clamped to the tier cap
2613
+ // (v1 ~10y) BEFORE the fetch + disclosed. An OPTIONAL free BLS_API_KEY rides ONLY
2614
+ // in the POST body (v2, ~500/day) — never a URL/header/label/_meta/log.
2615
+ const BlsTimeseriesInput = z.object({
2616
+ series: z
2617
+ .array(z.enum(bls.BLS_SERIES_KEYS))
2618
+ .max(bls.BLS_SERIES_KEYS.length)
2619
+ .optional()
2620
+ .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."),
2621
+ seriesId: z
2622
+ .array(z.string().regex(/^[A-Z0-9]{1,20}$/))
2623
+ .max(bls.BLS_SERIES_KEYS.length + 50)
2624
+ .optional()
2625
+ .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."),
2626
+ startYear: z
2627
+ .number()
2628
+ .int()
2629
+ .min(1900)
2630
+ .max(bls.YEAR_MAX)
2631
+ .optional()
2632
+ .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).`),
2633
+ endYear: z
2634
+ .number()
2635
+ .int()
2636
+ .min(1900)
2637
+ .max(bls.YEAR_MAX)
2638
+ .optional()
2639
+ .describe(`Inclusive end year (1900..${bls.YEAR_MAX}). Default: the current year. Must be ≥ startYear.`),
2640
+ });
2641
+ // ─── BLS OEWS — occupational wage benchmarking (2nd tool on api.bls.gov) ── ADR-0033
2642
+ // The LEVEL layer next to bls_timeseries's ESCALATION layer: mean/median annual &
2643
+ // hourly wages + employment by SOC occupation × geography. OEWS series IDs are 25
2644
+ // chars — they EXCEED the bls_timeseries raw-seriesId cap (^[A-Z0-9]{1,20}$), so
2645
+ // this tool BUILDS the 25-char ID INTERNALLY from validated structured inputs
2646
+ // (area/occupation/datatype), reusing the same POST/JSON transport + honesty
2647
+ // layer. NO year input (OEWS serves only the latest annual release). The module
2648
+ // re-validates every component (belt-and-suspenders behind these schemas).
2649
+ const BlsOewsWagesInput = z.object({
2650
+ occupation: z
2651
+ .array(z.enum(bls.BLS_OEWS_OCCUPATION_KEYS))
2652
+ .max(bls.BLS_OEWS_OCCUPATION_KEYS.length)
2653
+ .optional()
2654
+ .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."),
2655
+ soc: z
2656
+ .array(z.string().regex(/^\d{6}$/))
2657
+ .max(50)
2658
+ .optional()
2659
+ .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."),
2660
+ area: z
2661
+ .array(z.string())
2662
+ .max(51)
2663
+ .optional()
2664
+ .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).'),
2665
+ datatype: z
2666
+ .array(z.enum(bls.BLS_OEWS_DATATYPE_KEYS))
2667
+ .max(bls.BLS_OEWS_DATATYPE_KEYS.length)
2668
+ .optional()
2669
+ .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).'),
2670
+ });
2671
+ // ─── BLS QCEW — county×NAICS market-size / wages / location-quotient ── ADR-0042
2672
+ // A THIRD BLS tool but a SECOND, DIFFERENT, keyless, un-rate-limited BLS DOMAIN:
2673
+ // the QCEW Open Data Access CSV files on data.bls.gov/cew (NOT the rate-limited
2674
+ // api.bls.gov/publicAPI timeseries API the two tools above share). SSRF surface =
2675
+ // a compile-time-CONSTANT host + charclass-validated path segments (year `^\d{4}$`,
2676
+ // quarter `^[1-4]$`, mode enum {area,industry}, area `^[0-9A-Za-z]{1,6}$`, industry
2677
+ // DIGIT-ONLY `^[0-9]{1,6}$` — a hyphenated NAICS 31-33 404s). Client-side filters
2678
+ // (ownership/aggregationLevel/sizeCode/narrow) NEVER touch the URL. Honesty crux:
2679
+ // the block/code/field-scoped disclosure→null (never 0). NO BLS_API_KEY on this
2680
+ // keyless path; a NEW self-throttle gate key ("bls_qcew"), NOT "bls".
2681
+ const BlsQcewInput = z.object({
2682
+ mode: z
2683
+ .enum(["area", "industry"])
2684
+ .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."),
2685
+ area: z
2686
+ .string()
2687
+ .regex(/^[0-9A-Za-z]{1,6}$/)
2688
+ .optional()
2689
+ .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)."),
2690
+ industry: z
2691
+ .string()
2692
+ .regex(/^[0-9]{1,6}$/)
2693
+ .optional()
2694
+ .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."),
2695
+ year: z
2696
+ .number()
2697
+ .int()
2698
+ .min(1990)
2699
+ .max(bls.YEAR_MAX)
2700
+ .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.`),
2701
+ quarter: z
2702
+ .enum(["1", "2", "3", "4"])
2703
+ .describe("REQUIRED — the quarter '1'|'2'|'3'|'4' (all four live-servable). The annual 'a' is not enabled this build."),
2704
+ ownership: z
2705
+ .string()
2706
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2707
+ .optional()
2708
+ .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)."),
2709
+ aggregationLevel: z
2710
+ .string()
2711
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2712
+ .optional()
2713
+ .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."),
2714
+ sizeCode: z
188
2715
  .string()
189
- .describe("Federal Register document number, e.g. '2026-08333'"),
2716
+ .regex(/^[0-9A-Za-z]{1,3}$/)
2717
+ .optional()
2718
+ .describe("Optional CLIENT-SIDE filter on size_code."),
2719
+ limit: z
2720
+ .number()
2721
+ .int()
2722
+ .min(1)
2723
+ .max(1000)
2724
+ .default(50)
2725
+ .describe("Rows per page (CLIENT-SIDE window over the fetched-once slice), 1..1000, default 50."),
2726
+ offset: z
2727
+ .number()
2728
+ .int()
2729
+ .min(0)
2730
+ .default(0)
2731
+ .describe("0-based row offset for CLIENT-SIDE pagination over the filtered set (QCEW has no server-side pagination), default 0."),
190
2732
  });
191
- const FedRegListAgenciesInput = z.object({
192
- perPage: z.number().min(1).max(500).optional(),
2733
+ // ─── US Census Geocoder (keyless source #22) — input schemas ──────
2734
+ // ADR-0023. KEYLESS, single fixed host (geocoding.geo.census.gov) + two fixed
2735
+ // endpoint paths (the SSRF core — no free host/path; NO id in the path). benchmark /
2736
+ // vintage are frozen enums (the Zod source of truth = census.CENSUS_BENCHMARKS /
2737
+ // census.CENSUS_VINTAGES). [M2] CENSUS_VINTAGES is the UNION of live-valid vintages
2738
+ // across the four benchmarks (25 distinct) so a VALID non-default pair is not Zod-
2739
+ // rejected; an INVALID (benchmark,vintage) pair fails-closed at HTTP 400. GEOIDs are
2740
+ // strings (leading zeros survive). Coordinate finiteness is re-guarded in the handler.
2741
+ const CensusBenchmarkEnum = z
2742
+ .enum(census.CENSUS_BENCHMARKS)
2743
+ .optional()
2744
+ .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.");
2745
+ const CensusVintageEnum = z
2746
+ .enum(census.CENSUS_VINTAGES)
2747
+ .optional()
2748
+ .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.");
2749
+ const CensusGeocodeAddressInput = z.object({
2750
+ address: z
2751
+ .string()
2752
+ .min(1)
2753
+ .max(500)
2754
+ .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."),
2755
+ benchmark: CensusBenchmarkEnum,
2756
+ vintage: CensusVintageEnum,
193
2757
  });
194
- // eCFR
195
- const EcfrSearchInput = z.object({
196
- query: z.string(),
197
- titleNumber: z
2758
+ const CensusGeographiesByCoordinatesInput = z
2759
+ .object({
2760
+ longitude: z
198
2761
  .number()
2762
+ .min(-180)
2763
+ .max(180)
199
2764
  .optional()
200
- .describe("CFR title (1-50). e.g. 48 = FAR (Federal Acquisition Regulation), 2 = Federal financial assistance."),
201
- perPage: z.number().min(1).max(20).optional(),
2765
+ .describe("Longitude (x), a finite number in [-180, 180]. Alias of `x`. e.g. -86.301883."),
2766
+ latitude: z
2767
+ .number()
2768
+ .min(-90)
2769
+ .max(90)
2770
+ .optional()
2771
+ .describe("Latitude (y), a finite number in [-90, 90]. Alias of `y`. e.g. 32.377612."),
2772
+ x: z
2773
+ .number()
2774
+ .min(-180)
2775
+ .max(180)
2776
+ .optional()
2777
+ .describe("Longitude — the Census API's own name for longitude (alias of `longitude`)."),
2778
+ y: z
2779
+ .number()
2780
+ .min(-90)
2781
+ .max(90)
2782
+ .optional()
2783
+ .describe("Latitude — the Census API's own name for latitude (alias of `latitude`)."),
2784
+ benchmark: CensusBenchmarkEnum,
2785
+ vintage: CensusVintageEnum,
2786
+ })
2787
+ .refine((v) => v.longitude !== undefined || v.x !== undefined, {
2788
+ message: "longitude (or its alias x) is required.",
2789
+ path: ["longitude"],
2790
+ })
2791
+ .refine((v) => v.latitude !== undefined || v.y !== undefined, {
2792
+ message: "latitude (or its alias y) is required.",
2793
+ path: ["latitude"],
202
2794
  });
203
- const EcfrListTitlesInput = z.object({});
204
- // Grants.gov
205
- const GrantsSearchInput = z.object({
206
- keyword: z.string().optional(),
207
- cfda: z.string().optional().describe("CFDA program number, e.g. '10.500'"),
208
- agency: z
2795
+ // ─── US Census County Business Patterns (CBP) — the FIRST key-required source ──
2796
+ const CensusBusinessPatternsInput = z.object({
2797
+ naics: z
209
2798
  .string()
2799
+ .regex(/^\d{2,6}$/)
210
2800
  .optional()
211
- .describe("Grants.gov agency code, e.g. 'DHS-FEMA'"),
212
- oppNum: z.string().optional().describe("Specific opportunity number"),
213
- oppStatuses: z
214
- .array(z.enum(["forecasted", "posted", "closed", "archived"]))
2801
+ .describe("A NAICS-2017 code (2–6 digits), e.g. '5415' (Computer Systems Design & Related Services) or '54' (Professional/Scientific/Technical). Omit to aggregate across all sectors. Validated ^\\d{2,6}$."),
2802
+ geography: z
2803
+ .enum(["us", "state", "county"])
215
2804
  .optional()
216
- .describe("Defaults to forecasted+posted"),
217
- rows: z.number().min(1).max(50).optional(),
2805
+ .describe("The geography level (default 'us'). 'state' returns one row per state (or a single state when `state` is given); 'county' returns every county in a state and REQUIRES `state`."),
2806
+ state: z
2807
+ .string()
2808
+ .regex(/^\d{2}$/)
2809
+ .optional()
2810
+ .describe("A 2-digit state FIPS code, e.g. '06' (California), '48' (Texas). Optional filter for geography='state'; REQUIRED for geography='county' (the CBP `in=state:` predicate). Validated ^\\d{2}$."),
2811
+ year: z
2812
+ .string()
2813
+ .regex(/^\d{4}$/)
2814
+ .optional()
2815
+ .describe("The CBP data year (default '2022', the latest confirmed vintage). Validated ^\\d{4}$ (it rides in the request path)."),
2816
+ limit: z
2817
+ .number()
2818
+ .int()
2819
+ .min(0)
2820
+ .optional()
2821
+ .describe("OPTIONAL client-side top-N cap on the returned rows. CBP has NO server-side pagination, so this slices AFTER the full set is fetched and DISCLOSES the omission (totalAvailable stays the full count). Omit to return every matching row."),
218
2822
  });
219
- const GrantsGetInput = z.object({
220
- opportunityId: z.string().describe("Grants.gov opportunity id (numeric string)"),
2823
+ // ─── FRED (Federal Reserve Economic Data) — the SECOND key-required source ──
2824
+ // ADR-0048. Macro context (GDP/CPI/rates/unemployment/PPI). REQUIRES a free
2825
+ // FRED_API_KEY; without it both tools throw an honest config error (the other 112
2826
+ // tools stay keyless). The key rides &api_key= ONLY. Missing observations ('.') → null.
2827
+ const FredSearchSeriesInput = z.object({
2828
+ query: z
2829
+ .string()
2830
+ .min(1)
2831
+ .describe("The FRED search_text — free-text terms to discover economic series, e.g. 'unemployment rate', 'CPI', 'GDP', '10-year treasury'. Required."),
2832
+ limit: z
2833
+ .number()
2834
+ .int()
2835
+ .min(1)
2836
+ .max(1000)
2837
+ .optional()
2838
+ .describe("Max series to return (default 25, max 1000). Offset-paginated."),
2839
+ offset: z
2840
+ .number()
2841
+ .int()
2842
+ .min(0)
2843
+ .optional()
2844
+ .describe("Row offset for pagination (default 0). Page with _meta.pagination.nextOffset."),
221
2845
  });
222
- // SAM.gov organization lookup (federal hierarchy)
223
- const SamLookupOrgInput = z.object({
224
- organizationId: z.string().describe("SAM.gov federal-organization id (numeric)"),
2846
+ const FredSeriesObservationsInput = z.object({
2847
+ seriesId: z
2848
+ .string()
2849
+ .regex(/^[A-Za-z0-9._-]+$/)
2850
+ .describe("A FRED series id, e.g. 'GDP', 'CPIAUCSL' (CPI), 'UNRATE' (unemployment), 'DGS10' (10-yr Treasury), 'PPIACO' (PPI). Discover ids with fred_search_series. Validated ^[A-Za-z0-9._-]+$. Required."),
2851
+ startDate: z
2852
+ .string()
2853
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2854
+ .optional()
2855
+ .describe("Earliest observation date (YYYY-MM-DD). Maps to FRED observation_start."),
2856
+ endDate: z
2857
+ .string()
2858
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
2859
+ .optional()
2860
+ .describe("Latest observation date (YYYY-MM-DD). Maps to FRED observation_end."),
2861
+ limit: z
2862
+ .number()
2863
+ .int()
2864
+ .min(1)
2865
+ .max(100000)
2866
+ .optional()
2867
+ .describe("Max observations to return (default 100, max 100000). Offset-paginated."),
2868
+ offset: z
2869
+ .number()
2870
+ .int()
2871
+ .min(0)
2872
+ .optional()
2873
+ .describe("Row offset for pagination (default 0). Page with _meta.pagination.nextOffset."),
2874
+ sortOrder: z
2875
+ .enum(["asc", "desc"])
2876
+ .optional()
2877
+ .describe("Observation date order: 'asc' (oldest first, FRED default) or 'desc' (newest first)."),
225
2878
  });
226
- const TOOLS = [
227
- // ━━━ SAM.gov (5) ━━━
228
- {
2879
+ // api_key_status takes no input — it is a pure status query over process.env.
2880
+ const ApiKeyStatusInput = z.object({});
2881
+ // Build a ToolDef whose `handler` is type-checked against the schema's inferred
2882
+ // input `I` at the call site (e.g. `input.searchText` is known-present). The
2883
+ // `I` binding is erased to `any` in the ToolDef[] array, so entries without a
2884
+ // handler need not use this helper.
2885
+ function defineTool(d) {
2886
+ return d;
2887
+ }
2888
+ // Exported for the offline registry-introspection fault fixtures (W3-1): the
2889
+ // harness asserts a tool's advertised inputSchema/description directly (e.g.
2890
+ // usas_search_expiring_contracts no longer carries `fiscalYear`; usas_search_awards
2891
+ // no longer promises "+ count"). Export-only — does NOT change tools/list output
2892
+ // or any dispatch behavior (main() stays entry-point-gated).
2893
+ export const TOOLS = [
2894
+ // ━━━ SAM.gov (8) ━━━
2895
+ defineTool({
229
2896
  name: "sam_search_opportunities",
230
2897
  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.",
231
2898
  inputSchema: SamSearchInput,
232
- },
233
- {
2899
+ handler: async (input, { sam }) => {
2900
+ const r = await sam.searchOpportunities({
2901
+ ...input,
2902
+ setAside: input.setAside,
2903
+ });
2904
+ // OUTAGE HONESTY (C19). r.degraded is set ONLY when EVERY access tier
2905
+ // threw (HAL down / network / 5xx-after-retry) — a total outage, NOT a
2906
+ // confirmed zero. searchOpportunities otherwise returns the real result
2907
+ // (incl. a genuine 0). Emit an explicitly-incomplete `_meta`: we do NOT
2908
+ // know the count (totalAvailable:null, NEVER 0), the source is flagged
2909
+ // degraded, the data count is null (not a fake 0 that reads as a real
2910
+ // count), and a note tells the AI to retry rather than conclude "no
2911
+ // matching notices". The genuine-zero + healthy paths below are UNCHANGED.
2912
+ if (r.degraded) {
2913
+ return withMeta({
2914
+ totalRecords: null,
2915
+ returned: 0,
2916
+ opportunities: [],
2917
+ }, {
2918
+ source: "sam.gov/sgs/v1 (keyless HAL) (DEGRADED — search backend unavailable)",
2919
+ keylessMode: sam.isKeyless,
2920
+ complete: false,
2921
+ totalAvailable: null,
2922
+ returned: 0,
2923
+ filtersApplied: [],
2924
+ filtersDropped: [],
2925
+ fieldsUnavailable: [],
2926
+ notes: [
2927
+ r.degraded.reason +
2928
+ " This is a service outage, not a confirmed zero — retry.",
2929
+ ],
2930
+ });
2931
+ }
2932
+ const data = {
2933
+ totalRecords: r.totalRecords,
2934
+ returned: r.opportunitiesData.length,
2935
+ opportunities: r.opportunitiesData.map((o) => ({
2936
+ noticeId: o.noticeId,
2937
+ title: o.title,
2938
+ agency: o.fullParentPathName,
2939
+ solicitationNumber: o.solicitationNumber,
2940
+ responseDeadline: o.responseDeadLine,
2941
+ naics: o.naicsCode,
2942
+ setAside: o.typeOfSetAside,
2943
+ uiLink: o.uiLink,
2944
+ })),
2945
+ };
2946
+ // A1 — filter honesty. Contrary to the earlier assumption, the keyless
2947
+ // HAL list endpoint DOES honor the structured facets server-side —
2948
+ // VERIFIED LIVE (2026-07): `naics`, `set_aside`, `pop_state` and `q` each
2949
+ // narrow the result set AND every returned notice's detail matches the
2950
+ // filter (the earlier "ignores facets" reading tested the WRONG param
2951
+ // name `ncode`, which is silently dropped; the real param is `naics`).
2952
+ // The one facet with no keyless param is organization-name (ignored).
2953
+ // Separately, the list PAYLOAD still omits each notice's
2954
+ // naics/set-aside/place-of-performance VALUES (null even when the filter
2955
+ // applied), so filtering is real but reading those field values needs
2956
+ // sam_get_opportunity. See spec §1.2 A1, §2.4.
2957
+ if (sam.isKeyless) {
2958
+ // NOTE: these truthiness checks must mirror EXACTLY the conditions under
2959
+ // which client.searchPublic() actually appends each param (it uses
2960
+ // `if (filters.x)` / `filters.setAside?.length`). Using `!== undefined`
2961
+ // here would over-report: an empty-string facet is not sent by the
2962
+ // client, so it must not appear in filtersApplied.
2963
+ const filtersApplied = [];
2964
+ if (input.query)
2965
+ filtersApplied.push("query");
2966
+ if (input.ncode)
2967
+ filtersApplied.push("ncode");
2968
+ if ((input.setAside?.length ?? 0) > 0)
2969
+ filtersApplied.push("setAside");
2970
+ if (input.state)
2971
+ filtersApplied.push("state");
2972
+ // organization-name is the only requested facet the keyless endpoint
2973
+ // cannot honor — flag it dropped so results aren't read as org-filtered.
2974
+ const filtersDropped = input.organizationName
2975
+ ? ["organizationName"]
2976
+ : [];
2977
+ // ── GSA-CSV inline enrichment (opt-in, non-blocking) ──────────────
2978
+ // The keyless HAL list payload nulls each row's naics/setAside/PoP/
2979
+ // deadline/type. When the GSA-CSV backbone is ENABLED and its index is
2980
+ // already warm, fill those nulls from today's snapshot in ONE lookup —
2981
+ // instead of N sam_get_opportunity detail calls. HARD guarantees:
2982
+ // - DISABLED (default) → resolveCsvConfig().enabled is false, so we
2983
+ // never enter this branch: `data`, fieldsUnavailable and notes are
2984
+ // byte-for-byte the pre-enrichment behavior, and ZERO network hits
2985
+ // the CSV.
2986
+ // - NON-BLOCKING → tryGetReadyIndex returns the already-loaded index
2987
+ // or null immediately (kicking a background warm); a cold CSV NEVER
2988
+ // stalls the search on a 225 MB download.
2989
+ // - A CSV error can't fail the search: tryGetReadyIndex swallows and
2990
+ // returns null → we degrade to the un-enriched page + a note.
2991
+ const csvCfg = gsaCsv.resolveCsvConfig();
2992
+ // Widen the opportunities element type so the enriched rows (which may
2993
+ // carry the added type/placeOfPerformance keys) are assignable; the
2994
+ // original `data` (narrower) widens into this cleanly.
2995
+ let enrichedData = data;
2996
+ // The fields still null after enrichment (rebuilt truthfully below).
2997
+ let fieldsUnavailable = ["naics", "setAside", "placeOfPerformance"];
2998
+ const enrichmentNotes = [];
2999
+ let source = "sam.gov/sgs/v1 (keyless HAL)";
3000
+ let freshness = undefined;
3001
+ if (csvCfg.enabled) {
3002
+ const ready = data.returned > 0 ? gsaCsv.tryGetReadyIndex(csvCfg) : null;
3003
+ if (ready) {
3004
+ const outcome = gsaCsv.enrichSearchOpportunities(enrichedData.opportunities, ready);
3005
+ enrichedData = { ...data, opportunities: outcome.opportunities };
3006
+ freshness = outcome.freshness;
3007
+ source = "sam.gov/sgs/v1 (keyless HAL) + gsa-csv (daily bulk CSV snapshot)";
3008
+ // Rebuild fieldsUnavailable: a field is only "unavailable" if it was
3009
+ // NOT filled on the whole page. Fields filled from the CSV drop off.
3010
+ // (naics/setAside/placeOfPerformance are the originally-null trio.)
3011
+ fieldsUnavailable = ["naics", "setAside", "placeOfPerformance"].filter((f) => !outcome.fieldsFilled.has(f));
3012
+ const filledList = [...outcome.fieldsFilled];
3013
+ if (filledList.length > 0) {
3014
+ 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.`);
3015
+ }
3016
+ else {
3017
+ 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.");
3018
+ }
3019
+ if (outcome.missingCount > 0) {
3020
+ 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.`);
3021
+ }
3022
+ 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.`);
3023
+ }
3024
+ else if (data.returned > 0) {
3025
+ // Enabled, rows exist that COULD be enriched, but the index isn't
3026
+ // warm yet (cold cache / background refresh in flight). Return the
3027
+ // normal HAL page un-enriched and disclose the pending warm — never
3028
+ // block on the download. Gated on returned>0: on a genuinely-empty
3029
+ // (returned===0) page there are NO rows to enrich, so a "retry for an
3030
+ // enriched page" note would be misleading — a retry cannot add rows.
3031
+ // That case falls through with the plain un-enriched source/notes
3032
+ // (the empty page is a complete, honest result).
3033
+ source = "sam.gov/sgs/v1 (keyless HAL) + gsa-csv (index warming)";
3034
+ 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.");
3035
+ }
3036
+ }
3037
+ const notes = [];
3038
+ if (filtersApplied.length > 0) {
3039
+ 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.");
3040
+ }
3041
+ else {
3042
+ 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.");
3043
+ }
3044
+ if (filtersDropped.length > 0) {
3045
+ 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.");
3046
+ }
3047
+ notes.push(...enrichmentNotes);
3048
+ // freshness is surfaced structurally in `data` (the ResponseMeta type
3049
+ // has no typed freshness field, mirroring sam_lookup_notice_fields) —
3050
+ // present only when enrichment actually ran.
3051
+ const dataOut = freshness !== undefined
3052
+ ? { ...enrichedData, freshness }
3053
+ : enrichedData;
3054
+ return withMeta(dataOut, {
3055
+ source,
3056
+ keylessMode: true,
3057
+ truncated: r.totalRecords > data.returned,
3058
+ returned: data.returned,
3059
+ totalAvailable: r.totalRecords,
3060
+ filtersApplied,
3061
+ filtersDropped,
3062
+ fieldsUnavailable,
3063
+ notes,
3064
+ });
3065
+ }
3066
+ // Keyed path: api.sam.gov honors the structured filters and populates
3067
+ // the fields, so nothing is dropped or unavailable.
3068
+ return withMeta(data, {
3069
+ source: "api.sam.gov/opportunities/v2 (keyed)",
3070
+ keylessMode: false,
3071
+ truncated: r.totalRecords > data.returned,
3072
+ returned: data.returned,
3073
+ totalAvailable: r.totalRecords,
3074
+ filtersApplied: [],
3075
+ filtersDropped: [],
3076
+ fieldsUnavailable: [],
3077
+ });
3078
+ },
3079
+ }),
3080
+ defineTool({
3081
+ name: "sam_search_shaping",
3082
+ 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.",
3083
+ inputSchema: SamSearchShapingInput,
3084
+ handler: async (input, { sam }) => {
3085
+ // Default shaping window = Sources Sought + Presolicitation + Special
3086
+ // Notice. These are the notice types that exist BEFORE an RFP — the
3087
+ // whole point of the radar.
3088
+ const noticeType = input.noticeType ?? ["r", "p", "s"];
3089
+ const wantWindow = input.responseDeadlineFrom !== undefined ||
3090
+ input.responseDeadlineTo !== undefined;
3091
+ // Map noticeType → filters.ptype so the client sends the keyless
3092
+ // `notice_type` facet (server-side filter, VERIFIED LIVE). We deliberately
3093
+ // do NOT pass responseDeadlineFrom/To to the client — the keyless feed
3094
+ // IGNORES rdlfrom/rdlto, so the window is applied client-side below and
3095
+ // disclosed. activeOnly is honored by searchPublic's is_active=true.
3096
+ const r = await sam.searchOpportunities({
3097
+ query: input.query,
3098
+ ncode: input.ncode,
3099
+ organizationName: input.organizationName,
3100
+ state: input.state,
3101
+ setAside: input.setAside,
3102
+ ptype: noticeType,
3103
+ limit: input.limit ?? 25,
3104
+ });
3105
+ // OUTAGE HONESTY (C19). r.degraded ⇒ the keyless feed was totally down
3106
+ // (all tiers threw), NOT a genuine "no shaping notices". Emit an
3107
+ // explicitly-incomplete `_meta` (complete:false, totalAvailable:null,
3108
+ // degraded source, retry note) and a null data count instead of the
3109
+ // silent "0 pre-solicitation notices, complete" lie. noticeTypesRequested
3110
+ // is still echoed so the caller knows what was attempted. The genuine-zero
3111
+ // path + the client-side response-deadline window disclosure below are
3112
+ // UNCHANGED.
3113
+ if (r.degraded) {
3114
+ return withMeta({
3115
+ totalRecords: null,
3116
+ returned: 0,
3117
+ noticeTypesRequested: noticeType,
3118
+ notices: [],
3119
+ }, {
3120
+ source: "sam.gov/api/prod/sgs/v1/search (keyless HAL, notice_type filter) (DEGRADED — search backend unavailable)",
3121
+ keylessMode: true,
3122
+ complete: false,
3123
+ totalAvailable: null,
3124
+ returned: 0,
3125
+ filtersApplied: [],
3126
+ filtersDropped: [],
3127
+ fieldsUnavailable: [],
3128
+ notes: [
3129
+ r.degraded.reason +
3130
+ " This is a service outage, not a confirmed zero — retry.",
3131
+ ],
3132
+ });
3133
+ }
3134
+ // Shape each keyless list row. naics/setAside/PoP are NULL in the keyless
3135
+ // list payload (fieldsUnavailable) — NOT fabricated. noticeTypeCode
3136
+ // (type.code) lets the AI rank r/p over s; daysUntilResponse is a whole-day
3137
+ // count (null when no deadline — counted, not hidden).
3138
+ const now = new Date();
3139
+ const allNotices = r.opportunitiesData.map((o) => ({
3140
+ noticeId: o.noticeId,
3141
+ title: o.title,
3142
+ noticeType: o.type ?? null, // type.value (human label)
3143
+ noticeTypeCode: o.baseType ?? null, // type.code (r/p/s/…)
3144
+ agency: o.fullParentPathName,
3145
+ solicitationNumber: o.solicitationNumber,
3146
+ postedDate: o.postedDate,
3147
+ responseDeadline: o.responseDeadLine ?? null,
3148
+ daysUntilResponse: daysUntilResponse(o.responseDeadLine, now),
3149
+ naics: o.naicsCode, // null in keyless list rows
3150
+ setAside: o.typeOfSetAside, // null in keyless list rows
3151
+ uiLink: o.uiLink,
3152
+ }));
3153
+ // Response-deadline WINDOW — CLIENT-SIDE over the fetched page (the feed
3154
+ // ignores rdlfrom/rdlto). A notice with no deadline is excluded from a
3155
+ // windowed query. Disclosed via filtersDropped + a note below.
3156
+ const notices = wantWindow
3157
+ ? applyResponseDeadlineWindow(allNotices, input.responseDeadlineFrom, input.responseDeadlineTo)
3158
+ : allNotices;
3159
+ const data = {
3160
+ totalRecords: r.totalRecords, // TRUE server-side count for type+facets
3161
+ returned: notices.length,
3162
+ noticeTypesRequested: noticeType,
3163
+ notices,
3164
+ };
3165
+ // _meta honesty. filtersApplied lists what the FEED honored server-side
3166
+ // (mirror EXACTLY searchPublic's append conditions). Always: noticeType.
3167
+ const filtersApplied = ["noticeType"];
3168
+ if (input.query)
3169
+ filtersApplied.push("query");
3170
+ if (input.ncode)
3171
+ filtersApplied.push("ncode");
3172
+ if ((input.setAside?.length ?? 0) > 0)
3173
+ filtersApplied.push("setAside");
3174
+ if (input.state)
3175
+ filtersApplied.push("state");
3176
+ // filtersDropped: organization-name has NO keyless param (ignored), and a
3177
+ // requested response-deadline window is applied client-side (feed ignores
3178
+ // rdlfrom/rdlto) — both must be disclosed so the AI never treats the page
3179
+ // as server-filtered on them.
3180
+ const filtersDropped = [];
3181
+ if (input.organizationName)
3182
+ filtersDropped.push("organizationName");
3183
+ if (wantWindow)
3184
+ filtersDropped.push("responseDeadline");
3185
+ const notes = [
3186
+ "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.",
3187
+ ];
3188
+ if (wantWindow) {
3189
+ 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.");
3190
+ }
3191
+ if (input.organizationName) {
3192
+ 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.");
3193
+ }
3194
+ 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.");
3195
+ // truncated when the server has more than we returned OR a client-side
3196
+ // deadline window trimmed the page (either way the caller isn't seeing the
3197
+ // complete in-scope set).
3198
+ const truncated = r.totalRecords > data.returned ||
3199
+ (wantWindow && allNotices.length !== notices.length);
3200
+ return withMeta(data, {
3201
+ source: "sam.gov/api/prod/sgs/v1/search (keyless HAL, notice_type filter)",
3202
+ keylessMode: true,
3203
+ truncated,
3204
+ returned: data.returned,
3205
+ totalAvailable: r.totalRecords,
3206
+ filtersApplied,
3207
+ filtersDropped,
3208
+ fieldsUnavailable: ["naics", "setAside", "placeOfPerformance"],
3209
+ notes,
3210
+ });
3211
+ },
3212
+ }),
3213
+ defineTool({
234
3214
  name: "sam_get_opportunity",
235
3215
  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.",
236
3216
  inputSchema: SamGetOpportunityInput,
237
- },
238
- {
3217
+ handler: async (input, { sam }) => {
3218
+ const { noticeId } = input;
3219
+ const o = await sam.getOpportunity(noticeId);
3220
+ if (!o)
3221
+ return { found: false, noticeId };
3222
+ const data = {
3223
+ found: true,
3224
+ noticeId: o.noticeId,
3225
+ title: o.title,
3226
+ agency: o.fullParentPathName,
3227
+ solicitationNumber: o.solicitationNumber,
3228
+ responseDeadline: o.responseDeadLine,
3229
+ type: o.type,
3230
+ naics: o.naicsCode,
3231
+ setAside: o.typeOfSetAside,
3232
+ placeOfPerformance: o.placeOfPerformance,
3233
+ pointsOfContact: o.pointOfContact ?? [],
3234
+ description: o.description,
3235
+ attachments: (o.resourceLinks ?? []).map((url, idx) => ({
3236
+ index: idx,
3237
+ url,
3238
+ })),
3239
+ uiLink: o.uiLink,
3240
+ };
3241
+ // A HEALTHY notice returns EXACTLY as before (plain object → the server
3242
+ // synthesizes a default complete:true `_meta`) — no crying wolf. ONLY
3243
+ // when an enrichment sub-fetch DEGRADED (an outage, not a genuine empty)
3244
+ // do we attach a degraded `_meta` disclosing that the empty field is
3245
+ // UNKNOWN, not confirmed-absent. One failing sub-fetch does not flag the
3246
+ // other: notes carry exactly one entry per degraded bucket.
3247
+ if (o.enrichmentDegraded?.length) {
3248
+ const notes = o.enrichmentDegraded.map((bucket) => bucket === "attachments"
3249
+ ? "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."
3250
+ : "The awarding-organization path could not be resolved (a service issue) — it is unavailable here, not absent.");
3251
+ return withMeta(data, {
3252
+ source: sam.isKeyless ? "sam.gov (keyless)" : "api.sam.gov (keyed)",
3253
+ keylessMode: sam.isKeyless,
3254
+ complete: false,
3255
+ degraded: {
3256
+ attempted: o.enrichmentDegraded.length,
3257
+ succeeded: 0,
3258
+ failed: o.enrichmentDegraded.length,
3259
+ },
3260
+ notes,
3261
+ });
3262
+ }
3263
+ return data;
3264
+ },
3265
+ }),
3266
+ defineTool({
239
3267
  name: "sam_fetch_description",
240
3268
  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.",
241
3269
  inputSchema: SamFetchDescriptionInput,
242
- },
243
- {
3270
+ handler: async (input, { sam }) => {
3271
+ const { noticeId } = input;
3272
+ const o = await sam.getOpportunity(noticeId);
3273
+ if (!o)
3274
+ return { found: false, noticeId };
3275
+ const text = o.description
3276
+ ? await sam.fetchOpportunityDescription(o.description)
3277
+ : "";
3278
+ return {
3279
+ found: true,
3280
+ noticeId,
3281
+ descriptionLength: text.length,
3282
+ description: text || "(no description body available)",
3283
+ };
3284
+ },
3285
+ }),
3286
+ defineTool({
244
3287
  name: "sam_attachment_url",
245
3288
  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.",
246
3289
  inputSchema: SamAttachmentUrlInput,
247
- },
248
- {
3290
+ handler: async (input, { sam }) => ({
3291
+ downloadUrl: sam.publicDownloadUrl(input.resourceId),
3292
+ }),
3293
+ }),
3294
+ defineTool({
3295
+ name: "sam_fetch_attachment_text",
3296
+ 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.",
3297
+ inputSchema: SamFetchAttachmentTextInput,
3298
+ handler: (input) => fetchAttachmentText(input),
3299
+ }),
3300
+ defineTool({
249
3301
  name: "sam_lookup_organization",
250
3302
  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.",
251
3303
  inputSchema: SamLookupOrgInput,
252
- },
253
- // ━━━ USAspending Awards & Recipients (8) ━━━
254
- {
3304
+ handler: async (input, { sam }) => {
3305
+ const { organizationId } = input;
3306
+ // SamGovClient internal method — exposed via direct fetch since
3307
+ // it's not on the public surface. Use the public sam.gov endpoint
3308
+ // directly (already keyless).
3309
+ const orgUrl = `https://sam.gov/api/prod/federalorganizations/v1/organizations/${encodeURIComponent(organizationId)}`;
3310
+ let r;
3311
+ try {
3312
+ r = await fetch(orgUrl, {
3313
+ headers: { Accept: "application/hal+json" },
3314
+ signal: AbortSignal.timeout(10_000),
3315
+ });
3316
+ }
3317
+ catch (e) {
3318
+ // A network-level fault (DNS, connection reset, timeout) is an OUTAGE, not
3319
+ // absence — classify as retryable rather than letting it surface as the
3320
+ // generic `unknown` (which an agent won't retry).
3321
+ if (e instanceof ToolErrorCarrier)
3322
+ throw e;
3323
+ throw new ToolErrorCarrier({
3324
+ kind: "upstream_unavailable",
3325
+ message: `SAM federalorganizations lookup for '${organizationId}' failed: ${e.message}. This is an outage, not a missing organization. Retry.`,
3326
+ retryable: true,
3327
+ retryAfterSeconds: 30,
3328
+ upstreamEndpoint: "sam:federalorganizations",
3329
+ });
3330
+ }
3331
+ if (!r.ok) {
3332
+ // 404 = the organization genuinely does not exist → a real negative (the
3333
+ // tool's found:false contract), NOT an error.
3334
+ if (r.status === 404) {
3335
+ return { found: false, organizationId, status: 404 };
3336
+ }
3337
+ // Every OTHER non-2xx is an upstream fault, not absence — classify via the
3338
+ // shared errorFromResponse matrix (400/403→invalid_input non-retryable,
3339
+ // 429→rate_limited with Retry-After, 5xx→upstream_unavailable retryable;
3340
+ // carries upstreamStatus) so a down/blocking service is NEVER read as "org
3341
+ // not found" (the fetch-failure-as-absent masquerade).
3342
+ throw new ToolErrorCarrier(errorFromResponse(r, "sam:federalorganizations"));
3343
+ }
3344
+ // This endpoint signals a NONEXISTENT org id with a 200 + EMPTY body
3345
+ // (live-verified 2026-07-06) — a genuine absence. Read text first so an
3346
+ // empty/degraded body never crashes `r.json()` into a mislabeled `unknown`.
3347
+ const orgText = await r.text();
3348
+ if (!orgText.trim()) {
3349
+ return { found: false, organizationId, status: 200 };
3350
+ }
3351
+ let orgJson;
3352
+ try {
3353
+ orgJson = JSON.parse(orgText);
3354
+ }
3355
+ catch {
3356
+ // A non-empty, non-JSON 200 (e.g. an HTML error/interstitial page from the
3357
+ // CDN/WAF) is a DEGRADED response — do NOT fabricate found:false on garbage;
3358
+ // surface it as schema_drift so the caller knows it's unconfirmed, not absent.
3359
+ throw new ToolErrorCarrier({
3360
+ kind: "schema_drift",
3361
+ message: `SAM federalorganizations returned a 200 with a non-JSON body for '${organizationId}' — unexpected shape; cannot confirm whether the organization exists.`,
3362
+ retryable: false,
3363
+ upstreamEndpoint: "sam:federalorganizations",
3364
+ });
3365
+ }
3366
+ const org = orgJson._embedded?.[0]?.org;
3367
+ return {
3368
+ found: !!org,
3369
+ organizationId,
3370
+ fullParentPathName: org?.fullParentPathName ?? "",
3371
+ agencyName: org?.agencyName ?? "",
3372
+ name: org?.name ?? "",
3373
+ type: org?.type,
3374
+ level: org?.level,
3375
+ };
3376
+ },
3377
+ }),
3378
+ defineTool({
3379
+ name: "sam_lookup_notice_fields",
3380
+ 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.",
3381
+ inputSchema: SamLookupNoticeFieldsInput,
3382
+ handler: (input) => gsaCsv.lookupNoticeFields(input),
3383
+ }),
3384
+ // ━━━ USAspending — Awards & Recipients (10) ━━━
3385
+ defineTool({
255
3386
  name: "usas_search_awards",
256
- 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?').",
3387
+ 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?').",
257
3388
  inputSchema: UsasFiltersBase,
258
- },
259
- {
3389
+ handler: (input) => usas.searchAwards(input),
3390
+ }),
3391
+ defineTool({
260
3392
  name: "usas_search_individual_awards",
261
3393
  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.",
262
3394
  inputSchema: UsasIndividualAwardsInput,
263
- },
264
- {
3395
+ handler: (input) => usas.searchIndividualAwards(input),
3396
+ }),
3397
+ defineTool({
265
3398
  name: "usas_search_subagency_spending",
266
3399
  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).",
267
3400
  inputSchema: UsasSubAgencyInput,
268
- },
269
- {
3401
+ handler: (input) => usas.searchSubAgencySpending(input),
3402
+ }),
3403
+ defineTool({
270
3404
  name: "usas_lookup_agency",
271
3405
  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.",
272
3406
  inputSchema: UsasLookupAgencyInput,
273
- },
274
- {
3407
+ handler: (input) => usas.lookupAgency(input.searchText),
3408
+ }),
3409
+ defineTool({
275
3410
  name: "usas_search_awards_by_recipient",
276
3411
  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.",
277
3412
  inputSchema: UsasRecipientAwardsInput,
278
- },
279
- {
3413
+ handler: (input) => usas.searchAwardsByRecipient(input),
3414
+ }),
3415
+ defineTool({
280
3416
  name: "usas_search_subawards",
281
3417
  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.",
282
3418
  inputSchema: UsasSubawardsInput,
283
- },
284
- {
3419
+ handler: (input) => usas.searchSubawards(input),
3420
+ }),
3421
+ defineTool({
3422
+ name: "usas_search_recompetes",
3423
+ 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.",
3424
+ inputSchema: UsasRecompetesInput,
3425
+ handler: (input) => usas.searchRecompetes(input),
3426
+ }),
3427
+ defineTool({
285
3428
  name: "usas_search_expiring_contracts",
286
- 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'.",
3429
+ 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.",
287
3430
  inputSchema: UsasExpiringInput,
288
- },
289
- {
3431
+ handler: (input) => usas.searchExpiringContracts(input),
3432
+ }),
3433
+ defineTool({
290
3434
  name: "usas_get_award_detail",
291
3435
  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.",
292
3436
  inputSchema: UsasAwardDetailInput,
293
- },
3437
+ handler: (input) => usas.getAwardDetail(input.generatedInternalId),
3438
+ }),
3439
+ defineTool({
3440
+ name: "usas_analyze_incumbent",
3441
+ 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).",
3442
+ inputSchema: UsasAnalyzeIncumbentInput,
3443
+ handler: (input) => usas.analyzeIncumbent(input),
3444
+ }),
294
3445
  // ━━━ USAspending — Aggregate Analysis (6) ━━━
295
- {
3446
+ defineTool({
296
3447
  name: "usas_spending_over_time",
297
- 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.",
3448
+ 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).",
298
3449
  inputSchema: UsasSpendingOverTimeInput,
299
- },
300
- {
3450
+ handler: (input) => usas.spendingOverTime(input),
3451
+ }),
3452
+ defineTool({
301
3453
  name: "usas_search_psc_spending",
302
3454
  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).",
303
3455
  inputSchema: UsasCategorySpendingInput,
304
- },
305
- {
3456
+ handler: (input) => usas.searchPscSpending(input),
3457
+ }),
3458
+ defineTool({
306
3459
  name: "usas_search_state_spending",
307
3460
  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'.",
308
3461
  inputSchema: UsasCategorySpendingInput,
309
- },
310
- {
3462
+ handler: (input) => usas.searchStateSpending(input),
3463
+ }),
3464
+ defineTool({
311
3465
  name: "usas_search_cfda_spending",
312
3466
  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.",
313
3467
  inputSchema: UsasCfdaInput,
314
- },
315
- {
3468
+ handler: (input) => usas.searchCfdaSpending(input),
3469
+ }),
3470
+ defineTool({
316
3471
  name: "usas_search_federal_account_spending",
317
3472
  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').",
318
3473
  inputSchema: UsasCategorySpendingInput,
319
- },
320
- {
3474
+ handler: (input) => usas.searchFederalAccountSpending(input),
3475
+ }),
3476
+ defineTool({
321
3477
  name: "usas_search_agency_spending",
322
3478
  description: "Spending broken down by awarding agency. Use for 'which agencies spend the most on NAICS 541512' — top buyers by $.",
323
3479
  inputSchema: UsasAgencySpendingInput,
324
- },
3480
+ handler: (input) => usas.searchAgencySpending(input),
3481
+ }),
325
3482
  // ━━━ USAspending — Agency Profile (3) ━━━
326
- {
3483
+ defineTool({
327
3484
  name: "usas_get_agency_profile",
328
3485
  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.",
329
3486
  inputSchema: UsasAgencyProfileInput,
330
- },
331
- {
3487
+ handler: (input) => usas.getAgencyProfile(input.toptierCode),
3488
+ }),
3489
+ defineTool({
332
3490
  name: "usas_get_agency_awards_summary",
333
- description: "High-level award activity for a fiscal year — transaction_count + obligations + latest_action_date. Snapshot of agency volume.",
3491
+ 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.",
334
3492
  inputSchema: UsasAgencyAwardsInput,
335
- },
336
- {
3493
+ handler: (input) => usas.getAgencyAwardsSummary(input),
3494
+ }),
3495
+ defineTool({
337
3496
  name: "usas_get_agency_budget_function",
338
3497
  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).",
339
3498
  inputSchema: UsasAgencyBudgetInput,
340
- },
3499
+ handler: (input) => usas.getAgencyBudgetFunction(input),
3500
+ }),
341
3501
  // ━━━ USAspending — Recipient Profile (2) ━━━
342
- {
3502
+ defineTool({
343
3503
  name: "usas_search_recipients",
344
3504
  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.",
345
3505
  inputSchema: UsasSearchRecipientsInput,
346
- },
347
- {
3506
+ handler: (input) => usas.searchRecipients(input),
3507
+ }),
3508
+ defineTool({
348
3509
  name: "usas_get_recipient_profile",
349
3510
  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.",
350
3511
  inputSchema: UsasGetRecipientInput,
351
- },
352
- // ━━━ USAspending — Reference / Autocomplete (4) ━━━
353
- {
3512
+ handler: (input) => usas.getRecipientProfile(input.recipientId),
3513
+ }),
3514
+ // ━━━ USAspending — Reference / Autocomplete (5) ━━━
3515
+ defineTool({
354
3516
  name: "usas_autocomplete_naics",
355
3517
  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.",
356
3518
  inputSchema: UsasAutocompleteInput,
357
- },
358
- {
3519
+ handler: (input) => usas.autocompleteNaics(input),
3520
+ }),
3521
+ defineTool({
359
3522
  name: "usas_autocomplete_recipient",
360
3523
  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.",
361
3524
  inputSchema: UsasAutocompleteInput,
362
- },
363
- {
3525
+ handler: (input) => usas.autocompleteRecipient(input),
3526
+ }),
3527
+ defineTool({
364
3528
  name: "usas_naics_hierarchy",
365
- 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').",
3529
+ 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).",
366
3530
  inputSchema: UsasNaicsHierarchyInput,
367
- },
368
- {
3531
+ handler: (input) => usas.naicsHierarchy(input),
3532
+ }),
3533
+ defineTool({
369
3534
  name: "usas_glossary",
370
3535
  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.",
371
3536
  inputSchema: UsasGlossaryInput,
372
- },
373
- {
3537
+ handler: (input) => usas.glossary(input),
3538
+ }),
3539
+ defineTool({
374
3540
  name: "usas_list_toptier_agencies",
375
3541
  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_*.",
376
3542
  inputSchema: UsasListAgenciesInput,
377
- },
378
- // ━━━ Federal Register (3) ━━━
379
- {
3543
+ handler: (input) => usas.listToptierAgencies(input),
3544
+ }),
3545
+ // ━━━ Federal Register (4) ━━━
3546
+ defineTool({
380
3547
  name: "fed_register_search_documents",
381
3548
  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?').",
382
3549
  inputSchema: FedRegSearchInput,
383
- },
384
- {
3550
+ handler: (input) => fedreg.searchDocuments(input),
3551
+ }),
3552
+ defineTool({
385
3553
  name: "fed_register_get_document",
386
3554
  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.",
387
3555
  inputSchema: FedRegGetDocInput,
388
- },
389
- {
3556
+ handler: (input) => fedreg.getDocument(input.documentNumber),
3557
+ }),
3558
+ defineTool({
390
3559
  name: "fed_register_list_agencies",
391
3560
  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?'",
392
3561
  inputSchema: FedRegListAgenciesInput,
393
- },
394
- // ━━━ eCFR (2) ━━━
395
- {
3562
+ handler: (input) => fedreg.listAgencies(input),
3563
+ }),
3564
+ defineTool({
3565
+ name: "fed_register_public_inspection",
3566
+ 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.",
3567
+ inputSchema: FedRegPublicInspectionInput,
3568
+ handler: (input) => fedreg.publicInspection(input),
3569
+ }),
3570
+ // ━━━ eCFR (5) ━━━
3571
+ defineTool({
396
3572
  name: "ecfr_search",
397
3573
  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.",
398
3574
  inputSchema: EcfrSearchInput,
399
- },
400
- {
3575
+ handler: (input) => ecfr.search(input),
3576
+ }),
3577
+ defineTool({
401
3578
  name: "ecfr_list_titles",
402
3579
  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.).",
403
3580
  inputSchema: EcfrListTitlesInput,
404
- },
3581
+ handler: () => ecfr.listTitles(),
3582
+ }),
3583
+ defineTool({
3584
+ name: "far_clause_lookup",
3585
+ 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.",
3586
+ inputSchema: FarClauseLookupInput,
3587
+ handler: (input) => far.farClauseLookup(input),
3588
+ }),
3589
+ defineTool({
3590
+ name: "far_compliance_matrix",
3591
+ 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.",
3592
+ inputSchema: FarComplianceMatrixInput,
3593
+ handler: (input) => far.farComplianceMatrix(input),
3594
+ }),
3595
+ defineTool({
3596
+ name: "far_search",
3597
+ 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.",
3598
+ inputSchema: FarSearchInput,
3599
+ handler: (input) => far.farSearch(input),
3600
+ }),
3601
+ // ━━━ SBA — Size Standards (1) ━━━
3602
+ defineTool({
3603
+ name: "sba_size_standard",
3604
+ 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).",
3605
+ inputSchema: SbaSizeStandardInput,
3606
+ handler: (input) => sba.sizeStandard(input),
3607
+ }),
405
3608
  // ━━━ Grants.gov (2) ━━━
406
- {
3609
+ defineTool({
407
3610
  name: "grants_search",
408
- 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.",
3611
+ 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.",
409
3612
  inputSchema: GrantsSearchInput,
410
- },
411
- {
3613
+ handler: (input) => grants.searchGrants(input),
3614
+ }),
3615
+ defineTool({
412
3616
  name: "grants_get_opportunity",
413
- 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.",
3617
+ 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.",
414
3618
  inputSchema: GrantsGetInput,
415
- },
3619
+ handler: (input) => grants.getGrant(input),
3620
+ }),
3621
+ // ━━━ Pricing / Wage (3) ━━━
3622
+ defineTool({
3623
+ name: "sam_search_wage_determinations",
3624
+ 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.",
3625
+ inputSchema: WageSearchInput,
3626
+ handler: (input) => pricing.searchWageDeterminations(input),
3627
+ }),
3628
+ defineTool({
3629
+ name: "sam_get_wage_rates",
3630
+ 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.",
3631
+ inputSchema: WageRatesInput,
3632
+ handler: (input) => pricing.getWageRates(input),
3633
+ }),
3634
+ defineTool({
3635
+ name: "gsa_benchmark_labor_rates",
3636
+ 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.",
3637
+ inputSchema: BenchmarkLaborInput,
3638
+ handler: (input) => pricing.benchmarkLaborRates(input),
3639
+ }),
3640
+ // ━━━ Integrity / Teaming (3) ━━━
3641
+ defineTool({
3642
+ name: "sam_check_exclusions",
3643
+ 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.",
3644
+ inputSchema: CheckExclusionsInput,
3645
+ handler: (input) => integrity.checkExclusions(input),
3646
+ }),
3647
+ defineTool({
3648
+ name: "sam_integrity_lookup",
3649
+ 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.",
3650
+ inputSchema: IntegrityLookupInput,
3651
+ handler: (input) => integrity.integrityLookup(input),
3652
+ }),
3653
+ defineTool({
3654
+ name: "usas_search_teaming_partners",
3655
+ 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).",
3656
+ inputSchema: TeamingPartnersInput,
3657
+ handler: (input) => integrity.searchTeamingPartners(input),
3658
+ }),
3659
+ // ━━━ OFAC — Denied-Party Sanctions Screening (1) ━━━ ADR-0034
3660
+ defineTool({
3661
+ name: "ofac_screen_entity",
3662
+ 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.",
3663
+ inputSchema: OfacScreenInput,
3664
+ handler: (input) => ofac.screenEntity(input),
3665
+ }),
3666
+ // ━━━ NVD + CISA KEV — the IT/CYBER-COMPLIANCE lane (2) ━━━ ADR-0035
3667
+ // Opens the FedRAMP/CMMC/SBOM IT-compliance lane the server lacked: NIST NVD
3668
+ // CVE/CVSS severity JOINED with the CISA KEV mandatory-remediation catalog.
3669
+ // Keyless (an OPTIONAL free NVD_API_KEY lifts the rate; header-only, never
3670
+ // logged). Never-fake: a genuine totalResults:0/found:false is honest, but any
3671
+ // 403/429/404/5xx/timeout/redirect-off-host THROWS; a KEV outage degrades
3672
+ // kev.listed to null (never false), and a kevOnly filter during an outage THROWS.
3673
+ defineTool({
3674
+ name: "cve_lookup",
3675
+ 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.",
3676
+ inputSchema: CveLookupInput,
3677
+ handler: (input) => nvd.cveLookup(input),
3678
+ }),
3679
+ defineTool({
3680
+ name: "cisa_kev_lookup",
3681
+ 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.",
3682
+ inputSchema: CisaKevLookupInput,
3683
+ handler: (input) => nvd.cisaKevLookup(input),
3684
+ }),
3685
+ // ━━━ NPPES NPI Registry — Healthcare-Provider Vetting (1) ━━━ ADR-0036
3686
+ defineTool({
3687
+ name: "nppes_lookup_provider",
3688
+ 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.",
3689
+ inputSchema: NppesLookupInput,
3690
+ handler: (input) => nppes.lookupProvider(input),
3691
+ }),
3692
+ // ━━━ CMS Open Payments — Healthcare Spend/Transparency (DKAN, keyless) (2) ━━━ ADR-0037
3693
+ defineTool({
3694
+ name: "cms_search_datasets",
3695
+ 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.",
3696
+ inputSchema: CmsSearchDatasetsInput,
3697
+ handler: (input) => cms.searchDatasets(input),
3698
+ }),
3699
+ defineTool({
3700
+ name: "cms_query_dataset",
3701
+ 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.",
3702
+ inputSchema: CmsQueryDatasetInput,
3703
+ handler: (input) => cms.queryDataset(input),
3704
+ }),
3705
+ // ━━━ FAC Federal Audit Clearinghouse — Single Audit audit-risk vetting (2) ━━━ ADR-0038
3706
+ defineTool({
3707
+ name: "fac_search_audits",
3708
+ 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).",
3709
+ inputSchema: FacSearchAuditsInput,
3710
+ handler: (input) => fac.searchAudits(input),
3711
+ }),
3712
+ defineTool({
3713
+ name: "fac_get_findings",
3714
+ 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).",
3715
+ inputSchema: FacGetFindingsInput,
3716
+ handler: (input) => fac.getFindings(input),
3717
+ }),
3718
+ // ━━━ GAO — Bid Protests (1) ━━━
3719
+ defineTool({
3720
+ name: "gao_protest_lookup",
3721
+ 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).",
3722
+ inputSchema: GaoProtestInput,
3723
+ handler: (input) => gao.gaoProtestLookup(input),
3724
+ }),
3725
+ // ━━━ US Treasury — Fiscal Data (keyless) (4) ━━━ ADR-0002
3726
+ defineTool({
3727
+ name: "treasury_query_dataset",
3728
+ 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.",
3729
+ inputSchema: TreasuryQueryDatasetInput,
3730
+ handler: (input) => treasury.queryDataset(input),
3731
+ }),
3732
+ defineTool({
3733
+ name: "treasury_debt_to_penny",
3734
+ 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).",
3735
+ inputSchema: TreasuryDebtToPennyInput,
3736
+ handler: (input) => treasury.debtToPenny(input),
3737
+ }),
3738
+ defineTool({
3739
+ name: "treasury_monthly_statement",
3740
+ 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.",
3741
+ inputSchema: TreasuryMonthlyStatementInput,
3742
+ handler: (input) => treasury.monthlyStatement(input),
3743
+ }),
3744
+ defineTool({
3745
+ name: "treasury_avg_interest_rates",
3746
+ 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').",
3747
+ inputSchema: TreasuryAvgInterestRatesInput,
3748
+ handler: (input) => treasury.avgInterestRates(input),
3749
+ }),
3750
+ // ━━━ 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
3751
+ defineTool({
3752
+ name: "edgar_lookup_cik",
3753
+ 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.",
3754
+ inputSchema: EdgarLookupCikInput,
3755
+ handler: (input) => edgar.lookupCik(input),
3756
+ }),
3757
+ defineTool({
3758
+ name: "edgar_company_filings",
3759
+ 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.",
3760
+ inputSchema: EdgarCompanyFilingsInput,
3761
+ handler: (input) => edgar.companyFilings(input),
3762
+ }),
3763
+ defineTool({
3764
+ name: "edgar_company_facts",
3765
+ 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.",
3766
+ inputSchema: EdgarCompanyFactsInput,
3767
+ handler: (input) => edgar.companyFacts(input),
3768
+ }),
3769
+ defineTool({
3770
+ name: "edgar_full_text_search",
3771
+ 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.",
3772
+ inputSchema: EdgarFullTextSearchInput,
3773
+ handler: (input) => edgar.fullTextSearch(input),
3774
+ }),
3775
+ defineTool({
3776
+ name: "edgar_xbrl_frames",
3777
+ 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.",
3778
+ inputSchema: EdgarXbrlFramesInput,
3779
+ handler: (input) => edgar.xbrlFrames(input),
3780
+ }),
3781
+ defineTool({
3782
+ name: "edgar_filing_index",
3783
+ 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.",
3784
+ inputSchema: EdgarFilingIndexInput,
3785
+ handler: (input) => edgar.filingIndex(input),
3786
+ }),
3787
+ defineTool({
3788
+ name: "edgar_daily_filing_index",
3789
+ 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.",
3790
+ inputSchema: EdgarDailyFilingIndexInput,
3791
+ handler: (input) => edgar.dailyFilingIndex(input),
3792
+ }),
3793
+ defineTool({
3794
+ name: "edgar_company_concept",
3795
+ 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.",
3796
+ inputSchema: EdgarCompanyConceptInput,
3797
+ handler: (input) => edgar.companyConcept(input),
3798
+ }),
3799
+ // ━━━ Socrata / SODA — keyless SLED + E-rate open data (2) ━━━ ADR-0004
3800
+ defineTool({
3801
+ name: "socrata_query",
3802
+ 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.",
3803
+ inputSchema: SocrataQueryInput,
3804
+ handler: (input) => socrata.query(input),
3805
+ }),
3806
+ defineTool({
3807
+ name: "socrata_discover_datasets",
3808
+ 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.",
3809
+ inputSchema: SocrataDiscoverDatasetsInput,
3810
+ handler: (input) => socrata.discoverDatasets(input),
3811
+ }),
3812
+ // ━━━ CKAN datastore_search — keyless SLED open data (2) ━━━ ADR-0006
3813
+ defineTool({
3814
+ name: "ckan_query",
3815
+ 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.",
3816
+ inputSchema: CkanQueryInput,
3817
+ handler: (input) => ckan.query(input),
3818
+ }),
3819
+ defineTool({
3820
+ name: "ckan_discover_datasets",
3821
+ 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).",
3822
+ inputSchema: CkanDiscoverDatasetsInput,
3823
+ handler: (input) => ckan.discoverDatasets(input),
3824
+ }),
3825
+ // ━━━ FDIC BankFind Suite — keyless institution directory + financials + failures + history + industry aggregates (5) ━━━ ADR-0028 / ADR-0029 / ADR-0030 / ADR-0031
3826
+ defineTool({
3827
+ name: "fdic_search_institutions",
3828
+ 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.",
3829
+ inputSchema: FdicSearchInstitutionsInput,
3830
+ handler: (input) => fdic.searchInstitutions(input),
3831
+ }),
3832
+ defineTool({
3833
+ name: "fdic_institution_financials",
3834
+ 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.",
3835
+ inputSchema: FdicInstitutionFinancialsInput,
3836
+ handler: (input) => fdic.institutionFinancials(input),
3837
+ }),
3838
+ defineTool({
3839
+ name: "fdic_bank_failures",
3840
+ 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.",
3841
+ inputSchema: FdicBankFailuresInput,
3842
+ handler: (input) => fdic.bankFailures(input),
3843
+ }),
3844
+ defineTool({
3845
+ name: "fdic_institution_history",
3846
+ 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.",
3847
+ inputSchema: FdicInstitutionHistoryInput,
3848
+ handler: (input) => fdic.institutionHistory(input),
3849
+ }),
3850
+ defineTool({
3851
+ name: "fdic_industry_summary",
3852
+ 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.",
3853
+ inputSchema: FdicIndustrySummaryInput,
3854
+ handler: (input) => fdic.industrySummary(input),
3855
+ }),
3856
+ // ━━━ FDIC BankFind Suite — WITHIN-SOURCE DEPTH: counterparty risk ratios + branch deposits (2) ━━━ ADR-0040
3857
+ defineTool({
3858
+ name: "fdic_risk_ratios",
3859
+ 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.",
3860
+ inputSchema: FdicRiskRatiosInput,
3861
+ handler: (input) => fdic.riskRatios(input),
3862
+ }),
3863
+ defineTool({
3864
+ name: "fdic_branch_deposits",
3865
+ 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.",
3866
+ inputSchema: FdicBranchDepositsInput,
3867
+ handler: (input) => fdic.branchDeposits(input),
3868
+ }),
3869
+ // ━━━ USITC Harmonized Tariff Schedule — keyless import-tariff / duty-rate lookup (1) ━━━ ADR-0039
3870
+ defineTool({
3871
+ name: "hts_lookup",
3872
+ 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.",
3873
+ inputSchema: HtsLookupInput,
3874
+ handler: (input) => usitc.htsLookup(input),
3875
+ }),
3876
+ // ━━━ BLS Public Data API v1/v2 — keyless US labor/price time series (1) ━━━ ADR-0032
3877
+ // A NEW capability axis: the PRICING / ESCALATION layer (CPI-U & ECI EPA-clause
3878
+ // escalation, PPI materials benchmarking, CES labor-rate context). The SECOND
3879
+ // POST-batch getJson-port consumer (after NIH). SSRF surface = a compile-time-
3880
+ // constant host+path; seriesids ride in the module-built POST body. Honesty crux:
3881
+ // the "-" unavailable marker → null-never-0 with the footnote reason surfaced; a
3882
+ // non-SUCCESS status THROWS (never a fake-empty); per-series units are labeled;
3883
+ // the span is clamped to the tier cap + disclosed. An OPTIONAL free BLS_API_KEY
3884
+ // rides ONLY in the POST body (v2) — never a URL/header/label/_meta/log.
3885
+ defineTool({
3886
+ name: "bls_timeseries",
3887
+ 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.",
3888
+ inputSchema: BlsTimeseriesInput,
3889
+ handler: (input) => bls.timeseries(input),
3890
+ }),
3891
+ // ━━━ BLS OEWS — keyless occupational wage benchmarking (2nd BLS tool) ━━━ ADR-0033
3892
+ // The LEVEL layer next to bls_timeseries's ESCALATION layer: mean/median annual &
3893
+ // hourly wages + employment by SOC occupation × geography — the highest-value B2G
3894
+ // BLS slice (labor-rate benchmarking) that bls_timeseries structurally cannot reach
3895
+ // (OEWS IDs are 25 chars > the raw-seriesId 20-char cap). BUILDS the 25-char series
3896
+ // ID INTERNALLY from validated structured inputs; REUSES the same POST/JSON transport
3897
+ // + parseBlsBody status-throw + mapObservation ("-"→null-never-0) + tier/key seam.
3898
+ defineTool({
3899
+ name: "bls_oews_wages",
3900
+ 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.",
3901
+ inputSchema: BlsOewsWagesInput,
3902
+ handler: (input) => bls.oewsWages(input),
3903
+ }),
3904
+ // ━━━ BLS QCEW — county×NAICS market-size / wages / location-quotient (3rd BLS tool) ━━━ ADR-0042
3905
+ // A SECOND, DIFFERENT, keyless, un-rate-limited BLS DOMAIN (data.bls.gov/cew — the
3906
+ // QCEW Open Data Access CSV, NOT the rate-limited api.bls.gov/publicAPI timeseries
3907
+ // API). Answers the market-size / competition-density question no existing tool can:
3908
+ // establishment COUNT (market size / competitor density), county×NAICS employment,
3909
+ // avg weekly wage (labor cost), and the LOCATION QUOTIENT (concentration vs national).
3910
+ // Honesty crux: a suppressed employment/wage 0-sentinel → null (never 0), block/code/
3911
+ // field-scoped (base/lq/oty each keyed on its OWN *_disclosure_code; qtrly_estabs /
3912
+ // lq_qtrly_estabs / oty_qtrly_estabs_chg stay disclosed under 'N'). Symmetric CSV
3913
+ // column-drift + a POST-parse quoted-header assertion → schema_drift. A per-tuple 404
3914
+ // → honest empty. NEW gate key "bls_qcew"; NO BLS_API_KEY on this keyless path.
3915
+ defineTool({
3916
+ name: "bls_qcew",
3917
+ 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.",
3918
+ inputSchema: BlsQcewInput,
3919
+ handler: (input) => bls.qcew(input),
3920
+ }),
3921
+ // ━━━ OpenFEMA — keyless disaster declarations + emergency-assistance spend (2) ━━━ ADR-0016
3922
+ defineTool({
3923
+ name: "fema_search_public_assistance",
3924
+ 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).",
3925
+ inputSchema: FemaSearchPublicAssistanceInput,
3926
+ handler: (input) => fema.searchPublicAssistance(input),
3927
+ }),
3928
+ defineTool({
3929
+ name: "fema_disaster_declarations",
3930
+ 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'.",
3931
+ inputSchema: FemaDisasterDeclarationsInput,
3932
+ handler: (input) => fema.disasterDeclarations(input),
3933
+ }),
3934
+ // ━━━ FPDS-NG — federal contract AWARD ACTIONS (keyless ATOM) (1) ━━━ ADR-0012
3935
+ // The FIRST XML/ATOM source (bounded, ReDoS-safe hand-parser — the far.ts/gao.ts
3936
+ // lineage; NOT the getJson port). FPDS is the system-of-record USAspending
3937
+ // derives from — this closes the action-level / mod-level latest-truth gap.
3938
+ defineTool({
3939
+ name: "fpds_search_awards",
3940
+ 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.",
3941
+ inputSchema: FpdsSearchAwardsInput,
3942
+ handler: (input) => fpds.searchAwards(input),
3943
+ }),
3944
+ // ━━━ NIH RePORTER v2 — keyless federal research-GRANT projects (1) ━━━ ADR-0014
3945
+ // The R2 getJson port's FIRST non-GET consumer (POST + JSON body). A NEW axis:
3946
+ // federal research-funding footprint by organization / UEI / state (recipient
3947
+ // enrichment, joinable to SAM/USAspending via primary_uei). SSRF surface = a
3948
+ // compile-time-constant URL; all filters ride in the module-built POST body.
3949
+ // Only live-confirmed-narrowing criteria ship (M1); agency_ic_codes is excluded
3950
+ // (silent no-op). The 15,000-record retrieval window is disclosed, not hidden.
3951
+ defineTool({
3952
+ name: "nih_reporter_search_projects",
3953
+ 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).",
3954
+ inputSchema: NihSearchProjectsInput,
3955
+ handler: (input) => nih.searchProjects(input),
3956
+ }),
3957
+ // ━━━ NSF Awards API — keyless federal research-GRANT awards (2) ━━━ ADR-0020
3958
+ // Source #20. The grant-SIBLING of NIH RePORTER on a DIFFERENT agency: NSF
3959
+ // research-grant awards with recipient / PI / UEI enrichment, strengthening the
3960
+ // WEAK entity/recipient layer (ueiNumber/parentUeiNumber join to SAM/USAspending).
3961
+ // SSRF surface = a compile-time-constant host+path; all filters ride in a
3962
+ // module-built URLSearchParams from a validated whitelist. HONESTY: totalCount is
3963
+ // EXACT below 10,000 and SATURATES at 10,000 (ES track_total_hits ⇒ totalIsLower-
3964
+ // Bound + a note); the offset+rpp ≤ 10,000 retrieval window is clamped/disclosed;
3965
+ // a multi-word keyword is OR-tokenized (disclosed, M1); a serviceNotification at
3966
+ // HTTP 200 loud-fails (never a fake empty); grant≠contract in every response.
3967
+ defineTool({
3968
+ name: "nsf_search_awards",
3969
+ 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.",
3970
+ inputSchema: NsfSearchAwardsInput,
3971
+ handler: (input) => nsf.searchAwards(input),
3972
+ }),
3973
+ defineTool({
3974
+ name: "nsf_get_award",
3975
+ 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).",
3976
+ inputSchema: NsfGetAwardInput,
3977
+ handler: (input) => nsf.getAward(input),
3978
+ }),
3979
+ // ━━━ ClinicalTrials.gov API v2 — keyless clinical-study registrations (2) ━━━ ADR-0021
3980
+ // Source #21. The trial-REGISTRATION sibling of the research-GRANT sources (NIH
3981
+ // RePORTER / NSF Awards): leadSponsor / collaborators / organization are the
3982
+ // pharma/biotech/university/agency entities that ALSO receive federal money.
3983
+ // SSRF surface = a compile-time host literal (CT_BASE) + a single audited getCT
3984
+ // helper ([M2]); the single-study nctId is ^NCT\d{8}$-validated before the path
3985
+ // is built. HONESTY: countTotal=true is ALWAYS sent (the exact filter-respecting
3986
+ // uncapped total — a missing totalCount ⇒ schema_drift, NEVER studies.length);
3987
+ // an OPAQUE nextPageToken cursor (terminal = token absent, passed back verbatim);
3988
+ // funderType is a 4-value enum RE-VALIDATED IN THE HANDLER ([M1] — an invalid
3989
+ // value silently fake-empties at HTTP 200); multi-word term/sponsor/condition is
3990
+ // AND-tokenized (disclosed); trial≠federal-award caveat in every response.
3991
+ defineTool({
3992
+ name: "clinicaltrials_search_studies",
3993
+ 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.",
3994
+ inputSchema: ClinicaltrialsSearchStudiesInput,
3995
+ handler: (input) => clinicaltrials.searchStudies(input),
3996
+ }),
3997
+ defineTool({
3998
+ name: "clinicaltrials_get_study",
3999
+ 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.",
4000
+ inputSchema: ClinicaltrialsGetStudyInput,
4001
+ handler: (input) => clinicaltrials.getStudy(input),
4002
+ }),
4003
+ // ── ADR-0024: the aggregate/statistical SIBLING (+1 tool). EXACT per-value study
4004
+ // counts over the WHOLE registry for whitelisted ENUM fields (/stats/field/values,
4005
+ // the SAME fixed host + audited getCT). HONESTY: [M1] _meta.totalAvailable/returned
4006
+ // count DISTINCT FIELD VALUES (not studies — a mandatory unit note); [M2] the
4007
+ // whole-registry scope note carries NO frozen registry size; the returned<unique⇒
4008
+ // truncated invariant discloses the 250-cap the instant it binds (never for v1
4009
+ // ENUMs); a non-ENUM shape for a whitelisted field ⇒ schema_drift; Phase is
4010
+ // ARRAY-valued (overlap/not-a-partition note); the facet-scoped trial≠award caveat
4011
+ // every response. NO free-text ⇒ no tokenization.
4012
+ defineTool({
4013
+ name: "clinicaltrials_facet_counts",
4014
+ 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).",
4015
+ inputSchema: ClinicaltrialsFacetCountsInput,
4016
+ handler: (input) => clinicaltrials.facetCounts(input),
4017
+ }),
4018
+ // ━━━ EPA ECHO REST — keyless facility environmental compliance/enforcement (2) ━━━ ADR-0009
4019
+ // A NEW capability axis: facility & competitor environmental compliance-risk
4020
+ // screening / due diligence. KEYLESS (keylessMode:true, byte-clean init), single
4021
+ // fixed host + three fixed service paths (the SSRF core). The two-step QueryID
4022
+ // pagination is HIDDEN in-call (the ephemeral globally-recycled QueryID is never
4023
+ // exposed); the 200-with-error-body failure mode is guarded FIRST (never a fake
4024
+ // empty). M2: sic narrows (real filter), naics is dropped upstream (best-effort +
4025
+ // disclosed in _meta.filtersDropped/notes).
4026
+ defineTool({
4027
+ name: "echo_search_facilities",
4028
+ 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.",
4029
+ inputSchema: EchoSearchFacilitiesInput,
4030
+ handler: (input) => echo.searchFacilities(input),
4031
+ }),
4032
+ defineTool({
4033
+ name: "echo_facility_report",
4034
+ 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).",
4035
+ inputSchema: EchoFacilityReportInput,
4036
+ handler: (input) => echo.facilityReport(input),
4037
+ }),
4038
+ // ━━━ api.data.gov keyed trio — Regulations.gov + Congress.gov (6) ━━━ ADR-0007/0044
4039
+ // The project's FIRST KEYED source. The key (DATA_GOV_API_KEY, else the public
4040
+ // DEMO_KEY) travels ONLY in the X-Api-Key header — never the URL/label/_meta.
4041
+ // keylessMode:false (genuinely keyed); a DEMO_KEY note discloses the shared
4042
+ // ~10 req/hr ceiling + the free-key upgrade path. ADR-0044 adds the two docket
4043
+ // tools (the rulemaking CONTAINER + its `rin` cross-source join key).
4044
+ defineTool({
4045
+ name: "regulations_search_documents",
4046
+ 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).",
4047
+ inputSchema: RegulationsSearchInput,
4048
+ handler: (input) => datagov.searchDocuments(input),
4049
+ }),
4050
+ defineTool({
4051
+ name: "regulations_search_comments",
4052
+ 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).",
4053
+ inputSchema: RegulationsSearchInput,
4054
+ handler: (input) => datagov.searchComments(input),
4055
+ }),
4056
+ defineTool({
4057
+ name: "congress_search_bills",
4058
+ 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).",
4059
+ inputSchema: CongressSearchBillsInput,
4060
+ handler: (input) => datagov.searchBills(input),
4061
+ }),
4062
+ defineTool({
4063
+ name: "congress_get_bill",
4064
+ 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).",
4065
+ inputSchema: CongressGetBillInput,
4066
+ handler: (input) => datagov.getBill(input),
4067
+ }),
4068
+ defineTool({
4069
+ name: "regulations_search_dockets",
4070
+ 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).",
4071
+ inputSchema: RegulationsSearchDocketsInput,
4072
+ handler: (input) => datagov.searchDockets(input),
4073
+ }),
4074
+ defineTool({
4075
+ name: "regulations_get_docket",
4076
+ 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.",
4077
+ inputSchema: RegulationsGetDocketInput,
4078
+ handler: (input) => datagov.getDocket(input),
4079
+ }),
4080
+ // ━━━ data.gov v4 Catalog API (api.gsa.gov) — CKAN-retirement replacement (1) ━━━ ADR-0046
4081
+ // Resilience Phase 3. data.gov RETIRED the CKAN package_search endpoint in 2025;
4082
+ // the v4 Catalog API restores federal open-dataset DISCOVERY as a NEW keyed source.
4083
+ // A DIFFERENT host (api.gsa.gov) than the datagov trio, but the SAME api.data.gov
4084
+ // key (X-Api-Key header, shared datagovKey.ts seam) — keylessMode:false. The v4
4085
+ // API reports NO match count ⇒ totalAvailable is NULL (P1, never results.length);
4086
+ // pagination is an OPAQUE `after` cursor (nextCursor passed back verbatim); the
4087
+ // dcat.accessLevel openness field is surfaced verbatim.
4088
+ defineTool({
4089
+ name: "datagov_search_datasets",
4090
+ 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).",
4091
+ inputSchema: DatagovSearchDatasetsInput,
4092
+ handler: (input) => datagovCatalog.searchDatasets(input),
4093
+ }),
4094
+ // ━━━ GovInfo (api.govinfo.gov) — the api.data.gov keyed trio's 3rd API (3) ━━━ ADR-0010
4095
+ // GPO-authoritative bulk publications (BILLS/PLAW/USCODE/CREC/CFR-FR editions/
4096
+ // BUDGET/GAOREPORTS) with PDF/XML/MODS downloads + provenance. 2nd consumer of the
4097
+ // shared api.data.gov env-key adapter (datagovKey.ts) — key ONLY in the X-Api-Key
4098
+ // header, keylessMode:false, DEMO_KEY disclosure. The novel piece is the OPAQUE
4099
+ // offsetMark cursor: continuation rides in _meta.nextCursor (passed back as
4100
+ // pageMark); pagination.offset/nextOffset are null (no numeric offset). The raw
4101
+ // upstream nextPage URL (which embeds pageSize+api_key) is NEVER surfaced.
4102
+ defineTool({
4103
+ name: "govinfo_list_collections",
4104
+ 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).",
4105
+ inputSchema: GovinfoListCollectionsInput,
4106
+ handler: () => govinfo.listCollections(),
4107
+ }),
4108
+ defineTool({
4109
+ name: "govinfo_search_packages",
4110
+ 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.",
4111
+ inputSchema: GovinfoSearchPackagesInput,
4112
+ handler: (input) => govinfo.searchPackages(input),
4113
+ }),
4114
+ defineTool({
4115
+ name: "govinfo_get_package",
4116
+ 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.",
4117
+ inputSchema: GovinfoGetPackageInput,
4118
+ handler: (input) => govinfo.getPackage(input),
4119
+ }),
4120
+ // ━━━ US Census Geocoder — keyless territory/geospatial (2) ━━━ ADR-0023
4121
+ // A NEW capability domain (territory/geospatial) serving the WEAK set-aside /
4122
+ // place-of-performance layer. KEYLESS (keylessMode:true, byte-clean init), single
4123
+ // fixed host + two fixed endpoint paths (the SSRF core — no id in the path). The
4124
+ // layer mapper resolves each canonical geography by SUFFIX pattern (the key names
4125
+ // ROLL: "119th Congressional Districts") and handles >1 KEY PER SUFFIX ([B1] — a
4126
+ // historical vintage returns 111th+113th CDs with DISTINCT GEOIDs; both surfaced +
4127
+ // a note, never silently dropped). Drift-guard scoped to 4 sentinels ([M1]); the
4128
+ // vintage enum is the (benchmark,vintage) UNION ([M2]); GEOIDs stay strings.
4129
+ defineTool({
4130
+ name: "census_geocode_address",
4131
+ 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.",
4132
+ inputSchema: CensusGeocodeAddressInput,
4133
+ handler: (input) => census.geocodeAddress(input),
4134
+ }),
4135
+ defineTool({
4136
+ name: "census_geographies_by_coordinates",
4137
+ 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.",
4138
+ inputSchema: CensusGeographiesByCoordinatesInput,
4139
+ handler: (input) => census.geographiesByCoordinates(input),
4140
+ }),
4141
+ // ━━━ US Census County Business Patterns — market sizing (1) ━━━ ADR-0047
4142
+ // ★The server's FIRST KEY-REQUIRED source: the Census Data API removed its
4143
+ // keyless tier, so WITHOUT a CENSUS_API_KEY this tool throws an honest
4144
+ // invalid_input config error (the other 111 tools stay keyless). NAICS×geography
4145
+ // establishments / employment / annual payroll — the demand-side market-sizing
4146
+ // lane. Census negative suppression sentinels (-999999999 …) map to null (never
4147
+ // a negative number / never 0). The 2D-array body is parsed by header name.
4148
+ defineTool({
4149
+ name: "census_business_patterns",
4150
+ description: "Market sizing by NAICS × geography — establishments, employment, and annual payroll from the US Census County Business Patterns (CBP) API (api.census.gov/data/{year}/cbp). ★REQUIRES a free CENSUS_API_KEY: the Census Data API has NO keyless tier, so without the key this tool THROWS an honest config error (get one at https://api.census.gov/data/key_signup.html; Census and FRED are the only key-required sources — every other tool is keyless). Input: optional `naics` (2–6 digit NAICS-2017, e.g. '5415'; omit to aggregate all sectors), `geography` (us|state|county, default us; county REQUIRES `state`), `state` (2-digit FIPS, e.g. '06'), `year` (default '2022'), optional `limit` (client-side top-N; CBP has no server pagination). Returns { rows:[{ name, geoId, naicsCode, naicsLabel, establishments, employees, annualPayrollUsd, state }] } + honest _meta. HONESTY: establishments/employees are integer counts and annualPayrollUsd is annual US dollars (×1000 from the source's $1,000-unit PAYANN); Census SUPPRESSED/withheld cells (large negative sentinels like -999999999) map to null — NEVER a negative number and NEVER 0 (a genuine 0 stays 0); geoId/naicsCode/state are STRINGS (leading zeros survive). CBP returns the COMPLETE geography set for the filter (no pagination) ⇒ totalAvailable = the row count, complete:true. A missing/invalid key ⇒ invalid_input (a 302 to the Missing-Key page); a header-only body ⇒ honest empty (returned:0); a 5xx ⇒ THROWS; a 200 non-JSON ⇒ schema_drift. The key rides ONLY in the &key= query param — never logged or echoed.",
4151
+ inputSchema: CensusBusinessPatternsInput,
4152
+ handler: (input) => censusEconomic.businessPatterns(input),
4153
+ }),
4154
+ // ━━━ FRED (Federal Reserve Economic Data) — macro context (2) ━━━ ADR-0048
4155
+ // ★The server's SECOND KEY-REQUIRED source: FRED has NO keyless tier, so WITHOUT
4156
+ // a FRED_API_KEY both tools throw an honest invalid_input config error (the other
4157
+ // 112 tools stay keyless). GDP/CPI/rates/unemployment/PPI — the macro backdrop for
4158
+ // bid escalation / market timing. A missing observation ('.') maps to null (never 0).
4159
+ defineTool({
4160
+ name: "fred_search_series",
4161
+ description: "Discover FRED economic series (GDP, CPI, interest rates, unemployment, PPI…) by free-text search (FRED /fred/series/search; api.stlouisfed.org). ★REQUIRES a free FRED_API_KEY: FRED has NO keyless tier, so without the key this tool THROWS an honest config error (get one at https://fred.stlouisfed.org/docs/api/api_key.html; this and fred_series_observations are the key-required macro tools — the other 112 tools stay keyless). Input: `query` (the search_text, required, e.g. 'unemployment rate' / 'CPI' / '10-year treasury'), optional `limit` (default 25, max 1000), `offset`. Returns { series:[{ id, title, frequency, frequencyShort, units, seasonalAdjustment, observationStart, observationEnd, lastUpdated, popularity }] } + honest _meta. Feed `id` into fred_series_observations for the time series. HONESTY: totalAvailable is FRED's EXACT reported `count` (offset pagination via hasMore/nextOffset — never fabricated); every scalar is null-never-empty-string; a genuine no-match ⇒ honest empty (returned:0); a 400 (bad/missing key) ⇒ invalid_input CARRYING FRED's error_message; a 5xx ⇒ THROWS; a 200 non-JSON / non-array `seriess` ⇒ schema_drift. The key rides ONLY in the &api_key= query param — never logged or echoed.",
4162
+ inputSchema: FredSearchSeriesInput,
4163
+ handler: (input) => fred.searchSeries(input),
4164
+ }),
4165
+ defineTool({
4166
+ name: "fred_series_observations",
4167
+ description: "Fetch a FRED series' time series of date/value observations (FRED /fred/series/observations; api.stlouisfed.org). ★REQUIRES a free FRED_API_KEY (FRED has NO keyless tier — without it this tool THROWS an honest config error; get one at https://fred.stlouisfed.org/docs/api/api_key.html). Input: `seriesId` (required, e.g. 'GDP', 'CPIAUCSL', 'UNRATE', 'DGS10', 'PPIACO'; discover with fred_search_series), optional `startDate`/`endDate` (YYYY-MM-DD), `limit` (default 100, max 100000), `offset`, `sortOrder` (asc|desc). Returns { observations:[{ date, value }] } + honest _meta. ★MISSING-VALUE HONESTY (the crux): FRED encodes a missing observation as the literal '.', which maps to value:null (missing) — NEVER 0; a genuine reported 0 is preserved as 0. HONESTY: totalAvailable is FRED's EXACT `count` (offset pagination via hasMore/nextOffset — never fabricated); a 400 (bad seriesId / missing key) ⇒ invalid_input CARRYING FRED's error_message (never a fake empty); a genuine empty ⇒ honest empty; a 5xx ⇒ THROWS; a 200 non-JSON / non-array `observations` ⇒ schema_drift. seriesId is charclass-validated (^[A-Za-z0-9._-]+$) and dates are YYYY-MM-DD; the key rides ONLY in the &api_key= query param.",
4168
+ inputSchema: FredSeriesObservationsInput,
4169
+ handler: (input) => fred.seriesObservations(input),
4170
+ }),
4171
+ // ━━━ Self-service key discovery (1) ━━━
4172
+ // KEYLESS. A local status query — reads process.env (+ any .env auto-loaded at
4173
+ // startup) and reports, per key, whether it is set (a BOOLEAN — the key VALUE is
4174
+ // NEVER read into the output). Makes the 2-required + 5-optional key situation
4175
+ // discoverable without reading source or docs.
4176
+ defineTool({
4177
+ name: "api_key_status",
4178
+ description: "List every API key this server can use, whether each is REQUIRED or OPTIONAL, the free signup URL + what it unlocks, and whether it is CURRENTLY configured — a boolean only; the key VALUE is NEVER shown. KEYLESS (no input). Most sources are keyless; only Census (census_business_patterns) and FRED (2 tools) REQUIRE a key (they throw without one), the other 5 keys are OPTIONAL (raise a rate limit or unlock one filter). Keys can be set as host env vars OR in a `.env` file in the server's working directory (auto-loaded at startup; real env wins over .env). Returns { keys:[{ envVar, sources[], required, signupUrl, unlocks, note, currentlySet }], requiredMissing:[envVars], optionalMissing:[envVars], allKeysFree:true }. This tool tells you the CONFIG state; to verify a key actually WORKS, call that source's own tool. Getting a key (creating the account at the signup URL) is your step — the server automates discovery + configuration, not signup.",
4179
+ inputSchema: ApiKeyStatusInput,
4180
+ handler: async () => keys.apiKeyStatus(),
4181
+ }),
416
4182
  ];
417
4183
  // ─── Server bootstrap ────────────────────────────────────────────
418
4184
  async function main() {
4185
+ // Auto-load API keys from a `.env` in the working directory BEFORE anything
4186
+ // reads process.env (tools read env at call time; SamGovClient below reads
4187
+ // SAM_GOV_API_KEY immediately). Real env wins over .env (precedence); no .env
4188
+ // present ⇒ zero change ⇒ byte-identical startup. We log only the COUNT — never
4189
+ // which keys or their values.
4190
+ const loadedFromEnvFile = keys.loadDotEnv();
4191
+ if (loadedFromEnvFile > 0) {
4192
+ console.error(`[mcp-sam-gov] loaded ${loadedFromEnvFile} key(s) from .env`);
4193
+ }
419
4194
  const sam = new SamGovClient({
420
4195
  apiKey: process.env.SAM_GOV_API_KEY?.trim() || undefined,
421
4196
  logger: {
@@ -437,10 +4212,18 @@ async function main() {
437
4212
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
438
4213
  const { name, arguments: args } = req.params;
439
4214
  try {
440
- const data = await runTool(name, args ?? {}, sam);
4215
+ const raw = await runTool(name, args ?? {}, sam);
4216
+ // A handler may return either its raw domain object OR a MetaBundle
4217
+ // (via withMeta) carrying a partial `_meta`. Unwrap to `data` + finalize
4218
+ // the `_meta` sibling. `data` is byte-identical either way.
4219
+ const data = isMetaBundle(raw) ? raw.data : raw;
4220
+ const _meta = isMetaBundle(raw)
4221
+ ? buildMeta(raw.meta)
4222
+ : synthesizeDefaultMeta(name, sam);
441
4223
  // Structured success envelope. Calling agent can rely on
442
- // `ok: true` to know the payload is in `data`.
443
- const envelope = { ok: true, data };
4224
+ // `ok: true` to know the payload is in `data`, and read `_meta`
4225
+ // for completeness / provenance (see meta.ts).
4226
+ const envelope = { ok: true, data, _meta };
444
4227
  return {
445
4228
  content: [
446
4229
  { type: "text", text: JSON.stringify(envelope, null, 2) },
@@ -449,7 +4232,11 @@ async function main() {
449
4232
  }
450
4233
  catch (err) {
451
4234
  // Structured error envelope. The agent can read `error.kind`
452
- // and `error.retryable` to decide what to do next.
4235
+ // and `error.retryable` to decide what to do next. Classification is
4236
+ // centralized in `toToolError`, which maps a Zod input-validation failure
4237
+ // (e.g. a value outside an enum, or a limit above the max) to a
4238
+ // NON-retryable `invalid_input` with readable field-level issues — never a
4239
+ // generic `unknown` carrying Zod's raw JSON dump.
453
4240
  const error = toToolError(err, name);
454
4241
  const envelope = { ok: false, error };
455
4242
  return {
@@ -464,171 +4251,81 @@ async function main() {
464
4251
  await server.connect(transport);
465
4252
  console.error(`[mcp-sam-gov] v${SERVER_VERSION} listening on stdio (${TOOLS.length} tools).`);
466
4253
  }
467
- async function runTool(name, args, sam) {
468
- switch (name) {
469
- // SAM.gov
470
- case "sam_search_opportunities": {
471
- const input = SamSearchInput.parse(args);
472
- const r = await sam.searchOpportunities({
473
- ...input,
474
- setAside: input.setAside,
475
- });
476
- return {
477
- totalRecords: r.totalRecords,
478
- returned: r.opportunitiesData.length,
479
- opportunities: r.opportunitiesData.map((o) => ({
480
- noticeId: o.noticeId,
481
- title: o.title,
482
- agency: o.fullParentPathName,
483
- solicitationNumber: o.solicitationNumber,
484
- responseDeadline: o.responseDeadLine,
485
- naics: o.naicsCode,
486
- setAside: o.typeOfSetAside,
487
- uiLink: o.uiLink,
488
- })),
489
- };
490
- }
491
- case "sam_get_opportunity": {
492
- const { noticeId } = SamGetOpportunityInput.parse(args);
493
- const o = await sam.getOpportunity(noticeId);
494
- if (!o)
495
- return { found: false, noticeId };
496
- return {
497
- found: true,
498
- noticeId: o.noticeId,
499
- title: o.title,
500
- agency: o.fullParentPathName,
501
- solicitationNumber: o.solicitationNumber,
502
- responseDeadline: o.responseDeadLine,
503
- type: o.type,
504
- naics: o.naicsCode,
505
- setAside: o.typeOfSetAside,
506
- placeOfPerformance: o.placeOfPerformance,
507
- pointsOfContact: o.pointOfContact ?? [],
508
- description: o.description,
509
- attachments: (o.resourceLinks ?? []).map((url, idx) => ({
510
- index: idx,
511
- url,
512
- })),
513
- uiLink: o.uiLink,
514
- };
515
- }
516
- case "sam_fetch_description": {
517
- const { noticeId } = SamFetchDescriptionInput.parse(args);
518
- const o = await sam.getOpportunity(noticeId);
519
- if (!o)
520
- return { found: false, noticeId };
521
- const text = o.description
522
- ? await sam.fetchOpportunityDescription(o.description)
523
- : "";
524
- return {
525
- found: true,
526
- noticeId,
527
- descriptionLength: text.length,
528
- description: text || "(no description body available)",
529
- };
530
- }
531
- case "sam_attachment_url": {
532
- const { resourceId } = SamAttachmentUrlInput.parse(args);
533
- return { downloadUrl: sam.publicDownloadUrl(resourceId) };
534
- }
535
- case "sam_lookup_organization": {
536
- const { organizationId } = SamLookupOrgInput.parse(args);
537
- // SamGovClient internal method — exposed via direct fetch since
538
- // it's not on the public surface. Use the public sam.gov endpoint
539
- // directly (already keyless).
540
- const r = await fetch(`https://sam.gov/api/prod/federalorganizations/v1/organizations/${encodeURIComponent(organizationId)}`, {
541
- headers: { Accept: "application/hal+json" },
542
- signal: AbortSignal.timeout(10_000),
543
- });
544
- if (!r.ok) {
545
- return { found: false, organizationId, status: r.status };
546
- }
547
- const json = (await r.json());
548
- const org = json._embedded?.[0]?.org;
549
- return {
550
- found: !!org,
551
- organizationId,
552
- fullParentPathName: org?.fullParentPathName ?? "",
553
- agencyName: org?.agencyName ?? "",
554
- name: org?.name ?? "",
555
- type: org?.type,
556
- level: org?.level,
557
- };
558
- }
559
- // USAspending — Awards & Recipients
560
- case "usas_search_awards":
561
- return await usas.searchAwards(UsasFiltersBase.parse(args));
562
- case "usas_search_individual_awards":
563
- return await usas.searchIndividualAwards(UsasIndividualAwardsInput.parse(args));
564
- case "usas_search_subagency_spending":
565
- return await usas.searchSubAgencySpending(UsasSubAgencyInput.parse(args));
566
- case "usas_lookup_agency":
567
- return await usas.lookupAgency(UsasLookupAgencyInput.parse(args).searchText);
568
- case "usas_search_awards_by_recipient":
569
- return await usas.searchAwardsByRecipient(UsasRecipientAwardsInput.parse(args));
570
- case "usas_search_subawards":
571
- return await usas.searchSubawards(UsasSubawardsInput.parse(args));
572
- case "usas_search_expiring_contracts":
573
- return await usas.searchExpiringContracts(UsasExpiringInput.parse(args));
574
- case "usas_get_award_detail":
575
- return await usas.getAwardDetail(UsasAwardDetailInput.parse(args).generatedInternalId);
576
- // USAspending — Aggregate
577
- case "usas_spending_over_time":
578
- return await usas.spendingOverTime(UsasSpendingOverTimeInput.parse(args));
579
- case "usas_search_psc_spending":
580
- return await usas.searchPscSpending(UsasCategorySpendingInput.parse(args));
581
- case "usas_search_state_spending":
582
- return await usas.searchStateSpending(UsasCategorySpendingInput.parse(args));
583
- case "usas_search_cfda_spending":
584
- return await usas.searchCfdaSpending(UsasCfdaInput.parse(args));
585
- case "usas_search_federal_account_spending":
586
- return await usas.searchFederalAccountSpending(UsasCategorySpendingInput.parse(args));
587
- case "usas_search_agency_spending":
588
- return await usas.searchAgencySpending(UsasAgencySpendingInput.parse(args));
589
- // USAspending — Agency Profile
590
- case "usas_get_agency_profile":
591
- return await usas.getAgencyProfile(UsasAgencyProfileInput.parse(args).toptierCode);
592
- case "usas_get_agency_awards_summary":
593
- return await usas.getAgencyAwardsSummary(UsasAgencyAwardsInput.parse(args));
594
- case "usas_get_agency_budget_function":
595
- return await usas.getAgencyBudgetFunction(UsasAgencyBudgetInput.parse(args));
596
- // USAspending — Recipient Profile
597
- case "usas_search_recipients":
598
- return await usas.searchRecipients(UsasSearchRecipientsInput.parse(args));
599
- case "usas_get_recipient_profile":
600
- return await usas.getRecipientProfile(UsasGetRecipientInput.parse(args).recipientId);
601
- // USAspending — Reference / Autocomplete
602
- case "usas_autocomplete_naics":
603
- return await usas.autocompleteNaics(UsasAutocompleteInput.parse(args));
604
- case "usas_autocomplete_recipient":
605
- return await usas.autocompleteRecipient(UsasAutocompleteInput.parse(args));
606
- case "usas_naics_hierarchy":
607
- return await usas.naicsHierarchy(UsasNaicsHierarchyInput.parse(args));
608
- case "usas_glossary":
609
- return await usas.glossary(UsasGlossaryInput.parse(args));
610
- case "usas_list_toptier_agencies":
611
- return await usas.listToptierAgencies(UsasListAgenciesInput.parse(args));
612
- // Federal Register
613
- case "fed_register_search_documents":
614
- return await fedreg.searchDocuments(FedRegSearchInput.parse(args));
615
- case "fed_register_get_document":
616
- return await fedreg.getDocument(FedRegGetDocInput.parse(args).documentNumber);
617
- case "fed_register_list_agencies":
618
- return await fedreg.listAgencies(FedRegListAgenciesInput.parse(args));
619
- // eCFR
620
- case "ecfr_search":
621
- return await ecfr.search(EcfrSearchInput.parse(args));
622
- case "ecfr_list_titles":
623
- return await ecfr.listTitles();
624
- // Grants.gov
625
- case "grants_search":
626
- return await grants.searchGrants(GrantsSearchInput.parse(args));
627
- case "grants_get_opportunity":
628
- return await grants.getGrant(GrantsGetInput.parse(args));
629
- default:
630
- throw new Error(`Unknown tool: ${name}`);
4254
+ /**
4255
+ * Minimal truthful `_meta` for handlers that don't attach their own.
4256
+ *
4257
+ * Defaults to `complete:true, truncated:false` — correct for the single-record
4258
+ * and known-complete tools (detail lookups, reference tables). List/search and
4259
+ * two-phase tools that can be capped or drop filters should instead return
4260
+ * `withMeta(...)` with the real completeness signals; those are migrated
4261
+ * incrementally (A1 landed first). The source label is keyless-aware for SAM
4262
+ * tools so provenance is honest from day one.
4263
+ */
4264
+ function synthesizeDefaultMeta(toolName, sam) {
4265
+ // The wage tools carry a `sam_` prefix but hit the keyless SGS/WDOL
4266
+ // subsystems (never the keyed opportunities API), so they are always keyless.
4267
+ const isWage = toolName === "sam_search_wage_determinations" ||
4268
+ toolName === "sam_get_wage_rates";
4269
+ // sam_lookup_notice_fields also carries a `sam_` prefix but is served from the
4270
+ // keyless GSA daily CSV (never the keyed opportunities API) — always keyless.
4271
+ const isGsaCsv = toolName === "sam_lookup_notice_fields";
4272
+ const isSam = toolName.startsWith("sam_") && !isWage && !isGsaCsv;
4273
+ const keylessMode = isSam ? sam.isKeyless : true;
4274
+ let source;
4275
+ if (isWage) {
4276
+ source = "sam.gov wage-determinations (keyless)";
4277
+ }
4278
+ else if (isGsaCsv) {
4279
+ source = "gsa.gov daily bulk CSV (keyless)";
4280
+ }
4281
+ else if (toolName.startsWith("gsa_")) {
4282
+ source = "api.gsa.gov CALC v3 (keyless)";
4283
+ }
4284
+ else if (isSam) {
4285
+ source = sam.isKeyless ? "sam.gov (keyless)" : "api.sam.gov (keyed)";
4286
+ }
4287
+ else if (toolName.startsWith("usas_")) {
4288
+ source = "usaspending.gov/api/v2";
4289
+ }
4290
+ else if (toolName.startsWith("fed_register_")) {
4291
+ source = "federalregister.gov/api/v1";
4292
+ }
4293
+ else if (toolName.startsWith("ecfr_")) {
4294
+ source = "ecfr.gov/api";
631
4295
  }
4296
+ else if (toolName.startsWith("grants_")) {
4297
+ source = "grants.gov/api";
4298
+ }
4299
+ else if (toolName.startsWith("sba_")) {
4300
+ source = "sba.gov naics.json (keyless)";
4301
+ }
4302
+ else if (toolName.startsWith("gao_")) {
4303
+ source = "gao.gov Legal Products RSS + decision pages (keyless)";
4304
+ }
4305
+ else if (toolName.startsWith("fpds_")) {
4306
+ source = "www.fpds.gov ezSearch ATOM (FPDS-NG, keyless)";
4307
+ }
4308
+ else if (toolName === "api_key_status") {
4309
+ source = "local (process.env + .env)";
4310
+ }
4311
+ else {
4312
+ source = "unknown";
4313
+ }
4314
+ return buildMeta({ source, keylessMode, complete: true, truncated: false });
4315
+ }
4316
+ export async function runTool(name, args, sam) {
4317
+ // R1 (ADR-0001) — registry dispatch. Every tool's TOOLS[] entry carries a
4318
+ // co-located `handler`: route through it by parsing `args` with the entry's
4319
+ // own schema, then calling the handler. Its return value flows into
4320
+ // CallTool's existing envelope logic (isMetaBundle? buildMeta :
4321
+ // synthesizeDefaultMeta) byte-identically. The legacy dispatch `switch` is
4322
+ // gone (all 52 tools migrated) — an unknown name has no entry and throws.
4323
+ const entry = TOOLS.find((t) => t.name === name);
4324
+ if (entry?.handler) {
4325
+ const input = entry.inputSchema.parse(args);
4326
+ return await entry.handler(input, { sam });
4327
+ }
4328
+ throw new Error(`Unknown tool: ${name}`);
632
4329
  }
633
4330
  /**
634
4331
  * Hand-rolled Zod → JSON Schema converter (subset we use).
@@ -687,8 +4384,46 @@ function zodToJsonSchema(schema) {
687
4384
  }
688
4385
  return { type: "string", ...(description ? { description } : {}) };
689
4386
  }
690
- main().catch((err) => {
691
- console.error("[mcp-sam-gov] FATAL:", err);
692
- process.exit(1);
693
- });
4387
+ // Start the stdio server ONLY when run directly (node dist/server.js / the
4388
+ // mcp-sam-gov bin) — NOT when imported (the fault-injection tests import
4389
+ // runTool to exercise the REAL tool-dispatch over a mocked fetch). Preserves
4390
+ // the launch: `node dist/server.js` → argv[1] === this file → main() runs;
4391
+ // smoke-test.mjs's spawn("node", ["dist/server.js"]) is the same (a subprocess
4392
+ // whose argv[1] is dist/server.js); an `import { runTool }` sets argv[1] to the
4393
+ // importing script → no match → main() does NOT run, the server is not spawned.
4394
+ // Was this module run DIRECTLY (node dist/server.js / the mcp-sam-gov bin), or
4395
+ // merely IMPORTED (the fault-injection tests import runTool over a mocked fetch)?
4396
+ // Only the direct case starts the stdio server. Canonicalize BOTH sides through
4397
+ // realpathSync before comparing, so it holds no matter how symlinks land:
4398
+ // - the `mcp-sam-gov` bin is a symlink to dist/server.js on Unix/macOS, and npm
4399
+ // installs the package dir itself via a symlink — argv[1] must be realpath'd;
4400
+ // - under `--preserve-symlinks-main`, Node keeps import.meta.url as the symlink
4401
+ // path, so THAT side must be realpath'd too.
4402
+ // Realpath'ing both and comparing as file:// URLs (pathToFileURL normalizes
4403
+ // Windows drive-casing/slashes) makes the check robust across every real launch.
4404
+ const invokedDirectly = (() => {
4405
+ const argv1 = process.argv[1];
4406
+ if (!argv1)
4407
+ return false;
4408
+ const canonical = (p) => {
4409
+ try {
4410
+ return pathToFileURL(realpathSync(p)).href;
4411
+ }
4412
+ catch {
4413
+ return pathToFileURL(p).href; // not a real path → best-effort raw
4414
+ }
4415
+ };
4416
+ try {
4417
+ return canonical(argv1) === canonical(fileURLToPath(import.meta.url));
4418
+ }
4419
+ catch {
4420
+ return import.meta.url === pathToFileURL(argv1).href; // extreme fallback
4421
+ }
4422
+ })();
4423
+ if (invokedDirectly) {
4424
+ main().catch((err) => {
4425
+ console.error("[mcp-sam-gov] FATAL:", err);
4426
+ process.exit(1);
4427
+ });
4428
+ }
694
4429
  //# sourceMappingURL=server.js.map