diff --git a/CHANGELOG.md b/CHANGELOG.md index 9192896..e293fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.33.0 + +`blockrun_nosana` — rent a GPU container on Nosana, a decentralized market on +Solana, with the wallet this MCP already holds. + +- A second landlord, not a cheaper one. `blockrun_modal` stays the fast path: + one HTTP call, USDC on Base, nothing extra to hold. Nosana is the durable + path — the market is an on-chain program, so no company can end a running + lease, and the cheapest GPU market measured $0.048/hr against $1.50/hr for a + managed T4. An agent that can only rent from one provider stops existing when + that provider does. +- `action:"rent" | "status" | "extend"`. A lease can be extended in place, so a + long-lived agent does not have to move house every time the clock runs out. +- Costs stated in the tool description, not discovered later: you must hold NOS + and a little SOL, and the job definition is pinned to PUBLIC IPFS — anything + secret has to travel through the confidential channel, never through `env`. +- `cmd` must be a single shell string. An array command is accepted by the SDK + and then kills the container a few seconds in with no error, so the tool + refuses it where the caller can still read why. +- `max_spend_usd` refuses an over-budget lease before any key is touched. +- `@nosana/sdk` is an OPTIONAL PEER dependency, imported lazily inside the + handler. Not `optionalDependencies` — npm installs those by default, which + would charge every user 113 packages for a tool most will never call. An + optional peer is installed only by the people who ask for it. + ## 0.32.9 Closes the external-review backlog: all 8 findings from the Kimi K3 review diff --git a/README.md b/README.md index fef34da..1b83be3 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@

Agents can't sign up for accounts. Agents can't enter credit cards.
Agents can only sign transactions.

-BlockRun MCP gives your agent 19 tools — markets, research, web search, images, video, on-chain data, and live Polymarket trading — paid per call in USDC. No accounts. No API keys. No dashboards.

+BlockRun MCP gives your agent 20 tools — markets, research, web search, images, video, on-chain data, and live Polymarket trading — paid per call in USDC. No accounts. No API keys. No dashboards.

Read the odds and place the bet, from one self-custody wallet.


