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 +1 -1
- package/discovery.js +62 -0
- package/index.js +79 -45
- package/networks.js +5 -5
- package/output-schemas.js +78 -12
- package/package.json +2 -1
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
|
|
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
|
|
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)
|
|
17
|
-
// SOLANA_AGENT_KEY base58 (or JSON byte-array) secret key of a funded Solana wallet (USDC on Solana)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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"
|
|
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
|
|
431
|
-
// Weighted slug/title/tagline/useCase/toolSlugs match
|
|
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
|
|
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
|
|
510
|
-
if (!res.ok) throw new Error(`Failed to render prompt "${name}" from ${BASE}:
|
|
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
|
-
|
|
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);
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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}:
|
|
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
|
|
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
|
|
857
|
+
const { res, attempts } = await fetchDiscovery(url);
|
|
825
858
|
if (!res.ok) {
|
|
826
|
-
return { content: [{ type: "text", text: `Failed to fetch leaderboard from ${BASE}:
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
909
|
-
//
|
|
910
|
-
log(`Could not load the catalog from ${BASE}: ${err.message}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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
|
],
|