coinopai-mcp 2.2.1 → 2.2.2

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 CHANGED
@@ -62,9 +62,9 @@ Trust the process less. Verify the record more.
62
62
 
63
63
  The agent calls a tool → the MCP server receives an `HTTP 402` → automatically signs a USDC micropayment → retries with the payment header → data returned. Configure once, pay automatically from the configured low-balance wallet.
64
64
 
65
- Current package: `coinopai-mcp@1.2.10`.
65
+ Current package: `coinopai-mcp@2.2.2`.
66
66
 
67
- Settlement note: the MCP pins `@x402/core` and `@x402/evm` to `2.11.0` and signs EIP-3009 authorizations with a chain-aware timestamp. This avoids the Base RPC/facilitator clock-skew failure mode where a payment can be rejected as "authorization is not yet valid" or "valid before expired". Successful object responses include x402 settlement metadata under `_payment`, including the on-chain transaction hash when available.
67
+ Settlement note: the MCP pins `@x402/core` and `@x402/evm` to `2.11.0`. Successful object responses include x402 settlement metadata under `_payment`, including the on-chain transaction hash when available.
68
68
 
69
69
  ---
70
70
 
@@ -160,22 +160,22 @@ Fresh audits can return `pending_window` until the evaluation window matures.
160
160
 
161
161
  ## Tools
162
162
 
163
- | Tool | What it does | Cost | Affiliate |
164
- |------|-------------|------|-----------|
165
- | `check_trade_preflight` | Gate check: market allowed, cooldown, regime, model context | $0.05 | ✓ |
166
- | `get_crypto_decision` | Probabilistic decision journal + `decision_id` | $0.15 | ✓ |
167
- | `audit_trade_decision` | Verify against real prices: verdict + PnL% | $0.07 | ✓ |
168
- | `get_crypto_signals` | Model context for BTC, ETH, SOL, XRP, ADA | $0.05 | ✓ |
169
- | `get_crypto_signal_history` | Up to 168h of context history for analysis | $0.05 | ✓ |
170
- | `get_crypto_forecast` | Conformally-calibrated 80% price range (~0.80 empirical coverage) for BTC, ETH, SOL, XRP, ADA | $0.05 | ✓ |
171
- | `get_perp_funding` | **Futures.** Live perp funding, mark/index, open interest, crowding (Kraken Futures + Hyperliquid) | $0.02 | — |
172
- | `check_futures_risk` | **Futures.** Will this side / leverage / entry survive the calibrated range? Liquidation distance + verdict | $0.05 | — |
173
- | `get_futures_decision` | **Futures.** LONG / SHORT / FLAT with stop and target on the calibrated range, leverage cap, liquidation, sizing, funding cost | $0.15 | — |
174
- | `review_signal_anomaly` | Score signal features for unusual conditions; returns review labels, drivers, and component scores | $0.07 | — |
175
- | `get_crypto_risk` | Market risk state and cooldown context | $0.02 | — |
176
- | `search_agent_automations` | Search 819 agent automation prompts | $0.01 | — |
177
- | `get_agent_automation` | Full prompt + workflow steps by slug | $0.01 | — |
178
- | `list_automation_categories` | All 35 automation categories with counts | $0.005 | — |
163
+ | Tool | What it does | Cost |
164
+ |------|-------------|------|
165
+ | `check_trade_preflight` | Gate check: market allowed, cooldown, regime, model context | $0.05 |
166
+ | `get_crypto_decision` | Probabilistic decision journal + `decision_id` | $0.15 |
167
+ | `audit_trade_decision` | Verify against real prices: verdict + PnL% | $0.07 |
168
+ | `get_crypto_signals` | Model context for BTC, ETH, SOL, XRP, ADA | $0.05 |
169
+ | `get_crypto_signal_history` | Up to 168h of context history for analysis | $0.05 |
170
+ | `get_crypto_forecast` | Conformally-calibrated 80% price range (~0.80 empirical coverage) for BTC, ETH, SOL, XRP, ADA | $0.05 |
171
+ | `get_perp_funding` | **Futures.** Live perp funding, mark/index, open interest, crowding (Kraken Futures + Hyperliquid) | $0.02 |
172
+ | `check_futures_risk` | **Futures.** Will this side / leverage / entry survive the calibrated range? Liquidation distance + verdict | $0.05 |
173
+ | `get_futures_decision` | **Futures.** LONG / SHORT / FLAT with stop and target on the calibrated range, leverage cap, liquidation, sizing, funding cost | $0.15 |
174
+ | `review_signal_anomaly` | Score signal features for unusual conditions; returns review labels, drivers, and component scores | $0.07 |
175
+ | `get_crypto_risk` | Market risk state and cooldown context | $0.02 |
176
+ | `search_agent_automations` | Search 819 agent automation prompts | $0.01 |
177
+ | `get_agent_automation` | Full prompt + workflow steps by slug | $0.01 |
178
+ | `list_automation_categories` | All 35 automation categories with counts | $0.005 |
179
179
 
