diff --git a/.github/trigger-deploy b/.github/trigger-deploy index 566643f7..95b7d23c 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -1 +1 @@ -1786027990 +1786099674 diff --git a/.github/trigger-test b/.github/trigger-test index 0302ffd0..95b7d23c 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -1 +1 @@ -1786029809 +1786099674 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2dbd8621..75064dab 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1388,6 +1388,9 @@ jobs: - name: A registry listing is not a seller answering (origin-responded) run: node scripts/test-origin-responded.js + - name: "A venue with many payees is not one seller (multi-payTo index — offline)" + run: node scripts/test-multi-payto.js + - name: A settlement rejection is not a charged failure (public stats honesty) run: STATS_ALLOW_EPHEMERAL=true node scripts/test-charged-failure-honesty.js @@ -1448,6 +1451,9 @@ jobs: - name: MCP search_tools — live-catalog smoke test (locks top-1 for agent queries) run: node scripts/test-mcp-search-ranking.js + - name: "Self-consistency — every tool and route our own text names must exist" + run: TARGET_URL=http://localhost:3000 node scripts/test-mcp-self-consistency.js + - name: Exercise tools against live sites run: | echo "=== extract: BBC News article ===" diff --git a/CLAUDE.md b/CLAUDE.md index 2d580e54..8f9a448a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -300,6 +300,38 @@ with `res.statusCode === 200`. (`node_modules/@x402/express/dist/esm/index.mjs`. server.js now rewrites HEAD on catalog GET routes to GET for the gate chain and suppresses the body at res.end (RFC 9110 semantics: 402 + identical headers, empty body). `scripts/test-head-paywall.js` (offline, in CI). +- **Surface self-consistency (`scripts/test-mcp-self-consistency.js`, 2026-08-07, in CI):** + every functional test drives the connector the way WE intend it to be used — they + call the tools whose names they already know — so nothing tested whether our own + published text names things that EXIST. Three times a tool had a working CallTool + handler and was absent from `tools/list` (about_agent402, top_x402_sellers, then + `request_tool`); the first two were fixed by hand with a comment and no test, so the + class stayed open and the third shipped. The third was the worst: about_agent402's + `missingATool` field tells agents to "Call request_tool", i.e. our orientation tool + instructed agents to do something our capabilities made impossible, and the whole + demand board only ever heard from callers who already knew the name. Found from + OUTSIDE (issue #705), not by us. The guard reads five agent-facing surfaces + (`tools/list` text, about_agent402, get_payment_info, `/llms.txt`, + `/.well-known/x402`) and asserts every tool name in a call-this position is + advertised, every named catalog slug exists, and every referenced route is + registered — plus both parity directions (a CallTool branch no advertised name can + reach; a listed tool with no handler or slug). **Route existence uses TWO oracles + and reports missing only when BOTH say no:** a source scan of `app.("…")` + (the only oracle that can see a POST-only route — a live GET 404 cannot + distinguish "no such route" from "wrong method", the ambiguity the #705 reporter + correctly refused to resolve) and a live GET (the only oracle that can see the + template-literal chain pages `app.get(\`/${chainKey}\`)`). The live probe never + touches `/api` or `/v1` — in FREE_MODE those handlers execute, and a consistency + check must not call a tool that spends money. Path matching is SEGMENT-aware so + `/api/wish` is never satisfied by `/api/wishes`. Extractors are proven against a + planted control before any clean run is believed (same doctrine as the free-tier + egress probe). **The first draft had a "does this look like one of our tools?" + filter that skipped any unknown snake_case name — which is exactly the defect + being hunted; a planted `Call submit_wish` passed a green run.** It is gone; the + only escape hatch is the explicit `NOT_A_TOOL` set (one entry: `route_and_execute`, + which is real but lives on the stdio npm package). Mutation-tested: removing + `request_tool` from the listing fails 2 assertions, a fake tool name fails 1, a + fake route fails 1. - **Marketplace latency / snapshot caching (`src/x402-economy.js`):** `GET /marketplace` (and `/api/x402-economy`) render from `x402EconomySnapshot()` — a ~500ms on-chain read (EIP-3009 USDC settlements on Base via CDP SQL). It is **stale-while-revalidate**: a fresh diff --git a/scripts/test-mcp-http.js b/scripts/test-mcp-http.js index cc8c819f..d0a8951e 100644 --- a/scripts/test-mcp-http.js +++ b/scripts/test-mcp-http.js @@ -42,26 +42,42 @@ const names = (list.result?.tools ?? []).map((t) => t.name).sort(); // uniformly verb_noun (action-first) — the pattern Glama's naming-consistency // rubric names as the target. Every prior spelling still routes. // -// Now 17, not 15: about_agent402 and top_x402_sellers had working handlers but -// were absent from tools/list, which is the ONLY surface an MCP client can -// discover from — and the service manifest already advertised +// Now 18, not 15: about_agent402, top_x402_sellers and request_tool had working +// handlers but were absent from tools/list, which is the ONLY surface an MCP +// client can discover from — and the service manifest already advertised // top_x402_sellers, so it was promised and unreachable. Listing them trades -// two tools of directory-scoring headroom for capabilities that were being +// three tools of directory-scoring headroom for capabilities that were being // paid for in documentation and not delivered. Keep this list tight: anything // further belongs behind call_tool. +// +// request_tool (added for issue #705, reported from OUTSIDE) was the third +// instance of that one defect, and the worst: about_agent402's own text tells +// agents to call it. Fixing instances three times without pinning the class is +// why a stranger found it before we did — scripts/test-mcp-self-consistency.js +// now fails on any handler that no advertised name can reach. const EXPECTED_LIST = [ "search_tools", "find_tool", "call_tool", "get_payment_info", "generate_hash", "convert_units", "generate_qr", "format_json", "decode_jwt", "convert_base64", "generate_uuid", "parse_csv", "convert_timezone", "get_wallet_balances", "get_wallet_transactions", - "about_agent402", "top_x402_sellers", + "about_agent402", "top_x402_sellers", "request_tool", ].sort(); assert( names.length === EXPECTED_LIST.length && EXPECTED_LIST.every((n) => names.includes(n)), `tools/list is the curated set (got ${names.length}: ${names.join(",")})` ); assert( - (list.result?.tools ?? []).every((t) => t.title && t.annotations?.readOnlyHint === true), - "every tool carries a title + read-only safety annotations (directory requirement)" + (list.result?.tools ?? []).every((t) => t.title && typeof t.annotations?.readOnlyHint === "boolean"), + "every tool carries a title + safety annotations (directory requirement)" +); +// The annotations must be TRUE, not merely present. request_tool records a wish +// row, so it is the one tool here that is not read-only; a client that trusts +// readOnlyHint to decide what it may call unattended would be misled by a +// blanket true. Equally, a read-only tool silently flipping to false would cost +// us calls from exactly those clients. +const writers = (list.result?.tools ?? []).filter((t) => t.annotations?.readOnlyHint === false).map((t) => t.name).sort(); +assert( + writers.length === 1 && writers[0] === "request_tool", + `request_tool is the only tool annotated as a writer (got ${writers.join(",") || "none"})` ); // Renames must never break an existing caller: the current verb_noun name, the diff --git a/scripts/test-mcp-self-consistency.js b/scripts/test-mcp-self-consistency.js new file mode 100644 index 00000000..f8ea59f3 --- /dev/null +++ b/scripts/test-mcp-self-consistency.js @@ -0,0 +1,311 @@ +// Read our own agent-facing text back to us and check it against what the +// server actually offers. +// +// WHY THIS EXISTS. Issue #705 was reported by an outside agent, and it was the +// THIRD instance of one defect: a tool with a working CallTool handler that was +// never added to tools/list. The first two (about_agent402, top_x402_sellers) +// were found and fixed by hand, with a comment in src/mcp-http.js noting that +// tools/list is the only discovery surface a client has - and no test was +// written, so the class stayed open and the third instance shipped. Worse, the +// unlisted tool was request_tool, which about_agent402's own missingATool field +// tells agents to call. Our published text instructed agents to do something +// our published capabilities made impossible, and every existing test passed, +// because every existing test drives the connector the way WE intend it to be +// used: they call the tools they know the names of. +// +// So this checks the thing no functional test can: INTERNAL CONSISTENCY. Does +// the text an agent reads name only tools that exist and routes that are +// registered? A defect here is invisible to a passing suite and obvious to any +// stranger who reads two of our surfaces side by side. +// +// Offline apart from the local server: static-reads the source for the handler +// branches and the route table, and reads the live tools/list + the free +// meta-tool payloads. Run against a booted free-mode server: +// TARGET_URL=http://localhost:3000 node scripts/test-mcp-self-consistency.js +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TARGET = process.env.TARGET_URL || "http://localhost:3000"; +const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); + +let passed = 0; +const failures = []; +function assert(cond, msg) { + if (cond) { passed++; console.log(`ok - ${msg}`); return; } + failures.push(msg); + console.log(`FAIL - ${msg}`); +} + +let nextId = 1; +async function rpc(method, params) { + const res = await fetch(`${TARGET}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), + }); + const ct = (res.headers.get("content-type") || "").split(";")[0]; + const body = ct === "text/event-stream" + ? JSON.parse((await res.text()).split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("")) + : await res.json(); + if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`); + return body.result; +} + +// ---------------------------------------------------------------- extractors +// +// Each extractor is proven against a planted control below before any clean +// result is believed. An extractor that silently matches nothing would report +// perfect consistency forever - the same way the free-tier egress probe once +// reported a clean run while blind. + +// "Call request_tool", "run search_tools", "the find_tool tool", `request_tool` +// in backticks. Restricted to snake_case (every MCP tool name here is +// snake_case by the convention documented in mcp-http.js), which keeps ordinary +// English out of the match set. +const SNAKE = "[a-z][a-z0-9]*(?:_[a-z0-9]+)+"; +function referencedToolNames(text) { + const out = new Set(); + const patterns = [ + new RegExp(`\\b(?:call|calls|calling|run|runs|running|invoke|use)\\s+\`?(${SNAKE})\`?`, "gi"), + new RegExp(`\`(${SNAKE})\`\\s+tool\\b`, "gi"), + new RegExp(`\\b(${SNAKE})\\s+tool\\b`, "gi"), + new RegExp(`\\bname:\\s*['"](${SNAKE})['"]`, "gi"), + // The service manifest advertises capabilities as mcpTool: "x". This is the + // exact field that promised top_x402_sellers while tools/list did not offer + // it - a third-party integrator reads the manifest, not our source. + new RegExp(`"?mcpTool"?:\\s*['"](${SNAKE})['"]`, "gi"), + ]; + for (const re of patterns) for (const m of text.matchAll(re)) out.add(m[1]); + return out; +} + +// call_tool { slug: 'unit-convert' } / "slug: \"x\"" - a slug named in prose +// must be a real catalog entry, or the worked example we hand an agent is dead. +function referencedSlugs(text) { + const out = new Set(); + for (const m of text.matchAll(/\bslug:\s*['"]([a-z0-9][a-z0-9-]*)['"]/gi)) out.add(m[1]); + return out; +} + +// Absolute URLs on our own host, plus bare paths. +// +// Bare paths are restricted to the families we unambiguously own. /.well-known +// is deliberately NOT among them: it is a shared standard namespace, and our +// own tool descriptions legitimately name OTHER sites' well-known paths (the +// A2A card fetcher documents /.well-known/agent-card.json on the site it is +// pointed at, not on us). Our own well-known routes still get checked whenever +// they appear as a full URL. +// +// A dot ends a path unless it begins a real file extension, so prose like +// "/api/extract.body" yields /api/extract rather than a route that never was. +const PATH_FAMILIES = "api|v1|mcp|tools|skills"; +const EXT = "txt|json|xml|html|md|ico|csv|yaml|js"; +function referencedPaths(text, baseUrl) { + const out = new Set(); + const add = (p) => { + let clean = String(p).split(/[?#]/)[0].replace(/[.,)\]]+$/, ""); + const dot = clean.indexOf("."); + if (dot > 0 && !new RegExp(`\\.(?:${EXT})$`, "i").test(clean)) clean = clean.slice(0, dot); + clean = clean.replace(/\/$/, ""); + if (clean && clean.startsWith("/")) out.add(clean); + }; + const host = baseUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); + const esc = host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (const m of text.matchAll(new RegExp(`https?://${esc}(/[\\w\\-./:*{}]*)`, "gi"))) add(m[1]); + for (const m of text.matchAll(new RegExp(`(?:^|[\\s(\`"'])(/(?:${PATH_FAMILIES})[\\w\\-./:*]*)`, "gi"))) add(m[1]); + return out; +} + +// ------------------------------------------------------- the server's truths +// Handler branches in the CallTool dispatch, grouped PER BRANCH rather than per +// name, because a branch may answer to more than one name: `get_payment_info || +// payment_info` keeps a legacy alias reachable on purpose. The rule is that +// every branch must be reachable under at least one advertised name - an alias +// nobody advertises is fine, a whole capability nobody advertises is #705. +const mcpSource = readFileSync(join(SRC, "mcp-http.js"), "utf8"); +const handlerBranches = [...mcpSource.matchAll(/^\s*if \(.*$/gm)] + .map((m) => [...m[0].matchAll(/name === "([a-z0-9_]+)"/g)].map((x) => x[1])) + .filter((names) => names.length); +// Any name the dispatch mentions at all, including the negative guard that lets +// call_tool through to the generic slug path (`name !== "call_tool"`). This set +// answers "is this name known to the dispatch", not "does it have its own +// branch" - the two questions have different right answers for call_tool. +const handledNames = new Set([...mcpSource.matchAll(/\bname\s*[!=]==\s*"([a-z0-9_]+)"/g)].map((m) => m[1])); + +// Curated catalog tools are listed under an MCP name that is NOT always the +// slug with dashes swapped for underscores - MCP_NAME_OVERRIDES renames them to +// the verb_noun convention (unit-convert is listed as convert_units). Reversing +// that map is what lets the "advertised but unimplemented" check resolve a +// listed name back to a real catalog entry. +const overrideBlock = mcpSource.match(/MCP_NAME_OVERRIDES\s*=\s*\{([\s\S]*?)\n\s*\};/); +const slugForMcpName = new Map(); +// Keys are quoted only when the slug contains a dash ("unit-convert"), bare +// otherwise (hash, qr, uuid) - both forms are real entries in that object. +for (const m of (overrideBlock?.[1] ?? "").matchAll(/(?:"([^"]+)"|([a-z0-9-]+)):\s*"([^"]+)"/g)) { + slugForMcpName.set(m[3], m[1] ?? m[2]); +} +if (slugForMcpName.size === 0) throw new Error("could not parse MCP_NAME_OVERRIDES - the rename table moved, and this check would silently pass without it"); + +// Registered express routes, read from the source rather than probed, because +// probing cannot distinguish "route does not exist" from "route exists but is +// POST-only" - exactly the ambiguity the #705 reporter correctly refused to +// resolve from a GET 404. +const routePaths = new Set(); +const mountPrefixes = new Set(); +for (const file of readdirSync(SRC).filter((f) => f.endsWith(".js"))) { + const text = readFileSync(join(SRC, file), "utf8"); + for (const m of text.matchAll(/\bapp\.(get|post|put|patch|delete|all|use)\(\s*"([^"]+)"/g)) { + if (m[1] === "use") mountPrefixes.add(m[2].replace(/\/$/, "")); + else routePaths.add(m[2].replace(/\/$/, "")); + } +} + +const segs = (p) => p.split("/").filter(Boolean); +// Segment-aware matching. "/v1" legitimately stands for the whole gateway +// family and matches /v1/nano/chat/completions; "/api/wish" must NOT be +// satisfied by "/api/wishes", which is the singular-vs-plural confusion that +// makes this check worth having at all. +function staticRouteExists(ref) { + const r = segs(ref); + const covers = (route) => { + const c = segs(route); + if (c.length < r.length) return false; + for (let i = 0; i < r.length; i++) { + const want = r[i], got = c[i]; + if (got.startsWith(":") || got.includes("*") || want === "*") continue; + if (want !== got) return false; + } + return true; + }; + if ([...routePaths].some(covers)) return true; + if ([...mountPrefixes].some((p) => p && p !== "/" && covers(p))) return true; + // Catalog tool routes come from the live server, not the source. + return catalogRoutes.has(ref); +} + +// TWO INDEPENDENT ORACLES, and a path is only reported missing when BOTH say +// no. The source scan cannot see routes registered through a template literal +// (the per-chain market pages are `app.get(\`/${chainKey}\`)` over a local map), +// and a live GET cannot distinguish "no such route" from "route exists but is +// POST-only" - the ambiguity the #705 reporter correctly refused to resolve +// from a GET 404. Each oracle covers the other's blind spot. +// +// The live probe is a LAST resort and never touches /api or /v1: in FREE_MODE +// those handlers execute, and a consistency check has no business calling a +// tool that spends money or reaches a third party. Those two families are also +// exactly where the source scan is strongest, so nothing is lost. +const probeCache = new Map(); +async function routeExists(ref) { + if (staticRouteExists(ref)) return true; + const family = segs(ref)[0]; + if (family === "api" || family === "v1") return false; + if (probeCache.has(ref)) return probeCache.get(ref); + let live = false; + try { + const res = await fetch(`${TARGET}${ref}`, { redirect: "manual" }); + live = res.status !== 404; + } catch { live = false; } + probeCache.set(ref, live); + return live; +} + +const pricing = await fetch(`${TARGET}/api/pricing`).then((r) => r.json()); +const catalogRoutes = new Set((pricing.endpoints || []).map((e) => String(e.path).replace(/\/$/, ""))); +const catalogSlugs = new Set((pricing.endpoints || []).map((e) => e.slug).filter(Boolean)); + +const listed = await rpc("tools/list", {}); +const listedNames = new Set((listed.tools || []).map((t) => t.name)); + +// snake_case words that appear in a call-this position in our own copy but are +// NOT callable tools of ours. Each entry is a claim that an agent reading this +// word will not try to call it. Keep it short and specific: this list is the +// only escape hatch, and a long one turns the check back into the heuristic it +// replaced. +const NOT_A_TOOL = new Set([ + // Tools on the STDIO npm package (agent402-mcp), named in our copy as the + // wallet-holding alternative to this authless connector. Real, just not here. + "route_and_execute", +]); + +// ------------------------------------------------------------- probe control +// Prove the extractors can see a defect before trusting them to report none. +const CONTROL = 'Call totally_fake_tool to continue, or POST https://example.test/api/not-a-real-route with slug: "no-such-slug-here". "mcpTool": "another_fake_tool"'; +assert(referencedToolNames(CONTROL).has("totally_fake_tool"), "control: the tool-name extractor sees a planted tool reference"); +assert(referencedToolNames(CONTROL).has("another_fake_tool"), "control: the tool-name extractor sees a planted manifest mcpTool reference"); +assert(referencedSlugs(CONTROL).has("no-such-slug-here"), "control: the slug extractor sees a planted slug reference"); +assert(referencedPaths(CONTROL, "https://example.test").has("/api/not-a-real-route"), "control: the path extractor sees a planted path reference"); +assert(!(await routeExists("/api/not-a-real-route")), "control: an unregistered route is reported missing"); +assert(await routeExists("/api/wishes"), "control: /api/wishes is found in the route table"); +assert(await routeExists("/api/wish"), "control: the POST-only /api/wish is found in the route table (a GET 404 must not read as absent)"); +assert(!(await routeExists("/api/wishe")), "control: a near-miss path is NOT satisfied by a longer sibling route"); +assert(await routeExists("/base"), "control: a template-literal route the source scan cannot see is confirmed by the live probe"); + +// --------------------------------------------- 1. handler / listing parity +// The #705 class, in the direction that bit us: implemented but undiscoverable. +// A tool an agent cannot see may as well not exist, and our own text was +// telling agents to call one. +for (const names of handlerBranches) { + assert( + names.some((n) => listedNames.has(n)), + `CallTool branch [${names.join(", ")}] is reachable under an advertised name (a capability no client can discover is dead code)`, + ); +} +// The mirror: advertised but unimplemented. Nothing hits this today; it is the +// failure an agent experiences as a broken promise rather than a missing one. +for (const name of [...listedNames].sort()) { + const slug = slugForMcpName.get(name) || name.replace(/_/g, "-"); + assert( + handledNames.has(name) || catalogSlugs.has(slug), + `listed tool "${name}" resolves to a handler or a real catalog slug (${slug})`, + ); +} + +// ------------------------------------------------- 2. the text agents read +// Every free, self-describing surface: the tool list itself, plus the payloads +// of the meta-tools whose entire job is to orient an agent. +const about = await rpc("tools/call", { name: "about_agent402", arguments: {} }); +const payment = await rpc("tools/call", { name: "get_payment_info", arguments: {} }); +const textOf = (r) => (r.content || []).map((c) => c.text || "").join("\n"); +const llms = await fetch(`${TARGET}/llms.txt`).then((r) => r.text()); +const manifest = await fetch(`${TARGET}/.well-known/x402`).then((r) => r.text()); + +const surfaces = [ + ["tools/list", (listed.tools || []).map((t) => `${t.title}\n${t.description}`).join("\n\n")], + ["about_agent402", textOf(about)], + ["get_payment_info", textOf(payment)], + ["/llms.txt", llms], + ["/.well-known/x402", manifest], +]; + +// A tool name we tell an agent to call must be one it can discover. This is the +// assertion that fails on #705: about_agent402 said "Call request_tool" while +// tools/list did not contain it. +for (const [surface, text] of surfaces) { + for (const name of [...referencedToolNames(text)].sort()) { + // NO "does this look like one of our tools?" filter here. The first draft + // had one, and it skipped any snake_case name that was neither listed nor + // handled - which is exactly the defect being hunted. Planting + // "Call submit_wish" in about_agent402 passed a green run. An unknown name + // in a call-this position is the FINDING, not noise to be filtered out. + // + // Prose that legitimately contains snake_case non-tools (protocol field + // names like max_tokens) goes in NOT_A_TOOL, one line per word, so adding + // one is a deliberate statement that it is not callable. + if (NOT_A_TOOL.has(name)) continue; + assert(listedNames.has(name), `${surface} names tool "${name}" in a call-this position, but tools/list does not offer it`); + } + for (const slug of [...referencedSlugs(text)].sort()) { + assert(catalogSlugs.has(slug), `${surface} names catalog slug "${slug}", which exists`); + } + for (const path of [...referencedPaths(text, TARGET)].sort()) { + assert(await routeExists(path), `${surface} names route "${path}", which is registered`); + } +} + +console.log(`\n${passed} assertions passed, ${failures.length} failed`); +if (failures.length) { + console.log("\nFailures:"); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); +} diff --git a/scripts/test-multi-payto.js b/scripts/test-multi-payto.js new file mode 100644 index 00000000..a526736c --- /dev/null +++ b/scripts/test-multi-payto.js @@ -0,0 +1,64 @@ +// A venue with many payees must not collapse to one seller. +// +// The index kept ONE payTo per network via a first-wins reduce. That is fine +// for a seller whose tools all pay the same address, and wrong for a +// marketplace that gives each author their own revenue split: measured on a +// live seller 2026-08-06, 236 paid routes across 22 authors with 22 distinct +// payTo addresses, of which the index retained exactly one. Twenty-one payees +// were invisible, and any ranking that aggregates by payTo (ours included) +// attributed the whole origin's on-chain volume to whichever address happened +// to be crawled first. +// +// `payToByNetwork` deliberately STAYS a single string per network - the +// router's chain-derived proven-ness join and the market pages index it +// directly, and changing its type would break them silently. `payTosByNetwork` +// carries the full set alongside it. +// +// node scripts/test-multi-payto.js +let passed = 0, failed = 0; +const ok = (c, m) => { if (c) { passed++; console.log(`ok - ${m}`); } else { failed++; console.error(`FAIL - ${m}`); } }; + +// IMPORT the real function rather than mirroring it. An earlier draft of this +// file reimplemented the reducer locally, which would have stayed green even if +// payTosByNetwork were deleted from the module outright - a test that proves +// its own copy works, not the shipped code. +const { allPayTosByNetwork: allPayTos } = await import("../src/x402-index.js"); +ok(typeof allPayTos === "function", "the reducer is exported from src/x402-index.js and is what is under test"); +const firstWins = (tools) => (tools || []).reduce((acc, t) => { + for (const [net, addr] of Object.entries(t.payToByNetwork || {})) if (!acc[net]) acc[net] = addr; + return acc; +}, {}); + +const BASE = "eip155:8453"; +// 22 authors, one payTo each - the real shape that exposed this. +const venue = Array.from({ length: 22 }, (_, i) => ({ payToByNetwork: { [BASE]: `0x${String(i).padStart(40, "a")}` } })); + +ok(Object.keys(firstWins(venue)).length === 1, "first-wins yields a single network key (unchanged behaviour)"); +ok(typeof firstWins(venue)[BASE] === "string", "payToByNetwork stays a STRING - the router join and market pages index it directly"); +ok(allPayTos(venue)[BASE].length === 22, `all 22 author payees are retained (got ${allPayTos(venue)[BASE].length})`); + +// Duplicates collapse, so a busy author does not inflate the payee count. +const dupes = [{ payToByNetwork: { [BASE]: "0xAAA" } }, { payToByNetwork: { [BASE]: "0xAAA" } }, { payToByNetwork: { [BASE]: "0xBBB" } }]; +ok(allPayTos(dupes)[BASE].length === 2, "the same payee across many routes counts once"); + +// Case-exactness: folding merges DISTINCT payees. Same rule as src/payer.js - +// base58/base32 are case-sensitive and EVM addresses are checksummed. +const cased = [{ payToByNetwork: { "solana:x": "SoLaNa1" } }, { payToByNetwork: { "solana:x": "solana1" } }]; +ok(allPayTos(cased)["solana:x"].length === 2, "case-different addresses stay distinct, never folded together"); + +// Multi-chain: a payee set per network, not one flat list. +const multi = [{ payToByNetwork: { [BASE]: "0xA", "solana:x": "S1" } }, { payToByNetwork: { [BASE]: "0xB" } }]; +ok(allPayTos(multi)[BASE].length === 2 && allPayTos(multi)["solana:x"].length === 1, + "payees are grouped per network, not merged across chains"); + +// Unbounded growth guard: an origin advertising thousands of payees must not +// balloon a cached index entry. +const many = Array.from({ length: 500 }, (_, i) => ({ payToByNetwork: { [BASE]: `0x${i}` } })); +ok(allPayTos(many)[BASE].length === 200, `payee list is capped (got ${allPayTos(many)[BASE].length}, want 200)`); + +// Empty / malformed tools must not throw or invent entries. +ok(JSON.stringify(allPayTos([])) === "{}", "no tools yields an empty object"); +ok(JSON.stringify(allPayTos([{}, { payToByNetwork: null }, null])) === "{}", "malformed tool rows are skipped, not thrown on"); + +console.log(`\n${failed ? "FAILED" : "OK"}: ${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/scripts/test-theme.js b/scripts/test-theme.js index 1fabb962..ed5fac6e 100644 --- a/scripts/test-theme.js +++ b/scripts/test-theme.js @@ -71,6 +71,26 @@ const stranded = [...LEDGER_CSS.matchAll(/\}\s*([^\n{}]*)\/\*/g)].map((m) => m[1 ok(stranded.length === 0, `no selector text stranded before a comment${stranded.length ? ` (found "${stranded[0].slice(0, 60)}")` : ""}`); +// --- the shell must not leak raw JavaScript into the page ------------------- +// SHIPPED BROKEN once. Removing the theme IIFE with a regex ate its ``. Browsers hoist stray head text into the body, so a +// wall of JavaScript rendered at the top of every page - and a402ToggleMenu was +// never defined, which silently broke the mobile burger menu on every route. +// Neither the theme assertions nor the page tests noticed: the markup was still +// well-formed by div-balance standards and every route still returned 200. +// Case-insensitive on purpose. A tag counter that only sees lowercase would +// undercount ` means a stripped opening tag)"); +const headOnly = html.slice(html.indexOf(""), html.indexOf("")); +ok(!/\n\s*function\s+\w+\s*\(/.test(headOnly), + "no bare function declaration sitting outside a + ${esc(title)} diff --git a/src/mcp-http.js b/src/mcp-http.js index 324d0448..74a3198c 100644 --- a/src/mcp-http.js +++ b/src/mcp-http.js @@ -338,10 +338,43 @@ export function mountMcp(app, catalog, { baseUrl, isComputePayable, onServed = ( inputSchema: schemaOf(def), }; }), - // Both of these have working handlers below but were never listed, so an + // These three have working handlers below but were never listed, so an // MCP client could not see them - tools/list is the only discovery // surface a client has. The service manifest already advertises // top_x402_sellers, so it was promised and unreachable. + // + // request_tool is the same defect found from the outside (issue #705): + // about_agent402's own missingATool field tells an agent to "call + // request_tool", and tools/list did not contain it. The one tool whose + // whole job is to catch an agent that just failed to find something was + // itself unfindable, so the demand board only ever heard from callers + // who already knew the name. Listed LAST among the meta-tools so it + // reads as the fallback it is, after search/find/call have missed. + // + // It is the only tool here that WRITES (a wish row), so it does not get + // the SAFE read-only annotations - a client that trusts readOnlyHint to + // decide what it may call without asking would be misled. + { + name: "request_tool", + title: "Request a tool Agent402 does not have", + annotations: { + title: "Request a tool Agent402 does not have", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + description: `[free] Tell Agent402 about a capability its ${tools.size} tools do not cover. Use it after search_tools or find_tool came back with nothing that fits, instead of giving up: requests are clustered by need, and the ones that keep coming up get built. Records demand only - it never returns a tool or runs anything. Same intake as POST ${baseUrl}/api/wish; aggregate demand is public at ${baseUrl}/api/wishes.`, + inputSchema: { + type: "object", + properties: { + need: { type: "string", maxLength: 500, description: 'What you needed and could not find, in plain language, e.g. "convert a HEIC image to JPEG" or "look up a UK company by registration number"' }, + context: { type: "string", maxLength: 300, description: "Optional: what you were trying to accomplish, or the input you had - helps disambiguate similar-sounding requests." }, + }, + required: ["need"], + additionalProperties: false, + }, + }, { name: "about_agent402", title: "About this Agent402 connector", diff --git a/src/x402-index.js b/src/x402-index.js index 770c3d85..f0224761 100644 --- a/src/x402-index.js +++ b/src/x402-index.js @@ -1916,11 +1916,31 @@ export function routableSellerSummaries() { for (const [net, addr] of Object.entries(t.payToByNetwork || {})) if (!acc[net]) acc[net] = addr; return acc; }, {}), + // Every advertised payTo, not just the first (see allPayTosByNetwork). + payTosByNetwork: allPayTosByNetwork(v.tools), }); } return out; } +// Every distinct payTo a tool list advertises, per network. The `payToByNetwork` +// fields elsewhere are first-wins single strings and must stay that way (the +// router's proven-ness join and the market pages index them directly), but +// first-wins DISCARDS every payee after the first - and an origin that gives +// each author their own revenue split legitimately advertises many. Measured on +// a live seller 2026-08-06: 236 paid routes, 22 authors, 22 distinct payTo, of +// which the index kept one. Case-exact, since folding base58/base32 or +// checksummed EVM addresses merges distinct payees (same rule as src/payer.js). +export function allPayTosByNetwork(tools) { + return (tools || []).reduce((acc, t) => { + for (const [net, addr] of Object.entries(t?.payToByNetwork || {})) { + const seen = (acc[net] ||= []); + if (!seen.includes(addr) && seen.length < 200) seen.push(addr); + } + return acc; + }, {}); +} + export function sellerDetail(originOrHost) { const q = String(originOrHost || "").trim().toLowerCase().slice(0, 253); if (!q) return null; @@ -1960,6 +1980,9 @@ export function sellerDetail(originOrHost) { for (const [net, addr] of Object.entries(t.payToByNetwork || {})) if (!acc[net]) acc[net] = addr; return acc; }, {}), + // Every payee this origin advertises, so a venue hosting many authors is + // not reported as a single seller (see allPayTosByNetwork). + payTosByNetwork: allPayTosByNetwork(v.tools), routable: isRoutable(v), tools: (v.tools || []).slice(0, 500).map((t) => ({ method: t.method || null, @@ -2040,6 +2063,7 @@ export function indexSnapshot({ baseUrl, catalog, prices, network, toolCount, wa for (const [net, addr] of Object.entries(t.payToByNetwork || {})) if (!acc[net]) acc[net] = addr; return acc; }, {}), + payTosByNetwork: allPayTosByNetwork([...(bazaarToolsByOrigin.get(origin) || []), ...(v.tools || [])]), })); // Collapse http/https duplicates of the same host into one seller. A registry // can list the same origin under both schemes (algo.netintel.dev appeared as @@ -2063,6 +2087,13 @@ export function indexSnapshot({ baseUrl, catalog, prices, network, toolCount, wa keep.stellarWallet = keep.stellarWallet || drop.stellarWallet; keep.algorandWallet = keep.algorandWallet || drop.algorandWallet; keep.payToByNetwork = { ...(drop.payToByNetwork || {}), ...(keep.payToByNetwork || {}) }; + // Union, not overwrite: the two schemes of one host can advertise different + // payees, and spreading one object over the other would drop a whole side. + keep.payTosByNetwork = Object.entries({ ...(drop.payTosByNetwork || {}), ...(keep.payTosByNetwork || {}) }) + .reduce((acc, [net]) => { + acc[net] = [...new Set([...((drop.payTosByNetwork || {})[net] || []), ...((keep.payTosByNetwork || {})[net] || [])])].slice(0, 200); + return acc; + }, {}); keep.toolCount = Math.max(keep.toolCount || 0, drop.toolCount || 0); if (keep.paidToolCount != null || drop.paidToolCount != null) { keep.paidToolCount = Math.max(keep.paidToolCount ?? 0, drop.paidToolCount ?? 0);