Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

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

## 0.39.1

Closes the three money-path holes 0.38.1 documented as "known and unfixed" —
all in `blockrun_video`, all pre-existing, all now pinned by
`test/video-money-path.test.ts`.

- **`fix(budget)` — an unreadable 402 amount aborts BEFORE signing.** When the
quote's `amount` failed to parse (missing, non-numeric, non-positive), the
old path skipped the re-reserve, signed a payment authorization for the raw
unvalidated value, and booked only the estimate — the last remaining way
past the budget cap. Now: fail closed with "no charge was made", nothing
signed, reservation fully released. Never sign what you could not read.

- **`fix(ssrf)` — `image_url` / `last_frame_url` get the guard `blockrun_image`
already had.** Scheme check (zod's `.url()` accepts `file://` et al.) plus
resolved-host check via `isBlockedFetchHostResolved` — resolved, not
literal, so wildcard-DNS names like `127.0.0.1.nip.io` are caught. This
process never fetches these URLs (the gateway's fetcher does), so the guard
is defense-in-depth plus a saved round trip: a private/metadata address was
previously forwarded, quoted, and paid for before failing server-side.

- **`fix(budget)` — a malformed "completed" poll can no longer un-book a real
charge.** Settlement happens server-side on the first poll the gateway
answers with `status:"completed"` — the USDC is gone the moment the client
observes it. The old path validated the payload first: a completed body with
no clip URL threw, the catch returned an error, and `finally` released the
reservation — a real charge the ledger never saw, silently raising the cap
by the lost amount. The spend is now booked at the instant "completed" is
observed, before any payload validation; the caller still gets the error.

The first draft of this fix double-booked the happy path (poll site AND the
old tail call) — caught by the new test's exactly-once assertion, which is
why it asserts on the ledger, not on the error text.

309 tests pass.

## 0.39.0

The Seedance capability tables, re-derived from token360's OWN published
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/mcp",
"version": "0.39.0",
"version": "0.39.1",
"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",
Expand Down
59 changes: 57 additions & 2 deletions src/tools/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { launchTopUp } from "../utils/onramp.js";
import { fetchWithTimeout, isTimeoutError } from "../utils/http.js";
import type { BudgetState } from "../types.js";
import { getChain, getOrCreateWalletKey } from "../utils/wallet.js";
import { isBlockedFetchHostResolved } from "../utils/ssrf.js";
import { privateKeyToAccount } from "viem/accounts";
import {
createPaymentPayload,
Expand Down Expand Up @@ -275,6 +276,32 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
}
}

// SSRF guard on caller-supplied URLs, mirroring blockrun_image
// (src/tools/image.ts). This process never fetches these URLs — the
// GATEWAY's fetcher does — so this is defense-in-depth plus a saved
// round trip: a URL pointing at localhost / the metadata endpoint /
// the private network was previously forwarded, quoted, and PAID for
// before failing (or worse, succeeding) server-side. Resolved, not
// literal: wildcard-DNS names like 127.0.0.1.nip.io are public strings
// that map to private addresses. zod's .url() accepts any scheme, so
// file:// etc. are rejected here too.
for (const [name, value] of [["image_url", image_url], ["last_frame_url", last_frame_url]] as const) {
if (!value) continue;
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return {
content: [{ type: "text", text: formatError(`${name} must be an http(s) URL — got scheme "${parsed.protocol}"`) }],
isError: true,
};
}
if (await isBlockedFetchHostResolved(parsed.hostname)) {
return {
content: [{ type: "text", text: formatError(`${name} resolves to a private/loopback/link-local address (${parsed.hostname}) — refusing to forward it to the gateway.`) }],
isError: true,
};
}
}

// Resolution ceilings, per model. Only Seedance
// is checked: Sora and Grok bill per second and ignore the parameter,
// which is what the schema promises — so for them it is DROPPED from the
Expand Down Expand Up @@ -356,13 +383,24 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
// 1080p/4K can far exceed the per-second table estimate). Book THIS, not
// the estimate, so the budget cap reflects what was actually settled.
const settledUsd = amountToUsd(details.amount);
// FAIL CLOSED on a quote we cannot price. amountToUsd returns null for
// a missing / non-numeric / non-positive amount — and the old path then
// SKIPPED the re-reserve, signed a payment for the raw unvalidated
// amount, and booked only the estimate: the last remaining way past the
// budget cap. Never sign what we could not read.
if (settledUsd === null) {
return {
content: [{ type: "text", text: formatError(`The gateway's 402 quote carried an unreadable amount (${JSON.stringify(details.amount)}). Refusing to sign a payment for an amount that could not be validated — no charge was made. This is a gateway fault; retry, and report it if it persists.`) }],
isError: true,
};
}

