agent402-mcp 0.13.11 → 0.13.13

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server for [Agent402](https://agent402.tools), the applied layer of [Agentic Finance](https://agent402.tools/agentic-finance) - a catalog of **500+ pay-per-call web tools and curated multi-tool skill packs** for AI agents, paid per call in USDC via the [x402 protocol](https://www.x402.org), **with compute (proof-of-work)** when no wallet is configured, or through a prepaid credits key already issued. (The hosted API is dual-stack: it also accepts [MPP](https://agent402.tools/what-is-mpp) clients on the same 402, settling on Base/Celo or natively on Tempo; this package pays via x402 or credits.) Built by [Havok Holdings LLC](https://github.com/MikeyPetrillo/Agent402).
4
4
 
5
- Your agent gets browser rendering, screenshots, PDF text extraction, URL→markdown, live web search **+ web answers with citations**, live **financial/crypto/macro data** (US equity quotes and daily bars from Databento, CoinGecko, FRED, ECB FX, World Bank, yield curve), **SEC EDGAR filings** (10-K/10-Q text, XBRL, insider, 13F, IPO calendar), **deterministic stats & forecasting** (Pearson correlation, OLS, Holt-Winters), **compression** (gzip/brotli), DNS/TLS/WHOIS + email-deliverability checks, wallet-keyed shared memory, and the pure-CPU utilities - plus **skill packs** like `security-audit`, `trend-analysis`, `structured-scrape`, `decode-blob`, and `forecasting-bake-off` callable as MCP prompts, and the **report products** (deep research, company dossier, 13F fund report, domain audit, token risk, FDA recall, insider flow, market brief) as ordinary catalog slugs. Payment handled invisibly underneath the MCP calls. No signup, no API key.
5
+ Your agent gets browser rendering, screenshots, PDF text extraction, URL→markdown, live web search **+ web answers with citations**, live **financial/crypto/macro data** (US equity quotes and daily bars from a licensed feed, CoinGecko, FRED, ECB FX, World Bank, yield curve), **SEC EDGAR filings** (10-K/10-Q text, XBRL, insider, 13F, IPO calendar), **deterministic stats & forecasting** (Pearson correlation, OLS, Holt-Winters), **compression** (gzip/brotli), DNS/TLS/WHOIS + email-deliverability checks, wallet-keyed shared memory, and the pure-CPU utilities - plus **skill packs** like `security-audit`, `trend-analysis`, `structured-scrape`, `decode-blob`, and `forecasting-bake-off` callable as MCP prompts, and the **report products** (deep research, company dossier, 13F fund report, domain audit, token risk, FDA recall, insider flow, market brief) as ordinary catalog slugs. Payment handled invisibly underneath the MCP calls. No signup, no API key.
6
6
 
7
7
  ## Quick start
8
8
 
package/discovery.js ADDED
@@ -0,0 +1,62 @@
1
+ // Discovery reads (the catalog, /api/find, skill packs, leaderboards, a
2
+ // proof-of-work challenge) settle nothing, so a 503 the server sheds under
3
+ // load is safe to repeat. It is retried only when it names its own delay
4
+ // (a Retry-After header or a retryAfterSeconds body field), at most
5
+ // DISCOVERY_ATTEMPTS tries in all, each wait capped at DISCOVERY_MAX_WAIT_MS.
6
+ // A paid or priced call never goes through here: a repeat of one could settle
7
+ // twice.
8
+ export const DISCOVERY_ATTEMPTS = 3;
9
+ export const DISCOVERY_MAX_WAIT_MS = 5000;
10
+
11
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ /** The wait (ms) a shed 503 asks for, or null when `r` is not one. */
14
+ export async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
15
+ if (!r || r.status !== 503) return null;
16
+ const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
17
+ let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
18
+ if (!Number.isFinite(seconds)) {
19
+ try {
20
+ const body = await (typeof r.clone === "function" ? r.clone() : r).json();
21
+ seconds = Number(body && body.retryAfterSeconds);
22
+ } catch { seconds = NaN; }
23
+ }
24
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
25
+ return Math.min(seconds * 1000, maxWaitMs);
26
+ }
27
+
28
+ /**
29
+ * Fetch a discovery surface, retrying a shed 503. Resolves with the last
30
+ * response (2xx or not) and how many attempts it took; the caller decides how
31
+ * to report a failure, naming the surface and `res.status`.
32
+ */
33
+ export async function fetchDiscovery(url, init, { fetchImpl = globalThis.fetch, sleep = sleepMs, attempts: max = DISCOVERY_ATTEMPTS } = {}) {
34
+ let res;
35
+ let attempts = 0;
36
+ for (;;) {
37
+ attempts++;
38
+ res = await fetchImpl(url, init);
39
+ if (res.ok || attempts >= max) return { res, attempts };
40
+ const wait = await shedRetryWaitMs(res);
41
+ if (wait == null) return { res, attempts };
42
+ await sleep(wait);
43
+ }
44
+ }
45
+
46
+ /** "HTTP 503 from /api/find after 3 attempts": the tail of a discovery failure. */
47
+ export function discoveryFailure(url, res, attempts) {
48
+ const path = (() => { try { return new URL(String(url)).pathname; } catch { return String(url).split("?")[0]; } })();
49
+ return `HTTP ${res.status} from ${path}${attempts > 1 ? ` after ${attempts} attempts` : ""}`;
50
+ }
51
+
52
+ /** GET JSON from a discovery surface; throws `${label} failed: HTTP <status> from <path>[ after N attempts]`. */
53
+ export async function getDiscoveryJson(label, url, init, opts) {
54
+ const { res, attempts } = await fetchDiscovery(url, init, opts);
55
+ if (!res.ok) {
56
+ const err = new Error(`${label} failed: ${discoveryFailure(url, res, attempts)}`);
57
+ err.status = res.status;
58
+ err.attempts = attempts;
59
+ throw err;
60
+ }
61
+ return res.json();
62
+ }
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Agent402 MCP server — exposes the agent402.tools catalog (1000+ pay-per-call
2
+ // Agent402 MCP server, exposes the agent402.tools catalog (1000+ pay-per-call
3
3
  // web tools) to any MCP client (Claude, ChatGPT, custom agents) and settles
4
4
  // payment underneath, so the model never sees the 402 dance:
5
5
  //
@@ -13,18 +13,19 @@
13
13
  //
14
14
  // Config (env):
15
15
  // AGENT402_URL target service (default https://agent402.tools)
16
- // AGENT_KEY hex private key of a funded EVM wallet (USDC on Base/Polygon/Arbitrum) — optional
17
- // SOLANA_AGENT_KEY base58 (or JSON byte-array) secret key of a funded Solana wallet (USDC on Solana) — optional
16
+ // AGENT_KEY hex private key of a funded EVM wallet (USDC on Base/Polygon/Arbitrum), optional
17
+ // SOLANA_AGENT_KEY base58 (or JSON byte-array) secret key of a funded Solana wallet (USDC on Solana), optional
18
18
  // AGENT402_TOOLS comma-separated slugs to expose first-class (overrides default)
19
19
  // AGENT402_MAX_PER_CALL refuse any single call priced above this many USD (e.g. 0.01)
20
20
  // AGENT402_CREDITS_KEY a prepaid credits key (a402_...) already issued (new credits are not on
21
21
  // sale); pays per successful call with no wallet
22
22
  // AGENT402_BUDGET hard cap on total USDC spent this session (e.g. 1.00)
23
23
  // AGENT402_NETWORKS restrict + order the chains to pay on (e.g. "robinhood" for USDG on
24
- // Robinhood Chain, "base,solana", or a raw CAIP-2 like eip155:4663) — optional
24
+ // Robinhood Chain, "base,solana", or a raw CAIP-2 like eip155:4663), optional
25
25
  import { createHash } from "node:crypto";
26
26
  import { readFileSync } from "node:fs";
27
27
  import { parseNetworkPrefs, withNetworkPreference } from "./networks.js";
28
+ import { fetchDiscovery, discoveryFailure, getDiscoveryJson } from "./discovery.js";
28
29
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
30
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
31
  import {
@@ -32,6 +33,8 @@ import {
32
33
  ListToolsRequestSchema,
33
34
  ListPromptsRequestSchema,
34
35
  GetPromptRequestSchema,
36
+ McpError,
37
+ ErrorCode,
35
38
  } from "@modelcontextprotocol/sdk/types.js";
36
39
  import {
37
40
  FLAGSHIP_MCP_NAMES,
@@ -44,6 +47,7 @@ import {
44
47
  mcpJsonResult,
45
48
  outputSchemaFromExample,
46
49
  mcpInitializeInstructions,
50
+ toolInputBoundRefusal,
47
51
  } from "./output-schemas.js";
48
52
 
49
53
  const BASE = (process.env.AGENT402_URL || "https://agent402.tools").replace(/\/$/, "");
@@ -58,12 +62,12 @@ const HAS_WALLET = Boolean(AGENT_KEY || SOLANA_AGENT_KEY);
58
62
  // list price only on a successful (200) call and returns X-Credits-Balance.
59
63
  const CREDITS_KEY = (process.env.AGENT402_CREDITS_KEY || "").trim();
60
64
  const HAS_CREDITS = /^a402_[A-Za-z0-9_-]{32,64}$/.test(CREDITS_KEY);
61
- // Version comes from package.json — the serverInfo self-report drifted from
65
+ // Version comes from package.json, the serverInfo self-report drifted from
62
66
  // the published version once (0.11.5 vs 0.12.1) because this was a hardcoded
63
67
  // string bumped by hand. Reading the manifest makes drift impossible.
64
68
  const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
65
69
 
66
- // Spend controls — enforced BEFORE a payment is ever signed, so a confused or
70
+ // Spend controls, enforced BEFORE a payment is ever signed, so a confused or
67
71
  // runaway model cannot drain the wallet. Unset = unlimited (back-compat).
68
72
  const num = (v) => (v !== undefined && v !== "" && Number.isFinite(Number(v)) ? Number(v) : undefined);
69
73
  const MAX_PER_CALL = num(process.env.AGENT402_MAX_PER_CALL) ?? Infinity;
@@ -71,7 +75,7 @@ const BUDGET = num(process.env.AGENT402_BUDGET) ?? Infinity;
71
75
  let spentUsd = 0;
72
76
 
73
77
  const DEFAULT_CURATED = [
74
- // Flagship demand set — keep aligned with src/mcp-flagship.js FLAGSHIP_SLUGS.
78
+ // Flagship demand set, keep aligned with src/mcp-flagship.js FLAGSHIP_SLUGS.
75
79
  // Search/answer is the front door; long tail stays behind catalog.search/catalog.call.
76
80
  "search", "answer", "search-news", "render",
77
81
  "stock-quote", "transcribe", "memory-read", "memory-write",
@@ -79,24 +83,28 @@ const DEFAULT_CURATED = [
79
83
  "decide", "decide-execute",
80
84
  ];
81
85
 
82
- // stdout is the MCP protocol channel — all logging goes to stderr.
86
+ // stdout is the MCP protocol channel, all logging goes to stderr.
83
87
  const log = (...a) => console.error("[agent402-mcp]", ...a);
84
88
 
85
89
  // ---------------------------------------------------------------------------
86
90
  // Catalog: built from the service's own machine-readable surfaces.
87
91
  const catalog = new Map(); // slug -> { slug, method, path, price, description, category, computePayable, inputSchema }
88
- // Skill packs — curated multi-tool workflows, fetched at startup from the
92
+ // Skill packs, curated multi-tool workflows, fetched at startup from the
89
93
  // hosted service so the npm package picks up new packs without a republish.
90
94
  // Empty if the discovery fetch fails (older services or transient errors);
91
95
  // prompts/list will just return an empty array in that case.
92
96
  let skillPacks = [];
93
97
 
98
+ // Each surface retries a shed 503 (./discovery.js). A failed pricing or
99
+ // openapi read throws, so the catalog is never built from an error body; the
100
+ // skill packs are optional and read as empty.
94
101
  async function loadCatalog() {
95
102
  const [pricing, openapi, packs] = await Promise.all([
96
- fetch(`${BASE}/api/pricing`).then((r) => r.json()),
97
- fetch(`${BASE}/openapi.json`).then((r) => r.json()),
98
- fetch(`${BASE}/api/skill-packs.json`).then((r) => (r.ok ? r.json() : { packs: [] })).catch(() => ({ packs: [] })),
103
+ getDiscoveryJson("catalog load", `${BASE}/api/pricing`),
104
+ getDiscoveryJson("catalog load", `${BASE}/openapi.json`),
105
+ getDiscoveryJson("skill packs", `${BASE}/api/skill-packs.json`).catch(() => ({ packs: [] })),
99
106
  ]);
107
+ if (!Array.isArray(pricing?.endpoints)) throw new Error(`catalog load failed: ${BASE}/api/pricing carried no endpoints list`);
100
108
  skillPacks = Array.isArray(packs?.packs) ? packs.packs : [];
101
109
  for (const e of pricing.endpoints) {
102
110
  const slug = e.slug ?? e.docs?.split("/tools/").pop();
@@ -162,7 +170,7 @@ function getPayFetch() {
162
170
  return payFetchPromise;
163
171
  }
164
172
 
165
- // @solana/kit KeyPairSigner from SOLANA_AGENT_KEY — base58 64-byte secret key
173
+ // @solana/kit KeyPairSigner from SOLANA_AGENT_KEY, base58 64-byte secret key
166
174
  // (Phantom / solana-keygen export) or a JSON byte array ([12,34,…]).
167
175
  let solanaSignerPromise;
168
176
  function solanaSigner() {
@@ -197,7 +205,7 @@ function walletRequiredText(tool) {
197
205
  `"${tool.slug}" costs ${tool.price}/call and requires a USDC wallet (it is not eligible for the proof-of-work tier).`,
198
206
  `To enable it: set AGENT_KEY on this MCP server to the hex private key of an EVM wallet funded with USDC`,
199
207
  `on Base (or Polygon/Arbitrum), and/or SOLANA_AGENT_KEY to the base58 secret key of a Solana wallet funded`,
200
- `with USDC on Solana. Payment is per call via the x402 protocol — no signup or API key.`,
208
+ `with USDC on Solana. Payment is per call via the x402 protocol: no signup or API key.`,
201
209
  `Already hold a prepaid credits key? Set AGENT402_CREDITS_KEY to the a402_ key and it pays per successful call (new credits are not on sale).`,
202
210
  `Pricing and details: ${BASE}/tools/${tool.slug}`,
203
211
  ].join(" ");
@@ -340,12 +348,12 @@ async function callEndpoint(tool, args = {}) {
340
348
  const payFetch = await getPayFetch();
341
349
  res = await payFetch(url, init);
342
350
  // Count spend when the server confirms settlement (payment receipt header),
343
- // falling back to any 2xx — conservative in the buyer's favor.
351
+ // falling back to any 2xx, conservative in the buyer's favor.
344
352
  if (res.headers.get("payment-response") || res.headers.get("x-payment-response") || res.ok) spentUsd += price;
345
353
  } else if (tool.computePayable) {
346
- // No wallet: pay with compute up front — solving before the call skips the
354
+ // No wallet: pay with compute up front, solving before the call skips the
347
355
  // 402 round-trip entirely (challenges are single-use and tool-scoped).
348
- const challenge = await (await fetch(`${BASE}/api/pow/challenge?slug=${encodeURIComponent(tool.slug)}`)).json();
356
+ const challenge = await getDiscoveryJson(`proof-of-work challenge for "${tool.slug}"`, `${BASE}/api/pow/challenge?slug=${encodeURIComponent(tool.slug)}`);
349
357
  const nonce = solvePow(challenge);
350
358
  res = await fetch(url, { ...init, headers: { ...init.headers, "X-Pow-Solution": `${challenge.token}:${nonce}` } });
351
359
  } else {
@@ -382,7 +390,7 @@ function applyFrontDoorTerms(terms, q) {
382
390
  if (!terms.includes("websearch") && (
383
391
  /\b(search\s+the\s+web|web\s+search|search\s+online|google\s+|bing\s+|look\s+up\s+online|find\s+on\s+the\s+web)\b/.test(ql)
384
392
  )) {
385
- // pricing descriptions carry "web search" / "Live web search" — boost those.
393
+ // pricing descriptions carry "web search" / "Live web search", boost those.
386
394
  terms.push("web", "search");
387
395
  }
388
396
  if (!terms.includes("answer") && (
@@ -427,8 +435,8 @@ function searchTools(query, limit = 10) {
427
435
 
428
436
  // Rank the curated multi-tool skill packs against the same query, so a single
429
437
  // search_tools call also tells the agent "this looks like a `security-audit`
430
- // or `email-deliverability` job — fetch the whole template via prompts/get".
431
- // Weighted slug/title/tagline/useCase/toolSlugs match — same shape as the
438
+ // or `email-deliverability` job, fetch the whole template via prompts/get".
439
+ // Weighted slug/title/tagline/useCase/toolSlugs match, same shape as the
432
440
  // hosted /api/find ranking, kept inline here so the stdio package stays
433
441
  // dependency-free. Returns [] when no pack scores above the noise floor.
434
442
  function rankWorkflows(query, k = 2) {
@@ -482,7 +490,7 @@ const server = new Server(
482
490
  { capabilities: { tools: {}, prompts: {} }, instructions: INIT_INSTRUCTIONS },
483
491
  );
484
492
 
485
- // Skill packs are exposed as MCP prompts — discoverable in slash menus on any
493
+ // Skill packs are exposed as MCP prompts, discoverable in slash menus on any
486
494
  // MCP-aware client. The list is fetched once at boot in loadCatalog(); the
487
495
  // per-prompt rendering is delegated to the hosted service so the npm package
488
496
  // stays thin and prompt text stays canonical with the website at /skills.
@@ -506,15 +514,34 @@ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
506
514
  for (const [k, v] of Object.entries(args || {})) {
507
515
  if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
508
516
  }
509
- const res = await fetch(url);
510
- if (!res.ok) throw new Error(`Failed to render prompt "${name}" from ${BASE}: HTTP ${res.status}`);
517
+ const { res, attempts } = await fetchDiscovery(url);
518
+ if (!res.ok) throw new Error(`Failed to render prompt "${name}" from ${BASE}: ${discoveryFailure(url, res, attempts)}`);
511
519
  return await res.json();
512
520
  });
513
521
 
514
522
  let curated = [];
515
523
  let pricingInfo = null;
516
524
 
517
- server.setRequestHandler(ListToolsRequestSchema, async () => {
525
+ // A catalog that could not load at boot (a shed or unreachable service) is
526
+ // read again on the next tools/list or tools/call, at most once per
527
+ // CATALOG_RETRY_MS, so a session that started during a busy minute recovers
528
+ // without a restart.
529
+ const CATALOG_RETRY_MS = 30_000;
530
+ let catalogRetryAt = 0;
531
+ async function ensureCatalog() {
532
+ if (catalog.size > 0 || Date.now() < catalogRetryAt) return;
533
+ catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
534
+ try {
535
+ pricingInfo = await loadCatalog();
536
+ curated = requested.map((slug) => catalog.get(slug)).filter(Boolean);
537
+ log(`catalog: ${catalog.size} tools from ${BASE} on a later read; ${curated.length} first-class`);
538
+ } catch (err) {
539
+ log(`Could not load the catalog from ${BASE}: ${err.message}: next read in ${CATALOG_RETRY_MS / 1000}s`);
540
+ }
541
+ }
542
+ const withCatalog = (handler) => async (req) => { await ensureCatalog(); return handler(req); };
543
+
544
+ server.setRequestHandler(ListToolsRequestSchema, withCatalog(async () => {
518
545
  const SAFE = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
519
546
  const OPEN = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
520
547
  const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
@@ -633,7 +660,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
633
660
  properties: {
634
661
  task: { type: "string", description: "Plain-language task, e.g. 'crypto news headlines' or 'sentiment of a tweet'. Resolved to a proven external seller." },
635
662
  params: { type: "object", description: "Input parameters for the resolved external tool (optional; the seller decides its own schema)." },
636
- maxUsd: { type: "number", description: "Max underlying seller price in USD. Default 0.005 (the $0.01 fee tier); a value above 0.005 uses the $0.55 tier (seller up to $0.50)." },
663
+ maxUsd: { type: "number", description: "Max underlying seller price in USD. Default 0.005 (the $0.01 fee tier); above 0.005 and up to 0.04 uses the $0.05 tier; above 0.04 the $0.55 tier (seller up to $0.50)." },
637
664
  },
638
665
  required: ["task"],
639
666
  },
@@ -641,20 +668,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
641
668
  }
642
669
  );
643
670
  return { tools };
644
- });
671
+ }));
645
672
 
646
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
673
+ server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req) => {
647
674
  const { name: rawName, arguments: args = {} } = req.params;
648
675
  const name = resolveListedName(rawName);
676
+ const overBound = toolInputBoundRefusal(rawName, args);
677
+ if (overBound) throw new McpError(ErrorCode.InvalidParams, overBound);
649
678
  try {
650
679
  if (name === "catalog.search") {
651
- const q = args.query ?? "";
680
+ const q = String(args.query ?? "").trim();
652
681
  const results = searchTools(q, args.limit ?? 10);
653
682
  const workflows = rankWorkflows(q, 2);
654
683
  if (!results.length && !workflows.length) {
684
+ // A caller that passed no query is told which argument to pass, not
685
+ // shown an empty string in quotes.
655
686
  return mcpJsonResult({
656
687
  results: [],
657
- message: `No tools matched "${q}". Browse the catalog at ${BASE}/tools or ${BASE}/api/pricing.`,
688
+ message: q
689
+ ? `No tools matched "${q}". Browse the catalog at ${BASE}/tools or ${BASE}/api/pricing.`
690
+ : `catalog.search needs a "query" argument: what the tool should do (for example "convert miles to kilometers"). Browse the catalog at ${BASE}/tools or ${BASE}/api/pricing.`,
658
691
  });
659
692
  }
660
693
  return mcpJsonResult({
@@ -672,9 +705,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
672
705
  const url = new URL(`${BASE}/api/find`);
673
706
  url.searchParams.set("q", task);
674
707
  url.searchParams.set("k", String(limit));
675
- const res = await fetch(url);
708
+ const { res, attempts } = await fetchDiscovery(url);
676
709
  if (!res.ok) {
677
- return { content: [{ type: "text", text: `catalog.find failed against ${BASE}/api/find: HTTP ${res.status}` }], isError: true };
710
+ return { content: [{ type: "text", text: `catalog.find failed against ${BASE}/api/find: ${discoveryFailure(url, res, attempts)}` }], isError: true };
678
711
  }
679
712
  const body = await res.json();
680
713
  const matches = (body.results || []).map((t) => ({
@@ -770,9 +803,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
770
803
  const limit = Math.min(Math.max(parseInt(args.limit, 10) || 10, 1), 50);
771
804
  const sort = args.sort === "usd" ? "usd" : "calls";
772
805
  const include = args.include === "all" ? "all" : "external";
773
- const res = await fetch(`${BASE}/api/mpp-leaderboard`);
806
+ const { res, attempts } = await fetchDiscovery(`${BASE}/api/mpp-leaderboard`);
774
807
  if (!res.ok) {
775
- return { content: [{ type: "text", text: `Failed to fetch the MPP leaderboard from ${BASE}: HTTP ${res.status}` }], isError: true };
808
+ return { content: [{ type: "text", text: `Failed to fetch the MPP leaderboard from ${BASE}: ${discoveryFailure(`${BASE}/api/mpp-leaderboard`, res, attempts)}` }], isError: true };
776
809
  }
777
810
  const lb = await res.json();
778
811
  let board = (Array.isArray(lb.rows) ? lb.rows : []).filter((r) => r.transfers > 0 || (r.d30?.transfers || 0) > 0);
@@ -816,17 +849,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
816
849
  const include = args.include === "all" ? "all" : "external";
817
850
  // /api/leaderboard is free + unpaywalled, so this stays free regardless
818
851
  // of payment mode. Honor its query params verbatim so the surface is a
819
- // thin pass-through — single source of truth for ranking + filtering.
852
+ // thin pass-through, single source of truth for ranking + filtering.
820
853
  const url = new URL(`${BASE}/api/leaderboard`);
821
854
  url.searchParams.set("top", String(limit));
822
855
  url.searchParams.set("sort", sort);
823
856
  url.searchParams.set("include", include);
824
- const res = await fetch(url);
857
+ const { res, attempts } = await fetchDiscovery(url);
825
858
  if (!res.ok) {
826
- return { content: [{ type: "text", text: `Failed to fetch leaderboard from ${BASE}: HTTP ${res.status}` }], isError: true };
859
+ return { content: [{ type: "text", text: `Failed to fetch leaderboard from ${BASE}: ${discoveryFailure(url, res, attempts)}` }], isError: true };
827
860
  }
828
861
  const snap = await res.json();
829
- // Trim to the same compact row shape the hosted MCP connector returns —
862
+ // Trim to the same compact row shape the hosted MCP connector returns,
830
863
  // cross-surface agents see one mental model. Full row (origins,
831
864
  // endpoints, scan metadata) stays accessible at /api/leaderboard.
832
865
  // Same untrusted-content marking as the hosted connector: a seller's
@@ -851,7 +884,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
851
884
  include: snap.include || include,
852
885
  totalSellers: snap.totalSellers ?? (snap.leaderboard || []).length,
853
886
  results: rows,
854
- ...(snap.warming || snap.scanSkipped ? { note: "Cache is warming — results may be partial. Retry in ~60s." } : {}),
887
+ ...(snap.warming || snap.scanSkipped ? { note: "Cache is warming: results may be partial. Retry in ~60s." } : {}),
855
888
  source: `${BASE}/api/leaderboard`,
856
889
  });
857
890
  }
@@ -867,13 +900,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
867
900
  : maxUsd > 0.005 ? "route-execute-plus"
868
901
  : "route-execute";
869
902
  const tool = catalog.get(slug);
870
- if (!tool) return { content: [{ type: "text", text: `External routing (${slug}) is not in the catalog from ${BASE} yet — it may be rolling out. Retry shortly.` }], isError: true };
903
+ if (!tool) return { content: [{ type: "text", text: `External routing (${slug}) is not in the catalog from ${BASE} yet. It may be rolling out; retry shortly.` }], isError: true };
871
904
  // Body matches the route-execute input schema; include:"external" is what
872
905
  // pays an OUTSIDE seller (vs this host's catalog). Paid via callEndpoint.
873
906
  return await callEndpoint(tool, { task, include: "external", maxUsd, ...(args.params && typeof args.params === "object" ? { params: args.params } : {}) });
874
907
  }
875
908
  // Curated tools are exposed verb_noun for tools/list consistency, but the
876
- // real slug is kebab — accept exposed name, plain snake, or raw slug.
909
+ // real slug is kebab, accept exposed name, plain snake, or raw slug.
877
910
  const mcpToSlug = new Map(Object.entries(FLAGSHIP_MCP_NAMES).map(([slug, mcp]) => [mcp, slug]));
878
911
  const wanted = name === "catalog.call"
879
912
  ? String(args.slug ?? "")
@@ -896,18 +929,19 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
896
929
  } catch (err) {
897
930
  return { content: [{ type: "text", text: `Agent402 call failed: ${err.message}` }], isError: true };
898
931
  }
899
- });
932
+ }));
900
933
 
901
934
  // ---------------------------------------------------------------------------
902
935
  try {
903
936
  pricingInfo = await loadCatalog();
904
937
  } catch (err) {
905
938
  // Don't hard-exit: starting with an empty catalog still lets the server
906
- // connect and answer introspection (tools/list) — required to pass directory
939
+ // connect and answer introspection (tools/list), required to pass directory
907
940
  // health checks (e.g. Glama) and more resilient if the catalog endpoint is
908
- // briefly unreachable. catalog.search/catalog.call just return nothing until the
909
- // catalog is reachable again.
910
- log(`Could not load the catalog from ${BASE}: ${err.message} — starting with an empty catalog`);
941
+ // briefly unreachable. The next tools/list or tools/call reads it again
942
+ // (ensureCatalog).
943
+ log(`Could not load the catalog from ${BASE}: ${err.message}: starting with an empty catalog`);
944
+ catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
911
945
  }
912
946
  const requested = (process.env.AGENT402_TOOLS || DEFAULT_CURATED.join(","))
913
947
  .split(",").map((s) => s.trim()).filter(Boolean);
package/networks.js CHANGED
@@ -1,9 +1,9 @@
1
- // Network preference for the x402 buyer — pure helpers, no side effects.
1
+ // Network preference for the x402 buyer, pure helpers, no side effects.
2
2
  //
3
3
  // AGENT402_NETWORKS (comma-separated, e.g. "robinhood" or "base,solana" or a
4
4
  // raw CAIP-2 like "eip155:4663") restricts AND orders which payment options
5
5
  // the buyer will consider from a seller's 402 `accepts`. Without it the
6
- // underlying @x402 client picks whichever registered scheme matches first —
6
+ // underlying @x402 client picks whichever registered scheme matches first,
7
7
  // which on a multi-chain seller is effectively always Base, making rails like
8
8
  // USDG on Robinhood Chain unreachable no matter what the wallet holds.
9
9
 
@@ -30,7 +30,7 @@ export function parseNetworkPrefs(raw) {
30
30
 
31
31
  /** Filter a 402's accepts to the preferred networks, ordered by preference
32
32
  * (all accepts for the 1st preferred chain, then the 2nd, …). Throws with an
33
- * actionable message when nothing matches — better than silently paying on a
33
+ * actionable message when nothing matches, better than silently paying on a
34
34
  * chain the operator excluded. No prefs → accepts unchanged. */
35
35
  export function filterAcceptsByNetworks(accepts, prefs) {
36
36
  if (!prefs?.length) return accepts;
@@ -39,14 +39,14 @@ export function filterAcceptsByNetworks(accepts, prefs) {
39
39
  if (!picked.length) {
40
40
  const offered = [...new Set(list.map((a) => a?.network).filter(Boolean))];
41
41
  throw new Error(
42
- `AGENT402_NETWORKS matched none of the seller's payment options — wanted [${prefs.join(", ")}], offered [${offered.join(", ")}]. ` +
42
+ `AGENT402_NETWORKS matched none of the seller's payment options: wanted [${prefs.join(", ")}], offered [${offered.join(", ")}]. ` +
43
43
  "Remove the restriction or add one of the offered networks."
44
44
  );
45
45
  }
46
46
  return picked;
47
47
  }
48
48
 
49
- /** Apply the preference to an @x402 client (duck-typed — any version with
49
+ /** Apply the preference to an @x402 client (duck-typed, any version with
50
50
  * createPaymentPayload works): the client only ever sees the filtered,
51
51
  * preference-ordered accepts. Returns the same client for chaining. */
52
52
  export function withNetworkPreference(client, prefs) {
package/output-schemas.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // (stdio package cannot import src/ when published). Keep in sync manually
3
3
  // when flagship names or output schemas change.
4
4
 
5
- // Flagship MCP surface — the small default tools/list agents see first.
5
+ // Flagship MCP surface, the small default tools/list agents see first.
6
6
  //
7
7
  // Product intent: Agent402 is a tools and models layer for agents -
8
8
  // deterministic utilities, a metered model gateway, finished reports. Default
@@ -26,7 +26,7 @@ export const FLAGSHIP_SLUGS = [
26
26
  "memory-write",
27
27
  ];
28
28
 
29
- // Smithery Naming grades DOT notation (domain.action tree), not snake_case —
29
+ // Smithery Naming grades DOT notation (domain.action tree), not snake_case,
30
30
  // stuck-at-98 lesson: "Tool names should form a navigable tree using
31
31
  // dot-notation (e.g. admin.tools.list)". CallTool still accepts prior snake /
32
32
  // digit names via MCP_CALL_ALIASES.
@@ -43,7 +43,7 @@ export const FLAGSHIP_MCP_NAMES = {
43
43
  "decide-execute": "decide.execute",
44
44
  };
45
45
 
46
- /** Meta tools listed alongside flagships — dotted canonical names. */
46
+ /** Meta tools listed alongside flagships, dotted canonical names. */
47
47
  export const META_MCP_NAMES = {
48
48
  search_tools: "catalog.search",
49
49
  find_tool: "catalog.find",
@@ -85,14 +85,31 @@ export const MCP_CALL_ALIASES = {
85
85
  route_and_execute: "router.execute",
86
86
  };
87
87
 
88
- /** Resolve any CallTool name to the canonical listed dotted name. */
88
+ /** Every listed dotted name (flagships, meta tools, the router). */
89
+ const LISTED_DOTTED = new Set([
90
+ ...Object.values(FLAGSHIP_MCP_NAMES),
91
+ ...Object.values(META_MCP_NAMES),
92
+ ...Object.values(MCP_CALL_ALIASES),
93
+ ]);
94
+
95
+ /**
96
+ * Resolve any CallTool name to the canonical listed dotted name: a prior
97
+ * public alias (MCP_CALL_ALIASES), or the snake form of a listed dotted name
98
+ * (catalog_search, catalog_find, payment_info, web_search), which a client
99
+ * that cannot send a dot in a tool name produces. Anything else is returned as
100
+ * given, so a raw catalog slug still reaches the slug lookup.
101
+ */
89
102
  export function resolveListedName(name) {
90
103
  if (!name) return name;
91
104
  if (MCP_CALL_ALIASES[name]) return MCP_CALL_ALIASES[name];
105
+ if (typeof name === "string" && name.includes("_") && !name.includes(".")) {
106
+ const dotted = name.replace(/_/g, ".");
107
+ if (LISTED_DOTTED.has(dotted)) return dotted;
108
+ }
92
109
  return name;
93
110
  }
94
111
 
95
- /** Open-world / egress tools — honest annotations for directory clients. */
112
+ /** Open-world / egress tools, honest annotations for directory clients. */
96
113
  export const FLAGSHIP_OPEN_WORLD = new Set([
97
114
  "search", "answer", "search-news", "render", "stock-quote", "transcribe", "decide",
98
115
  ]);
@@ -108,7 +125,7 @@ export const MCP_SERVER_DESCRIPTION =
108
125
  export const MCP_SERVER_WEBSITE = "https://agent402.tools";
109
126
 
110
127
  // ---------------------------------------------------------------------------
111
- // outputSchema (JSON Schema) for every listed tool — Smithery Capability
128
+ // outputSchema (JSON Schema) for every listed tool, Smithery Capability
112
129
  // Quality grades presence of named-field schemas; bare {type:"object"} does
113
130
  // not count. Keep in sync with CallTool structuredContent shapes in mcp-http.js
114
131
  // and the duplicated copy in mcp/output-schemas.js (stdio package).
@@ -225,7 +242,7 @@ export const META_OUTPUT_SCHEMAS = {
225
242
  },
226
243
  additionalProperties: true,
227
244
  },
228
- // Smithery Naming wants pure [a-z_]+ verb_noun — no digits / brand tokens
245
+ // Smithery Naming wants pure [a-z_]+ verb_noun, no digits / brand tokens
229
246
  // (describe_agent402 / list_x402_sellers failed the scan). Old names stay
230
247
  // CallTool aliases.
231
248
  "server.describe": {
@@ -496,17 +513,17 @@ export function mcpInstallHints(baseUrl) {
496
513
  },
497
514
  },
498
515
  npm: "npx -y agent402-mcp",
499
- smithery: "Paste the hosted URL at https://smithery.ai/new (or: smithery mcp publish \"https://agent402.tools/mcp\" -n @MikeyPetrillo/agent402). Submission is external; Agent402 does not auto-publish.",
516
+ smithery: `Paste ${hosted} into the add-server form at https://smithery.ai/new. Submission is external; Agent402 does not auto-publish.`,
500
517
  maintainer: "Havok Holdings LLC",
501
518
  };
502
519
  }
503
520
 
504
521
  /**
505
- * MCP initialize.instructions — orientation for clients that never call
522
+ * MCP initialize.instructions, orientation for clients that never call
506
523
  * server.describe. Keep tool names listed-only (dotted Smithery form) (self-consistency) and lead
507
524
  * with search/answer as the front door.
508
525
  */
509
- export function mcpInitializeInstructions(baseUrl, { decide = false } = {}) {
526
+ export function mcpInitializeInstructions(baseUrl, { decide = false, mpp = false } = {}) {
510
527
  const install = mcpInstallHints(baseUrl);
511
528
  const hosted = install.hostedUrl;
512
529
  return [
@@ -520,9 +537,58 @@ export function mcpInitializeInstructions(baseUrl, { decide = false } = {}) {
520
537
  "Orientation: call server.describe. Payment rails / wallet setup: call payment.info.",
521
538
  "Missing a tool: call demand.request. Ecosystem sellers: call sellers.list.",
522
539
  `Install (npm + wallet, pays for web.search, web.answer and the rest): ${install.claudeCodeNpm}`,
523
- `No wallet yet (hosted, proof-of-work tools only): ${install.claudeCodeHosted}`,
540
+ // The hosted line says what that connector takes: with MPP on it, a wallet
541
+ // can pay the other tools there too.
542
+ `${mpp ? "Hosted (proof-of-work tools free; the rest payable there over MPP from a wallet)" : "No wallet yet (hosted, proof-of-work tools only)"}: ${install.claudeCodeHosted}`,
524
543
  `Cursor mcp.json: { "mcpServers": { "agent402": { "url": "${hosted}" } } }`,
525
- `Why pay here (usage priced under a quoted ceiling, a failed call is not charged and the receipt proves it, keyed retries never pay twice, one key for tools + models + reports, no wallet needed for the proof-of-work tools or a report by card, proof at /status and /revenue): ${baseUrl}/why`,
544
+ `Why pay here (usage priced under a quoted ceiling, a failed call is not charged and the receipt proves it, keyed retries never pay twice, one key for tools + models + reports, no wallet needed for the proof-of-work tools or a report by card, proof at /status and /revenue): ${baseUrl}/#why`,
526
545
  `Docs: ${baseUrl}/llms.txt · ${baseUrl}/api/find?q=… · status ${baseUrl}/status`,
527
546
  ].join("\n");
528
547
  }
548
+
549
+ // Tool input element bound. The SDK's maxToolInputElements option lives on
550
+ // McpServer and is checked inside McpServer's own tools/call handler; both
551
+ // connectors use the low-level Server with their own CallTool handler, where
552
+ // that option is never read. So the same check runs here, with the SDK's
553
+ // counting rule (every array element and every own object member, at any
554
+ // depth) and its InvalidParams message.
555
+ //
556
+ // The bound: a JSON element costs at least two bytes ("0,"), so the hosted
557
+ // connector's 100kb body parser admits at most 51,200 of them. A bound at that
558
+ // figure never refuses a body the parser accepts, and the largest
559
+ // handler-capped input (three 10,000-value series) sits well under it. On the
560
+ // stdio package, which reads lines with no byte cap, an argument tree past it
561
+ // is refused before it is serialized and sent to a server that would 413 it.
562
+ export const MCP_MAX_TOOL_INPUT_ELEMENTS = 51_200;
563
+
564
+ // Counts array elements and object members in `value`, stopping once the
565
+ // count passes `max` (so a huge tree costs at most max + 1 steps).
566
+ export function toolInputElementCount(value, max = MCP_MAX_TOOL_INPUT_ELEMENTS) {
567
+ let count = 0;
568
+ const stack = [value];
569
+ while (stack.length > 0) {
570
+ const node = stack.pop();
571
+ if (node === null || typeof node !== "object") continue;
572
+ if (Array.isArray(node)) {
573
+ for (const child of node) {
574
+ if (++count > max) return count;
575
+ if (child !== null && typeof child === "object") stack.push(child);
576
+ }
577
+ } else {
578
+ for (const key in node) {
579
+ if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
580
+ if (++count > max) return count;
581
+ const child = node[key];
582
+ if (child !== null && typeof child === "object") stack.push(child);
583
+ }
584
+ }
585
+ }
586
+ return count;
587
+ }
588
+
589
+ // The refusal text for an over-bound call, or null when the input is within
590
+ // the bound. Same wording as the SDK's McpServer refusal.
591
+ export function toolInputBoundRefusal(toolName, args, max = MCP_MAX_TOOL_INPUT_ELEMENTS) {
592
+ if (toolInputElementCount(args, max) <= max) return null;
593
+ return `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${max} elements`;
594
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-mcp",
3
- "version": "0.13.11",
3
+ "version": "0.13.13",
4
4
  "mcpName": "io.github.MikeyPetrillo/agent402",
5
5
  "author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
6
6
  "homepage": "https://agent402.tools",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "index.js",
19
19
  "networks.js",
20
+ "discovery.js",
20
21
  "output-schemas.js",
21
22
  "README.md"
22
23
  ],