@forgemeshlabs/gov-transparency-mcp 0.1.3 → 0.1.5
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 +3 -1
- package/index.js +75 -100
- package/package.json +3 -2
- package/x402-guard.js +141 -0
package/README.md
CHANGED
|
@@ -43,7 +43,9 @@ All official, all public domain: US House Clerk financial disclosures (parsed fr
|
|
|
43
43
|
|
|
44
44
|
`WALLET_PRIVATE_KEY` must be a **dedicated low-balance wallet** holding a small amount of USDC on Base mainnet — never your primary wallet. `get_endpoint_spec` works without one.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
Node.js 20+ and a dedicated, low-balance Base wallet funded with USDC. The server refuses to sign for any payee other than the two Utility Grid wallets, any network other than Base mainnet, any asset other than USDC, or any amount over the cap ($0.02 per call, $10 per session). The env vars `X402_MAX_PRICE_USD` and `X402_SESSION_BUDGET_USD` can only lower those caps. Requests are same-origin, time out after 60 seconds, are capped at 2 MB, and never follow redirects.
|
|
47
49
|
|
|
48
50
|
## How payment works
|
|
49
51
|
|
package/index.js
CHANGED
|
@@ -8,12 +8,17 @@ const { x402Client, x402HTTPClient } = require("@x402/core/client");
|
|
|
8
8
|
const { ExactEvmScheme } = require("@x402/evm/exact/client");
|
|
9
9
|
const { toClientEvmSigner } = require("@x402/evm");
|
|
10
10
|
const { privateKeyToAccount } = require("viem/accounts");
|
|
11
|
-
const {
|
|
12
|
-
const { base } = require("viem/chains");
|
|
11
|
+
const { createGuard } = require("./x402-guard");
|
|
13
12
|
|
|
14
|
-
const VERSION = "
|
|
15
|
-
const BASE_URL =
|
|
16
|
-
|
|
13
|
+
const VERSION = require("./package.json").version;
|
|
14
|
+
const BASE_URL = "https://x402.forgemesh.io";
|
|
15
|
+
// Highest listed price is $0.02; the guard refuses to sign for any other payee, network, asset, or higher amount.
|
|
16
|
+
const guard = createGuard({
|
|
17
|
+
baseUrl: BASE_URL,
|
|
18
|
+
payTo: ["0x850363a27F0aC6fEb9C7a3eC4C1d295262dF9432", "0x84A1827F1705C257e80771fDc2B152Aea4A57a08"],
|
|
19
|
+
maxPriceUsd: 0.02,
|
|
20
|
+
sessionBudgetUsd: 10,
|
|
21
|
+
});
|
|
17
22
|
|
|
18
23
|
// Every tool maps to one paid route on the ForgeMesh Utility Grid's
|
|
19
24
|
// gov-transparency shelf. All underlying data is official US government
|
|
@@ -31,70 +36,12 @@ function buildBaseHttpClient() {
|
|
|
31
36
|
}
|
|
32
37
|
const pk = key.startsWith("0x") ? key : "0x" + key;
|
|
33
38
|
const account = privateKeyToAccount(pk);
|
|
34
|
-
const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account)));
|
|
39
|
+
const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account))).registerPolicy(guard.policy);
|
|
35
40
|
return { httpClient: new x402HTTPClient(coreClient), account };
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
async function createChainTimedPaymentPayload(httpClient, paymentRequired) {
|
|
41
|
-
try {
|
|
42
|
-
const publicClient = createPublicClient({ chain: base, transport: http(BASE_RPC_URL) });
|
|
43
|
-
const block = await publicClient.getBlock();
|
|
44
|
-
const chainNow = Number(block.timestamp);
|
|
45
|
-
const originalNow = Date.now;
|
|
46
|
-
const localNow = Math.floor(originalNow() / 1000);
|
|
47
|
-
const timeout = Number(paymentRequired.accepts?.[0]?.maxTimeoutSeconds || 300);
|
|
48
|
-
const lowerBound = localNow + 30 - timeout;
|
|
49
|
-
const upperBound = chainNow + 600;
|
|
50
|
-
const signingNow = Math.min(Math.max(chainNow, lowerBound), upperBound);
|
|
51
|
-
Date.now = () => signingNow * 1000;
|
|
52
|
-
try {
|
|
53
|
-
return await httpClient.createPaymentPayload(paymentRequired);
|
|
54
|
-
} finally {
|
|
55
|
-
Date.now = originalNow;
|
|
56
|
-
}
|
|
57
|
-
} catch (_) {
|
|
58
|
-
return httpClient.createPaymentPayload(paymentRequired);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function paidPost(ctx, path, body) {
|
|
63
|
-
const { httpClient } = ctx;
|
|
64
|
-
const url = BASE_URL + path;
|
|
65
|
-
const init = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) };
|
|
66
|
-
const res = await fetch(url, init);
|
|
67
|
-
|
|
68
|
-
if (res.status === 402) {
|
|
69
|
-
let challengeBody;
|
|
70
|
-
try {
|
|
71
|
-
challengeBody = await res.clone().json();
|
|
72
|
-
} catch (_) {}
|
|
73
|
-
const paymentRequired = httpClient.getPaymentRequiredResponse((name) => res.headers.get(name), challengeBody);
|
|
74
|
-
const paymentPayload = await createChainTimedPaymentPayload(httpClient, paymentRequired);
|
|
75
|
-
const paidRes = await fetch(url, {
|
|
76
|
-
...init,
|
|
77
|
-
headers: { ...init.headers, ...httpClient.encodePaymentSignatureHeader(paymentPayload) },
|
|
78
|
-
});
|
|
79
|
-
if (!paidRes.ok) {
|
|
80
|
-
const errBody = await paidRes.text().catch(() => paidRes.statusText);
|
|
81
|
-
throw new Error(`HTTP ${paidRes.status}: ${errBody.slice(0, 300)}`);
|
|
82
|
-
}
|
|
83
|
-
const data = await paidRes.json();
|
|
84
|
-
try {
|
|
85
|
-
const settleResponse = httpClient.getPaymentSettleResponse((name) => paidRes.headers.get(name));
|
|
86
|
-
if (settleResponse && data && typeof data === "object" && !Array.isArray(data)) {
|
|
87
|
-
return { ...data, _payment: settleResponse };
|
|
88
|
-
}
|
|
89
|
-
} catch (_) {}
|
|
90
|
-
return data;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!res.ok) {
|
|
94
|
-
const errBody = await res.text().catch(() => res.statusText);
|
|
95
|
-
throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 300)}`);
|
|
96
|
-
}
|
|
97
|
-
return res.json();
|
|
43
|
+
function paidPost(ctx, path, body) {
|
|
44
|
+
return guard.callPaid(ctx.httpClient, path, { method: "POST", body: body || {} });
|
|
98
45
|
}
|
|
99
46
|
|
|
100
47
|
// --- free discovery ---------------------------------------------------------
|
|
@@ -112,9 +59,10 @@ const GOV_PATHS = [
|
|
|
112
59
|
];
|
|
113
60
|
|
|
114
61
|
async function getEndpointSpec(args) {
|
|
115
|
-
const res = await
|
|
62
|
+
const res = await guard.fetchBounded(`${BASE_URL}/openapi.json`);
|
|
116
63
|
if (!res.ok) throw new Error(`Failed to fetch discovery doc: HTTP ${res.status}`);
|
|
117
|
-
|
|
64
|
+
let spec;
|
|
65
|
+
try { spec = JSON.parse(res.text); } catch { throw new Error("Failed to fetch discovery doc: non-JSON response"); }
|
|
118
66
|
let p = String(args.path || "").trim();
|
|
119
67
|
if (p && !p.startsWith("/")) p = "/" + p;
|
|
120
68
|
if (!p) {
|
|
@@ -154,13 +102,13 @@ const TOOLS = [
|
|
|
154
102
|
inputSchema: {
|
|
155
103
|
type: "object",
|
|
156
104
|
properties: {
|
|
157
|
-
member: { type: "string", description: "Member name substring, e.g. 'wittman'" },
|
|
158
|
-
ticker: { type: "string", description: "Exact ticker, e.g. 'NVDA'" },
|
|
105
|
+
member: { type: "string", maxLength: 100, description: "Member name substring, e.g. 'wittman'" },
|
|
106
|
+
ticker: { type: "string", maxLength: 10, pattern: "^[A-Za-z0-9.-]+$", description: "Exact ticker, e.g. 'NVDA'" },
|
|
159
107
|
type: { type: "string", enum: ["purchase", "sale", "sale_partial", "exchange"] },
|
|
160
|
-
state: { type: "string", description: "State or district prefix, e.g. 'VA' or 'VA01'" },
|
|
161
|
-
since: { type: "string", description: "ISO date lower bound on transaction date" },
|
|
162
|
-
until: { type: "string", description: "ISO date upper bound on transaction date" },
|
|
163
|
-
limit: { type: "integer", description: "Max rows (default 25, max 100)" },
|
|
108
|
+
state: { type: "string", maxLength: 8, pattern: "^[A-Za-z0-9]+$", description: "State or district prefix, e.g. 'VA' or 'VA01'" },
|
|
109
|
+
since: { type: "string", maxLength: 32, pattern: "^\\d{4}-\\d{2}-\\d{2}([T ][0-9:.Z+-]{1,20})?$", description: "ISO date lower bound on transaction date" },
|
|
110
|
+
until: { type: "string", maxLength: 32, pattern: "^\\d{4}-\\d{2}-\\d{2}([T ][0-9:.Z+-]{1,20})?$", description: "ISO date upper bound on transaction date" },
|
|
111
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max rows (default 25, max 100)" },
|
|
164
112
|
},
|
|
165
113
|
},
|
|
166
114
|
},
|
|
@@ -177,11 +125,11 @@ const TOOLS = [
|
|
|
177
125
|
inputSchema: {
|
|
178
126
|
type: "object",
|
|
179
127
|
properties: {
|
|
180
|
-
member: { type: "string", description: "Member name substring" },
|
|
181
|
-
state: { type: "string", description: "State or district prefix" },
|
|
182
|
-
filing_type: { type: "string", description: "Filing type code, e.g. 'P' for trade reports" },
|
|
183
|
-
year: { type: "integer", description: "Filing year, e.g. 2026" },
|
|
184
|
-
limit: { type: "integer", description: "Max rows (default 25, max 100)" },
|
|
128
|
+
member: { type: "string", maxLength: 100, description: "Member name substring" },
|
|
129
|
+
state: { type: "string", maxLength: 8, pattern: "^[A-Za-z0-9]+$", description: "State or district prefix" },
|
|
130
|
+
filing_type: { type: "string", maxLength: 3, pattern: "^[A-Za-z]+$", description: "Filing type code, e.g. 'P' for trade reports" },
|
|
131
|
+
year: { type: "integer", minimum: 1990, maximum: 2100, description: "Filing year, e.g. 2026" },
|
|
132
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max rows (default 25, max 100)" },
|
|
185
133
|
},
|
|
186
134
|
},
|
|
187
135
|
},
|
|
@@ -198,11 +146,11 @@ const TOOLS = [
|
|
|
198
146
|
inputSchema: {
|
|
199
147
|
type: "object",
|
|
200
148
|
properties: {
|
|
201
|
-
recipient: { type: "string", description: "Company name, min 3 chars (required)" },
|
|
202
|
-
agency: { type: "string", description: "Awarding top-tier agency, e.g. 'Department of Defense'" },
|
|
203
|
-
since: { type: "string", description: "ISO start date (default: 2 years back)" },
|
|
204
|
-
until: { type: "string", description: "ISO end date (default: today)" },
|
|
205
|
-
limit: { type: "integer", description: "Max awards (default 10, max 50)" },
|
|
149
|
+
recipient: { type: "string", maxLength: 200, description: "Company name, min 3 chars (required)" },
|
|
150
|
+
agency: { type: "string", maxLength: 200, description: "Awarding top-tier agency, e.g. 'Department of Defense'" },
|
|
151
|
+
since: { type: "string", maxLength: 32, pattern: "^\\d{4}-\\d{2}-\\d{2}([T ][0-9:.Z+-]{1,20})?$", description: "ISO start date (default: 2 years back)" },
|
|
152
|
+
until: { type: "string", maxLength: 32, pattern: "^\\d{4}-\\d{2}-\\d{2}([T ][0-9:.Z+-]{1,20})?$", description: "ISO end date (default: today)" },
|
|
153
|
+
limit: { type: "integer", minimum: 1, maximum: 50, description: "Max awards (default 10, max 50)" },
|
|
206
154
|
},
|
|
207
155
|
required: ["recipient"],
|
|
208
156
|
},
|
|
@@ -220,8 +168,8 @@ const TOOLS = [
|
|
|
220
168
|
inputSchema: {
|
|
221
169
|
type: "object",
|
|
222
170
|
properties: {
|
|
223
|
-
company: { type: "string", description: "Company name, min 3 chars (required)" },
|
|
224
|
-
limit: { type: "integer", description: "Max entities (default 5, max 20)" },
|
|
171
|
+
company: { type: "string", maxLength: 200, description: "Company name, min 3 chars (required)" },
|
|
172
|
+
limit: { type: "integer", minimum: 1, maximum: 20, description: "Max entities (default 5, max 20)" },
|
|
225
173
|
},
|
|
226
174
|
required: ["company"],
|
|
227
175
|
},
|
|
@@ -239,8 +187,8 @@ const TOOLS = [
|
|
|
239
187
|
inputSchema: {
|
|
240
188
|
type: "object",
|
|
241
189
|
properties: {
|
|
242
|
-
candidate: { type: "string", description: "Candidate name, min 3 chars" },
|
|
243
|
-
candidate_id: { type: "string", description: "Exact FEC candidate id (alternative to name)" },
|
|
190
|
+
candidate: { type: "string", maxLength: 200, description: "Candidate name, min 3 chars" },
|
|
191
|
+
candidate_id: { type: "string", maxLength: 20, pattern: "^[A-Za-z0-9]+$", description: "Exact FEC candidate id (alternative to name)" },
|
|
244
192
|
office: { type: "string", enum: ["president", "senate", "house"] },
|
|
245
193
|
},
|
|
246
194
|
},
|
|
@@ -258,7 +206,7 @@ const TOOLS = [
|
|
|
258
206
|
inputSchema: {
|
|
259
207
|
type: "object",
|
|
260
208
|
properties: {
|
|
261
|
-
candidate: { type: "string", description: "Candidate name, min 3 chars (required)" },
|
|
209
|
+
candidate: { type: "string", maxLength: 200, description: "Candidate name, min 3 chars (required)" },
|
|
262
210
|
},
|
|
263
211
|
required: ["candidate"],
|
|
264
212
|
},
|
|
@@ -276,9 +224,9 @@ const TOOLS = [
|
|
|
276
224
|
inputSchema: {
|
|
277
225
|
type: "object",
|
|
278
226
|
properties: {
|
|
279
|
-
client: { type: "string", description: "Client organization name, e.g. 'coinbase'" },
|
|
280
|
-
registrant: { type: "string", description: "Lobbying firm name" },
|
|
281
|
-
year: { type: "string", description: "Filing year, e.g. '2025'" },
|
|
227
|
+
client: { type: "string", maxLength: 200, description: "Client organization name, e.g. 'coinbase'" },
|
|
228
|
+
registrant: { type: "string", maxLength: 200, description: "Lobbying firm name" },
|
|
229
|
+
year: { type: "string", maxLength: 4, pattern: "^\\d{4}$", description: "Filing year, e.g. '2025'" },
|
|
282
230
|
},
|
|
283
231
|
},
|
|
284
232
|
},
|
|
@@ -295,7 +243,7 @@ const TOOLS = [
|
|
|
295
243
|
inputSchema: {
|
|
296
244
|
type: "object",
|
|
297
245
|
properties: {
|
|
298
|
-
q: { type: "string", description: "Search term, e.g. 'stablecoin'" },
|
|
246
|
+
q: { type: "string", maxLength: 200, description: "Search term, e.g. 'stablecoin'" },
|
|
299
247
|
},
|
|
300
248
|
},
|
|
301
249
|
},
|
|
@@ -312,9 +260,9 @@ const TOOLS = [
|
|
|
312
260
|
inputSchema: {
|
|
313
261
|
type: "object",
|
|
314
262
|
properties: {
|
|
315
|
-
congress: { type: "string", description: "Congress number, e.g. '119'" },
|
|
316
|
-
type: { type: "string", description: "Bill type: hr, s, hjres, sjres, hconres, sconres, hres, sres" },
|
|
317
|
-
number: { type: "string", description: "Bill number, e.g. '1'" },
|
|
263
|
+
congress: { type: "string", maxLength: 3, pattern: "^\\d{1,3}$", description: "Congress number, e.g. '119'" },
|
|
264
|
+
type: { type: "string", enum: ["hr", "s", "hjres", "sjres", "hconres", "sconres", "hres", "sres"], description: "Bill type: hr, s, hjres, sjres, hconres, sconres, hres, sres" },
|
|
265
|
+
number: { type: "string", maxLength: 6, pattern: "^\\d{1,6}$", description: "Bill number, e.g. '1'" },
|
|
318
266
|
},
|
|
319
267
|
},
|
|
320
268
|
},
|
|
@@ -331,7 +279,7 @@ const TOOLS = [
|
|
|
331
279
|
inputSchema: {
|
|
332
280
|
type: "object",
|
|
333
281
|
properties: {
|
|
334
|
-
path: { type: "string", description: "Route path, e.g. 'congress-stock-trades'" },
|
|
282
|
+
path: { type: "string", maxLength: 100, pattern: "^/?[A-Za-z0-9._-]+$", description: "Route path, e.g. 'congress-stock-trades'" },
|
|
335
283
|
},
|
|
336
284
|
},
|
|
337
285
|
},
|
|
@@ -349,6 +297,32 @@ const TOOL_ROUTES = {
|
|
|
349
297
|
lookup_bill: "/congress-bill-lookup",
|
|
350
298
|
};
|
|
351
299
|
|
|
300
|
+
// Validate arguments against the tool's own inputSchema before any network call or payment.
|
|
301
|
+
function validateArgs(name, args) {
|
|
302
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
303
|
+
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
304
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) throw new Error("arguments must be an object");
|
|
305
|
+
for (const key of tool.inputSchema.required || []) if (args[key] === undefined) throw new Error(`Missing required argument: ${key}`);
|
|
306
|
+
for (const [key, spec] of Object.entries(tool.inputSchema.properties)) {
|
|
307
|
+
const v = args[key];
|
|
308
|
+
if (v === undefined) continue;
|
|
309
|
+
if (spec.type === "string") {
|
|
310
|
+
if (typeof v !== "string" || v.length > (spec.maxLength || 2000)) throw new Error(`Invalid ${key}: expected string up to ${spec.maxLength || 2000} chars`);
|
|
311
|
+
if (spec.enum && !spec.enum.includes(v)) throw new Error(`Invalid ${key}: must be one of ${spec.enum.join(", ")}`);
|
|
312
|
+
if (spec.pattern && !new RegExp(spec.pattern).test(v)) throw new Error(`Invalid ${key}: unexpected format`);
|
|
313
|
+
} else if (spec.type === "integer") {
|
|
314
|
+
if (!Number.isInteger(v)) throw new Error(`Invalid ${key}: expected integer`);
|
|
315
|
+
if (v < spec.minimum || v > spec.maximum) throw new Error(`Invalid ${key}: must be between ${spec.minimum} and ${spec.maximum}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Forward only the arguments the tool declares.
|
|
321
|
+
function pickArgs(name, args) {
|
|
322
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
323
|
+
return Object.fromEntries(Object.entries(args).filter(([k]) => k in tool.inputSchema.properties));
|
|
324
|
+
}
|
|
325
|
+
|
|
352
326
|
async function main() {
|
|
353
327
|
let ctxPromise;
|
|
354
328
|
async function getPaymentContext() {
|
|
@@ -363,11 +337,12 @@ async function main() {
|
|
|
363
337
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
364
338
|
const { name, arguments: args = {} } = req.params;
|
|
365
339
|
try {
|
|
340
|
+
validateArgs(name, args);
|
|
366
341
|
let data;
|
|
367
342
|
if (name === "get_endpoint_spec") {
|
|
368
343
|
data = await getEndpointSpec(args);
|
|
369
344
|
} else if (TOOL_ROUTES[name]) {
|
|
370
|
-
data = await paidPost(await getPaymentContext(), TOOL_ROUTES[name], args);
|
|
345
|
+
data = await paidPost(await getPaymentContext(), TOOL_ROUTES[name], pickArgs(name, args));
|
|
371
346
|
} else {
|
|
372
347
|
throw new Error(`Unknown tool: ${name}`);
|
|
373
348
|
}
|
|
@@ -379,7 +354,7 @@ async function main() {
|
|
|
379
354
|
|
|
380
355
|
const transport = new StdioServerTransport();
|
|
381
356
|
await server.connect(transport);
|
|
382
|
-
console.error(`gov-transparency-mcp v${VERSION} ready
|
|
357
|
+
console.error(`gov-transparency-mcp v${VERSION} ready`);
|
|
383
358
|
}
|
|
384
359
|
|
|
385
360
|
if (require.main === module) {
|
|
@@ -389,4 +364,4 @@ if (require.main === module) {
|
|
|
389
364
|
});
|
|
390
365
|
}
|
|
391
366
|
|
|
392
|
-
module.exports = { TOOLS, TOOL_ROUTES, getEndpointSpec, buildBaseHttpClient };
|
|
367
|
+
module.exports = { TOOLS, TOOL_ROUTES, validateArgs, pickArgs, getEndpointSpec, buildBaseHttpClient };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgemeshlabs/gov-transparency-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Watch the watchers: congressional stock trades, federal contracts, campaign finance, lobbying, and new regulations as nine MCP tools. All official US government data, paid per call with x402 USDC on Base — no account or API key.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
+
"x402-guard.js",
|
|
11
12
|
"README.md",
|
|
12
13
|
"LICENSE"
|
|
13
14
|
],
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"url": "git+https://github.com/forgemeshlabs/gov-transparency-mcp.git"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
42
43
|
"@x402/core": "^2.17.0",
|
|
43
44
|
"@x402/evm": "^2.17.0",
|
|
44
45
|
"viem": "^2.0.0"
|
package/x402-guard.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* x402-guard — client-side payment and HTTP guard for ForgeMesh MCP servers (vendored copy per repo).
|
|
4
|
+
*
|
|
5
|
+
* const { createGuard } = require("./x402-guard");
|
|
6
|
+
* const guard = createGuard({
|
|
7
|
+
* baseUrl: "https://x402.coinopai.com", // the ONLY origin this server talks to
|
|
8
|
+
* payTo: ["0x1304EC1A8945365e43A5c18a734065f107B417cA"], // wallets a 402 may name
|
|
9
|
+
* maxPriceUsd: 1.00, // per-call ceiling (env X402_MAX_PRICE_USD overrides, lower only)
|
|
10
|
+
* sessionBudgetUsd: 10.00, // cumulative ceiling per process (env X402_SESSION_BUDGET_USD)
|
|
11
|
+
* });
|
|
12
|
+
* const client = new x402Client().register("eip155:*", new ExactEvmScheme(signer)).registerPolicy(guard.policy);
|
|
13
|
+
* const data = await guard.callPaid(httpClient, "/api/route", { method: "POST", body: {...} });
|
|
14
|
+
*
|
|
15
|
+
* What it enforces, before any signature is produced:
|
|
16
|
+
* network = eip155:8453 (Base mainnet), asset = USDC on Base, payTo in the allowlist,
|
|
17
|
+
* amount <= maxPriceUsd, running total <= sessionBudgetUsd.
|
|
18
|
+
* What it enforces on every HTTP call: same origin as baseUrl, https, no redirects,
|
|
19
|
+
* REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES. No global monkeypatching anywhere.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const BASE_MAINNET = "eip155:8453";
|
|
23
|
+
const USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
25
|
+
const MAX_RESPONSE_BYTES = 2_000_000;
|
|
26
|
+
|
|
27
|
+
function usdFromRequirement(r) {
|
|
28
|
+
const raw = r.amount ?? r.maxAmountRequired ?? r.value;
|
|
29
|
+
if (raw === undefined || raw === null) return NaN;
|
|
30
|
+
const s = String(raw);
|
|
31
|
+
if (s.startsWith("$")) return Number(s.slice(1));
|
|
32
|
+
return Number(BigInt(s)) / 1e6; // USDC atomic units
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lowerCap(defaultUsd, envName) {
|
|
36
|
+
const env = Number(process.env[envName]);
|
|
37
|
+
return Number.isFinite(env) && env > 0 ? Math.min(env, defaultUsd) : defaultUsd;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createGuard(opts) {
|
|
41
|
+
if (!opts || !opts.baseUrl) throw new Error("x402-guard: baseUrl is required");
|
|
42
|
+
const base = new URL(opts.baseUrl);
|
|
43
|
+
if (base.protocol !== "https:") throw new Error("x402-guard: baseUrl must be https");
|
|
44
|
+
const origin = base.origin;
|
|
45
|
+
// An empty allowlist means "this server never signs payments": the policy refuses every 402 and
|
|
46
|
+
// only fetchBounded is useful. Pass payTo: [] explicitly for free-only servers.
|
|
47
|
+
if (!Array.isArray(opts.payTo)) throw new Error("x402-guard: payTo allowlist is required (use [] for a server that never signs)");
|
|
48
|
+
const payTo = new Set(opts.payTo.map((a) => String(a).toLowerCase()));
|
|
49
|
+
const maxPriceUsd = lowerCap(opts.maxPriceUsd ?? 1.0, "X402_MAX_PRICE_USD");
|
|
50
|
+
const sessionBudgetUsd = lowerCap(opts.sessionBudgetUsd ?? 10.0, "X402_SESSION_BUDGET_USD");
|
|
51
|
+
let spentUsd = 0; // reserved at signing time, never released: a signed authorization can be settled even if our retry fails
|
|
52
|
+
|
|
53
|
+
function policy(_version, requirements) {
|
|
54
|
+
if (payTo.size === 0) throw new Error("x402-guard refused to sign: this server has no payee allowlist and never signs payments");
|
|
55
|
+
const kept = requirements.filter((r) => {
|
|
56
|
+
if (r.network !== BASE_MAINNET) return false;
|
|
57
|
+
if (String(r.asset || "").toLowerCase() !== USDC_BASE) return false;
|
|
58
|
+
if (!payTo.has(String(r.payTo || "").toLowerCase())) return false;
|
|
59
|
+
const usd = usdFromRequirement(r);
|
|
60
|
+
if (!Number.isFinite(usd) || usd <= 0 || usd > maxPriceUsd) return false;
|
|
61
|
+
if (spentUsd + usd > sessionBudgetUsd) return false;
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
if (kept.length === 0) {
|
|
65
|
+
const seen = requirements.map((r) => `${r.network} ${r.payTo} $${usdFromRequirement(r)}`).join("; ");
|
|
66
|
+
throw new Error(`x402-guard refused to sign: no payment option passed the guard (network must be ${BASE_MAINNET}, asset USDC, payTo in allowlist, price <= $${maxPriceUsd}, session total <= $${sessionBudgetUsd}). Offered: ${seen}`);
|
|
67
|
+
}
|
|
68
|
+
// Hand the selector exactly one option so the amount we reserve is the amount that gets signed.
|
|
69
|
+
const chosen = kept.reduce((a, b) => (usdFromRequirement(b) < usdFromRequirement(a) ? b : a));
|
|
70
|
+
spentUsd += usdFromRequirement(chosen);
|
|
71
|
+
return [chosen];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchBounded(url, init = {}) {
|
|
75
|
+
const target = new URL(String(url), origin);
|
|
76
|
+
if (target.origin !== origin) throw new Error(`x402-guard: refusing request to ${target.origin}; this server only talks to ${origin}`);
|
|
77
|
+
const ctrl = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(target.toString(), { ...init, signal: ctrl.signal, redirect: "error" });
|
|
81
|
+
const declared = Number(res.headers.get("content-length") || 0);
|
|
82
|
+
if (declared > MAX_RESPONSE_BYTES) { ctrl.abort(); throw new Error(`x402-guard: response too large (${declared} bytes)`); }
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let received = 0;
|
|
85
|
+
if (res.body) {
|
|
86
|
+
const reader = res.body.getReader();
|
|
87
|
+
for (;;) {
|
|
88
|
+
const { done, value } = await reader.read();
|
|
89
|
+
if (done) break;
|
|
90
|
+
received += value.byteLength;
|
|
91
|
+
if (received > MAX_RESPONSE_BYTES) { ctrl.abort(); throw new Error("x402-guard: response too large"); }
|
|
92
|
+
chunks.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const bytes = Buffer.concat(chunks);
|
|
96
|
+
return { status: res.status, ok: res.ok, headers: res.headers, bytes, text: bytes.toString("utf8") };
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseJson(res) {
|
|
103
|
+
try { return JSON.parse(res.text); } catch { throw new Error(`HTTP ${res.status}: non-JSON response`); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// GET or POST a paid route: unpaid request → 402 → guarded signature → retry. Returns parsed JSON;
|
|
107
|
+
// on a paid response attaches the settlement receipt as `_payment` when the body is an object.
|
|
108
|
+
async function callPaid(httpClient, path, { method = "GET", body, query, headers = {} } = {}) {
|
|
109
|
+
const url = new URL(path, origin);
|
|
110
|
+
if (query) for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
111
|
+
const init = { method, headers: { ...headers, ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) };
|
|
112
|
+
const res = await fetchBounded(url, init);
|
|
113
|
+
if (res.status !== 402) {
|
|
114
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.text.slice(0, 200)}`);
|
|
115
|
+
return parseJson(res);
|
|
116
|
+
}
|
|
117
|
+
let challenge;
|
|
118
|
+
try { challenge = JSON.parse(res.text); } catch { challenge = undefined; }
|
|
119
|
+
const paymentRequired = httpClient.getPaymentRequiredResponse((n) => res.headers.get(n), challenge);
|
|
120
|
+
const payload = await httpClient.createPaymentPayload(paymentRequired); // policy runs (and reserves budget) inside
|
|
121
|
+
const paid = await fetchBounded(url, { ...init, headers: { ...init.headers, ...httpClient.encodePaymentSignatureHeader(payload) } });
|
|
122
|
+
if (!paid.ok) throw new Error(`Payment failed — HTTP ${paid.status}: ${paid.text.slice(0, 200)}`);
|
|
123
|
+
let settle = null;
|
|
124
|
+
try { settle = httpClient.getPaymentSettleResponse((n) => paid.headers.get(n)) || null; } catch { settle = null; }
|
|
125
|
+
const contentType = paid.headers.get("content-type") || "";
|
|
126
|
+
// Non-JSON paid responses (audio, images) come back as raw bytes, never decoded as text.
|
|
127
|
+
if (!contentType.includes("json")) return { _binary: true, content_type: contentType, bytes: paid.bytes, _payment: settle };
|
|
128
|
+
const data = parseJson(paid);
|
|
129
|
+
if (settle && data && typeof data === "object" && !Array.isArray(data)) return { ...data, _payment: settle };
|
|
130
|
+
return data;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
policy,
|
|
135
|
+
fetchBounded,
|
|
136
|
+
callPaid,
|
|
137
|
+
limits: () => ({ maxPriceUsd, sessionBudgetUsd, spentUsd, origin, payTo: [...payTo] }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { createGuard, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, BASE_MAINNET, USDC_BASE };
|