agent402-mcp 0.13.12 → 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/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
@@ -25,6 +25,7 @@
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(/\/$/, "");
@@ -91,12 +95,16 @@ const catalog = new Map(); // slug -> { slug, method, path, price, description,
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();
@@ -345,7 +353,7 @@ async function callEndpoint(tool, args = {}) {
345
353
  } else if (tool.computePayable) {
346
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 {
@@ -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);
@@ -821,9 +854,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
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
862
  // Trim to the same compact row shape the hosted MCP connector returns,
@@ -896,7 +929,7 @@ 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 {
@@ -905,9 +938,10 @@ try {
905
938
  // Don't hard-exit: starting with an empty catalog still lets the server
906
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.
941
+ // briefly unreachable. The next tools/list or tools/call reads it again
942
+ // (ensureCatalog).
910
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/output-schemas.js CHANGED
@@ -85,10 +85,27 @@ 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
 
@@ -528,3 +545,50 @@ export function mcpInitializeInstructions(baseUrl, { decide = false, mpp = false
528
545
  `Docs: ${baseUrl}/llms.txt · ${baseUrl}/api/find?q=… · status ${baseUrl}/status`,
529
546
  ].join("\n");
530
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.12",
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
  ],