The official quickstart builds a weather server in twenty minutes and it’s good. Do it. This post is what to do in minute twenty-one, when the server works and you’re about to add the third tool.

A tool definition is not a function signature. It is a paragraph the model reads before every single reply, for as long as the server is connected.

So this is a tutorial with a scale in it. We’ll build a small server, look at exactly what it sends the model, put a number on it, and then decide what a tool is allowed to cost.

What an MCP Server Actually Sends

The Model Context Protocol is JSON-RPC between a host (Claude Code, Claude Desktop, Cursor, your own client) and a server you write. The current spec revision is 2026-07-28. A server exposes three kinds of things: resources, prompts and tools. Tools are the ones that matter here, because tools are the ones the model calls on its own.

Discovery is one request, tools/list, and the spec says what comes back: a name, a description (“Human-readable description of functionality”), an inputSchema in JSON Schema, and optionally a title, an outputSchema and annotations. The host takes that list and puts it in front of the model. Anthropic’s API docs show the template: a system prompt that says “Here are the functions available in JSONSchema format:” followed by your definitions, ahead of your own system prompt.

The spec knows where the list ends up. It asks servers to return tools in a deterministic order because that “improves LLM prompt cache hit rates when tools are included in model context.” Included in model context. Every request.

That’s the whole post, really. The rest is a server and a scale.

Build One

I’ll build the server I actually want, not a weather one. The workflow post says the orchestrator is the source of truth, not the agent, and that you can expose make verify over MCP so the model calls the gate the same way every time. Here it is. One tool. It runs a documented Makefile target and returns the exit code and the last forty lines.

The Python SDK is at 2.x now, and the class you’ll see in older tutorials was renamed: FastMCP became MCPServer, per the migration guide. Install it with uv add "mcp[cli]" or pip install "mcp[cli]"; this was written against 2.2.0.

"""makegate: one MCP tool that runs a documented Makefile target."""

import os
import re
import subprocess
from pathlib import Path
from typing import Annotated

from mcp.server import MCPServer
from pydantic import Field

REPO = Path(os.environ.get("MAKEGATE_REPO", ".")).resolve()
TAIL = 40

mcp = MCPServer("makegate")


def documented_targets() -> dict[str, str]:
    """Targets that carry a `## help` comment in the Makefile, name -> help text."""
    found = {}
    for line in (REPO / "Makefile").read_text().splitlines():
        m = re.match(r"^([A-Za-z0-9_-]+):.*?## (.*)$", line)
        if m:
            found[m.group(1)] = m.group(2)
    return found


@mcp.tool(structured_output=False)
def make(
    target: Annotated[str, Field(description="A target name from `make help`, such as check or lint.")],
) -> str:
    """Run one documented Makefile target. Returns the exit code and the last 40 lines of output."""
    allowed = documented_targets()
    if target not in allowed:
        return f"refused: '{target}' is not a documented target. Documented: {', '.join(sorted(allowed))}"
    proc = subprocess.run(["make", target], cwd=REPO, capture_output=True, text=True, timeout=600)
    lines = (proc.stdout + proc.stderr).splitlines()
    return f"exit {proc.returncode}\n" + "\n".join(lines[-TAIL:])


if __name__ == "__main__":
    mcp.run(transport="stdio")

Three decisions are in there and I’ll come back to each: the allowlist is the Makefile’s own ## comments, so the model can only run what a human documented; the refusal lists what is allowed, so the model can correct itself without a round trip to you; and the output is capped at forty lines.

Register it with Claude Code. The -- separates Claude’s options from the command that runs your server:

claude mcp add --transport stdio --env MAKEGATE_REPO=/path/to/repo makegate -- python makegate.py

Then ask it to run the checks. I drove the same server with the SDK’s own stdio client instead, and make with target: "check" came back as exit 2 and the Makefile’s own one-line complaint that SITE is required: the gate, refusing, in forty lines or fewer. It works. Now let’s look at what it shipped.

Read What It Ships

The same client dumps tools/list. My first version had a plain docstring with an Args: block and no structured_output flag, and this is what the model was going to read on every turn:

[
  {
    "name": "make",
    "description": "Run one documented Makefile target and return its exit code and the last 40 lines of output.\n\n    Args:\n        target: a target name from `make help`, such as check or lint\n    ",
    "inputSchema": {
      "properties": { "target": { "title": "Target", "type": "string" } },
      "required": ["target"],
      "type": "object",
      "title": "makeArguments"
    },
    "outputSchema": {
      "properties": { "result": { "title": "Result", "type": "string" } },
      "required": ["result"],
      "type": "object",
      "title": "makeOutput"
    }
  }
]

Two things I didn’t write. The docstring’s Args: block went into the description verbatim, indentation and all, while the parameter itself got no description. And the SDK generated an outputSchema wrapping my string in a result object, because I returned str and didn’t say otherwise. 488 bytes of compact JSON for one tool with one parameter.

The version above, with structured_output=False and the parameter described through Field, comes to 332 bytes. Same tool, same behaviour, a third less to read. That is the whole edit: a keyword argument and moving one sentence.

Now the number. I don’t have to guess at tokens, because the token counting endpoint is free and accepts tool definitions. Rename inputSchema to input_schema, drop the schema title fields, and paste the tool in:

curl https://api.anthropic.com/v1/messages/count_tokens \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "tools": [{
      "name": "make",
      "description": "Run one documented Makefile target. Returns the exit code and the last 40 lines of output.",
      "input_schema": {
        "type": "object",
        "properties": {"target": {"type": "string", "description": "A target name from `make help`, such as check or lint."}},
        "required": ["target"]
      }
    }],
    "messages": [{"role": "user", "content": "Run the checks."}]
  }'

For scale, the docs’ own example of that call, one weather tool with one parameter and a seven-word question, returns 403 input tokens. Of those, 286 tokens are the tool-use system prompt Claude Opus 5 adds to any request that carries tools at all, whether you define one tool or fifty. So a small tool is on the order of a hundred tokens, and the first one costs you three hundred more for turning tools on. On Claude 4.7 and later, and on the Fable and Mythos models, the newer tokenizer makes the same text about 30 percent more; count against the model you’ll run.

A hundred tokens is nothing. Multiply.

Every Turn, Not Once

Claude Code, like any client, re-sends the full context on every request, and the tool definitions it has loaded are in the system prompt, so they go with it. A forty-turn session reads your definition forty times. Still nothing, for one tool. The problem is that nobody has one tool.

Anthropic published its own numbers in November: a GitHub server at 35 tools is roughly 26,000 tokens of definitions, Slack’s 11 tools about 21,000, Sentry’s 5 about 3,000, and the three together are “58 tools consuming approximately 55K tokens before the conversation even starts.” Internally they had seen “tool definitions consume 134K tokens before optimization.” The tool search docs add the part that isn’t about money: “Claude’s ability to pick the right tool degrades once you exceed 30–50 available tools.”

This is the context post with a JSON schema on it. Context is prioritized knowledge the model sees before it generates a token; a tool definition is knowledge you have decided it must see every time, whatever the task. It is the same tax I put on CLAUDE.md: a line there rides along on every request, and so does a tool. The difference is that the tool came from a pip install, and you never read it.

The hosts noticed before most server authors did. Claude Code now defers MCP tool definitions by default: “Only tool names and server instructions load at session start”, and the model searches for a tool’s full definition when a task needs it. ENABLE_TOOL_SEARCH=auto loads them upfront only “while their definitions total less than 10% of the context window”; a server can opt out with alwaysLoad: true, and the docs say to use that “for a small number of tools that Claude needs on every turn.” On the API the same mechanism is defer_loading: true plus a tool search tool, with the advice to keep your “3–5 most frequently used tools non-deferred.” And there’s a hard edge: Claude Code “truncates tool descriptions and server instructions at 2KB each.”

Deferral changes when the definition is paid for. It doesn’t change what the definition is. The one the model finds by searching is the one you wrote, and if it’s 2 KB of manual, the search found 2 KB of manual.

What a Tool Is Allowed to Cost

Five rules, and the server above follows all of them.

One tool, one decision. Anthropic’s own guidance is to consolidate related operations “into a single tool with an action parameter” rather than a tool per verb. makegate is one tool and the catalogue of what it can do lives in the Makefile, not in the definition. The model learns the targets from the result, when it asks, not from the schema, every turn.

Describe the decision, not the manual. The same page says to “aim for at least 3–4 sentences for each tool description” and calls detailed descriptions “by far the most important factor in tool performance.” I’d sharpen that rather than disagree with it: detailed about when, not long about how. What it does, when to use it, when not to, what it won’t return. The sentence that changes the model’s decision stays. The sentence that repeats the schema, or explains the Makefile, goes into a resource or the result. The 2 KB truncation is the host telling you the same thing.

Cap the output. The result is context too. Every tool result goes through the model, and then stays in the conversation. Anthropic’s code-execution post has the pathological case, a transcript that “flows through twice”, and its fix took one workflow “from 150,000 tokens to 2,000 tokens.” Claude Code warns at 10,000 tokens of tool output and cuts at 25,000 by default. Don’t rely on the host. A failing build is the last forty lines; the rest is on disk.

Refuse in the result. The spec separates protocol errors from tool execution errors, and says the second kind should carry “actionable feedback that language models can use to self-correct.” A refusal that lists the documented targets is one more tool call away from the right answer. A refusal that says “not allowed” is a round trip to a human.

Ship what you read. Dump tools/list and read it as the model will, before every reply. The SDK gave me an output schema and an indented docstring I never intended to send. That is the @ import in CLAUDE.md all over again: a default that pastes more than you meant.

Measure It, Then Add the Third Tool

The loop is short. Build the tool. Drive the server with the SDK client and print tools/list. Run it through count_tokens against the model you use. In Claude Code, /context shows the live breakdown, MCP tools included. Then decide if the next tool is worth its line on every turn, or if it’s an action on the one you have.

The GitHub server in Anthropic’s example is 35 tools and 26,000 tokens. That is a tool per endpoint, and it is the shape most servers ship in because it is the shape the quickstart teaches: one function, one decorator, next function. It works in the demo, which is one turn. It is a tax on every turn after it.


The quickstart tells you how to build an MCP server and it’s right. The thing it leaves out is that you’re not writing an API. You’re writing the first page of every conversation the model will ever have while your server is connected.

Build the server. Then read what it ships, the way the model will: every turn.