-19 tools  +20 tools  Agent native  No API keys  Read and trade Polymarket  @@ -42,7 +42,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest --- -> **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 19 tools for real-time data and real actions: 66 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. Authentication is a wallet signature (no API keys); you pay per call in USDC via the [x402](https://x402.org) protocol (no credit cards, no subscriptions). One self-custody wallet on Base or Solana. MIT licensed. +> **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 20 tools for real-time data and real actions: 66 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. Authentication is a wallet signature (no API keys); you pay per call in USDC via the [x402](https://x402.org) protocol (no credit cards, no subscriptions). One self-custody wallet on Base or Solana. MIT licensed. ## 🏆 First of its kind — the signal → trade loop in Claude Code @@ -56,7 +56,7 @@ Every other data integration was built for **human developers** — create an ac **Agents can't do any of that.** BlockRun MCP is built for the agent-first world: -- **One wallet, every source** — 19 tools behind a single self-custody wallet. No per-vendor signups. +- **One wallet, every source** — 20 tools behind a single self-custody wallet. No per-vendor signups. - **No API keys** — your wallet signature *is* authentication. - **No credit cards** — pay per request in USDC via [x402](https://x402.org), fractions of a cent each. - **Starts free** — the free tier (`blockrun_chat mode:"free"`, `blockrun_dex`, crypto `blockrun_price`, `blockrun_models`) costs $0. @@ -71,7 +71,7 @@ Every other data integration was built for **human developers** — create an ac | ------------------- | -------------------------------- | ------------------------- | ----------------------------------------- | | **Setup** | Account + API key *per vendor* | Account/key for 1 vendor | **Wallet auto-created, no signup** | | **Payment** | Credit card, monthly minimums | Credit card / vendor plan | **USDC per-call via x402** | -| **Data sources** | One per integration | One vendor | **19 tools — LLMs, media, markets, chain**| +| **Data sources** | One per integration | One vendor | **20 tools — LLMs, media, markets, chain**| | **Place real bets** | Build it yourself | Rare | **Yes — Polymarket CLOB, confirm-gated** | | **Pay-chain** | — | — | **Base + Solana** | | **Agent budgets** | Manual | — | **Built-in per-agent delegation** | @@ -147,7 +147,7 @@ Expose a trimmed tool set so the client loads fewer schemas into context. Pass ` | Profile | Tools | |---------|-------| -| `full` *(default)* | everything (19 tools) | +| `full` *(default)* | everything (20 tools) | | `media` | `wallet` `models` `image` `video` `realface` `music` `speech` | | `trading` | `wallet` `price` `dex` `markets` `surf` `defi` `rpc` `polymarket` | | `research` | `wallet` `models` `chat` `search` `exa` `surf` | @@ -213,6 +213,7 @@ Claude reads the odds with `blockrun_markets` and — with your confirmation — | `blockrun_rpc` | Raw JSON-RPC on 40 chains (Ethereum, Base, Solana, Bitcoin, Sui, NEAR, …) via Tatum | $0.002/call | | `blockrun_defi` | DefiLlama — protocol TVL, chain TVL, yield pools (APY), token prices | $0.001–0.005/call | | `blockrun_modal` | Isolated code execution in a BlockRun-hosted Modal sandbox — disposable container, optional GPU (T4 → H100) | $0.01 create; $0.001/op | +| `blockrun_nosana` | Rent a GPU container on Nosana, a decentralized market on Solana — no provider can end a running lease; extendable | ~$0.05/GPU-hr, paid on-chain in NOS | | `blockrun_phone` | Outbound AI voice calls (Bland) + wallet-owned US/CA numbers (Twilio), carrier + fraud lookups | $0.54/call; $5/number | | `blockrun_models` | Live catalogue of every LLM/image/video/music model + pricing | free | | `blockrun_wallet` | Balance, spending, agent budgets, setup QR, chain switch | free | @@ -382,7 +383,7 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda ## FAQ **What is BlockRun MCP?** -An open-source MCP server that gives Claude and other agents 19 tools for real-time data and real actions (trading, media, on-chain), paid per call in USDC. No accounts, no API keys. +An open-source MCP server that gives Claude and other agents 20 tools for real-time data and real actions (trading, media, on-chain), paid per call in USDC. No accounts, no API keys. **Do I need API keys or accounts?** No. A wallet is auto-created locally on first run; you fund it with USDC. No signups, no dashboards, no key rotation. diff --git a/brand-numbers.json b/brand-numbers.json index 312505d..17b8fc5 100644 --- a/brand-numbers.json +++ b/brand-numbers.json @@ -21,7 +21,7 @@ "aliases": 202 }, "mcp": { - "tools": 19 + "tools": 20 }, "chains": { "rpc": 40 diff --git a/package.json b/package.json index 45ef275..011adcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.32.9", + "version": "0.33.0", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", @@ -86,5 +86,13 @@ }, "engines": { "node": ">=20.19" + }, + "peerDependencies": { + "@nosana/sdk": "^0.4.86" + }, + "peerDependenciesMeta": { + "@nosana/sdk": { + "optional": true + } } } diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 9838ab7..b5be52d 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -24,6 +24,7 @@ import { registerSurfTool } from "./tools/surf.js"; import { registerRpcTool } from "./tools/rpc.js"; import { registerDefiTool } from "./tools/defi.js"; import { registerPolymarketTool } from "./tools/polymarket.js"; +import { registerNosanaTool } from "./tools/nosana.js"; import { resolveTools, type ToolName } from "./profiles.js"; /** @@ -74,6 +75,7 @@ export function initializeMcpServer( rpc: () => registerRpcTool(server, budget), defi: () => registerDefiTool(server, budget), polymarket: () => registerPolymarketTool(server), + nosana: () => registerNosanaTool(server), }; for (const [name, register] of Object.entries(registrars) as [ToolName, () => void][]) { diff --git a/src/profiles.ts b/src/profiles.ts index e4e4273..21c24f6 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -27,14 +27,15 @@ export type ToolName = | "surf" | "rpc" | "defi" - | "polymarket"; + | "polymarket" + | "nosana"; // `as const satisfies` keeps the literal tuple type (so the exhaustiveness // guard below can see the actual entries) AND rejects any entry that isn't a // real ToolName (catches typos). export const ALL_TOOLS = [ "wallet", "chat", "models", "image", "music", "speech", "video", "realface", - "search", "exa", "markets", "price", "dex", "modal", "phone", "surf", "rpc", "defi", + "search", "exa", "markets", "price", "dex", "modal", "phone", "surf", "rpc", "defi", "nosana", "polymarket", ] as const satisfies readonly ToolName[]; diff --git a/src/tools/nosana.ts b/src/tools/nosana.ts new file mode 100644 index 0000000..0f50721 --- /dev/null +++ b/src/tools/nosana.ts @@ -0,0 +1,222 @@ +// src/tools/nosana.ts +// +// Nosana — rent a GPU container on a decentralized market with the Solana wallet +// this MCP already manages. +// +// Why this sits next to blockrun_modal rather than replacing it. Modal is the +// fast path: one HTTP call, USDC on Base, nothing else to hold. Nosana is the +// durable path: the market is a Solana program, so no company can switch off a +// running lease, and the cheapest GPU market measured $0.048/hr against $1.50/hr +// for a managed T4. An agent that can only rent from one provider stops existing +// when that provider does, so the useful thing is having both verbs available +// with the same wallet — not picking a winner. +// +// The cost is real and is stated up front in the tool description: you must hold +// NOS and a little SOL, and the job definition is pinned to public IPFS, so +// anything secret has to travel through the confidential channel instead of env. +// +// @nosana/sdk is an OPTIONAL PEER dependency, imported lazily inside the handler. +// Deliberately not `optionalDependencies`: npm installs those by default, so +// every user would carry the weight of a tool most will never call. + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { formatError, extractErrorMessage } from "../utils/errors.js"; + +// The cheapest GPU market on the network as of 2026-07-27. Overridable per call; +// a default only exists so the common case is one argument, not three. +export const NOSANA_DEFAULT_MARKET = "7AtiXMSH6R1jjBxrcYjehCkkSF7zvYWte63gwEDBcGHq"; +export const NOSANA_DEFAULT_USD_PER_HOUR = 0.04796; +export const NOSANA_MIN_SECONDS = 60; +export const NOSANA_MAX_SECONDS = 86400; + +export type NosanaPlan = + | { ok: true; seconds: number; market: string; estimateUsd: number } + | { ok: false; reason: string }; + +/** + * Exported for unit tests. Decide what to ask the market for, and refuse + * anything unaskable BEFORE any key is touched or any transaction is built. + * + * The duration bounds are the market's, not ours: below a minute the escrow + * accounting is not worth the signature, and a day is the ceiling a caller can + * reason about. `maxSpendUsd` is the caller's own brake — an agent looping on + * this tool should be stopped by arithmetic, not by its balance running out. + */ +export function planNosanaRental({ + seconds, + market = NOSANA_DEFAULT_MARKET, + usdPerHour = NOSANA_DEFAULT_USD_PER_HOUR, + maxSpendUsd, +}: { + seconds: number; + market?: string; + usdPerHour?: number; + maxSpendUsd?: number; +}): NosanaPlan { + if (!Number.isInteger(seconds) || seconds < NOSANA_MIN_SECONDS || seconds > NOSANA_MAX_SECONDS) { + return { ok: false, reason: `seconds must be a whole number from ${NOSANA_MIN_SECONDS} to ${NOSANA_MAX_SECONDS}` }; + } + if (typeof market !== "string" || market.length < 32) { + return { ok: false, reason: "market must be a Solana account address" }; + } + const estimateUsd = (usdPerHour * seconds) / 3600; + if (typeof maxSpendUsd === "number" && estimateUsd > maxSpendUsd) { + return { ok: false, reason: `that lease costs about $${estimateUsd.toFixed(4)}, over the $${maxSpendUsd} you allowed` }; + } + return { ok: true, seconds, market, estimateUsd }; +} + +/** + * Exported for unit tests. Build the job definition the market pins. + * + * `cmd` must be a flat string. An array command is accepted by the SDK and then + * kills the container a few seconds in — a failure with no error message, so it + * is worth refusing here where the caller can still read why. + */ +export function buildNosanaDefinition({ + image, + port, + cmd, + env, + gpu = true, +}: { + image: string; + port?: number; + cmd?: string; + env?: Record; + gpu?: boolean; +}): { definition: unknown } | { error: string } { + if (!image || typeof image !== "string") return { error: "image is required" }; + if (cmd !== undefined && typeof cmd !== "string") { + return { error: "cmd must be a single shell string — an array command starts and then dies silently on the node" }; + } + const args: Record = { image, gpu }; + if (port !== undefined) args.expose = port; + if (cmd !== undefined) args.cmd = cmd; + if (env !== undefined) args.env = env; + return { definition: { version: "0.1", type: "container", ops: [{ type: "container/run", id: "rented", args }] } }; +} + +/** The Solana key this MCP already manages, same precedence getClient() uses. */ +async function solanaKey(): Promise { + if (process.env.SOLANA_WALLET_KEY) return process.env.SOLANA_WALLET_KEY; + const { loadSolanaWallet } = await import("@blockrun/llm"); + return loadSolanaWallet() || null; +} + +type NosanaSdk = { + ipfs: { pin: (definition: unknown) => Promise }; + jobs: { + list: (hash: string, seconds: number, market: string) => Promise; + get: (job: string) => Promise; + extend: (job: string, seconds: number, wait: boolean) => Promise; + }; +}; + +// Imported through a variable so the compiler does not try to resolve an optional +// package that most installs will not have. The shape is asserted below instead. +const NOSANA_SDK = "@nosana/sdk"; + +async function loadSdk(key: string): Promise<{ sdk: NosanaSdk; exposeUrl: (job: string, port: number) => string }> { + const mod = (await import(NOSANA_SDK)) as unknown as { + Client: new (network: string, key: string) => NosanaSdk; + getExposeIdHash: (job: string, opIndex: number, port: number) => string; + }; + return { + sdk: new mod.Client("mainnet", key), + exposeUrl: (job, port) => `https://${mod.getExposeIdHash(job, 0, port)}.node.k8s.prd.nos.ci`, + }; +} + +export function registerNosanaTool(server: McpServer): void { + server.registerTool( + "blockrun_nosana", + { + description: `Rent a GPU container on Nosana, a decentralized market on Solana, with the wallet this MCP already holds. + +Use this when the container must outlive its provider: the market is an on-chain program, so no company can end a running lease, and the cheapest GPU market is about $0.05/hr against $1.50/hr for a managed T4. Use blockrun_modal instead when you want a box in one call and do not want to hold a second token. + +action:"rent" — post a job and get back its address and public URL. +action:"status" — read a job's state and how much lease time is left. +action:"extend" — buy more time on a lease you already pay for. + +What this costs you beyond money: you must hold NOS and a little SOL on the wallet, and the job definition is pinned to PUBLIC IPFS. Never put a key or token in \`env\` — it would be world-readable. cmd must be a single shell string. + +Requires the optional @nosana/sdk package: npm install @nosana/sdk`, + inputSchema: { + action: z.enum(["rent", "status", "extend"]).describe("What to do."), + image: z.string().optional().describe("Container image, e.g. 'docker.io/library/nginx:alpine'. Required for rent."), + seconds: z.number().optional().describe("Lease length in seconds (60..86400). Required for rent and extend."), + port: z.number().optional().describe("Port to expose publicly. Returns a URL derived from the job address."), + cmd: z.string().optional().describe("Command to run, as ONE shell string."), + env: z.record(z.string(), z.string()).optional().describe("Environment variables. PUBLIC — never put secrets here."), + market: z.string().optional().describe(`Market account. Defaults to ${NOSANA_DEFAULT_MARKET}.`), + job: z.string().optional().describe("Job address. Required for status and extend."), + max_spend_usd: z.number().optional().describe("Refuse the lease if it would cost more than this."), + }, + }, + async ({ action, image, seconds, port, cmd, env, market, job, max_spend_usd }) => { + try { + const key = await solanaKey(); + if (!key) { + return { + content: [{ type: "text", text: formatError("No Solana wallet found. Use blockrun_wallet to create one, or set SOLANA_WALLET_KEY.") }], + isError: true, + }; + } + + let loaded; + try { + loaded = await loadSdk(key); + } catch { + return { + content: [{ type: "text", text: formatError("@nosana/sdk is not installed. Run: npm install @nosana/sdk") }], + isError: true, + }; + } + const { sdk, exposeUrl } = loaded; + + if (action === "status") { + if (!job) return { content: [{ type: "text", text: formatError("status needs a job address.") }], isError: true }; + const result = await sdk.jobs.get(job); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + if (action === "extend") { + if (!job) return { content: [{ type: "text", text: formatError("extend needs a job address.") }], isError: true }; + const plan = planNosanaRental({ seconds: seconds ?? 0, market, maxSpendUsd: max_spend_usd }); + if (!plan.ok) return { content: [{ type: "text", text: formatError(plan.reason) }], isError: true }; + const result = await sdk.jobs.extend(job, plan.seconds, false); + return { + content: [{ type: "text", text: JSON.stringify({ job, added_seconds: plan.seconds, result }, null, 2) }], + }; + } + + const plan = planNosanaRental({ seconds: seconds ?? 0, market, maxSpendUsd: max_spend_usd }); + if (!plan.ok) return { content: [{ type: "text", text: formatError(plan.reason) }], isError: true }; + const built = buildNosanaDefinition({ image: image ?? "", port, cmd, env }); + if ("error" in built) return { content: [{ type: "text", text: formatError(built.error) }], isError: true }; + + const ipfsHash = await sdk.ipfs.pin(built.definition); + const listed = (await sdk.jobs.list(ipfsHash, plan.seconds, plan.market)) as { job?: string; address?: string } | string; + const jobAddress = typeof listed === "string" ? listed : listed.job || listed.address; + if (!jobAddress) { + return { content: [{ type: "text", text: formatError("The market accepted no job.") }], isError: true }; + } + + const out = { + job: jobAddress, + market: plan.market, + seconds: plan.seconds, + estimated_usd: Number(plan.estimateUsd.toFixed(4)), + ipfs: ipfsHash, + url: port === undefined ? null : exposeUrl(jobAddress, port), + }; + return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out }; + } catch (err) { + return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true }; + } + } + ); +} diff --git a/test/nosana.test.ts b/test/nosana.test.ts new file mode 100644 index 0000000..01df32a --- /dev/null +++ b/test/nosana.test.ts @@ -0,0 +1,72 @@ +// Run with: npm test (tsx --test) +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + planNosanaRental, + buildNosanaDefinition, + NOSANA_DEFAULT_MARKET, + NOSANA_DEFAULT_USD_PER_HOUR, +} from "../src/tools/nosana.js"; + +test("planNosanaRental prices by the hour and defaults to the cheapest market", () => { + const plan = planNosanaRental({ seconds: 3600 }); + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.market, NOSANA_DEFAULT_MARKET); + assert.equal(plan.estimateUsd, NOSANA_DEFAULT_USD_PER_HOUR); +}); + +test("planNosanaRental refuses durations the market will not take", () => { + for (const seconds of [0, 59, 86401, 1.5, NaN]) { + const plan = planNosanaRental({ seconds }); + assert.equal(plan.ok, false, `${seconds} should be refused`); + } +}); + +test("planNosanaRental stops a caller from overspending before any key is touched", () => { + const plan = planNosanaRental({ seconds: 86400, maxSpendUsd: 0.1 }); + assert.equal(plan.ok, false); + if (plan.ok) return; + assert.match(plan.reason, /over the \$0\.1 you allowed/); +}); + +test("planNosanaRental allows a lease that fits inside the caller's cap", () => { + const plan = planNosanaRental({ seconds: 600, maxSpendUsd: 0.05 }); + assert.equal(plan.ok, true); +}); + +test("buildNosanaDefinition produces a container op the market accepts", () => { + const built = buildNosanaDefinition({ image: "docker.io/library/nginx:alpine", port: 8080 }); + assert.ok("definition" in built); + if (!("definition" in built)) return; + assert.deepEqual(built.definition, { + version: "0.1", + type: "container", + ops: [{ type: "container/run", id: "rented", args: { image: "docker.io/library/nginx:alpine", gpu: true, expose: 8080 } }], + }); +}); + +test("buildNosanaDefinition rejects an array command instead of letting the node die silently", () => { + const built = buildNosanaDefinition({ + image: "node:20-alpine", + cmd: ["sh", "-c", "echo hi"] as unknown as string, + }); + assert.ok("error" in built); + if (!("error" in built)) return; + assert.match(built.error, /single shell string/); +}); + +test("buildNosanaDefinition requires an image", () => { + const built = buildNosanaDefinition({ image: "" }); + assert.ok("error" in built); +}); + +test("buildNosanaDefinition omits optional fields rather than sending empty ones", () => { + const built = buildNosanaDefinition({ image: "alpine" }); + assert.ok("definition" in built); + if (!("definition" in built)) return; + const args = (built.definition as { ops: { args: Record }[] }).ops[0].args; + assert.equal("expose" in args, false); + assert.equal("cmd" in args, false); + assert.equal("env" in args, false); +}); diff --git a/test/profiles.test.ts b/test/profiles.test.ts index 883e9a4..7be4f2b 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -4,16 +4,16 @@ import assert from "node:assert/strict"; import { ALL_TOOLS, PROFILES, resolveProfileName, resolveTools } from "../src/profiles.js"; const EXPECTED_COUNTS: Record = { - full: 19, + full: 20, media: 7, trading: 8, research: 6, chat: 3, }; -test("ALL_TOOLS has the full 19-tool set", () => { - assert.equal(ALL_TOOLS.length, 19); - assert.equal(new Set(ALL_TOOLS).size, 19, "no duplicates"); +test("ALL_TOOLS has the full 20-tool set", () => { + assert.equal(ALL_TOOLS.length, 20); + assert.equal(new Set(ALL_TOOLS).size, 20, "no duplicates"); }); test("resolveProfileName precedence: --profile flag > env > default", () => { @@ -46,16 +46,16 @@ test("every profile includes wallet (needed to pay)", () => { } }); -test("unknown profile name falls back to full (19 tools)", () => { +test("unknown profile name falls back to full (20 tools)", () => { const { profile, tools } = resolveTools(["--profile", "nonsense"], {}); assert.equal(profile, "full"); - assert.equal(tools.size, 19); + assert.equal(tools.size, 20); }); test("no args → full", () => { const { profile, tools } = resolveTools([], {}); assert.equal(profile, "full"); - assert.equal(tools.size, 19); + assert.equal(tools.size, 20); }); test("Object.prototype key names fall back to full instead of crashing", () => { @@ -64,7 +64,7 @@ test("Object.prototype key names fall back to full instead of crashing", () => { for (const name of ["constructor", "__proto__", "toString", "hasOwnProperty"]) { const { profile, tools } = resolveTools(["--profile", name], {}); assert.equal(profile, "full", `${name} should fall back to full`); - assert.equal(tools.size, 19, `${name} should expose all 19 tools`); + assert.equal(tools.size, 20, `${name} should expose all 19 tools`); } });