Skip to content

[Feature]: ctx_* tool descriptions are ~80% steering prose — ~6.2K tokens of tool definitions per call on Pi #1031

Description

@soft4rchitecture

Platform: Pi. The ctx_* tool surface costs 6,219 tokens (27,074 bytes) in tools/list.

On Pi specifically, the adapter registers each MCP tool individually via pi.registerTool() (build/adapters/pi/extension.js:127), so all 11 tools sit in the tool registry for every model call in the session — regardless of whether any ctx_* tool is invoked. I have only verified this registration path for Pi; other hosts may handle the payload differently, so the per-call framing below is scoped to Pi.

Relevant precedent from the same adapter: the Pi routing anchor at extension.js:574 carries the comment "7KB routing block is too heavy for Pi's context budget", and was deliberately compressed for that reason. The static tool payload is comparable in size and is the remaining always-on cost on that budget.

Measured against context-mode@1.0.169 (current latest) by driving the stdio server directly over JSON-RPC and counting with a real tokenizer (gpt-tokenizer, o200k_base) rather than a bytes/4 estimate — token counts per field:

tool desc schema total
ctx_execute 756 399 1157
ctx_batch_execute 498 497 998
ctx_search 739 182 923
ctx_fetch_and_index 518 347 869
ctx_index 339 386 727
ctx_execute_file 476 201 680
ctx_purge 369 171 543
ctx_insight 87 24 114
ctx_upgrade 56 24 82
ctx_doctor 41 24 68
ctx_stats 32 24 58
TOTAL 6219
  • 6 core tools: 5,354 tokens
  • 5 admin tools (stats, doctor, upgrade, purge, insight): 865 tokens
  • 3.1% of a 200K window — small as a share, but it is resident: it occupies that share of every request in the session, and it is the one component a user cannot reduce.

Two details worth noting:

  • inputSchema is 37% of the total and near-parity with prose on some entries (ctx_batch_execute: 498 desc + 497 schema), so trimming descriptions alone would leave a large share in place.

  • The descriptions are mostly not descriptions. Splitting each description into its leading "what this tool does" sentence versus the trailing steering sections (WHEN / WHEN NOT / RETURNS / EXAMPLE / Think-in-Code narrative):

    tokens share
    leading "what it does" sentence 194 5%
    steering / teaching prose 3,059 80%
    remainder (params, notes) 553 15%

    ctx_execute is the extreme case: 9 tokens describe the tool ("Run code in a sandboxed subprocess."), 711 tokens argue for using it.

    The MCP spec describes description as "a human-readable description of the tool… like a 'hint' to the model" and marks it optional, while inputSchema is required. Persuasion and worked examples are a different genre from a hint, and they are the genre that scales badly: it is billed on every call, forever, whereas the routing decision it informs happens at most once per session. Skills, the PreToolUse hook, and the routing anchor are all better-suited carriers — they are fetched when relevant.

Repro script (no auth or API key needed)
#!/usr/bin/env node
// Measure the static tools/list payload of the context-mode MCP server.
//   node probe.mjs [path-to-context-mode-package]
// Default resolves context-mode from NODE_PATH / node_modules.
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";

let root = process.argv[2];
if (!root) {
	try {
		const req = createRequire(`${process.cwd()}/`);
		root = path.dirname(req.resolve("context-mode/package.json"));
	} catch {
		console.error(
			"pass the package path: node probe.mjs <path-to-context-mode>",
		);
		process.exit(1);
	}
}
const entry = path.join(root, "build/server.js");
if (!fs.existsSync(entry)) {
	console.error(`no build/server.js under ${root}`);
	process.exit(1);
}
let version = "unknown";
try {
	version = JSON.parse(
		fs.readFileSync(path.join(root, "package.json"), "utf8"),
	).version;
} catch {
	/* cosmetic only */
}

const p = spawn(process.execPath, [entry], { stdio: ["pipe", "pipe", "pipe"] });
let out = "";
p.stdout.on("data", (d) => (out += d));
const send = (o) => p.stdin.write(`${JSON.stringify(o)}\n`);

send({
	jsonrpc: "2.0",
	id: 1,
	method: "initialize",
	params: {
		protocolVersion: "2024-11-05",
		capabilities: {},
		clientInfo: { name: "probe", version: "1" },
	},
});

let asked = false;
p.stdout.on("data", () => {
	if (asked || !/"id":\s*1\b/.test(out)) return;
	asked = true;
	send({ jsonrpc: "2.0", method: "notifications/initialized" });
	send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
});

setTimeout(() => {
	p.kill();
	const frame = out
		.split("\n")
		.filter(Boolean)
		.map((l) => {
			try {
				return JSON.parse(l);
			} catch {
				return null;
			}
		})
		.find((j) => j?.id === 2);
	if (!frame) {
		console.error("no tools/list response");
		process.exit(1);
	}

	const rows = frame.result.tools
		.map((t) => {
			const d = Buffer.byteLength(t.description || "");
			const s = Buffer.byteLength(JSON.stringify(t.inputSchema || {}));
			return { name: t.name, d, s, total: Buffer.byteLength(t.name) + d + s };
		})
		.sort((a, b) => b.total - a.total);

	const total = rows.reduce((a, r) => a + r.total, 0);
	console.log(`context-mode v${version}${rows.length} tools\n`);
	console.log("tool                  desc  schema   total");
	for (const r of rows)
		console.log(
			r.name.padEnd(20),
			String(r.d).padStart(6),
			String(r.s).padStart(7),
			String(r.total).padStart(7),
		);
	console.log(`\nTOTAL ${total} bytes ≈ ${Math.round(total / 4)} tokens`);
	console.log(`% of a 200K window: ${((total / 4 / 200000) * 100).toFixed(2)}`);
}, 6000);