// The 402 carries the REAL price; Seedance/Sora are token-priced, so a
// 1080p/4K render can far exceed the per-second estimate reserved at
// the gate. Re-reserve against the cap BEFORE paying so a single high-res
// call can't settle past the budget (and concurrent jobs hold the true
// amount, not the low estimate, for the whole polling window).
if (settledUsd !== null && settledUsd > estimatedCost) {
if (settledUsd > estimatedCost) {
gate?.release();
gate = reserveBudget(budget, agent_id, settledUsd);
if (!gate.allowed) {
Expand Down Expand Up @@ -425,6 +463,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC

const startedAt = Date.now();
let lastStatus = submitData.status || "queued";
let spendBooked = false;
let completed: {
url: string;
source_url?: string;
Expand Down Expand Up @@ -458,6 +497,20 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC

lastStatus = pollData.status || lastStatus;

// Settlement happens SERVER-SIDE on the first poll the gateway
// answers with status "completed" — the USDC is gone the moment we
// observe it, regardless of what the rest of the payload looks like.
// Book immediately: the old path validated the payload first, so a
// malformed completed body threw, the catch returned an error, and
// finally released the reservation — a real charge the ledger never
// saw, silently raising the cap by the lost amount.
if (lastStatus === "completed" && !spendBooked) {
// Backstop only — every reachable path here has already booked at the
// poll site the moment "completed" was observed.
if (!spendBooked) recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
spendBooked = true;
}

if (pollResp.status === 202 && (lastStatus === "queued" || lastStatus === "in_progress")) {
continue;
}
Expand Down Expand Up @@ -506,7 +559,9 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
...(completed.request_id ? [`Request ID: ${completed.request_id}`] : []),
...(completed.txHash ? [`Tx: ${completed.txHash}`] : []),
];
recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
// Backstop only — every reachable path here has already booked at the
// poll site the moment "completed" was observed.
if (!spendBooked) recordActualSpend(budget, settledUsd, estimatedCost, agent_id);

return {
content: [{ type: "text", text: lines.join("\n") }],
Expand Down
169 changes: 169 additions & 0 deletions test/video-money-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Run with: npm test (tsx --experimental-test-module-mocks --test)
//
// Pins the three money-path holes the 0.38.1 review documented as "known and
// unfixed", now fixed:
//
// 1. An unquotable 402 amount must abort BEFORE any payment is signed — the
// old path skipped the re-reserve, signed the raw unvalidated amount, and
// booked only the estimate: the last remaining way past the budget cap.
// 2. image_url / last_frame_url get the same SSRF guard blockrun_image has —
// scheme check plus resolved-host check, BEFORE any network call, so a
// private/metadata address is never forwarded to (or paid for at) the
// gateway.
// 3. Settlement happens server-side on the first "completed" poll. The spend
// must be booked the moment that status is observed — a malformed
// completed payload used to throw first, releasing the reservation while
// the USDC was already gone.
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import type { BudgetState } from "../src/types.js";

const TEST_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";

function headers(map: Record<string, string>) {
const lower: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) lower[k.toLowerCase()] = v;
return { get: (name: string) => lower[name.toLowerCase()] ?? null };
}

// Scriptable fetch: each test sets `script` to the remaining responses; every
// call shifts one. Empty script = the test expected NO network call.
let script: Array<() => unknown> = [];
let fetchCalls = 0;
mock.module("../src/utils/http.js", {
namedExports: {
fetchWithTimeout: async () => {
fetchCalls++;
const next = script.shift();
if (!next) throw new Error("UNEXPECTED_NETWORK_CALL");
return next();
},
isTimeoutError: () => false,
},
});
mock.module("../src/utils/wallet.js", {
namedExports: {
getChain: () => "base",
getOrCreateWalletKey: () => TEST_KEY,
getWalletInfo: async () => ({ address: "0xTEST" }),
},
});
// Hostname-keyed, no DNS: the real resolver is covered by ssrf.test.ts; here we
// only need "this hostname is private" to be decidable offline.
mock.module("../src/utils/ssrf.js", {
namedExports: {
isBlockedFetchHostResolved: async (hostname: string) =>
hostname === "169.254.169.254" || hostname === "127.0.0.1.nip.io",
isBlockedFetchHost: () => false,
},
});
// Scriptable 402 amount + a payment-signing tripwire.
let quotedAmount: unknown = "400000";
let paymentsSigned = 0;
mock.module("@blockrun/llm", {
namedExports: {
createPaymentPayload: async () => { paymentsSigned++; return "0xpaymentpayloadmock"; },
parsePaymentRequired: () => ({}),
extractPaymentDetails: () => ({
amount: quotedAmount,
recipient: "0x0000000000000000000000000000000000000001",
network: "eip155:8453",
resource: { url: "https://blockrun.ai/api/v1/videos/generations", description: "BlockRun Video Generation" },
maxTimeoutSeconds: 600,
extra: {},
}),
},
});

const { registerVideoTool } = await import("../src/tools/video.js");

function makeHarness() {
let handler: ((args: Record<string, unknown>) => Promise<any>) | undefined;
const server = {
registerTool: (_n: string, _c: unknown, h: any) => { handler = h; },
server: { getClientCapabilities: () => ({}) },
} as any;
const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() };
registerVideoTool(server, budget);
return { call: (args: Record<string, unknown>) => handler!(args), budget };
}

const resp402 = () => ({ status: 402, ok: false, headers: headers({ "payment-required": "x402 base ..." }), json: async () => ({}) });
const respSubmit = () => ({ status: 202, ok: false, headers: headers({}), json: async () => ({ id: "vid_1", poll_url: "/api/v1/videos/poll/vid_1", status: "queued" }) });
const respPoll = (body: unknown) => ({ status: 200, ok: true, headers: headers({}), json: async () => body });

test("SSRF: non-http(s) schemes and private-resolving hosts are refused before ANY network call", async () => {
for (const args of [
{ image_url: "file:///etc/passwd" },
{ image_url: "http://169.254.169.254/latest/meta-data/" },
{ image_url: "https://127.0.0.1.nip.io/a.png" },
{ image_url: "https://ok.example.com/a.png", last_frame_url: "http://169.254.169.254/b.png" },
]) {
script = []; fetchCalls = 0;
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "bytedance/seedance-2.0", ...args });
const text = res.content.map((c: any) => c.text).join("\n");
assert.equal(res.isError, true, text);
assert.match(text, /http\(s\) URL|private\/loopback\/link-local/);
assert.equal(fetchCalls, 0, `network was reached for ${JSON.stringify(args)}`);
assert.equal(budget.spent, 0);
}
});