180
180
  No API keys. No subscriptions. Pay per call in USDC.
181
181
 
@@ -242,6 +242,8 @@ Prepared MCP Registry identity: `io.github.forgemeshlabs/coinopai-mcp`. Refresh
242
242
  3. Buy or bridge USDC ($1 = ~3 full verified cycles)
243
243
  4. Use a dedicated low-balance Base wallet for agent payments and provide its private key locally via environment variable.
244
244
 
245
+ **Payment safeguards.** The server only signs for the CoinOpAI payee wallet on Base mainnet, in USDC, at or below $0.15 per call and $10 per session. It refuses any other payee, network, asset, or amount before signing. Env `X402_MAX_PRICE_USD` and `X402_SESSION_BUDGET_USD` can only lower these caps. The server talks only to `https://x402.coinopai.com` (no redirects, 60s timeout, 2 MB response cap). Use a dedicated, low-balance wallet.
246
+
245
247
  > Your wallet key stays local. It never leaves your machine. Each payment is a signed micropayment — not a blanket approval.
246
248
 
247
249
  ---
@@ -331,63 +333,6 @@ Free-tier tool responses may carry a single disclosed sponsored card from [Lulu
331
333
 
332
334
  ---
333
335
 
334
- ## Affiliate Attribution (via Pyrimid)
335
-
336
- High-value tools accept an optional `affiliate_id` parameter. When provided, payment routes through the [Pyrimid](https://pyrimid.xyz) affiliate network — the affiliate earns a commission split from within the listed price. **No extra cost to the caller.**
337
-
338
- ### How the split works
339
-
340
- ```
341
- Direct call (no affiliate_id):
342
- Caller pays $0.15 → CoinOpAI receives $0.15
343
-
344
- Affiliate call (affiliate_id present):
345
- Caller pays $0.15 → CoinOpAI: 79.2% ($0.1188)
346
- → Affiliate: 19.8% ($0.0297)
347
- → Protocol: 1.0% ($0.0015)
348
- ```
349
-
350
- The buyer always pays the listed price. The split comes out of the vendor's portion.
351
-
352
- ### Usage
353
-
354
- Pass `affiliate_id` in any supporting tool call:
355
-
356
- ```js
357
- // As an agent or user
358
- await mcp.call("get_crypto_decision", {
359
- symbol: "BTC",
360
- affiliate_id: "af_youraffiliateID"
361
- })
362
- ```
363
-
364
- ### Building a wrapper? Set it once via env
365
-
366
- If you're building an agent framework, MCP wrapper, or automation that embeds CoinOpAI tools, set your affiliate ID as an environment variable. Every call through your wrapper earns you a commission automatically.
367
-
368
- ```json
369
- {
370
- "mcpServers": {
371
- "coinopai": {
372
- "command": "npx",
373
- "args": ["-y", "coinopai-mcp"],
374
- "env": {
375
- "WALLET_PRIVATE_KEY": "0x<agent-wallet-key>",
376
- "PYRIMID_AFFILIATE_ID": "af_<your-affiliate-id>"
377
- }
378
- }
379
- }
380
- }
381
- ```
382
-
383
- The tool-level `affiliate_id` argument takes precedence over the env var. Callers can always override.
384
-
385
- ### Without an affiliate_id
386
-
387
- Normal x402 flow — CoinOpAI receives 100% of the listed price. Nothing changes for the caller.
388
-
389
- ---
390
-
391
336
  ## Disclaimer
392
337
 
393
338
  Decision outputs are probabilistic context and journal entries for experimental automated workflows only. Not financial advice. Directional bias alone has not been validated as a standalone trading strategy. Results will vary. Never risk capital you can't afford to lose.
package/index.js CHANGED
@@ -8,36 +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 { createPublicClient, createWalletClient, http, parseAbi } = require("viem");
12
- const { base } = require("viem/chains");
13
11
  const { attachSponsored, FREE_TIER_TOOLS } = require("./ads.js");
12
+ const { createGuard } = require("./x402-guard");
14
13
 
15
14
  const BASE_URL = "https://x402.coinopai.com";
16
-
17
- // Pyrimid constants — on-chain addresses for affiliate payment routing
18
- const PYRIMID_ROUTER = "0xc949AEa380D7b7984806143ddbfE519B03ABd68B";
19
- const PYRIMID_VENDOR_ID = "0x034604e25078e293d7b181fa23b3f2f6";
20
- const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
21
- const ROUTER_ABI = parseAbi([
22
- "function routePayment(bytes16 vendorId, uint256 productId, bytes16 affiliateId, address buyer, uint256 maxPrice) external",
23
- ]);
24
- const USDC_ABI = parseAbi([
25
- "function approve(address spender, uint256 amount) external returns (bool)",
26
- ]);
27
- // Paths with registered Pyrimid product IDs — other paths fall back to standard x402
28
- const PYRIMID_PRODUCTS = {
29
- "/api/kronos/signals": { productId: 1n, priceUsdc: 50000n },
30
- "/api/kronos/decision": { productId: 2n, priceUsdc: 150000n },
31
- "/api/kronos/preflight": { productId: 4n, priceUsdc: 50000n },
32
- "/api/kronos/audit": { productId: 5n, priceUsdc: 70000n },
33
- "/api/kronos/risk": { productId: 6n, priceUsdc: 20000n },
34
- "/api/kronos/history": { productId: 7n, priceUsdc: 50000n },
35
- };
36
- const IMAGEGEN_URL = "https://imagegen.coinopai.com";
37
- const PYRIMID_PRODUCTS_IMAGEGEN = {
38
- "/generate": { productId: 3n, priceUsdc: 100000n },
39
- };
40
- const BASE_RPC_URL = process.env.BASE_RPC_URL || "https://mainnet.base.org";
15
+ // Highest listed price on this backend is $0.15; the guard refuses anything above it.
16
+ const guard = createGuard({
17
+ baseUrl: BASE_URL,
18
+ payTo: ["0x1304EC1A8945365e43A5c18a734065f107B417cA"],
19
+ maxPriceUsd: 0.15,
20
+ sessionBudgetUsd: 10,
21
+ });
41
22
 
42
23
  const TOOLS = [
43
24
  {
@@ -52,7 +33,7 @@ const TOOLS = [
52
33
  inputSchema: {
53
34
  type: "object",
54
35
  properties: {
55
- query: { type: "string", description: "Search keyword (e.g. 'slack', 'notion', 'github')" },
36
+ query: { type: "string", maxLength: 200, description: "Search keyword (e.g. 'slack', 'notion', 'github')" },
56
37
  limit: { type: "number", description: "Max results to return (default 20, max 50)" }
57
38
  },
58
39
  required: ["query"]
@@ -70,7 +51,7 @@ const TOOLS = [
70
51
  inputSchema: {
71
52
  type: "object",
72
53
  properties: {
73
- slug: { type: "string", description: "Automation slug (e.g. 'slack-to-notion')" }
54
+ slug: { type: "string", maxLength: 200, pattern: "^[A-Za-z0-9._-]+$", description: "Automation slug (e.g. 'slack-to-notion')" }
74
55
  },
75
56
  required: ["slug"]
76
57
  }
@@ -98,7 +79,6 @@ const TOOLS = [
98
79
  inputSchema: {
99
80
  type: "object",
100
81
  properties: {
101
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
102
82
  }
103
83
  }
104
84
  },
@@ -125,8 +105,7 @@ const TOOLS = [
125
105
  inputSchema: {
126
106
  type: "object",
127
107
  properties: {
128
- hours: { type: "number", description: "Hours of history to fetch (default 24, max 168)" },
129
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
108
+ hours: { type: "number", description: "Hours of history to fetch (default 24, max 168)" }
130
109
  }
131
110
  }
132
111
  },
@@ -142,8 +121,7 @@ const TOOLS = [
142
121
  inputSchema: {
143
122
  type: "object",
144
123
  properties: {
145
- symbol: { type: "string", description: "Symbol to evaluate: BTC, ETH, SOL, XRP, or ADA" },
146
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
124
+ symbol: { type: "string", description: "Symbol to evaluate: BTC, ETH, SOL, XRP, or ADA" }
147
125
  },
148
126
  required: ["symbol"]
149
127
  }
@@ -160,8 +138,7 @@ const TOOLS = [
160
138
  inputSchema: {
161
139
  type: "object",
162
140
  properties: {
163
- symbol: { type: "string", description: "Symbol to check: BTC, ETH, SOL, XRP, or ADA" },
164
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
141
+ symbol: { type: "string", description: "Symbol to check: BTC, ETH, SOL, XRP, or ADA" }
165
142
  },
166
143
  required: ["symbol"]
167
144
  }
@@ -179,8 +156,7 @@ const TOOLS = [
179
156
  type: "object",
180
157
  properties: {
181
158
  decision_id: { type: "string", description: "UUID from a previous get_crypto_decision call" },
182
- window: { type: "string", description: "Evaluation window: 1h, 4h, or 24h (default: 4h)" },
183
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
159
+ window: { type: "string", description: "Evaluation window: 1h, 4h, or 24h (default: 4h)" }
184
160
  },
185
161
  required: ["decision_id"]
186
162
  }
@@ -254,8 +230,7 @@ const TOOLS = [
254
230
  inputSchema: {
255
231
  type: "object",
256
232
  properties: {
257
- symbol: { type: "string", description: "Symbol: BTC, ETH, SOL, XRP, ADA (default: BTC)" },
258
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID (af_xxxxx). Affiliate earns a commission from within the listed price — no extra cost to you." }
233
+ symbol: { type: "string", description: "Symbol: BTC, ETH, SOL, XRP, ADA (default: BTC)" }
259
234
  }
260
235
  }
261
236
  },
@@ -299,213 +274,129 @@ function buildHttpClient() {
299
274
  if (!key) throw new Error("WALLET_PRIVATE_KEY required — set a Base wallet private key with USDC funded");
300
275
  const pk = key.startsWith("0x") ? key : "0x" + key;
301
276
  const account = privateKeyToAccount(pk);
302
- const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account)));
303
- return { httpClient: new x402HTTPClient(coreClient), account };
304
- }
305
-
306
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
307
-
308
- async function createChainTimedPaymentPayload(httpClient, paymentRequired) {
309
- try {
310
- const publicClient = createPublicClient({ chain: base, transport: http(BASE_RPC_URL) });
311
- const block = await publicClient.getBlock();
312
- const chainNow = Number(block.timestamp);
313
- const originalNow = Date.now;
314
- const localNow = Math.floor(originalNow() / 1000);
315
- const timeout = Number(paymentRequired.accepts?.[0]?.maxTimeoutSeconds || 300);
316
- const lowerBound = localNow + 30 - timeout;
317
- const upperBound = chainNow + 600;
318
- const signingNow = Math.min(Math.max(chainNow, lowerBound), upperBound);
319
- // x402 derives EIP-3009 validity windows from Date.now; choose a timestamp valid for both Base block time and facilitator wall-clock checks.
320
- Date.now = () => signingNow * 1000;
321
- try {
322
- return await httpClient.createPaymentPayload(paymentRequired);
323
- } finally {
324
- Date.now = originalNow;
325
- }
326
- } catch (_) {
327
- return httpClient.createPaymentPayload(paymentRequired);
328
- }
277
+ const coreClient = new x402Client().register("eip155:*", new ExactEvmScheme(toClientEvmSigner(account))).registerPolicy(guard.policy);
278
+ return new x402HTTPClient(coreClient);
329
279
  }
330
280
 
331
- // Pyrimid affiliate flow — approve + routePayment on-chain, then retry with tx hash
332
- async function callPyrimid(account, path, affiliateId, baseUrl, products) {
333
- const pathname = new URL(path, baseUrl).pathname;
334
- const product = products[pathname];
335
- if (!product) throw new Error(`No Pyrimid product registered for ${pathname}`);
336
- const url = baseUrl + path;
337
- const transport = http(BASE_RPC_URL);
338
- const publicClient = createPublicClient({ chain: base, transport });
339
- const walletClient = createWalletClient({ account, chain: base, transport });
340
-
341
- const approveHash = await walletClient.writeContract({
342
- address: USDC_ADDRESS, abi: USDC_ABI,
343
- functionName: "approve", args: [PYRIMID_ROUTER, product.priceUsdc],
344
- });
345
- await publicClient.waitForTransactionReceipt({ hash: approveHash });
346
- await sleep(3000);
347
-
348
- const routeHash = await walletClient.writeContract({
349
- address: PYRIMID_ROUTER, abi: ROUTER_ABI,
350
- functionName: "routePayment",
351
- args: [PYRIMID_VENDOR_ID, product.productId, "0x00000000000000000000000000000000", account.address, product.priceUsdc],
352
- });
353
- await publicClient.waitForTransactionReceipt({ hash: routeHash });
354
- await sleep(3000);
355
-
356
- const paidRes = await fetch(url, { headers: { "X-Affiliate-ID": affiliateId, "X-Payment": routeHash } });
357
- if (!paidRes.ok) {
358
- const err = await paidRes.text().catch(() => paidRes.statusText);
359
- throw new Error(`Pyrimid retry failed: ${paidRes.status} ${err.slice(0, 200)}`);
360
- }
361
- return paidRes.json();
281
+ // Argument validation — runs before any network call or wallet access.
282
+ const SYMBOLS = ["BTC", "ETH", "SOL", "XRP", "ADA"];
283
+ function str(v, name, { max = 200, pattern } = {}) {
284
+ if (typeof v !== "string" || v.length === 0 || v.length > max) throw new Error(`${name} must be a non-empty string of at most ${max} characters`);
285
+ if (pattern && !pattern.test(v)) throw new Error(`${name} has an invalid format`);
286
+ return v;
362
287
  }
363
-
364
- function queryString(params) {
365
- const parts = Object.entries(params)
366
- .filter(([, v]) => v !== undefined && v !== null && v !== "")
367
- .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`);
368
- return parts.length ? `?${parts.join("&")}` : "";
288
+ function num(v, name, min, max) {
289
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max) throw new Error(`${name} must be a number between ${min} and ${max}`);
290
+ return v;
369
291
  }
370
-
371
- async function callPaid(ctx, path, affiliateId, opts = {}) {
372
- const { httpClient, account } = ctx;
373
- const baseUrl = opts.baseUrl || BASE_URL;
374
- const pyrimidProducts = opts.pyrimidProducts || PYRIMID_PRODUCTS;
375
- const pathname = new URL(path, baseUrl).pathname;
376
- const method = opts.method || "GET";
377
- const body = opts.body;
378
- const bodyHeaders = body ? { "Content-Type": "application/json" } : {};
379
- const bodyInit = body ? { body: JSON.stringify(body) } : {};
380
-
381
- // Use Pyrimid affiliate flow when affiliate_id present and product is registered.
382
- // Fall back to direct x402 if the affiliate route is unavailable or not yet cataloged.
383
- if (method === "GET" && affiliateId && pyrimidProducts[pathname]) {
384
- try {
385
- return await callPyrimid(account, path, affiliateId, baseUrl, pyrimidProducts);
386
- } catch (_) {
387
- affiliateId = null;
388
- }
389
- }
390
-
391
- // Standard x402 EIP-3009 flow
392
- const url = baseUrl + path;
393
- const extraHeaders = affiliateId ? { "X-Affiliate-ID": affiliateId } : {};
394
- const res = await fetch(url, { method, headers: { ...bodyHeaders, ...extraHeaders }, ...bodyInit });
395
-
396
- if (res.status === 402) {
397
- let body;
398
- try { body = await res.clone().json(); } catch (_) {}
399
- const paymentRequired = httpClient.getPaymentRequiredResponse(
400
- (name) => res.headers.get(name), body
401
- );
402
- const paymentPayload = await createChainTimedPaymentPayload(httpClient, paymentRequired);
403
- const paidRes = await fetch(url, {
404
- method,
405
- headers: { ...bodyHeaders, ...httpClient.encodePaymentSignatureHeader(paymentPayload), ...extraHeaders },
406
- ...bodyInit,
407
- });
408
- if (!paidRes.ok) {
409
- const errBody = await paidRes.text().catch(() => paidRes.statusText);
410
- throw new Error(`HTTP ${paidRes.status}: ${errBody.slice(0, 200)}`);
411
- }
412
- const data = await paidRes.json();
413
- try {
414
- const settleResponse = httpClient.getPaymentSettleResponse((name) => paidRes.headers.get(name));
415
- if (settleResponse && data && typeof data === "object" && !Array.isArray(data)) {
416
- return { ...data, _payment: settleResponse };
417
- }
418
- } catch (_) {}
419
- return data;
420
- }
421
-
422
- if (!res.ok) {
423
- const errBody = await res.text().catch(() => res.statusText);
424
- throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
425
- }
426
- return res.json();
292
+ function optNum(v, name, min, max) { return v === undefined || v === null ? undefined : num(v, name, min, max); }
293
+ function symbol(v, dflt) {
294
+ if (v === undefined || v === null) return dflt;
295
+ const up = str(v, "symbol", { max: 10 }).toUpperCase();
296
+ if (!SYMBOLS.includes(up)) throw new Error(`symbol must be one of ${SYMBOLS.join(", ")}`);
297
+ return up;
427
298
  }
428
299
 
429
300
  async function main() {
430
- let ctx;
431
- function getPaymentContext() {
432
- if (!ctx) ctx = buildHttpClient();
433
- return ctx;
301
+ let httpClient;
302
+ function getHttpClient() {
303
+ if (!httpClient) httpClient = buildHttpClient();
304
+ return httpClient;
434
305
  }
435
306
 
436
307
  const server = new Server(
437
- { name: "coinopai-mcp", version: "2.2.1" },
308
+ { name: "coinopai-mcp", version: require("./package.json").version },
438
309
  { capabilities: { tools: {} } }
439
310
  );
440
311
 
441
312
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
442
313
 
443
314
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
444
- const { name, arguments: args } = req.params;
315
+ const { name } = req.params;
316
+ const args = req.params.arguments || {};
445
317
  try {
446
- // Affiliate ID: tool arg takes precedence, then env fallback, then none
447
- const affiliateId = args.affiliate_id || process.env.PYRIMID_AFFILIATE_ID || null;
448
- // list_tools is free: plain fetch, no wallet, never touches callPaid
449
- const paymentContext = name === "list_tools" ? null : getPaymentContext();
450
318
  let data;
451
319
  switch (name) {
452
320
  case "list_tools": {
453
- const r = await fetch(`${BASE_URL}/menu`);
321
+ // Free: plain bounded fetch, no wallet
322
+ const r = await guard.fetchBounded("/menu");
454
323
  if (!r.ok) throw new Error(`/menu returned ${r.status}`);
455
- data = await r.json();
324
+ try { data = JSON.parse(r.text); } catch (e) { throw new Error("/menu returned non-JSON: " + e.message); }
456
325
  break;
457
326
  }
458
- // Low-value utility endpoints — no affiliate routing
459
- case "search_agent_automations":
460
- data = await callPaid(paymentContext, `/api/search?q=${encodeURIComponent(args.query || "")}&limit=${args.limit || 20}`, null);
327
+ case "search_agent_automations": {
328
+ const query = { q: str(args.query, "query", { max: 200 }), limit: args.limit === undefined ? 20 : num(args.limit, "limit", 1, 50) };
329
+ data = await guard.callPaid(getHttpClient(), "/api/search", { query });
461
330
  break;
331
+ }
462
332
  case "get_agent_automation":
463
- data = await callPaid(paymentContext, `/api/automation/${encodeURIComponent(args.slug)}`, null);
333
+ data = await guard.callPaid(getHttpClient(), `/api/automation/${encodeURIComponent(str(args.slug, "slug", { max: 200, pattern: /^[A-Za-z0-9._-]+$/ }))}`);
464
334
  break;
465
335
  case "list_automation_categories":
466
- data = await callPaid(paymentContext, "/api/categories", null);
336
+ data = await guard.callPaid(getHttpClient(), "/api/categories");
467
337
  break;
468
338
  case "get_crypto_risk":
469
- data = await callPaid(paymentContext, "/api/kronos/risk", affiliateId);
339
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/risk");
470
340
  break;
471
- // High-value endpoints — affiliate routing enabled
472
341
  case "get_crypto_signals":
473
- data = await callPaid(paymentContext, "/api/kronos/signals", affiliateId);
342
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/signals");
474
343
  break;
475
344
  case "get_crypto_signal_history":
476
- data = await callPaid(paymentContext, `/api/kronos/history?hours=${args.hours || 24}`, affiliateId);
345
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/history", { query: { hours: args.hours === undefined ? 24 : num(args.hours, "hours", 1, 168) } });
477
346
  break;
478
347
  case "get_crypto_decision":
479
- data = await callPaid(paymentContext, `/api/kronos/decision?symbol=${encodeURIComponent(args.symbol || "BTC")}`, affiliateId);
348
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/decision", { query: { symbol: symbol(args.symbol, "BTC") } });
480
349
  break;
481
350
  case "check_trade_preflight":
482
- data = await callPaid(paymentContext, `/api/kronos/preflight?symbol=${encodeURIComponent(args.symbol || "BTC")}`, affiliateId);
351
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/preflight", { query: { symbol: symbol(args.symbol, "BTC") } });
483
352
  break;
484
- case "audit_trade_decision":
485
- data = await callPaid(paymentContext, `/api/kronos/audit?decision_id=${encodeURIComponent(args.decision_id)}&window=${encodeURIComponent(args.window || "4h")}`, affiliateId);
353
+ case "audit_trade_decision": {
354
+ const decisionId = str(args.decision_id, "decision_id", { max: 64, pattern: /^[A-Za-z0-9-]+$/ });
355
+ const windowArg = args.window === undefined ? "4h" : args.window;
356
+ if (!["1h", "4h", "24h"].includes(windowArg)) throw new Error("window must be one of 1h, 4h, 24h");
357
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/audit", { query: { decision_id: decisionId, window: windowArg } });
486
358
  break;
359
+ }
487
360
  case "get_crypto_forecast":
488
- data = await callPaid(paymentContext, `/api/kronos/forecast?symbol=${encodeURIComponent(args.symbol || "BTC")}`, affiliateId);
361
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/forecast", { query: { symbol: symbol(args.symbol, "BTC") } });
489
362
  break;
490
363
  case "get_futures_decision":
491
- data = await callPaid(paymentContext, `/api/kronos/futures/decision${queryString({ symbol: args.symbol || "BTC", equity: args.equity, max_loss_pct: args.max_loss_pct, max_leverage: args.max_leverage })}`, null);
364
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/futures/decision", { query: {
365
+ symbol: symbol(args.symbol, "BTC"),
366
+ equity: optNum(args.equity, "equity", 0, 1e12),
367
+ max_loss_pct: optNum(args.max_loss_pct, "max_loss_pct", 0, 1),
368
+ max_leverage: optNum(args.max_leverage, "max_leverage", 1, 5),
369
+ } });
492
370
  break;
493
371
  case "get_perp_funding":
494
- data = await callPaid(paymentContext, `/api/kronos/futures/funding${queryString({ symbol: args.symbol })}`, null);
372
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/futures/funding", { query: { symbol: args.symbol === undefined ? undefined : symbol(args.symbol) } });
495
373
  break;
496
- case "check_futures_risk":
497
- data = await callPaid(paymentContext, `/api/kronos/futures/risk${queryString({ symbol: args.symbol || "BTC", side: args.side, leverage: args.leverage, entry: args.entry, notional: args.notional, horizon_hours: args.horizon_hours })}`, null);
374
+ case "check_futures_risk": {
375
+ const side = typeof args.side === "string" ? args.side.toUpperCase() : "";
376
+ if (side !== "LONG" && side !== "SHORT") throw new Error("side must be LONG or SHORT");
377
+ data = await guard.callPaid(getHttpClient(), "/api/kronos/futures/risk", { query: {
378
+ symbol: symbol(args.symbol, "BTC"),
379
+ side,
380
+ leverage: num(args.leverage, "leverage", 1, 125),
381
+ entry: optNum(args.entry, "entry", 0, 1e12),
382
+ notional: optNum(args.notional, "notional", 0, 1e12),
383
+ horizon_hours: optNum(args.horizon_hours, "horizon_hours", 0, 1e5),
384
+ } });
498
385
  break;
499
- case "review_signal_anomaly":
500
- data = await callPaid(paymentContext, "/api/anomaly", null, {
386
+ }
387
+ case "review_signal_anomaly": {
388
+ if (!args.features || typeof args.features !== "object" || Array.isArray(args.features)) throw new Error("features must be an object of numeric feature values");
389
+ if (JSON.stringify(args.features).length > 4000) throw new Error("features is too large");
390
+ data = await guard.callPaid(getHttpClient(), "/api/anomaly", {
501
391
  method: "POST",
502
392
  body: {
503
- symbol: args.symbol || "BTC",
504
- window: args.window || "24h",
505
- features: args.features || {},
393
+ symbol: str(args.symbol, "symbol", { max: 20, pattern: /^[A-Za-z0-9._-]+$/ }),
394
+ window: args.window === undefined ? "24h" : str(args.window, "window", { max: 20, pattern: /^[A-Za-z0-9]+$/ }),
395
+ features: args.features,
506
396
  },
507
397
  });
508
398
  break;
399
+ }
509
400
  default:
510
401
  throw new Error("Unknown tool: " + name);
511
402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coinopai-mcp",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "mcpName": "io.github.forgemeshlabs/coinopai-mcp",
5
5
  "description": "Auditable market intelligence MCP with x402 payments, calibrated forecast ranges, anomaly review, risk context, and outcome audits",
6
6
  "main": "index.js",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "index.js",
12
12
  "ads.js",
13
+ "x402-guard.js",
13
14
  "README.md",
14
15
  "server.json"
15
16
  ],
@@ -24,7 +25,6 @@
24
25
  "model-context-protocol",
25
26
  "x402",
26
27
  "agent-commerce",
27
- "affiliate-routing",
28
28
  "monetization",
29
29
  "forgemesh",
30
30
  "crypto",
@@ -48,7 +48,7 @@
48
48
  "url": "https://github.com/forgemeshlabs/coinopai-mcp/issues"
49
49
  },
50
50
  "dependencies": {
51
- "@modelcontextprotocol/sdk": "^1.30.0",
51
+ "@modelcontextprotocol/sdk": "^1.32.1",
52
52
  "@x402/core": "2.11.0",
53
53
  "@x402/evm": "2.11.0",
54
54
  "lulu-ads": "^0.9.19",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/forgemeshlabs/coinopai-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.2.1",
9
+ "version": "2.2.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "coinopai-mcp",
14
- "version": "2.2.1",
14
+ "version": "2.2.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
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 };