Run: node probe.mjs /path/to/node_modules/context-mode

The script reports bytes and a bytes/4 approximation. The token figures in the table above come from encoding the same strings with gpt-tokenizer (o200k_base); for this corpus bytes/4 overstates by ~9% (actual ratio 4.35 bytes/token), so the byte output is a safe upper bound if you don't want the tokenizer dependency.

Use case

In Pi, doc-heavy, orchestration-heavy, and infra/ops sessions frequently invoke zero ctx_* tools, yet still carry ~6.2K tokens of tool definitions in every call.

The share of a 200K window (~3%) understates this, for two reasons:

  • It is non-reclaimable. Conversation history can be compacted; a resident tool payload cannot. It is the floor of every request.
  • It competes with the extension's own value. context-mode exists to keep bytes out of context; a session that never triggers a ctx_* call has paid ~6.2K tokens for that guarantee and received none of it.

It does not land in isolation

context-mode is rarely the only extension loaded. A realistic Pi session in my setup carries 48 tools: 11 from context-mode, 9 core Pi tools, and the rest from four other extensions (knowledge base, code intelligence, subagent orchestration) plus an MCP gateway.

Rather than extrapolate, I measured what Pi actually sends. Pi records totalTokens per turn in its
session transcripts, so the first substantive turn of a fresh session is a direct reading of resident
overhead — system prompt, skills, and the full tool payload, before any real work.

Across 71 fresh sessions on this machine:

first substantive prompt
min 15,725
p25 24,076
median 27,810
p75 41,652

So a fresh session here starts at roughly 25–28K tokens before the first instruction — about
14% of a 200K window, and well over a fifth in the heavier cases. Tool definitions are not the
only contributor (system prompt and skill preambles are in there too), but at 11 tools and 6,219
tokens context-mode is a consistent, measurable share of it.

For per-tool cost as a sanity check, two stdio servers I can measure precisely:

server tools tokens per tool
github/github-mcp-server (default toolset) 44 10,023 228
context-mode 11 6,219 565

That is the part I'd flag: at 565 tokens/tool context-mode costs ~2.5× per tool what GitHub's
server does, and GitHub's covers a far larger API surface with four times the tools. The difference
is not capability, it is prose. When the baseline is already 25K+, a 6.2K component that is 80%
teaching material is a meaningful and easily reclaimable piece of it.

This is the same class of concern as #641 (routing-block cost, shipped as #680) and #964 (SessionStart injection knob), but for the static tool payload, which neither addresses. build/server.js has 11 unconditional server.registerTool("ctx_…") calls (lines 1455, 1810, 1988, 2279, 3100, 3331, 3559, 3772, 3901, 4068, 4394) with no gating; the only related env var, CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS, governs crash handlers. So today the only way to reduce this is to disable the extension entirely.

Proposed solution

These compose; (3) is the largest single win and the one I'd argue for most.

  1. CONTEXT_MODE_TOOLS allow-list. GitHub's own MCP server ships GITHUB_TOOLSETS + GITHUB_TOOLS for exactly this reason (measured on their Docker image: all = 85 tools vs a hand-picked 4 via GITHUB_TOOLS — a ~15x difference in payload).

    CONTEXT_MODE_TOOLS=ctx_execute,ctx_search   # register only these

    Skipping registration — rather than filtering the response — is what actually removes the bytes.

  2. Fold the 5 admin tools into one ctx_admin({action}). Reclaims ~865 tokens with no capability loss; doctor/upgrade/purge/stats/insight are rare, deliberate operations.

  3. Move the steering prose out of description entirely. Not a toggle — a separation of concerns. description states what the tool does and what it returns; the WHEN / WHEN NOT / EXAMPLE material moves to the context-mode skill and the PreToolUse hook, which are already in place and are consulted when relevant rather than billed every call. On the measured split that is the ~80% of description tokens, i.e. ~3.0K tokens — the single largest reclaimable item, and it needs no new env var.

    If the concern is that routing quality depends on the prose being in-band, that is testable: ship terse descriptions behind CONTEXT_MODE_TERSE_DESCRIPTIONS=1 first and compare tool-choice behaviour. Shorten tool and skill descriptions for command palette readability #177 proposed one-line descriptions for palette readability and was closed unmerged; the tokens argument is a stronger case for the same change, and the descriptions have grown substantially since.

Happy to send a PR for (1) or (2) if either is acceptable in principle.

Alternatives considered

  • Disabling the extension entirely — works, but loses the tools in sessions where they would occasionally help.

The session-level figure above is reproducible on any Pi install:

# first substantive prompt size per session, across all recorded sessions
for f in ~/.pi/agent/sessions/*/*.jsonl; do
  grep -o '"totalTokens":[0-9]*' "$f" | cut -d: -f2 \
    | awk '$1>5000{print; exit}'
done | sort -n | awk '{a[NR]=$1} END{print "n="NR, "median="a[int(NR/2)]}'

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions