◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/AI Models
◉ AI Models/2026-08-07Advanced

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.

MCP4Rork612React Native243Expo234Architecture23Measurement3advanced7

✦ Premium Article

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 app
const 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);
  if (req.id === undefined) return;        // No reply to id-less notifications (e.g. notifications/initialized)
  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.

Real clients send notifications/initialized after initialize. A message without an id is a notification, and JSON-RPC says it gets no reply, so the server above returns silently when req.id === undefined. If you forget this and answer notifications too, some clients will treat the line as a response with an unknown id, which makes for a confusing mismatch.

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.

Toolstools/list responseAverage per tool
1297 bytes297 bytes
3873 bytes291 bytes
51,260 bytes252 bytes
71,665 bytes238 bytes
102,106 bytes211 bytes
122,377 bytes198 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.

or
Unlock all articles with Membership →
Share

Thank You for Reading

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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-06-22
When a Poisoned Cache Crashes Your App on Every Launch — Designing a Safe-Mode Boot Your Users Can Escape On Their Own
When a persisted cache goes bad and the app crashes at the same spot on every launch, the only option left to the user is to reinstall. This article designs a safe-mode boot for Expo (React Native): the app counts its own early crashes, confirms a launch only once it becomes interactive, and resets just the dangerous state in graduated steps.
◉ AI Models2026-10-25
Is Your Rork App Still Calling Sonnet 4.5? Finding Out Before November 30
Claude Sonnet 4.5 is scheduled to retire from the API on November 30, 2026. A small script to find leftover model IDs in your app, and the order to fix them in depending on where they live.
◉ AI Models2026-09-12
Ask Rork for an iOS 27 feature and you may get last year's code back, with no error at all
iOS 27 ships on September 14. When a model is asked for an API it has never seen, there are two kinds of failure: the kind that stops your build, and the kind that does not. Here is why standard Rork and Rork Max differ, and the order I check generated code in.