@cliwant/mcp-sam-gov 1.5.0 → 1.7.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
@@ -1,219 +1,219 @@
1
- /**
2
- * nist-controls.ts — NIST SP 800-53 Rev 5 security & privacy CONTROLS catalog
3
- * (OSCAL, keyless). The cyber-compliance controls backbone for FedRAMP / CMMC / RMF
4
- * work: look up a control (AC-2, SC-7, …) or a family (Access Control, System &
5
- * Communications Protection, …) and get its title, requirement STATEMENT, discussion
6
- * guidance, and control enhancements. No other tool here exposes the controls catalog
7
- * (we have NVD CVEs + CISA KEV, but not the requirement side).
8
- *
9
- * SOURCE: NIST's OFFICIAL OSCAL content, published at github.com/usnistgov/oscal-content
10
- * (the canonical machine-readable release; the .gov PDF is the human copy). NOT a
11
- * .gov API host, so provenance is disclosed on every response (the ProPublica /
12
- * CourtListener / get.gov republisher idiom — here NIST is the first-party author).
13
- *
14
- * PATTERN: the CISA-KEV static-file idiom (nvd.ts) — a fixed-host const URL fetched
15
- * via getJson (redirect:"error" + 30s timeout), memoized 6h, with a plausibility
16
- * FLOOR (a truncated catalog must NEVER read as "control not found"), then
17
- * client-side filter/lookup. An outage/4xx/timeout THROWS (never a fake empty).
18
- *
19
- * SSRF: fixed host `raw.githubusercontent.com` + a fixed, pinned path (no free
20
- * host/path). Filtering is CLIENT-SIDE over the parsed catalog.
21
- */
22
-
23
- import { getJson, driftError } from "./datasource.js";
24
- import { memoize } from "./cache.js";
25
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
26
-
27
- export const OSCAL_HOST = "raw.githubusercontent.com";
28
- const OSCAL_URL =
29
- "https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json";
30
- const OSCAL_LABEL = "nist-oscal:sp800-53r5";
31
- const OSCAL_TIMEOUT_MS = 30_000;
32
- const OSCAL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
33
- // Plausibility floor: 800-53 Rev5 has 20 control families and ~1000 controls. A
34
- // truncated catalog with far fewer must THROW, never read as "control not found".
35
- const FAMILY_FLOOR = 15;
36
-
37
- const PROVENANCE_NOTE =
38
- "Source: NIST SP 800-53 Rev 5 OSCAL catalog, published at github.com/usnistgov/oscal-content (NIST's canonical machine-readable release; authoritative first-party data served from GitHub, not a .gov API host).";
39
- const CLIENT_FILTER_NOTE =
40
- "The catalog has no query API — the full published OSCAL JSON is fetched (cached 6h) and filtered CLIENT-SIDE (controlId exact, family exact, keyword = case-insensitive substring over title+statement). totalAvailable is the EXACT match count.";
41
- const REFERENCE_NOTE =
42
- "This is the REQUIREMENT catalog (control text), NOT an assessment or an authorization. A control's applicability depends on the system's FIPS-199 impact baseline (Low/Moderate/High) and overlay — which this catalog does not encode.";
43
-
44
- // ─── Parsed shapes ────────────────────────────────────────────────
45
- export type NistControl = {
46
- id: string; // display form, e.g. "AC-2"
47
- family: string; // e.g. "AC — Access Control"
48
- title: string;
49
- statement: string; // assembled requirement prose (labelled, multi-line)
50
- guidance: string | null; // discussion prose
51
- enhancements: { id: string; title: string }[]; // e.g. AC-2(1)
52
- };
53
-
54
- type OscalPart = {
55
- name?: string;
56
- prose?: string;
57
- props?: { name?: string; value?: string }[];
58
- parts?: OscalPart[];
59
- };
60
- type OscalControl = {
61
- id?: string;
62
- title?: string;
63
- parts?: OscalPart[];
64
- controls?: OscalControl[];
65
- };
66
- type OscalGroup = { id?: string; title?: string; controls?: OscalControl[] };
67
-
68
- /** OSCAL control id ("ac-2", "ac-2.1") → display id ("AC-2", "AC-2(1)"). */
69
- function displayId(rawId: string): string {
70
- const m = /^([a-z]+)-(\d+)(?:\.(\d+))?$/i.exec(rawId.trim());
71
- if (!m) return rawId.toUpperCase();
72
- const fam = (m[1] ?? "").toUpperCase();
73
- // Strip leading zeros so a zero-padded input ('AC-02') normalizes to the catalog's
74
- // canonical unpadded form ('AC-2') — else an exact controlId lookup would miss.
75
- const num = String(Number(m[2] ?? "0"));
76
- const enh = m[3] !== undefined ? String(Number(m[3])) : undefined;
77
- return enh !== undefined ? `${fam}-${num}(${enh})` : `${fam}-${num}`;
78
- }
79
-
80
- /** Recursively collect a statement part's prose as labelled, indented lines. */
81
- function collectProse(part: OscalPart, depth: number, out: string[]): void {
82
- const label = part.props?.find((p) => p.name === "label")?.value;
83
- const indent = " ".repeat(depth);
84
- const prefix = label ? `${label} ` : "";
85
- if (part.prose && part.prose.trim().length > 0) {
86
- out.push(`${indent}${prefix}${part.prose.trim()}`);
87
- } else if (label) {
88
- out.push(`${indent}${prefix}`.trimEnd());
89
- }
90
- for (const sub of part.parts ?? []) collectProse(sub, depth + 1, out);
91
- }
92
-
93
- function partProse(control: OscalControl, name: string): string | null {
94
- const part = (control.parts ?? []).find((p) => p.name === name);
95
- if (!part) return null;
96
- const out: string[] = [];
97
- collectProse(part, 0, out);
98
- const text = out.join("\n").trim();
99
- return text.length > 0 ? text : null;
100
- }
101
-
102
- function mapControl(control: OscalControl, family: string): NistControl {
103
- return {
104
- id: displayId(control.id ?? ""),
105
- family,
106
- title: (control.title ?? "").trim(),
107
- statement: partProse(control, "statement") ?? "",
108
- guidance: partProse(control, "guidance"),
109
- enhancements: (control.controls ?? []).map((e) => ({
110
- id: displayId(e.id ?? ""),
111
- title: (e.title ?? "").trim(),
112
- })),
113
- };
114
- }
115
-
116
- /**
117
- * Fetch + parse the OSCAL catalog into a flat list of TOP-LEVEL controls, memoized
118
- * 6h. Header/shape guarded: `catalog.groups` MUST be an array with ≥ FAMILY_FLOOR
119
- * families (else driftError — a truncated catalog is NEVER a fake empty). Enhancements
120
- * are carried on each control (not indexed as top-level entries).
121
- */
122
- async function loadControls(): Promise<NistControl[]> {
123
- return memoize(
124
- "nist:sp800-53r5",
125
- async () => {
126
- const built = new URL(OSCAL_URL);
127
- if (built.hostname !== OSCAL_HOST || built.protocol !== "https:") {
128
- throw driftError(OSCAL_LABEL, `Constructed OSCAL URL host ${JSON.stringify(built.hostname)} is not ${OSCAL_HOST} over https — refusing to fetch (SSRF safety).`);
129
- }
130
- const body = (await getJson(OSCAL_URL, {
131
- label: OSCAL_LABEL,
132
- redirect: "error",
133
- timeoutMs: OSCAL_TIMEOUT_MS,
134
- })) as { catalog?: { groups?: unknown } };
135
- const groups = body.catalog?.groups;
136
- if (!Array.isArray(groups) || groups.length < FAMILY_FLOOR) {
137
- throw driftError(
138
- OSCAL_LABEL,
139
- `OSCAL catalog.groups missing or implausibly small (${Array.isArray(groups) ? groups.length : "not-an-array"} < ${FAMILY_FLOOR} families) — treating as schema drift / truncation, never a fake-empty catalog.`,
140
- );
141
- }
142
- const controls: NistControl[] = [];
143
- for (const g of groups as OscalGroup[]) {
144
- const family = `${(g.id ?? "").toUpperCase()} — ${(g.title ?? "").trim()}`;
145
- for (const c of g.controls ?? []) controls.push(mapControl(c, family));
146
- }
147
- return controls;
148
- },
149
- OSCAL_CACHE_TTL_MS,
150
- );
151
- }
152
-
153
- // ─── Tool: nist_800_53_controls ───────────────────────────────────
154
- /**
155
- * Look up NIST SP 800-53 Rev 5 controls by controlId (exact, e.g. "AC-2"), family
156
- * (exact family letter or name, e.g. "AC" / "Access Control"), and/or keyword
157
- * (case-insensitive substring over title + statement). Client-side filters over the
158
- * cached OSCAL catalog; honest `_meta` (exact match total; provenance disclosed).
159
- */
160
- export async function searchControls(args: {
161
- controlId?: string;
162
- family?: string;
163
- keyword?: string;
164
- limit?: number;
165
- offset?: number;
166
- }): Promise<MetaBundle> {
167
- const limit = args.limit ?? 25;
168
- const offset = args.offset ?? 0;
169
- const all = await loadControls();
170
-
171
- const filtersApplied: string[] = [];
172
- const idQ = args.controlId !== undefined ? displayId(args.controlId) : undefined;
173
- const famQ = args.family?.trim().toLowerCase();
174
- const kwQ = args.keyword?.trim().toLowerCase();
175
- if (args.controlId !== undefined) filtersApplied.push("controlId");
176
- if (args.family !== undefined) filtersApplied.push("family");
177
- if (args.keyword !== undefined) filtersApplied.push("keyword");
178
-
179
- const matched = all.filter((c) => {
180
- if (idQ && c.id.toUpperCase() !== idQ.toUpperCase()) return false;
181
- if (famQ) {
182
- // family field is "AC — Access Control"; match either the letter code or a
183
- // substring of the title (both case-insensitive).
184
- const fam = c.family.toLowerCase();
185
- const code = (fam.split("—")[0] ?? "").trim();
186
- if (code !== famQ && !fam.includes(famQ)) return false;
187
- }
188
- if (kwQ) {
189
- // Search the title + requirement statement AND each enhancement's title, so a
190
- // term that lives only in an enhancement (e.g. "multi-factor" → IA-2(1)) still
191
- // surfaces the parent control. filtersApplied still lists 'keyword'.
192
- const hay = `${c.title}\n${c.statement}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
193
- if (!hay.includes(kwQ)) return false;
194
- }
195
- return true;
196
- });
197
-
198
- const totalAvailable = matched.length;
199
- const page = matched.slice(offset, offset + limit);
200
- const returned = page.length;
201
- const hasMore = offset + returned < totalAvailable;
202
- const nextOffset = hasMore ? offset + returned : null;
203
-
204
- return withMeta(
205
- { controls: page },
206
- {
207
- source: "NIST SP 800-53 Rev 5 (OSCAL catalog, keyless)",
208
- keylessMode: true,
209
- returned,
210
- totalAvailable,
211
- truncated: hasMore,
212
- filtersApplied,
213
- filtersDropped: [],
214
- fieldsUnavailable: [],
215
- pagination: { offset, limit, hasMore, nextOffset },
216
- notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE],
217
- } satisfies Partial<ResponseMeta>,
218
- );
219
- }
1
+ /**
2
+ * nist-controls.ts — NIST SP 800-53 Rev 5 security & privacy CONTROLS catalog
3
+ * (OSCAL, keyless). The cyber-compliance controls backbone for FedRAMP / CMMC / RMF
4
+ * work: look up a control (AC-2, SC-7, …) or a family (Access Control, System &
5
+ * Communications Protection, …) and get its title, requirement STATEMENT, discussion
6
+ * guidance, and control enhancements. No other tool here exposes the controls catalog
7
+ * (we have NVD CVEs + CISA KEV, but not the requirement side).
8
+ *
9
+ * SOURCE: NIST's OFFICIAL OSCAL content, published at github.com/usnistgov/oscal-content
10
+ * (the canonical machine-readable release; the .gov PDF is the human copy). NOT a
11
+ * .gov API host, so provenance is disclosed on every response (the ProPublica /
12
+ * CourtListener / get.gov republisher idiom — here NIST is the first-party author).
13
+ *
14
+ * PATTERN: the CISA-KEV static-file idiom (nvd.ts) — a fixed-host const URL fetched
15
+ * via getJson (redirect:"error" + 30s timeout), memoized 6h, with a plausibility
16
+ * FLOOR (a truncated catalog must NEVER read as "control not found"), then
17
+ * client-side filter/lookup. An outage/4xx/timeout THROWS (never a fake empty).
18
+ *
19
+ * SSRF: fixed host `raw.githubusercontent.com` + a fixed, pinned path (no free
20
+ * host/path). Filtering is CLIENT-SIDE over the parsed catalog.
21
+ */
22
+
23
+ import { getJson, driftError } from "./datasource.js";
24
+ import { memoize } from "./cache.js";
25
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
26
+
27
+ export const OSCAL_HOST = "raw.githubusercontent.com";
28
+ const OSCAL_URL =
29
+ "https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json";
30
+ const OSCAL_LABEL = "nist-oscal:sp800-53r5";
31
+ const OSCAL_TIMEOUT_MS = 30_000;
32
+ const OSCAL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
33
+ // Plausibility floor: 800-53 Rev5 has 20 control families and ~1000 controls. A
34
+ // truncated catalog with far fewer must THROW, never read as "control not found".
35
+ const FAMILY_FLOOR = 15;
36
+
37
+ const PROVENANCE_NOTE =
38
+ "Source: NIST SP 800-53 Rev 5 OSCAL catalog, published at github.com/usnistgov/oscal-content (NIST's canonical machine-readable release; authoritative first-party data served from GitHub, not a .gov API host).";
39
+ const CLIENT_FILTER_NOTE =
40
+ "The catalog has no query API — the full published OSCAL JSON is fetched (cached 6h) and filtered CLIENT-SIDE (controlId exact, family exact, keyword = case-insensitive substring over title+statement). totalAvailable is the EXACT match count.";
41
+ const REFERENCE_NOTE =
42
+ "This is the REQUIREMENT catalog (control text), NOT an assessment or an authorization. A control's applicability depends on the system's FIPS-199 impact baseline (Low/Moderate/High) and overlay — which this catalog does not encode.";
43
+
44
+ // ─── Parsed shapes ────────────────────────────────────────────────
45
+ export type NistControl = {
46
+ id: string; // display form, e.g. "AC-2"
47
+ family: string; // e.g. "AC — Access Control"
48
+ title: string;
49
+ statement: string; // assembled requirement prose (labelled, multi-line)
50
+ guidance: string | null; // discussion prose
51
+ enhancements: { id: string; title: string }[]; // e.g. AC-2(1)
52
+ };
53
+
54
+ type OscalPart = {
55
+ name?: string;
56
+ prose?: string;
57
+ props?: { name?: string; value?: string }[];
58
+ parts?: OscalPart[];
59
+ };
60
+ type OscalControl = {
61
+ id?: string;
62
+ title?: string;
63
+ parts?: OscalPart[];
64
+ controls?: OscalControl[];
65
+ };
66
+ type OscalGroup = { id?: string; title?: string; controls?: OscalControl[] };
67
+
68
+ /** OSCAL control id ("ac-2", "ac-2.1") → display id ("AC-2", "AC-2(1)"). */
69
+ function displayId(rawId: string): string {
70
+ const m = /^([a-z]+)-(\d+)(?:\.(\d+))?$/i.exec(rawId.trim());
71
+ if (!m) return rawId.toUpperCase();
72
+ const fam = (m[1] ?? "").toUpperCase();
73
+ // Strip leading zeros so a zero-padded input ('AC-02') normalizes to the catalog's
74
+ // canonical unpadded form ('AC-2') — else an exact controlId lookup would miss.
75
+ const num = String(Number(m[2] ?? "0"));
76
+ const enh = m[3] !== undefined ? String(Number(m[3])) : undefined;
77
+ return enh !== undefined ? `${fam}-${num}(${enh})` : `${fam}-${num}`;
78
+ }
79
+
80
+ /** Recursively collect a statement part's prose as labelled, indented lines. */
81
+ function collectProse(part: OscalPart, depth: number, out: string[]): void {
82
+ const label = part.props?.find((p) => p.name === "label")?.value;
83
+ const indent = " ".repeat(depth);
84
+ const prefix = label ? `${label} ` : "";
85
+ if (part.prose && part.prose.trim().length > 0) {
86
+ out.push(`${indent}${prefix}${part.prose.trim()}`);
87
+ } else if (label) {
88
+ out.push(`${indent}${prefix}`.trimEnd());
89
+ }
90
+ for (const sub of part.parts ?? []) collectProse(sub, depth + 1, out);
91
+ }
92
+
93
+ function partProse(control: OscalControl, name: string): string | null {
94
+ const part = (control.parts ?? []).find((p) => p.name === name);
95
+ if (!part) return null;
96
+ const out: string[] = [];
97
+ collectProse(part, 0, out);
98
+ const text = out.join("\n").trim();
99
+ return text.length > 0 ? text : null;
100
+ }
101
+
102
+ function mapControl(control: OscalControl, family: string): NistControl {
103
+ return {
104
+ id: displayId(control.id ?? ""),
105
+ family,
106
+ title: (control.title ?? "").trim(),
107
+ statement: partProse(control, "statement") ?? "",
108
+ guidance: partProse(control, "guidance"),
109
+ enhancements: (control.controls ?? []).map((e) => ({
110
+ id: displayId(e.id ?? ""),
111
+ title: (e.title ?? "").trim(),
112
+ })),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Fetch + parse the OSCAL catalog into a flat list of TOP-LEVEL controls, memoized
118
+ * 6h. Header/shape guarded: `catalog.groups` MUST be an array with ≥ FAMILY_FLOOR
119
+ * families (else driftError — a truncated catalog is NEVER a fake empty). Enhancements
120
+ * are carried on each control (not indexed as top-level entries).
121
+ */
122
+ async function loadControls(): Promise<NistControl[]> {
123
+ return memoize(
124
+ "nist:sp800-53r5",
125
+ async () => {
126
+ const built = new URL(OSCAL_URL);
127
+ if (built.hostname !== OSCAL_HOST || built.protocol !== "https:") {
128
+ throw driftError(OSCAL_LABEL, `Constructed OSCAL URL host ${JSON.stringify(built.hostname)} is not ${OSCAL_HOST} over https — refusing to fetch (SSRF safety).`);
129
+ }
130
+ const body = (await getJson(OSCAL_URL, {
131
+ label: OSCAL_LABEL,
132
+ redirect: "error",
133
+ timeoutMs: OSCAL_TIMEOUT_MS,
134
+ })) as { catalog?: { groups?: unknown } };
135
+ const groups = body.catalog?.groups;
136
+ if (!Array.isArray(groups) || groups.length < FAMILY_FLOOR) {
137
+ throw driftError(
138
+ OSCAL_LABEL,
139
+ `OSCAL catalog.groups missing or implausibly small (${Array.isArray(groups) ? groups.length : "not-an-array"} < ${FAMILY_FLOOR} families) — treating as schema drift / truncation, never a fake-empty catalog.`,
140
+ );
141
+ }
142
+ const controls: NistControl[] = [];
143
+ for (const g of groups as OscalGroup[]) {
144
+ const family = `${(g.id ?? "").toUpperCase()} — ${(g.title ?? "").trim()}`;
145
+ for (const c of g.controls ?? []) controls.push(mapControl(c, family));
146
+ }
147
+ return controls;
148
+ },
149
+ OSCAL_CACHE_TTL_MS,
150
+ );
151
+ }
152
+
153
+ // ─── Tool: nist_800_53_controls ───────────────────────────────────
154
+ /**
155
+ * Look up NIST SP 800-53 Rev 5 controls by controlId (exact, e.g. "AC-2"), family
156
+ * (exact family letter or name, e.g. "AC" / "Access Control"), and/or keyword
157
+ * (case-insensitive substring over title + statement). Client-side filters over the
158
+ * cached OSCAL catalog; honest `_meta` (exact match total; provenance disclosed).
159
+ */
160
+ export async function searchControls(args: {
161
+ controlId?: string;
162
+ family?: string;
163
+ keyword?: string;
164
+ limit?: number;
165
+ offset?: number;
166
+ }): Promise<MetaBundle> {
167
+ const limit = args.limit ?? 25;
168
+ const offset = args.offset ?? 0;
169
+ const all = await loadControls();
170
+
171
+ const filtersApplied: string[] = [];
172
+ const idQ = args.controlId !== undefined ? displayId(args.controlId) : undefined;
173
+ const famQ = args.family?.trim().toLowerCase();
174
+ const kwQ = args.keyword?.trim().toLowerCase();
175
+ if (args.controlId !== undefined) filtersApplied.push("controlId");
176
+ if (args.family !== undefined) filtersApplied.push("family");
177
+ if (args.keyword !== undefined) filtersApplied.push("keyword");
178
+
179
+ const matched = all.filter((c) => {
180
+ if (idQ && c.id.toUpperCase() !== idQ.toUpperCase()) return false;
181
+ if (famQ) {
182
+ // family field is "AC — Access Control"; match either the letter code or a
183
+ // substring of the title (both case-insensitive).
184
+ const fam = c.family.toLowerCase();
185
+ const code = (fam.split("—")[0] ?? "").trim();
186
+ if (code !== famQ && !fam.includes(famQ)) return false;
187
+ }
188
+ if (kwQ) {
189
+ // Search the title + requirement statement AND each enhancement's title, so a
190
+ // term that lives only in an enhancement (e.g. "multi-factor" → IA-2(1)) still
191
+ // surfaces the parent control. filtersApplied still lists 'keyword'.
192
+ const hay = `${c.title}\n${c.statement}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
193
+ if (!hay.includes(kwQ)) return false;
194
+ }
195
+ return true;
196
+ });
197
+
198
+ const totalAvailable = matched.length;
199
+ const page = matched.slice(offset, offset + limit);
200
+ const returned = page.length;
201
+ const hasMore = offset + returned < totalAvailable;
202
+ const nextOffset = hasMore ? offset + returned : null;
203
+
204
+ return withMeta(
205
+ { controls: page },
206
+ {
207
+ source: "NIST SP 800-53 Rev 5 (OSCAL catalog, keyless)",
208
+ keylessMode: true,
209
+ returned,
210
+ totalAvailable,
211
+ truncated: hasMore,
212
+ filtersApplied,
213
+ filtersDropped: [],
214
+ fieldsUnavailable: [],
215
+ pagination: { offset, limit, hasMore, nextOffset },
216
+ notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE],
217
+ } satisfies Partial<ResponseMeta>,
218
+ );
219
+ }