test("SSRF: a public https seed image still goes through", async () => {
script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 5 }] })];
quotedAmount = "400000";
const { call } = makeHarness();
const res = await call({ prompt: "a cube", model: "bytedance/seedance-2.0", image_url: "https://ok.example.com/a.png" });
assert.notEqual(res.isError, true, res.content?.[0]?.text);
});

test("an unreadable 402 amount aborts BEFORE signing — nothing signed, nothing booked", async () => {
for (const bad of ["garbage", "", "-5", 0, undefined, {}]) {
script = [resp402]; fetchCalls = 0; paymentsSigned = 0;
quotedAmount = bad;
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
const text = res.content.map((c: any) => c.text).join("\n");
assert.equal(res.isError, true, text);
assert.match(text, /unreadable amount/);
assert.match(text, /no charge was made/);
assert.equal(paymentsSigned, 0, `signed a payment for amount ${JSON.stringify(bad)}`);
assert.equal(fetchCalls, 1, "must stop after the quote — no paid submit");
assert.equal(budget.spent, 0, "reservation must be fully released");
}
quotedAmount = "400000";
});

test("a malformed completed poll still BOOKS the settled spend (the money already moved)", async () => {
// Poll answers completed with an empty data[] — the old path threw on the
// missing URL, the catch returned an error, and finally released the
// reservation: $0.40 gone on-chain, $0 in the ledger.
script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [] })];
quotedAmount = "400000";
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
const text = res.content.map((c: any) => c.text).join("\n");
assert.equal(res.isError, true, "a payload with no clip URL is still an error for the caller");
assert.match(text, /missing video URL/);
assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `settled charge must stay booked: spent=${budget.spent}`);
});

test("the happy path books the settled amount exactly once", async () => {
script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })];
quotedAmount = "400000";
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
assert.notEqual(res.isError, true, res.content?.[0]?.text);
assert.equal(res.structuredContent.cost_usd, 0.4);
assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `booked once, not twice: spent=${budget.spent}`);
});

test("upstream failure before completion books nothing (no charge per gateway contract)", async () => {
script = [resp402, respSubmit, () => respPoll({ status: "failed", error: "render exploded" })];
quotedAmount = "400000";
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
assert.equal(res.isError, true);
assert.equal(budget.spent, 0, "failed jobs are not charged and must not be booked");
});
Loading