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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

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

## 0.45.0

**Every tool schema got smaller, and the README now tells you by how much.**
Installing an MCP server spends your context on every turn — the client loads
each tool's schema into the model's prompt and re-sends it for the whole
session, whether or not you ever call the tools. Package managers have shown
install size for thirty years; almost no MCP server shows this. Now ours does:
a `🧮 12.9K Context Tokens` badge, and a table of what each profile costs at
the point where you are already choosing one. `--profile trading` is **57% less
context** than the default for the same trading workflow — already true, never
stated. The harness that produces the number ships too:
`npm run measure:schema`, and `-- <cmd>` points it at any other stdio MCP
server, so the claim is checkable by anyone against anyone.
`test/schema-tokens.test.ts` fails the build when the README disagrees with the
live server, asserting the published figure rather than a raw count so wording
edits do not break CI but a real regression does.

Alongside it, 300 of those tokens are simply gone. The SDK's zod→JSON-Schema
conversion stamps `"$schema": "http://json-schema.org/draft-07/schema#"` onto
every tool's `inputSchema` — 15 tokens each, on all 20 tools, that no code path
in the reference SDK ever reads and that its bundled validator never receives.
It is not our string and the SDK exposes no way to suppress it, so
`stripJsonSchemaDialect()` wraps the `tools/list` handler through the public
`setRequestHandler`. It is never load-bearing: if a future SDK stops routing
that way the wrapper stops matching and the header simply returns.

Honest about the rest: 54% of what remains is tool *descriptions*, not schemas,
and that work is not done. Numbers are `o200k_base`, a proxy — Claude's
tokenizer is not public and runs higher on JSON — so every figure published is
a slight under-count, never an over-count.

## 0.44.0

**Two MCP Apps: a Polymarket order card and a wallet panel.** Hosts that
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Agents can only sign transactions.<br><br>
<br>

<img src="https://img.shields.io/badge/🧰_20_Tools-success?style=for-the-badge" alt="20 tools">&nbsp;
<img src="https://img.shields.io/badge/🧮_12.9K_Context_Tokens-5B9BF6?style=for-the-badge" alt="12.9K context tokens">&nbsp;
<img src="https://img.shields.io/badge/🤖_Agent--Native-black?style=for-the-badge" alt="Agent native">&nbsp;
<img src="https://img.shields.io/badge/🔑_Zero_API_Keys-blue?style=for-the-badge" alt="No API keys">&nbsp;
<img src="https://img.shields.io/badge/📈_Read_+_Trade_Polymarket-e11d48?style=for-the-badge" alt="Read and trade Polymarket">&nbsp;
Expand Down Expand Up @@ -173,6 +174,49 @@ codex mcp add blockrun-trading -- npx -y @blockrun/mcp@latest --profile trading
```

An unknown profile name falls back to `full`. `modal` and `phone` are `full`-profile only.

#### What each profile costs your context

Installing an MCP server spends context on **every turn**, whether or not you call the tools —
the client loads each tool's schema into the model's prompt and re-sends it for the whole session.
Package managers have shown install size for decades. Almost no MCP server shows this. Ours:

| Profile | Tools | Context |
|---------|-------|---------|
| `full` *(default)* | 20 | 12,900 |
| `trading` | 9 | 5,554 |
| `media` | 7 | 5,436 |
| `research` | 6 | 3,024 |
| `chat` | 3 | 1,924 |

Running `--profile trading` instead of the default costs **57% less context** for the same trading
workflow. If you only ever ask about markets, that is the single cheapest change you can make.

Measure it yourself — against us, or against any other stdio MCP server:

```bash
npm i gpt-tokenizer
node scripts/measure-tool-schema.mjs # this server, every profile
node scripts/measure-tool-schema.mjs -- npx -y @some/other-mcp-server
```

<details>
<summary>How the number is computed, and why it is a slight under-count</summary>

It counts the **model-visible projection** — `{name, description, input_schema}` per tool, with the
`mcp__blockrun__` prefix the host prepends — because that is what lands in the API `tools` array.
It excludes `annotations`, `_meta` and `outputSchema`, which the host consumes and never forwards
to the model (a further ~3.7% on the wire).

The tokenizer is `o200k_base`. Claude's tokenizer is not public and runs a few percent higher on
JSON, so **every figure here is a slight under-count**, never an over-count.

Two caveats worth stating plainly. Tool schemas sit at the front of the prompt and are covered by
prompt caching, so after the first turn they re-send at cache-read rates — the *context-window*
cost is 100% every turn, the *dollar* cost is roughly a tenth of that. And 54% of our own cost is
tool **descriptions**, not schemas, which is where the remaining work is.

</details>
For a complete live signal → order-preview presentation, use
[`skills/signal-to-trade-demo/SKILL.md`](skills/signal-to-trade-demo/SKILL.md)
with the [Stanford runbook](docs/stanford-trading-demo.md).
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/mcp",
"version": "0.44.0",
"version": "0.45.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",
Expand All @@ -23,6 +23,7 @@
"test": "tsx --experimental-test-module-mocks --test test/*.test.ts",
"prepublishOnly": "npm run build",
"verify:prices": "tsx scripts/verify-prices.ts",
"measure:schema": "node scripts/measure-tool-schema.mjs",
"e2e:polymarket:readonly": "tsx scripts/polymarket-e2e-readonly.ts",
"e2e:polymarket:approvals": "tsx scripts/polymarket-e2e-verify-approvals.ts",
"e2e:polymarket:approve": "tsx scripts/polymarket-e2e-approve.ts",
Expand Down Expand Up @@ -86,6 +87,7 @@
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.6",
"gpt-tokenizer": "^4.0.0",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
Expand Down
160 changes: 160 additions & 0 deletions scripts/measure-tool-schema.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Measure what an MCP server costs a model's context window.
*
* Installing an MCP server puts every tool's schema into the model's prompt,
* on every turn, whether or not the tools are ever called. Package managers
* have shown install size for decades; nothing shows this. So: measure it.
*
* node scripts/measure-tool-schema.mjs this repo, every profile
* node scripts/measure-tool-schema.mjs --json machine-readable
* node scripts/measure-tool-schema.mjs -- npx -y @foo/bar ANY stdio MCP server
*
* Requires `gpt-tokenizer` (a devDependency here; `npm i gpt-tokenizer` elsewhere).
*
* WHAT IS COUNTED: the model-visible projection — `{name, description,
* input_schema}` per tool, with the host's name prefix — because that is what
* lands in the API `tools` array. Not counted: `annotations`, `_meta` and
* `outputSchema`, which the host consumes and does not forward to the model.
* On this server that wire overhead is a further ~3.7%.
*
* TWO WAYS TO GET THIS WRONG, both of which cost us a retraction:
*
* 1. Reading your own source instead of the wire. The schema the model
* receives is GENERATED — the SDK adds fields your source never mentions.
* Always measure a live `tools/list`.
* 2. Re-encoding before counting. `JSON.stringify` is correct because it
* leaves non-ASCII alone. Python's `json.dumps` defaults to
* `ensure_ascii=True`, which turns every em-dash into `\uXXXX` — six
* characters where the model sees one glyph. That inflated our first
* numbers by 4.4%. If your count disagrees with a colleague's while your
* DESCRIPTION totals match to the token, this is why.
*
* The tokenizer is o200k_base. Claude's tokenizer is not public and runs a few
* percent higher on JSON, so every number here is a slight UNDER-count.
*/
import { spawn } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { encode } from "gpt-tokenizer/encoding/o200k_base";

const argv = process.argv.slice(2);
const sep = argv.indexOf("--");
const userCmd = sep === -1 ? [] : argv.slice(sep + 1);
const flags = new Set(sep === -1 ? argv : argv.slice(0, sep));
const asJson = flags.has("--json");

const SERVER = fileURLToPath(new URL("../dist/index.js", import.meta.url));
// Only this repo's own server has profiles to sweep; a foreign server is one run.
const PROFILES = userCmd.length ? [null] : ["full", "media", "trading", "research", "chat"];
const PREFIX = process.env.MCP_PREFIX ?? (userCmd.length ? "" : "mcp__blockrun__");

/** One MCP handshake over stdio. Returns the tools array verbatim. */
function listTools(cmd) {
return new Promise((resolve, reject) => {
const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "ignore"] });
const pending = new Map();
let buf = "";
let id = 0;

const send = (m) => child.stdin.write(`${JSON.stringify(m)}\n`);
const rpc = (method, params) =>
new Promise((res) => {
pending.set(++id, res);
send({ jsonrpc: "2.0", id, method, params });
});

child.on("error", reject);
const timer = setTimeout(() => {
child.kill();
reject(new Error(`timed out waiting for ${cmd.join(" ")}`));
}, 60_000);

child.stdout.on("data", (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
const done = pending.get(msg.id);
if (done) { pending.delete(msg.id); done(msg); }
}
});

(async () => {
await rpc("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "measure-tool-schema", version: "1.0.0" },
});
send({ jsonrpc: "2.0", method: "notifications/initialized" });
const { result } = await rpc("tools/list", {});
clearTimeout(timer);
child.kill();
resolve(result?.tools ?? []);
})().catch(reject);
});
}

/** The projection the model actually receives, tokenized. */
export function measure(tools, prefix = "") {
const rows = tools.map((t) => {
const schema = t.inputSchema ?? {};
const description = t.description ?? "";
return {
tool: t.name,
tokens: encode(JSON.stringify({
name: prefix + t.name,
description,
input_schema: schema,
})).length,
description: encode(description).length,
schema: encode(JSON.stringify(schema)).length,
};
});
rows.sort((a, b) => b.tokens - a.tokens);
const total = rows.reduce((n, r) => n + r.tokens, 0);
return {
tools: rows.length,
total,
descriptions: rows.reduce((n, r) => n + r.description, 0),
schemas: rows.reduce((n, r) => n + r.schema, 0),
perTool: rows.length ? Math.round(total / rows.length) : 0,
rows,
};
}

/** 12900 -> "12.9K". The form the README badge publishes. */
export const asK = (n) => `${(Math.round(n / 100) / 10).toFixed(1)}K`;

// Importable: test/schema-tokens.test.ts reuses measure()/asK() to pin the
// published number, so the CLI half must not run on import.
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (!isCli) { /* library use */ } else await main();

async function main() {
const results = {};
for (const profile of PROFILES) {
const cmd = userCmd.length
? userCmd
: ["node", SERVER, ...(profile && profile !== "full" ? ["--profile", profile] : [])];
results[profile ?? userCmd.join(" ")] = measure(await listTools(cmd), PREFIX);
}

if (asJson) {
console.log(JSON.stringify(results, null, 2));
} else {
const [headline] = Object.values(results);
for (const [name, r] of Object.entries(results)) {
const pct = (n) => `${((n / r.total) * 100).toFixed(1)}%`;
console.log(
`\n${name} — ${r.total.toLocaleString()} tokens across ${r.tools} tools ` +
`(${r.perTool}/tool · descriptions ${pct(r.descriptions)} · schemas ${pct(r.schemas)})`,
);
if (name === "full" || PROFILES.length === 1) console.table(r.rows);
}
console.log(`\nbadge: ${asK(headline.total)}`);
}
}
87 changes: 87 additions & 0 deletions test/schema-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Run with: npm test (tsx --test)
//
// Pins the context-window cost this repo publishes.
//
// Installing an MCP server spends the user's context on every turn, whether or
// not the tools are called. We put that number on the README the way a package
// manager puts install size on a download — which only means anything if it
// cannot quietly go stale.
//
// So this measures the live server and fails HERE, in the repo that can fix it,
// rather than letting the badge make a claim that stopped being true three
// description edits ago.
//
// The assertion is on the PUBLISHED form ("12.9K"), not the raw token count.
// Pinning 12,900 exactly would fail CI on every wording tweak; pinning the
// rounded figure fails exactly when the published claim becomes wrong.
//
// When it fails: run `npm run measure:schema`, confirm the change is intended,
// and update the README badge and the profile table to what it prints.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { initializeMcpServer } from "../src/mcp-handler.js";
// @ts-expect-error -- plain .mjs, no types; measure()/asK() are the contract.
import { measure, asK } from "../scripts/measure-tool-schema.mjs";

const README = readFileSync(new URL("../README.md", import.meta.url), "utf8");
const PREFIX = "mcp__blockrun__";

/** Same projection the CLI harness measures, over an in-process handshake. */
async function measureProfile(profile: string) {
const server = new McpServer({ name: "test", version: "0.0.0" });
initializeMcpServer(server, { argv: ["--profile", profile], env: {} });
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "0.0.0" });
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);
const { tools } = await client.listTools();
await client.close();
return measure(tools, PREFIX) as {
tools: number; total: number; descriptions: number; perTool: number;
};
}

test("the README badge states the measured context cost", async () => {
const full = await measureProfile("full");
const shown = asK(full.total) as string;
assert.ok(
README.includes(`badge/🧮_${shown}_Context_Tokens`),
`badge URL should show ${shown} (measured ${full.total} tokens)`,
);
assert.ok(README.includes(`alt="${shown} context tokens"`), "badge alt text");
});

test("the profile table states each profile's measured cost", async () => {
// Every row is a public claim; a trimmed profile that quietly grew would
// otherwise keep advertising a saving it no longer delivers.
for (const profile of ["full", "media", "trading", "research", "chat"]) {
const { total, tools } = await measureProfile(profile);
// Tolerant of row decoration (`full` carries a *(default)* marker), strict
// on both numbers — the decoration is prose, the numbers are the claim.
const row = new RegExp(
`\\| \`${profile}\`[^|]*\\| ${tools} \\| ${total.toLocaleString()} \\|`,
);
assert.match(README, row,
`README row for ${profile} should read ${tools} tools / ${total.toLocaleString()} tokens`);
}
});

test("the advertised profile saving is the one measured", async () => {
const full = await measureProfile("full");
const trading = await measureProfile("trading");
const cut = Math.round((1 - trading.total / full.total) * 100);
assert.ok(README.includes(`${cut}% less context`), `saving should read ${cut}%`);
});

test("descriptions are the majority of the cost, which is why the table ranks them", async () => {
// Not decoration: it is the finding the whole measurement exists to surface,
// and if schemas ever overtake descriptions the README's advice is wrong.
const full = await measureProfile("full");
assert.ok(
full.descriptions > full.total / 2,
`descriptions ${full.descriptions} should exceed half of ${full.total}`,
);
});
Loading