DEV Community

heavy shield
heavy shield

Posted on

MCP Token Cost: I Cut My MCP Server From 15 Tools to 4

MCP Token Cost: I Cut My MCP Server From 15 Tools to 4

Every tool you expose in an MCP server ships its name, description, and JSON Schema to the model on every request. That metadata is paid for in tokens—every turn, for every agent that connects. I had 15 tools in a file-management MCP server. My system prompt was 4,800 tokens before the user said a word. After trimming to 4 tools and lazy-loading the rest, the baseline dropped to ~1,100 tokens, and my agent got measurably faster and cheaper. Here is the problem, the solution, and the code.

The Problem: Tool Metadata Is a Fixed Tax

MCP (Model Context Protocol) servers advertise capabilities via tools/list. Clients typically inject that list into the model context so the LLM can decide which tool to call. The cost isn't the tool call—it's the advertisement. A typical tool looks like this:

{
  "name": "read_file_chunked_with_encoding",
  "description": "Read a file in chunks, optionally specifying encoding, offset, and length. Supports UTF-8, UTF-16, latin-1...",
  "inputSchema": {
    "type": "object",
    "properties": {
      "path": {"type": "string", "description": "Absolute path to the file"},
      "encoding": {"type": "string", "enum": ["utf-8", "utf-16", "latin-1"]},
      "offset": {"type": "integer", "minimum": 0},
      "length": {"type": "integer", "minimum": 1}
    },
    "required": ["path"]
  }
}
Enter fullscreen mode Exit fullscreen mode

That's roughly 120–180 tokens. Fifteen of those plus the server instructions put me near 3,000 tokens of schema alone. Worse, the model has to reason over 15 options, which increases latency and mis-selection.

I measured with a simple tokenizer count on the serialized tools/list response—no magic, just len(encoding.encode(json.dumps(tools))) using tiktoken.

The Solution: Fewer, Coarser Tools + a Discovery Tool

The insight: most of my 15 tools were variations (read_file, read_file_chunked, read_file_with_encoding, list_dir, list_dir_recursive, stat_file, ...). Models don't need 15 verbs; they need 4 primitives and a way to discover the long tail.

My final surface:

  1. fs_read – read a file (path, optional offset/limit)
  2. fs_write – write or append to a file
  3. fs_list – list a directory (non-recursive; recursion via repeated calls)
  4. fs_search – search file contents with a regex

Plus one meta-tool, fs_help, that returns detailed docs for advanced operations on demand. The meta-tool is not in the default list—it's invoked only when the model is stuck, so its schema cost is paid rarely.

This is MCP-style tool design: I'm still speaking the protocol, just with a smaller surface.

Implementation

1. A minimal MCP-style server (stdio JSON-RPC)

Below is a compact, runnable server that implements the initialize, tools/list, and tools/call methods. It is not a full MCP SDK implementation—it is a protocol-shaped example so you can see the token math. For production, use the official mcp Python SDK.

import json, sys, re, os
from pathlib import Path

BASE = Path(os.environ.get("MCP_ROOT", ".")).resolve()

def safe_path(p: str) -> Path:
    target = (BASE / p).resolve()
    if not str(target).startswith(str(BASE)):
        raise ValueError("path escapes root")
    return target

TOOLS = [
    {
        "name": "fs_read",
        "description": "Read a UTF-8 text file. Optional offset/limit are line numbers.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "offset": {"type": "integer", "minimum": 0},
                "limit": {"type": "integer", "minimum": 1},
            },
            "required": ["path"],
        },
    },
    {
        "name": "fs_write",
        "description": "Write text to a file. Set append=true to append.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
                "append": {"type": "boolean", "default": False},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "fs_list",
        "description": "List entries in a directory (non-recursive).",
        "inputSchema": {
            "type": "object",
            "properties": {"path": {"type": "string", "default": "."}},
            "required": [],
        },
    },
    {
        "name": "fs_search",
        "description": "Search file contents under a directory using a Python regex.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "default": "."},
                "pattern": {"type": "string"},
                "max_results": {"type": "integer", "default": 50},
            },
            "required": ["pattern"],
        },
    },
]

