Skip to content

Commit 79c69c4

Browse files
authored
0.32.3 — nine audit findings, including one in 0.32.2's own headline fix (#64)
A multi-agent audit of 0.32.2 across nine angles, every finding put through two independent skeptics. 32 findings, 23 survived, 9 refuted. This ships the nine that could actually hurt someone. The nastiest is not a money bug: sharp is an optionalDependency but was imported at the top level, so ESM resolution failed before main() ran. With sharp absent, `node dist/index.js --version` exited 1 with ERR_MODULE_NOT_FOUND and empty stdout — all 19 tools gone, for a preview feature that is off by default. A fresh npx install hits it on musl, unusual arch, offline CI or --no-optional, and no dev machine ever saw it because every dev machine has sharp built. 0.32.2's own headline fix had its premise inverted: the free-tier cap is on CHARACTERS, not bytes. That sweep only probed ASCII, where the two are the same number. 131,000 CJK characters (393,000 bytes) pass through whole. Because UTF-8 length is always >= string length, the byte check could only over-fire — it never missed a real truncation, it invented ones, and advised callers to leave a working $0 model for a paid one on a false premise. Also: the SSRF guard was literal-only and 127.0.0.1.nip.io was verified reading a local server end-to-end; two reserve bypasses (exa contents?x=1 at 17x, chat ignoring input tokens at 11.4x); running blockrun_wallet flipped the chain Base->Solana and made the funding path unreachable; a reverted pUSD transfer reported success; the 0.32.2 CI automation could fail after npm had published; and the README understated markets/surf pricing by 27%. 204 tests, typecheck, build and verify:prices (20/20 exact) green. Each fix carries a regression test; sharp, the chain flip and SSRF were additionally verified by reproducing the original failure and re-running after.
1 parent 9a7e99e commit 79c69c4

17 files changed

Lines changed: 386 additions & 94 deletions

.github/workflows/publish.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ jobs:
9393
# Same independent-guard shape as the two publishes above — it checks
9494
# whether the tag already exists rather than assuming, so re-runs and
9595
# workflow_dispatch are safe.
96+
# Gated on the same version diff as the npm publish above. Without it, a
97+
# push that only touched publish.yml (or a workflow_dispatch) would tag
98+
# HEAD and title the release from an unrelated commit subject — precisely
99+
# in the "release is missing" state this automation exists to repair.
96100
- name: Tag + GitHub release
101+
if: steps.v.outputs.pkg != steps.v.outputs.npm
97102
env:
98103
GH_TOKEN: ${{ github.token }}
99104
VERSION: ${{ steps.v.outputs.pkg }}
@@ -123,10 +128,19 @@ jobs:
123128
TITLE="$TAG — $HEADLINE"
124129
fi
125130
126-
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
131+
# Ask the REMOTE, not the local clone: actions/checkout fetches no tags
132+
# by default, so `git rev-parse "$TAG"` always missed and the push ran
133+
# unconditionally. An annotated tag already on origin is then rejected
134+
# as non-fast-forward, and `set -e` fails the job AFTER npm and the
135+
# registry have already published — the worst place to fail.
136+
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
137+
echo "tag $TAG already on origin — not re-pushing"
138+
else
127139
git tag -a "$TAG" -m "$TITLE"
128140
git push origin "$TAG"
129141
fi
130142
131-
gh release create "$TAG" --title "$TITLE" --notes-file /tmp/notes.md --latest
143+
# Pin the release to the tag's commit explicitly rather than letting
144+
# GitHub default to the branch head.
145+
gh release create "$TAG" --title "$TITLE" --notes-file /tmp/notes.md --latest --target "$(git rev-parse HEAD)"
132146
echo "Released $TAG"

CHANGELOG.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,67 @@
22

33
All notable changes to BlockRun MCP will be documented in this file.
44

