@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/README.md +55 -5
- package/dist/cache.d.ts +40 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +60 -0
- package/dist/cache.js.map +1 -0
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +18 -16
- package/dist/ecfr.js.map +1 -1
- package/dist/errors.d.ts +76 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +179 -0
- package/dist/errors.js.map +1 -0
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +17 -16
- package/dist/federal-register.js.map +1 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +3 -5
- package/dist/grants.js.map +1 -1
- package/dist/server.js +14 -5
- package/dist/server.js.map +1 -1
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +95 -87
- package/dist/usaspending.js.map +1 -1
- package/package.json +1 -1
- package/src/cache.ts +68 -0
- package/src/ecfr.ts +134 -127
- package/src/errors.ts +220 -0
- package/src/federal-register.ts +197 -191
- package/src/grants.ts +158 -155
- package/src/server.ts +14 -5
- package/src/usaspending.ts +948 -925
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|