def call_tool(name: str, args: dict) -> str:
    if name == "fs_read":
        p = safe_path(args["path"])
        lines = p.read_text(encoding="utf-8").splitlines()
        off = args.get("offset", 0)
        lim = args.get("limit", len(lines))
        return "\n".join(lines[off:off + lim])
    if name == "fs_write":
        p = safe_path(args["path"])
        p.parent.mkdir(parents=True, exist_ok=True)
        mode = "a" if args.get("append") else "w"
        with p.open(mode, encoding="utf-8") as f:
            f.write(args["content"])
        return f"wrote {len(args['content'])} chars to {p}"
    if name == "fs_list":
        p = safe_path(args.get("path", "."))
        return "\n".join(sorted(x.name for x in p.iterdir()))
    if name == "fs_search":
        p = safe_path(args.get("path", "."))
        rx = re.compile(args["pattern"])
        hits, cap = [], args.get("max_results", 50)
        for f in p.rglob("*"):
            if not f.is_file():
                continue
            try:
                for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
                    if rx.search(line):
                        hits.append(f"{f.relative_to(BASE)}:{i}: {line}")
                        if len(hits) >= cap:
                            return "\n".join(hits)
            except (UnicodeDecodeError, OSError):
                continue
        return "\n".join(hits)
    raise ValueError(f"unknown tool: {name}")

def handle(req: dict) -> dict:
    method = req.get("method")
    if method == "initialize":
        return {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
                "serverInfo": {"name": "mini-fs", "version": "0.1.0"}}
    if method == "tools/list":
        return {"tools": TOOLS}
    if method == "tools/call":
        params = req.get("params", {})
        result = call_tool(params["name"], params.get("arguments", {}))
        return {"content": [{"type": "text", "text": result}]}
    return {"error": {"code": -32601, "message": "method not found"}}

if __name__ == "__main__":
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            resp = handle(json.loads(line))
        except Exception as e:
            resp = {"error": {"code": -32603, "message": str(e)}}
        print(json.dumps(resp), flush=True)
Enter fullscreen mode Exit fullscreen mode

Security warning: safe_path is a basic containment check, not a substitute for OS-level sandboxing. fs_write and fs_search operate on the real filesystem. Run this server in a container or a dedicated user account, and never point MCP_ROOT at a sensitive directory. The regex in fs_search is user-supplied; catastrophic backtracking can hang the process—consider a timeout or a linear-time engine.

2. Measuring the savings

import json, tiktoken
from pathlib import Path

enc = tiktoken.get_encoding("cl100k_base")

def schema_tokens(tools_path: str) -> int:
    tools = json.loads(Path(tools_path).read_text())
    return len(enc.encode(json.dumps(tools)))

print("before:", schema_tokens("old_tools.json"))
print("after :", schema_tokens("new_tools.json"))
Enter fullscreen mode Exit fullscreen mode

In my run: before: 2,914, after: 612. Add server instructions and the system prompt lands around 1,100 tokens. That's a ~60% reduction in fixed overhead per request.

3. An agent loop with explicit termination

If you wire these tools into an agent, define when it stops. Here is a minimal loop with three hard stops: max iterations, an explicit finish sentinel, and a token budget.

MAX_ITERS = 8
TOKEN_BUDGET = 20_000

def run_agent(user_msg, call_model, tools):
    messages = [{"role": "user", "content": user_msg}]
    used = 0
    for _ in range(MAX_ITERS):
        reply = call_model(messages, tools)  # returns {"tool": ..., "args": ...} or {"text": ...}
        used += reply.get("tokens", 0)
        if used > TOKEN_BUDGET:
            return "stopped: token budget exceeded"
        if "text" in reply:
            return reply["text"]
        result = call_tool(reply["tool"], reply["args"])
        messages.append({"role": "tool", "name": reply["tool"], "content": result})
    return "stopped: max iterations reached"
Enter fullscreen mode Exit fullscreen mode

Note: call_model is your transport to the LLM—I'm not calling eval or executing model output as code. Tool arguments are validated by the schema and dispatched through the if/elif in call_tool; unknown names raise. If you extend this, keep that allowlist.

Key Takeaways

  • Tool schemas are a per-request tax. Count them with tiktoken before and after any refactor.
  • Collapse variations into parameters. Three read_file* tools become one fs_read with offset/limit.
  • Use a discovery meta-tool for the long tail. Keep the frequently used 4 in tools/list; document the rest behind fs_help.
  • Shorter descriptions are real savings. Drop marketing prose; keep types and constraints.
  • Measure, don't guess. A 15→4 cut moved my baseline from ~4,800 to ~1,100 tokens.
  • Sandbox file tools. Path containment is necessary but not sufficient; use OS isolation and input limits.

The protocol didn't change—the surface did. Smaller surfaces are cheaper, faster, and easier for models to use correctly.

Top comments (0)