●VERIFY — Android developer verification becomes fully mandatory in September 2026. On Android 17 devices verification lives in the OS, so unverified apps can be blocked from new installs at the OS level●REVIEW — From September, responses are required when submitting new apps or updates to the App Store, and when notarizing for alternative distribution●SDK — iOS 27 and Xcode 27 are expected to ship in September, but the requirement to submit iOS 27 SDK builds does not land until spring 2027●TIMELINE — Rork's $15M seed and the Paperline acquisition were announced on April 9, 2026 — worth dating precisely rather than treating as breaking news●MAX — Rork Max emits native Swift for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage, while standard Rork generates cross-platform apps with React Native (Expo)●EXPORT — If the automated Publish step fails, you can sync to GitHub and export the full React Native source for free, then finish the submission by hand●VERIFY — Android developer verification becomes fully mandatory in September 2026. On Android 17 devices verification lives in the OS, so unverified apps can be blocked from new installs at the OS level●REVIEW — From September, responses are required when submitting new apps or updates to the App Store, and when notarizing for alternative distribution●SDK — iOS 27 and Xcode 27 are expected to ship in September, but the requirement to submit iOS 27 SDK builds does not land until spring 2027●TIMELINE — Rork's $15M seed and the Paperline acquisition were announced on April 9, 2026 — worth dating precisely rather than treating as breaking news●MAX — Rork Max emits native Swift for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage, while standard Rork generates cross-platform apps with React Native (Expo)●EXPORT — If the automated Publish step fails, you can sync to GitHub and export the full React Native source for free, then finish the submission by hand
Cutting MCP Tools Didn't Make Anything Lighter — 2,377 Bytes of Definitions vs 110,298 Bytes of Response
I wrote a minimal MCP server for a wallpaper catalog and measured the byte cost of tool definitions against the byte cost of responses. Here is which side actually matters, and the real reason to merge tools.
The MCP server behind my wallpaper app catalog had quietly grown to twelve tools.
List, search, find by condition, fetch one. The kind of accumulation where each addition seems reasonable on its own.
Working as an indie developer across several apps, tooling like this grows without anyone else ever reviewing it. That is usually why the growth goes unnoticed.
Assuming the count was the problem, I removed the six least-used tools. Nothing felt different.
Either I had not cut deeply enough, or I had cut in the wrong place. Guessing between those two is how afternoons disappear.
So I measured instead.
A dependency-free MCP server, written for measurement
Using the official SDK adds framing and session bookkeeping to every byte count. Since I wanted to isolate what costs what, I implemented JSON-RPC 2.0 over stdio directly.
MCP is, at its core, one JSON-RPC message per line on standard input and output. Supporting only initialize, tools/list, and tools/call takes about this much code.
#!/usr/bin/env node// server.mjs — a minimal MCP server built for measurement (stdio / JSON-RPC 2.0)import { createInterface } from "node:readline";// 324 wallpaper records, matching the field layout of the real appconst CATALOG = Array.from({ length: 324 }, (_, i) => ({ id: `wp_${String(i + 1).padStart(4, "0")}`, title: `Wallpaper ${i + 1}`, category: ["nature", "abstract", "minimal", "space", "city"][i % 5], width: 2796, height: 1290, bytes: 380000 + (i * 977) % 220000, sha256: Array.from({ length: 64 }, (_, k) => "0123456789abcdef"[(i * 7 + k) % 16]).join(""), tags: ["calm", "blue", "gradient", "night"].slice(0, (i % 4) + 1), license: "internal", createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z", overlayScrim: { top: 0.42, bottom: 0.18 },}));const TOOLS = [ { name: "list_wallpapers", description: "Lists wallpapers in the catalog. Can be narrowed by category.", inputSchema: { type: "object", properties: { category: { type: "string", description: "Category name" }, limit: { type: "integer", description: "Maximum number of records" } } } }, { name: "search_wallpapers", description: "Searches catalog wallpapers by keyword across tags and titles.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search term" }, limit: { type: "integer", description: "Maximum number of records" } }, required: ["query"] } }, // …plus find_wallpapers / get_wallpaper / get_locale_keys / // get_missing_translations / check_i18n_completeness / get_build_config / // get_app_config / list_categories / get_catalog_stats / validate_catalog];function call(name, args = {}) { if (name === "list_wallpapers") { let rows = CATALOG; if (args.category) rows = rows.filter((r) => r.category === args.category); return rows.slice(0, args.limit ?? rows.length); } if (name === "list_wallpapers_projected") { let rows = CATALOG; if (args.category) rows = rows.filter((r) => r.category === args.category); return rows.slice(0, args.limit ?? rows.length) .map((r) => ({ id: r.id, title: r.title, category: r.category })); } if (name === "get_wallpaper") return CATALOG.find((r) => r.id === args.id) ?? null; if (name === "list_categories") return [...new Set(CATALOG.map((r) => r.category))]; return { ok: true };}const rl = createInterface({ input: process.stdin });rl.on("line", (line) => { if (!line.trim()) return; // Skip blank lines, or JSON.parse throws here const req = JSON.parse(line); let result; if (req.method === "initialize") { result = { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "wallpaper-catalog", version: "0.1.0" }, }; } else if (req.method === "tools/list") { const n = req.params?.n ?? TOOLS.length; // Variable tool count, for measurement only result = { tools: TOOLS.slice(0, n) }; } else if (req.method === "tools/call") { const payload = call(req.params.name, req.params.arguments); result = { content: [{ type: "text", text: JSON.stringify(payload) }] }; } else { result = {}; } process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: req.id, result }) + "\n");});
Everything below was measured on Node.js v22.22.3 under Linux, with the server running as a child process and requests sent across standard input and output. These are not device numbers or numbers over a network — they are byte counts and local round trips.
The extra n parameter on tools/list is not part of the spec. It exists so I could vary the tool count one at a time and watch the response size move.
Tool definitions cost two orders of magnitude less than I assumed
Here is the size of the tools/list result, counted in UTF-8 bytes.
Tools
tools/list response
Average per tool
1
297 bytes
297 bytes
3
873 bytes
291 bytes
5
1,260 bytes
252 bytes
7
1,665 bytes
238 bytes
10
2,106 bytes
211 bytes
12
2,377 bytes
198 bytes
Dividing the span from one tool to twelve gives a marginal cost of 189.1 bytes per additional tool.
The six tools I had deleted therefore added up to roughly 1.2 KB.
That number alone explains why nothing changed. 1.2 KB is not a quantity anyone notices.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦12 tool definitions cost 2,377 bytes; one response cost 110,298 bytes — measured, not estimated
✦A paginator that slices on record boundaries from a byte budget, with 99.3–100.0% fill rates
✦Measuring bigram similarity between tool descriptions to find the 45.5% collision worth merging
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Next I called tools/call and measured the returned text.
Call
Response size
Share of the largest
All 324 records, all fields
110,298 bytes
100.0%
All 324 records, projected to 3 fields
19,659 bytes
17.8%
One category (65 records), all fields
22,115 bytes
20.1%
One category (65 records), projected
3,944 bytes
3.6%
20 records, projected
1,192 bytes
1.1%
All twelve tool definitions together — 2,377 bytes — amount to 2.2% of a single large response.
Narrowing the returned fields to id, title, and category took 110,298 bytes down to 19,659. That is an 82.2% reduction, against the roughly 1.2 KB I saved by halving the tool count. Two orders of magnitude apart.
This was the part that genuinely surprised me. "Too many tools makes it heavy" is a claim I had read often enough to stop questioning. Measured, definitions behave like a fixed cost that stays small, and the entire variable cost sits in the response.
I also timed 500 round trips while I had the harness open. A single-record get_wallpaper call came back at p50 = 0.21 ms, p95 = 0.27 ms, p99 = 0.35 ms, with a maximum of 0.67 ms. Locally, the stdio round trip is not the constraint. When something feels slow, the transport is the wrong suspect — the payload is the right one.
Summed across a session, the gap widens further
initialize and tools/list run once at the start of a session. tools/call runs repeatedly for as long as the work lasts.
Taking the measured values and summing a session of twenty calls:
Configuration
Definitions (×1)
Responses (×20)
Total
Definitions' share
Unprojected (one category, all fields)
2,377 B
442,300 B
444,677 B
0.5%
Projected (one category, 3 fields)
2,377 B
78,880 B
81,257 B
2.9%
Projection alone takes the session total from 444,677 bytes to 81,257 — an 81.7% reduction.
In neither configuration do the definitions exceed 3% of the total. The more calls a session makes, the smaller that share becomes.
Put the projection in the contract, not in the return statement
My first fix was a map right before returning. It works.
The trouble is that from the caller's side, the tool still looks like something that might hand back every field. When a future version of me reuses list_wallpapers for a different purpose, 110 KB comes back silently.
So I moved the projection into the tool's description: "returns only id, title, and category." Written there, it stops being an implementation detail and becomes a contract. It also settles the division of labour — when you need every field, you fetch one record through get_wallpaper.
Slicing on a byte boundary breaks every time
The obvious way to cap a response is to truncate the JSON string at N bytes.
I wanted to know how bad that actually is. Taking the full 110,298-byte JSON string, I cut it at every 100-byte step from 1,000 to 19,000 and fed each result to JSON.parse.
Of 181 cut positions, 181 failed. 100.0%.
In hindsight it could hardly be otherwise. Cut inside an array and the bracket never closes. Cut inside a string and neither does the quote. A lucky landing on a clean boundary is theoretically possible, and across 324 records it never happened once.
Cut on record boundaries instead. Fix the byte budget first, then add records until the next one would overflow.
// Slice a page out of a byte budget. Boundaries are always whole records.const byteLen = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");function pageByByteBudget(rows, budget, offset = 0) { const out = []; let bytes = 2; // Account for the "[]" of an empty array up front let i = offset; for (; i < rows.length; i++) { const add = byteLen(rows[i]) + (out.length ? 1 : 0); // Don't forget the separating comma if (bytes + add > budget) break; out.push(rows[i]); bytes += add; } // Not even one record fits. Fail loudly here, or the caller loops forever. if (out.length === 0 && i < rows.length) { throw new Error( `record at ${i} exceeds budget: ${byteLen(rows[i])}B > ${budget}B. ` + `Project more aggressively, or raise the budget.` ); } return { rows: out, bytes: byteLen(out), nextOffset: i < rows.length ? i : null, // null means the end };}
That out.length === 0 branch was added afterwards. I had set a 2 KB budget while forgetting to project, so nothing fit, nextOffset never advanced, and the caller paged through the same position indefinitely. Anything that stalls quietly at a boundary condition takes a long time to trace back.
Measured across budgets:
Byte budget
Records that fit
Actual size
Fill rate
2,048 B
34
2,034 B
99.3%
4,096 B
68
4,075 B
99.5%
8,192 B
136
8,189 B
100.0%
16,384 B
270
16,363 B
99.9%
Fill rates land between 99.3% and 100.0% — considerably more honest than hard-coding limit: 20 and hoping.
Having "136 records at 8 KB" written down also ends the debate about default values. I set the default limit to 50 and the ceiling to 136.
The reason to merge tools turned out to be legibility, not volume
By this point the case for reducing the tool count had evaporated. Each one costs 189 bytes.
I merged twelve into six anyway, for a completely different reason.
What had actually been bothering me was not size. It was how often the wrong tool got picked — a request that clearly meant list_wallpapers arriving at search_wallpapers.
That is measurable. Concatenate each tool's name and description, reduce it to a set of character bigrams, and the Jaccard coefficient tells you which pairs are hard to tell apart.
// Measure how hard tools are to tell apart: Jaccard over name+description bigramsconst bigrams = (s) => { const t = s.toLowerCase().replace(/[_\s]/g, ""); const set = new Set(); for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2)); return set;};const jaccard = (a, b) => { let inter = 0; for (const x of a) if (b.has(x)) inter++; return inter / (a.size + b.size - inter);};function collisionReport(tools, threshold = 0.30) { const sets = tools.map((t) => bigrams(t.name + " " + t.description)); const pairs = []; for (let i = 0; i < tools.length; i++) { for (let j = i + 1; j < tools.length; j++) { pairs.push({ a: tools[i].name, b: tools[j].name, score: jaccard(sets[i], sets[j]) }); } } pairs.sort((x, y) => y.score - x.score); return { worst: pairs.slice(0, 5), over: pairs.filter((p) => p.score >= threshold), mean: pairs.reduce((s, p) => s + p.score, 0) / pairs.length, };}
Run against the twelve:
Similarity
Tool pair
45.5%
get_build_config × get_app_config
33.3%
get_catalog_stats × validate_catalog
32.3%
list_wallpapers × search_wallpapers
30.0%
list_wallpapers × find_wallpapers
25.0%
find_wallpapers × get_wallpaper
Four of 66 pairs sat at or above 30%, with a mean of 13.1%.
The 45.5% at the top says that get_build_config and get_app_config share both a naming shape and a description shape. "Retrieves the build configuration" and "retrieves the app configuration" give no basis for choosing between them. A human reviewer hesitates there too.
The merge followed five rules:
Fold the three search-shaped tools into one query_wallpapers, moving category, tag, query, and limit into arguments
Collapse the two configuration tools into get_config, with a profile argument selecting which
Collapse the three i18n tools into i18n_report, returning coverage and missing keys in one response
Keep get_wallpaper as the single entry point for one record with every field
Include one sentence in every description stating what the tool does not return
Measuring the resulting six the same way gives a maximum of 17.0% and a mean of 9.4% — down from 45.5% and 13.1%.
The falling numbers matter less than what they revealed: the thing to reduce was overlap between descriptions, not the count. Halve the tools while leaving the wording similar and the mix-ups survive. Keep twelve tools that read distinctly and you are fine.
Merging too far creates a different problem
To be fair to the alternative, giving query_wallpapers four arguments is not free. The more argument combinations exist, the more room there is to be called with a combination nobody intended.
My rule is to cap a tool at four arguments and split it once a fifth is wanted — on the reasoning that a fifth argument usually signals a separate concern. There is no theory behind that threshold. It simply has not failed me yet.
A small harness for measuring your own server
Here is the measurement side of the code that produced these numbers. Point it at an existing MCP server by swapping the server.mjs path.
// bench.mjs — measure byte sizes and round trips for a local MCP server// run: node bench.mjs ./server.mjsimport { spawn } from "node:child_process";import { createInterface } from "node:readline";const target = process.argv[2] ?? "./server.mjs";const child = spawn("node", [target], { stdio: ["pipe", "pipe", "inherit"] });const rl = createInterface({ input: child.stdout });const pending = new Map();let id = 0;rl.on("line", (line) => { const msg = JSON.parse(line); const resolve = pending.get(msg.id); if (resolve) { pending.delete(msg.id); resolve(msg); }});const rpc = (method, params) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n"); });const B = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");await rpc("initialize", {});// (1) Cost of the definition sideconst list = await rpc("tools/list", {});console.log(`tools/list: ${B(list.result)} bytes / ${list.result.tools.length} tools`);// (2) Cost of the response side — pick the call most likely to be largeconst call = await rpc("tools/call", { name: "list_wallpapers", arguments: {} });const respBytes = Buffer.byteLength(call.result.content[0].text, "utf8");console.log(`tools/call: ${respBytes} bytes`);console.log(`ratio: response is ${(respBytes / B(list.result)).toFixed(1)}x the definitions`);// (3) Round trip time — confirm whether the transport is the constraintconst samples = [];for (let i = 0; i < 200; i++) { const t0 = process.hrtime.bigint(); await rpc("tools/call", { name: "list_categories", arguments: {} }); samples.push(Number(process.hrtime.bigint() - t0) / 1e6);}samples.sort((a, b) => a - b);console.log(`round trip p50=${samples[100].toFixed(2)}ms p95=${samples[190].toFixed(2)}ms`);child.kill();
The id matching inside rl.on("line") is not optional. JSON-RPC makes no ordering guarantee, so responses have to be paired back to requests by id — skip it and you will eventually read someone else's answer.
If the ratio line comes back as a two-digit number, you are looking at the same situation described here. Start on the response side.
Three rules I now follow
Ignore the tool count. A fixed cost of roughly 189 bytes per tool is negligible against the variable cost of responses
Constrain every response with projection and a byte budget. State the returned fields in the description, and route output through a record-boundary paginator
Measure description similarity before merging. Any pair above 30% is a signal to merge or to rewrite
If you have an MCP server in front of you, two measurements are enough to start. Count the bytes of one tools/list response, and count the bytes of your largest tools/call response. If those numbers differ by two orders of magnitude, you already know which side to work on.
I spent half a day deleting tools on an assumption. The measurement takes about twenty lines.
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.