5+
## 0.32.3
6+
7+
A multi-agent audit of 0.32.2 across nine angles, every finding put through two
8+
independent skeptics. It caught nine real defects — including one in 0.32.2's own
9+
headline fix. The nastiest is not a money bug: **an optional dependency could kill
10+
the entire server.**
11+
12+
- **`fix(server)` — a missing `sharp` took down all 19 tools.** `sharp` is an
13+
`optionalDependency`, but `inline-image.ts` imported it at the top level, so ESM
14+
resolution failed before `main()` ran. Reproduced: with sharp absent,
15+
`node dist/index.js --version` exits **1** with `ERR_MODULE_NOT_FOUND` and empty
16+
stdout — for a preview feature that is **off by default**, on profiles that never
17+
register `blockrun_image`. A fresh `npx -y @blockrun/mcp@latest` hits it on musl,
18+
unusual arch, offline CI or `--no-optional`. No dev machine ever saw it, because
19+
every dev machine has sharp built. `qr.ts` documents this exact hazard and loads
20+
lazily; this module now does too. Verified after the fix: `--version` returns
21+
`0.32.3` and `tools/list` returns all 19 tools with sharp removed.
22+
- **`fix(chat)` — 0.32.2's truncation cap was measured in BYTES; the gateway caps
23+
CHARACTERS.** The original sweep only probed ASCII, where the two are the same
24+
number. CJK separates them: **131,000 CJK characters (393,000 bytes) pass through
25+
whole** (`prompt_tokens 131,065`), and both alphabets cap at ~131,072 characters.
26+
Because UTF-8 length is always >= string length, the byte check could only
27+
**over**-fire — it never missed a real truncation, it invented ones, bolting
28+
"⚠️ TRUNCATED … only the first ~87%" onto complete, correct answers for any
29+
non-ASCII prompt and advising the caller to switch to a **paid** model. It pushed
30+
agents off a working $0 path into real USDC spend on a false premise, which is
31+
worse than the silence it was meant to fix. The test that pinned the wrong
32+
behaviour is now inverted.
33+
- **`fix(security)` — SSRF guard was literal-only; wildcard DNS walked through it.**
34+
`127.0.0.1.nip.io` is a public name that resolves to loopback, and was verified
35+
end-to-end reading a local server and base64ing the body into the data URI sent
36+
onward to the gateway; `169.254.169.254.nip.io` reaches cloud metadata the same
37+
way. No redirect needed, so the per-hop literal check saw nothing suspicious.
38+
Hostnames are now resolved and **every** returned address is checked, failing
39+
closed on NXDOMAIN.
40+
- **`fix(budget)` — two reserve bypasses.** `blockrun_exa` matched the raw path, so
41+
`contents?x=1` missed the per-URL branch: 100 URLs reserved **$0.012** and settled
42+
**$0.202** (17x), booked wrong permanently. And `estimateChatCost` ignored input
43+
entirely — a 100k-word prompt settled **$0.2557** against a **$0.0225** reserve
44+
(11.4x), with break-even at ~15 KB, an ordinary pasted file. Both now priced.
45+
- **`fix(wallet)` — running `blockrun_wallet` flipped the chain Base → Solana.**
46+
`ensureBothWallets()` (the *default* status action) writes `.solana-session`, and
47+
chain autodetect keys off that file existing. Afterwards every paid tool signs
48+
from a zero-balance Solana wallet or refuses "Base-only" — including
49+
`action:"deposit"`, so the funding path itself became unreachable. Reproduced in a
50+
clean HOME; the chain is now pinned across provisioning.
51+
- **`fix(polymarket)` — a reverted pUSD transfer reported success.** viem does not
52+
throw on revert; the receipt was discarded, so a failed withdrawal still printed
53+
"✅ Withdrawal submitted … the bridge delivers USDC to Base". `redeem.ts` and
54+
`setup.ts` both assert `receipt.status`; this path now does too.
55+
- **`fix(ci)` — the 0.32.2 tag/release automation had two holes.** The step had no
56+
version-changed guard, so a `publish.yml`-only push or `workflow_dispatch` would
57+
tag HEAD and title the release from an unrelated commit. And `actions/checkout`
58+
fetches no tags, so the "tag exists" check always missed and the push ran
59+
unconditionally — failing the job *after* npm had already published. Now gated on
60+
the version diff and checked against `git ls-remote`.
61+
- **`docs` — the README advertised $0.0075 for `blockrun_markets`/`blockrun_surf`;
62+
both charge $0.0095.** A 27% understatement of real money in the most-read file,
63+
and "$5 covers ~5,000 market queries" was 9.5x off (~525). Five lines corrected.
64+
- 204 tests, typecheck, build and `verify:prices` (20/20 exact) green.
65+
566
## 0.32.2
667

