@cliwant/mcp-sam-gov 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ecfr.ts CHANGED
@@ -1,127 +1,134 @@
1
- /**
2
- * eCFR (Electronic Code of Federal Regulations) wrappers (keyless).
3
- *
4
- * eCFR is the up-to-date version of the CFR — Title 48 = FAR (Federal
5
- * Acquisition Regulation), Title 2 = Federal financial assistance, etc.
6
- * For a federal contractor, eCFR is the primary source for regulation
7
- * text the agent should quote when answering compliance questions.
8
- *
9
- * Endpoints:
10
- * - /versioner/v1/titles.json — list 50 CFR titles + last-amended dates
11
- * - /search/v1/results — full-text search across the entire CFR
12
- *
13
- * Both keyless. Documented at https://www.ecfr.gov/developers/.
14
- */
15
-
16
- const ECFR = "https://www.ecfr.gov/api";
17
-
18
- async function fetchJson<T>(url: string): Promise<T> {
19
- const r = await fetch(url, {
20
- headers: { Accept: "application/json" },
21
- signal: AbortSignal.timeout(15_000),
22
- });
23
- if (!r.ok) {
24
- throw new Error(`eCFR ${url} returned ${r.status}`);
25
- }
26
- return (await r.json()) as T;
27
- }
28
-
29
- export async function listTitles() {
30
- type Resp = {
31
- titles?: {
32
- number?: number;
33
- name?: string;
34
- latest_amended_on?: string;
35
- latest_issue_date?: string;
36
- up_to_date_as_of?: string;
37
- reserved?: boolean;
38
- }[];
39
- };
40
- const json = await fetchJson<Resp>(`${ECFR}/versioner/v1/titles.json`);
41
- return {
42
- titles: (json.titles ?? []).map((t) => ({
43
- number: t.number ?? 0,
44
- name: t.name ?? "",
45
- latestAmendedOn: t.latest_amended_on,
46
- latestIssueDate: t.latest_issue_date,
47
- upToDateAsOf: t.up_to_date_as_of,
48
- reserved: !!t.reserved,
49
- })),
50
- };
51
- }
52
-
53
- export async function search(args: {
54
- query: string;
55
- titleNumber?: number;
56
- perPage?: number;
57
- }) {
58
- const url = new URL(`${ECFR}/search/v1/results`);
59
- url.searchParams.set("query", args.query);
60
- url.searchParams.set("per_page", String(args.perPage ?? 5));
61
- if (args.titleNumber) {
62
- // eCFR search filter: hierarchy[title]=N (NOT just title=N — that's
63
- // an "unpermitted parameter" error from the eCFR API).
64
- url.searchParams.set("hierarchy[title]", String(args.titleNumber));
65
- }
66
-
67
- type Resp = {
68
- results?: {
69
- starts_on?: string;
70
- ends_on?: string | null;
71
- type?: string;
72
- hierarchy?: {
73
- title?: string;
74
- chapter?: string;
75
- subchapter?: string;
76
- part?: string;
77
- subpart?: string;
78
- section?: string;
79
- };
80
- hierarchy_headings?: Record<string, string | null>;
81
- headings?: Record<string, string | null>;
82
- full_text_excerpt?: string;
83
- score?: number;
84
- }[];
85
- };
86
- const json = await fetchJson<Resp>(url.toString());
87
- return {
88
- results: (json.results ?? []).map((r) => ({
89
- type: r.type ?? "",
90
- title: r.hierarchy?.title ?? "",
91
- chapter: r.hierarchy?.chapter,
92
- part: r.hierarchy?.part,
93
- subpart: r.hierarchy?.subpart,
94
- section: r.hierarchy?.section,
95
- headingPath: Object.values(r.hierarchy_headings ?? {})
96
- .filter(Boolean)
97
- .join(" "),
98
- excerpt: stripHtml(r.full_text_excerpt ?? ""),
99
- score: r.score ?? 0,
100
- // Stable ecfr.gov URL pattern from the hierarchy
101
- ecfrUrl: r.hierarchy
102
- ? buildEcfrUrl(r.hierarchy)
103
- : "",
104
- effectiveOn: r.starts_on ?? "",
105
- })),
106
- };
107
- }
108
-
109
- function stripHtml(s: string): string {
110
- return s
111
- .replace(/<[^>]+>/g, "")
112
- .replace(/\s+/g, " ")
113
- .trim();
114
- }
115
-
116
- function buildEcfrUrl(h: {
117
- title?: string;
118
- chapter?: string;
119
- part?: string;
120
- section?: string;
121
- }): string {
122
- const base = `https://www.ecfr.gov/current/title-${h.title}`;
123
- if (h.section) return `${base}/section-${h.section}`;
124
- if (h.part) return `${base}/part-${h.part}`;
125
- if (h.chapter) return `${base}/chapter-${h.chapter}`;
126
- return base;
127
- }
1
+ /**
2
+ * eCFR (Electronic Code of Federal Regulations) wrappers (keyless).
3
+ *
4
+ * eCFR is the up-to-date version of the CFR — Title 48 = FAR (Federal
5
+ * Acquisition Regulation), Title 2 = Federal financial assistance, etc.
6
+ * For a federal contractor, eCFR is the primary source for regulation
7
+ * text the agent should quote when answering compliance questions.
8
+ *
9
+ * Endpoints:
10
+ * - /versioner/v1/titles.json — list 50 CFR titles + last-amended dates
11
+ * - /search/v1/results — full-text search across the entire CFR
12
+ *
13
+ * Both keyless. Documented at https://www.ecfr.gov/developers/.
14
+ */
15
+
16
+ import { fetchWithRetry } from "./errors.js";
17
+ import { memoize } from "./cache.js";
18
+
19
+ const ECFR = "https://www.ecfr.gov/api";
20
+
21
+ async function fetchJson<T>(url: string): Promise<T> {
22
+ const r = await fetchWithRetry(
23
+ url,
24
+ {
25
+ headers: { Accept: "application/json" },
26
+ signal: AbortSignal.timeout(15_000),
27
+ },
28
+ `ecfr:${url.split("/api/")[1] ?? url}`,
29
+ );
30
+ return (await r.json()) as T;
31
+ }
32
+
33
+ export async function listTitles() {
34
+ // 50 CFR titles change very infrequently. Cache aggressively (5 min).
35
+ return memoize("ecfr:titles", async () => {
36
+ type Resp = {
37
+ titles?: {
38
+ number?: number;
39
+ name?: string;
40
+ latest_amended_on?: string;
41
+ latest_issue_date?: string;
42
+ up_to_date_as_of?: string;
43
+ reserved?: boolean;
44
+ }[];
45
+ };
46
+ const json = await fetchJson<Resp>(`${ECFR}/versioner/v1/titles.json`);
47
+ return {
48
+ titles: (json.titles ?? []).map((t) => ({
49
+ number: t.number ?? 0,
50
+ name: t.name ?? "",
51
+ latestAmendedOn: t.latest_amended_on,
52
+ latestIssueDate: t.latest_issue_date,
53
+ upToDateAsOf: t.up_to_date_as_of,
54
+ reserved: !!t.reserved,
55
+ })),
56
+ };
57
+ });
58
+ }
59
+
60
+ export async function search(args: {
61
+ query: string;
62
+ titleNumber?: number;
63
+ perPage?: number;
64
+ }) {
65
+ const url = new URL(`${ECFR}/search/v1/results`);
66
+ url.searchParams.set("query", args.query);
67
+ url.searchParams.set("per_page", String(args.perPage ?? 5));
68
+ if (args.titleNumber) {
69
+ // eCFR search filter: hierarchy[title]=N (NOT just title=N — that's
70
+ // an "unpermitted parameter" error from the eCFR API).
71
+ url.searchParams.set("hierarchy[title]", String(args.titleNumber));
72
+ }
73
+
74
+ type Resp = {
75
+ results?: {
76
+ starts_on?: string;
77
+ ends_on?: string | null;
78
+ type?: string;
79
+ hierarchy?: {
80
+ title?: string;
81
+ chapter?: string;
82
+ subchapter?: string;
83
+ part?: string;
84
+ subpart?: string;
85
+ section?: string;
86
+ };
87
+ hierarchy_headings?: Record<string, string | null>;
88
+ headings?: Record<string, string | null>;
89
+ full_text_excerpt?: string;
90
+ score?: number;
91
+ }[];
92
+ };
93
+ const json = await fetchJson<Resp>(url.toString());
94
+ return {
95
+ results: (json.results ?? []).map((r) => ({
96
+ type: r.type ?? "",
97
+ title: r.hierarchy?.title ?? "",
98
+ chapter: r.hierarchy?.chapter,
99
+ part: r.hierarchy?.part,
100
+ subpart: r.hierarchy?.subpart,
101
+ section: r.hierarchy?.section,
102
+ headingPath: Object.values(r.hierarchy_headings ?? {})
103
+ .filter(Boolean)
104
+ .join(" "),
105
+ excerpt: stripHtml(r.full_text_excerpt ?? ""),
106
+ score: r.score ?? 0,
107
+ // Stable ecfr.gov URL pattern from the hierarchy
108
+ ecfrUrl: r.hierarchy
109
+ ? buildEcfrUrl(r.hierarchy)
110
+ : "",
111
+ effectiveOn: r.starts_on ?? "",
112
+ })),
113
+ };
114
+ }
115
+
116
+ function stripHtml(s: string): string {
117
+ return s
118
+ .replace(/<[^>]+>/g, "")
119
+ .replace(/\s+/g, " ")
120
+ .trim();
121
+ }
122
+
123
+ function buildEcfrUrl(h: {
124
+ title?: string;
125
+ chapter?: string;
126
+ part?: string;
127
+ section?: string;
128
+ }): string {
129
+ const base = `https://www.ecfr.gov/current/title-${h.title}`;
130
+ if (h.section) return `${base}/section-${h.section}`;
131
+ if (h.part) return `${base}/part-${h.part}`;
132
+ if (h.chapter) return `${base}/chapter-${h.chapter}`;
133
+ return base;
134
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Structured error envelope for every tool response.
3
+ *
4
+ * Why this exists
5
+ * ----------------
6
+ * Federal APIs fail in 5 distinct ways: rate-limited (429), down
7
+ * (5xx), schema-drift (200 with unexpected shape), notice-not-found
8
+ * (404), and transient network. Each has a different retry strategy.
9
+ * If we just throw, the LLM sees "Tool error: TypeError: x is
10
+ * undefined" and gives up.
11
+ *
12
+ * Every tool should return either:
13
+ * { ok: true, data: ... }
14
+ * { ok: false, error: { kind, message, retryable, retryAfterSeconds? } }
15
+ *
16
+ * The MCP server layer surfaces this as JSON to the calling agent.
17
+ * The agent can then decide: retry now, retry later, or surface
18
+ * the error to the user with appropriate framing.
19
+ */
20
+
21
+ export type ErrorKind =
22
+ /** HTTP 429. Retry after `retryAfterSeconds`. */
23
+ | "rate_limited"
24
+ /** HTTP 5xx or network error. Likely transient. */
25
+ | "upstream_unavailable"
26
+ /** HTTP 404 / empty results. Don't retry. */
27
+ | "not_found"
28
+ /** Caller passed bad input (e.g. malformed noticeId). Don't retry. */
29
+ | "invalid_input"
30
+ /** API returned 200 but we couldn't parse / shape doesn't match. */
31
+ | "schema_drift"
32
+ /** Anything else. Don't retry. */
33
+ | "unknown";
34
+
35
+ export type ToolError = {
36
+ kind: ErrorKind;
37
+ message: string;
38
+ /** Whether the agent should retry. Pairs with retryAfterSeconds. */
39
+ retryable: boolean;
40
+ /** If rate-limited, advisory wait time. Honors `Retry-After` header. */
41
+ retryAfterSeconds?: number;
42
+ /** Echo upstream HTTP status when available — helps debug. */
43
+ upstreamStatus?: number;
44
+ /** Endpoint that failed — for ops. */
45
+ upstreamEndpoint?: string;
46
+ };
47
+
48
+ export type ToolResult<T> =
49
+ | { ok: true; data: T }
50
+ | { ok: false; error: ToolError };
51
+
52
+ const RATE_LIMIT_DEFAULT_SECONDS = 30;
53
+
54
+ export class ToolErrorCarrier extends Error {
55
+ readonly toolError: ToolError;
56
+ constructor(toolError: ToolError) {
57
+ super(toolError.message);
58
+ this.toolError = toolError;
59
+ this.name = "ToolErrorCarrier";
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Convert a fetch Response into a structured tool error.
65
+ *
66
+ * Honors `Retry-After` (both seconds-int and HTTP-date forms).
67
+ */
68
+ export function errorFromResponse(
69
+ r: Response,
70
+ endpoint: string,
71
+ ): ToolError {
72
+ const upstreamStatus = r.status;
73
+ if (r.status === 429) {
74
+ const retryAfter = parseRetryAfter(r.headers.get("Retry-After"));
75
+ return {
76
+ kind: "rate_limited",
77
+ message: `Upstream rate-limited (HTTP 429) at ${endpoint}. Retry after ${retryAfter}s.`,
78
+ retryable: true,
79
+ retryAfterSeconds: retryAfter,
80
+ upstreamStatus,
81
+ upstreamEndpoint: endpoint,
82
+ };
83
+ }
84
+ if (r.status === 404) {
85
+ return {
86
+ kind: "not_found",
87
+ message: `Resource not found at ${endpoint} (HTTP 404).`,
88
+ retryable: false,
89
+ upstreamStatus,
90
+ upstreamEndpoint: endpoint,
91
+ };
92
+ }
93
+ if (r.status >= 500) {
94
+ return {
95
+ kind: "upstream_unavailable",
96
+ message: `Upstream server error (HTTP ${r.status}) at ${endpoint}. Try again later.`,
97
+ retryable: true,
98
+ retryAfterSeconds: 60,
99
+ upstreamStatus,
100
+ upstreamEndpoint: endpoint,
101
+ };
102
+ }
103
+ if (r.status >= 400) {
104
+ return {
105
+ kind: "invalid_input",
106
+ message: `Bad request (HTTP ${r.status}) at ${endpoint}.`,
107
+ retryable: false,
108
+ upstreamStatus,
109
+ upstreamEndpoint: endpoint,
110
+ };
111
+ }
112
+ return {
113
+ kind: "unknown",
114
+ message: `Unexpected status ${r.status} at ${endpoint}.`,
115
+ retryable: false,
116
+ upstreamStatus,
117
+ upstreamEndpoint: endpoint,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Wrap a fetch + json call in retry-with-backoff for transient errors.
123
+ *
124
+ * Strategy: up to 3 attempts. On 429: respect Retry-After up to 60s.
125
+ * On 5xx: 1s, 2s, 4s exponential. On parse error: no retry (schema
126
+ * drift — needs human investigation).
127
+ */
128
+ export async function fetchWithRetry(
129
+ url: string,
130
+ init: RequestInit,
131
+ endpointLabel: string,
132
+ ): Promise<Response> {
133
+ const maxAttempts = 3;
134
+ let lastErr: ToolError | undefined;
135
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
136
+ try {
137
+ const r = await fetch(url, init);
138
+ if (r.ok) return r;
139
+ const err = errorFromResponse(r, endpointLabel);
140
+ if (!err.retryable || attempt === maxAttempts) {
141
+ throw new ToolErrorCarrier(err);
142
+ }
143
+ lastErr = err;
144
+ const wait = err.retryAfterSeconds
145
+ ? Math.min(err.retryAfterSeconds, 60)
146
+ : Math.pow(2, attempt - 1);
147
+ await new Promise((res) => setTimeout(res, wait * 1000));
148
+ } catch (e) {
149
+ if (e instanceof ToolErrorCarrier) throw e;
150
+ // Network-level error
151
+ lastErr = {
152
+ kind: "upstream_unavailable",
153
+ message: `Network error reaching ${endpointLabel}: ${(e as Error).message}`,
154
+ retryable: true,
155
+ retryAfterSeconds: 30,
156
+ upstreamEndpoint: endpointLabel,
157
+ };
158
+ if (attempt === maxAttempts) {
159
+ throw new ToolErrorCarrier(lastErr);
160
+ }
161
+ await new Promise((res) =>
162
+ setTimeout(res, Math.pow(2, attempt - 1) * 1000),
163
+ );
164
+ }
165
+ }
166
+ throw new ToolErrorCarrier(
167
+ lastErr ?? {
168
+ kind: "unknown",
169
+ message: `${endpointLabel} failed after ${maxAttempts} attempts.`,
170
+ retryable: false,
171
+ upstreamEndpoint: endpointLabel,
172
+ },
173
+ );
174
+ }
175
+
176
+ function parseRetryAfter(value: string | null): number {
177
+ if (!value) return RATE_LIMIT_DEFAULT_SECONDS;
178
+ const asInt = Number.parseInt(value, 10);
179
+ if (Number.isFinite(asInt)) return asInt;
180
+ // HTTP-date form
181
+ const date = Date.parse(value);
182
+ if (!Number.isNaN(date)) {
183
+ return Math.max(1, Math.ceil((date - Date.now()) / 1000));
184
+ }
185
+ return RATE_LIMIT_DEFAULT_SECONDS;
186
+ }
187
+
188
+ /**
189
+ * Convert any thrown error into a serializable ToolError envelope.
190
+ * Used at the dispatcher boundary — server.ts catches everything
191
+ * and wraps before returning to the MCP client.
192
+ */
193
+ export function toToolError(e: unknown, endpointLabel?: string): ToolError {
194
+ if (e instanceof ToolErrorCarrier) return e.toolError;
195
+ if (e instanceof Error) {
196
+ const msg = e.message;
197
+ // Common fetch timeout signature
198
+ if (e.name === "TimeoutError" || /timeout|aborted/i.test(msg)) {
199
+ return {
200
+ kind: "upstream_unavailable",
201
+ message: `${endpointLabel ?? "upstream"} timed out: ${msg}`,
202
+ retryable: true,
203
+ retryAfterSeconds: 30,
204
+ upstreamEndpoint: endpointLabel,
205
+ };
206
+ }
207
+ return {
208
+ kind: "unknown",
209
+ message: msg,
210
+ retryable: false,
211
+ upstreamEndpoint: endpointLabel,
212
+ };
213
+ }
214
+ return {
215
+ kind: "unknown",
216
+ message: String(e),
217
+ retryable: false,
218
+ upstreamEndpoint: endpointLabel,
219
+ };
220
+ }