768
The free tier throws away everything past 128 KiB and returns `200` as if it

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ An unknown profile name falls back to `full`. `modal` and `phone` are `full`-pro
163163
164164
Run `blockrun_wallet` to see your address. The server pays on **Base** by default — send USDC on Base (Coinbase → USDC → Base network → paste address, or bridge via [bridge.base.org](https://bridge.base.org)).
165165
166-
**$5 covers** ~5,000 market queries · ~500 Exa searches · ~250 image generations · ~10 Seedance 1.5-pro clips.
166+
**$5 covers** ~525 market queries · ~500 Exa searches · ~250 image generations · ~10 Seedance 1.5-pro clips.
167167
168168
Prefer Solana? See [Fund your wallet](#fund-your-wallet) — two tool calls, no restart.
169169
@@ -181,7 +181,7 @@ Claude reads the odds with `blockrun_markets` and — with your confirmation —
181181
182182
> **Claude:** According to Polymarket, the market puts a **73% probability** on the Fed holding rates steady, 24% on a 25bp cut, 3% on a hike. 24h volume: $2.1M. The "Hold" contract last traded at $0.73.
183183
>
184-
> *(via `blockrun_markets` · cost: $0.0075)*
184+
> *(via `blockrun_markets` · cost: $0.0095)*
185185
186186
---
187187
@@ -204,9 +204,9 @@ Claude reads the odds with `blockrun_markets` and — with your confirmation —
204204
| `blockrun_music` | MiniMax music generation | per track |
205205
| `blockrun_speech` | ElevenLabs TTS (Flash/Turbo/Multilingual/v3, 8 voices) + cinematic sound effects; free voice listing | $0.05–0.10/1k chars |
206206
| `blockrun_price` | Pyth-backed realtime + OHLC — crypto / FX / commodity (free), 12 stock markets (paid) | free or $0.001/call |
207-
| `blockrun_markets` | Polymarket (markets, candles, trades, orderbooks, leaderboards, smart-wallet PnL/clusters, UMA oracle), Kalshi, Limitless, Opinion, Predict.Fun, dFlow, Binance Futures, cross-platform search | $0.0075/query |
207+
| `blockrun_markets` | Polymarket (markets, candles, trades, orderbooks, leaderboards, smart-wallet PnL/clusters, UMA oracle), Kalshi, Limitless, Opinion, Predict.Fun, dFlow, Binance Futures, cross-platform search | $0.0095/query |
208208
| `blockrun_polymarket` | **Trade on Polymarket** (CLOB V2): place/cancel real bets, positions, redeem winnings — signed locally, settled in pUSD from a gasless deposit wallet. Confirm-gated, $25/order default cap. [Details ↓](#-polymarket-trading) | free tool; bets are your funds |
209-
| `blockrun_surf` | Surf (asksurf.ai) — 84 endpoints: CEX data, on-chain SQL (13 chains, 80+ tables), 100M+ labeled wallets, Polymarket + Kalshi, social mindshare, news, Surf-1.5 chat with citations | $0.0075/call |
209+
| `blockrun_surf` | Surf (asksurf.ai) — 84 endpoints: CEX data, on-chain SQL (13 chains, 80+ tables), 100M+ labeled wallets, Polymarket + Kalshi, social mindshare, news, Surf-1.5 chat with citations | $0.0095/call |
210210
| `blockrun_exa` | Neural web search (Exa) — research, competitors, papers, URL content | $0.01/query |
211211
| `blockrun_search` | Grok Live Search — web + X/Twitter + news with citations | $0.025 × max_results |
212212
| `blockrun_dex` | Live DEX prices via DexScreener | free |
@@ -274,7 +274,7 @@ Run `blockrun_wallet` to see your address. The server pays on **Base** by defaul
274274
| Coinbase | Send → USDC → Base network → paste address |
275275
| Bridge from Ethereum | [bridge.base.org](https://bridge.base.org) |
276276
277-
$5 covers ~5,000 market queries, ~500 Exa searches, ~250 image generations, or ~10 Seedance 1.5-pro clips (5s @ 720p+audio, ~$0.46 each).
277+
$5 covers ~525 market queries, ~500 Exa searches, ~250 image generations, or ~10 Seedance 1.5-pro clips (5s @ 720p+audio, ~$0.46 each).
278278
279279
### Pay on Solana
280280
@@ -326,8 +326,8 @@ Prompts and a worked example are in [`skills/image-prompting/SKILL.md`](skills/i
326326
| | Direct APIs | BlockRun |
327327
|---|---|---|
328328
| Exa | Sign up, $20/mo minimum | $0.01/call, no subscription |
329-
| Polymarket | Undocumented, rate-limited | $0.0075/call, clean JSON — plus you can **trade** |
330-
| Surf (asksurf.ai) | Account + monthly plan | $0.0075/call, no account, 84 endpoints |
329+
| Polymarket | Undocumented, rate-limited | $0.0095/call, clean JSON — plus you can **trade** |
330+
| Surf (asksurf.ai) | Account + monthly plan | $0.0095/call, no account, 84 endpoints |
331331
| Multiple sources | 3 accounts, 3 API keys, 3 billing pages | **1 wallet** |
332332
333333
One wallet. All sources. No dashboards.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.32.2
1+
0.32.3

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blockrun/mcp",
3-
"version": "0.32.2",
3+
"version": "0.32.3",
44
"mcpName": "io.github.BlockRunAI/blockrun-mcp",
55
"description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.",
66
"type": "module",

src/tools/chat.ts

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,35 @@ import { z } from "zod";
44
import { buildClient, buildClientWithTimeout, getAnthropicClient, baseOnlyMessage } from "../utils/wallet.js";
55
import { handleAnthropicNative, isAnthropicModel } from "./chat-anthropic.js";
66
import { extractErrorMessage, formatError } from "../utils/errors.js";
7-
import { MODEL_TIERS, FREE_MODEL_TIMEOUT_MS, FREE_TIER_DEADLINE_MS, FREE_TIER_MAX_PROMPT_BYTES, type RoutingMode } from "../utils/constants.js";
7+
import { MODEL_TIERS, FREE_MODEL_TIMEOUT_MS, FREE_TIER_DEADLINE_MS, FREE_TIER_MAX_PROMPT_CHARS, type RoutingMode } from "../utils/constants.js";
88
import { reserveBudget, recordActualSpend } from "../utils/budget.js";
99
import { withTxFee } from "../utils/tx-fee.js";
1010
import type { ApiClient } from "../utils/wallet.js";
1111
import type { BudgetState } from "../types.js";
1212

1313
/**
14-
* Byte size of everything we are about to send as prompt. Used only to decide
15-
* whether the free path will silently drop part of it.
14+
* Character length of everything we are about to send as prompt. Used only to
15+
* decide whether the free path will silently drop part of it.
16+
*
17+
* CHARACTERS, not bytes. 0.32.2 measured this in bytes and was wrong — see
18+
* FREE_TIER_MAX_PROMPT_CHARS for the CJK measurements that disproved it.
1619
*/
17-
export function promptByteSize(
20+
export function promptCharSize(
1821
message: string,
1922
system?: string,
2023
messages?: Array<{ content: unknown }>,
2124
): number {
22-
let bytes = Buffer.byteLength(message, "utf8");
23-
if (system) bytes += Buffer.byteLength(system, "utf8");
25+
let chars = message.length;
26+
if (system) chars += system.length;
2427
for (const m of messages ?? []) {
25-
bytes += Buffer.byteLength(
26-
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
27-
"utf8",
28-
);
28+
chars += (typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "")).length;
2929
}
30-
return bytes;
30+
return chars;
3131
}
3232

3333
/**
3434
* The free NVIDIA path truncates at 128 KiB and still answers 200 with a
35-
* confident, well-formed reply — see FREE_TIER_MAX_PROMPT_BYTES for the
35+
* confident, well-formed reply — see FREE_TIER_MAX_PROMPT_CHARS for the
3636
* measurements. Nothing in the response says the input was cut, so an agent
3737
* summarising a large document over mode:"free" would present an answer about
3838
* the first 128 KiB as an answer about the whole thing.
@@ -41,15 +41,15 @@ export function promptByteSize(
4141
* it is indistinguishable from success. We cannot stop the gateway doing it, so
4242
* the tool says so out loud. Returns null when nothing was lost.
4343
*/
44-
export function freeTierTruncationNote(promptBytes: number, model: string): string | null {
44+
export function freeTierTruncationNote(promptChars: number, model: string): string | null {
4545
if (!model.startsWith("nvidia/")) return null; // paid models scale past this
46-
if (promptBytes <= FREE_TIER_MAX_PROMPT_BYTES) return null;
47-
const keptPct = Math.round((FREE_TIER_MAX_PROMPT_BYTES / promptBytes) * 100);
46+
if (promptChars <= FREE_TIER_MAX_PROMPT_CHARS) return null;
47+
const keptPct = Math.round((FREE_TIER_MAX_PROMPT_CHARS / promptChars) * 100);
4848
return (
49-
`\n\n⚠️ TRUNCATED: the prompt was ${Math.round(promptBytes / 1024)} KiB, but the free ` +
50-
`NVIDIA path silently caps input at ${FREE_TIER_MAX_PROMPT_BYTES / 1024} KiB. Roughly ` +
51-
`${100 - keptPct}% of it never reached the model, so the answer above covers only the ` +
52-
`first ~${keptPct}%. Paid models do not truncate — pass an explicit model (or a paid ` +
49+
`\n\n⚠️ TRUNCATED: the prompt was ${promptChars.toLocaleString("en-US")} characters, but ` +
50+
`the free NVIDIA path silently caps input at ${FREE_TIER_MAX_PROMPT_CHARS.toLocaleString("en-US")}. ` +
51+
`Roughly ${100 - keptPct}% of it never reached the model, so the answer above covers only ` +
52+
`the first ~${keptPct}%. Paid models do not truncate — pass an explicit model (or a paid ` +
5353
`mode) to send the whole prompt.`
5454
);
5555
}
@@ -68,6 +68,7 @@ export function estimateChatCost(
6868
mode: string | undefined,
6969
model: string | undefined,
7070
thinkingBudget?: number,
71+
promptChars?: number,
7172
): number {
7273
// Free paths bypass the gate entirely — but ONLY when the call is genuinely
7374
// free, and `mode` alone does not make it so.
@@ -100,7 +101,17 @@ export function estimateChatCost(
100101
// reserve must cover the CHARGE, not the base. Rounded to micro-dollars because
101102
// the raw float drifts — (1024/1e6)*20 is 0.020479999999999998, which then
102103
// surfaces verbatim in budget messages.
103-
const frontierReserve = withTxFee(Math.max(0.01, Math.round((out / 1_000_000) * 20 * 1e6) / 1e6));
104+
// INPUT counts too. Until 0.32.3 the reserve was derived from output alone,
105+
// so a large pasted document reserved the same as a one-line question: a
106+
// 100k-word prompt settled $0.2557 against a $0.0225 reserve (11.4x short),
107+
// and break-even was only ~15 KB of prompt — an ordinary pasted file. A
108+
// BLOCKRUN_BUDGET_LIMIT was then blown several times over by ONE approved
109+
// call, and concurrency multiplied it. ~4 chars/token, priced at a frontier
110+
// input rate; over-reserving only tightens the gate, the ledger still books
111+
// the real settled cost via recordActualSpend().
112+
const inTokens = Math.ceil((promptChars ?? 0) / 4);
113+
const inputReserve = (rate: number) => Math.round((inTokens / 1_000_000) * rate * 1e6) / 1e6;
114+
const frontierReserve = withTxFee(Math.max(0.01, Math.round((out / 1_000_000) * 20 * 1e6) / 1e6) + inputReserve(5));
104115

105116
// Any tier whose FIRST-CHOICE model is a frontier model, plus any explicit
106117
// single model, can settle at a price we can't know up front — reserve
@@ -123,7 +134,7 @@ export function estimateChatCost(
123134
if (model) return frontierReserve;
124135

125136
// Only the explicitly-cheap tiers (cheap/fast/glm) pick budget models.
126-
return withTxFee(Math.max(0.002, Math.round((out / 1_000_000) * 3 * 1e6) / 1e6));
137+
return withTxFee(Math.max(0.002, Math.round((out / 1_000_000) * 3 * 1e6) / 1e6) + inputReserve(1));
127138
}
128139

129140
/**
@@ -197,11 +208,11 @@ Run blockrun_models to see all available models with pricing.`,
197208

198209
// Measured once and checked against whichever model each path settles on:
199210
// the free NVIDIA path drops everything past 128 KiB without saying so.
200-
const promptBytes = promptByteSize(message, system, messages);
211+
const promptChars = promptCharSize(message, system, messages);
201212

202213
// Budget gate: global + per-agent enforcement. The tier/model is resolved
203214
// AFTER the gate, so reserve the worst case it could settle at.
204-
const estimatedCost = estimateChatCost(max_tokens, mode, model, thinking?.budget_tokens);
215+
const estimatedCost = estimateChatCost(max_tokens, mode, model, thinking?.budget_tokens, promptChars);
205216
// Reserve the estimate up front so concurrent calls can't each pass a
206217
// stale budget; release in finally once the call settles or fails (the
207218
// real settled cost is booked separately via recordActualSpend).
@@ -275,7 +286,7 @@ Run blockrun_models to see all available models with pricing.`,
275286
}));
276287
const reply = result.choices?.[0]?.message?.content || "";
277288
recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
278-
const note = freeTierTruncationNote(promptBytes, targetModel);
289+
const note = freeTierTruncationNote(promptChars, targetModel);
279290
return {
280291
content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]\n\n${reply}${note ?? ""}` }],
281292
structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length, ...(note ? { truncated: true } : {}) },
@@ -296,7 +307,7 @@ Run blockrun_models to see all available models with pricing.`,
296307
stop,
297308
}));
298309
recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
299-
return { content: [{ type: "text", text: `${response}${freeTierTruncationNote(promptBytes, model) ?? ""}` }] };
310+
return { content: [{ type: "text", text: `${response}${freeTierTruncationNote(promptChars, model) ?? ""}` }] };
300311
} catch (error) {
301312
return {
302313
content: [{ type: "text", text: formatError(extractErrorMessage(error)) }],
@@ -335,7 +346,7 @@ Run blockrun_models to see all available models with pricing.`,
335346
stop,
336347
}));
337348
recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
338-
const note = freeTierTruncationNote(promptBytes, m);
349+
const note = freeTierTruncationNote(promptChars, m);
339350
return {
340351
content: [{ type: "text", text: `[${m}]\n\n${response}${note ?? ""}` }],
341352
structuredContent: { model_used: m, response, ...(note ? { truncated: true } : {}) },

0 commit comments

Comments
 (0)