InterviewPrepKit

Home / Learn / AI Agent System Design

Tools and MCP

A language model on its own can only produce text. In this lesson, we’ll give it the ability to act (look something up, edit a file, issue a refund) and shape that ability so the model uses it correctly.

By the end you should be able to:

  • Write a tool definition from scratch.
  • Fix two overlapping tools so the model stops guessing between them.
  • Explain why an MCP server you did not write sits inside your security boundary.
  • Diagnose the failure where the agent has the right tool but never uses it.

Two terms to pin down first

A tool is a function you expose to the model. You describe it, the model asks for it by name with arguments, your code runs it, and you hand the answer back. The model never runs anything itself.

The harness is the ordinary program you write around the model: the loop that sends a request, reads the reply, executes any tool the model asked for, appends the answer, and sends the next request. Building one from scratch is covered in harness engineering.

Bad tool design is the most common cause of “the model is dumb” complaints that are really bugs in the harness.

What goes in, and what comes back

Three pieces of JSON carry the whole interaction. (JSON, JavaScript Object Notation, is the plain-text data format the API speaks: nested objects of keys and values.) Everything else in this lesson is a consequence of these three pieces.

The tool definition — what you send

A tool definition (or tool schema) is a JSON object with three fields:

  • name: the identifier the model uses to ask for the tool.
  • description: English prose written for the model to read.
  • input_schema: the arguments, written in JSON Schema, a standard notation for saying “this field is a string, that one is required.”

You put a list of these in the tools array of every request. The description is a sentence for a reader; input_schema is a machine-checkable structure:

{
  "name": "search_docs",
  "description": "Search internal product documentation. Returns the top 5 passages, each with a source id.",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": {"type": "string", "description": "What to search for, in plain words"}
    },
    "required": ["query"]
  }
}

The tool call — what comes back

When the model decides to use the tool, the response contains a tool_use block: the tool’s name, the arguments it chose, and an id used to match your eventual answer back to this request. The response’s stop_reason field is set to "tool_use": your signal that the model is waiting on you.

{
  "type": "tool_use",
  "id": "toolu_01A09q90qw90lq917835lq9",
  "name": "search_docs",
  "input": {"query": "refund window"}
}

The tool result — what you send next

You run the search, then append a tool_result block carrying the same tool_use_id and call the API again. The model now sees the answer and writes its reply. The tool_use_id matches the id above exactly, and the block is sent with "role": "user".

{"role": "user", "content": [
  {"type": "tool_result",
   "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
   "content": "[{\"source\": \"policy-14\", \"text\": \"Refunds are accepted within 45 days of delivery for unopened items.\"}]"}
]}

Why a user turn, when a program produced the text? Because there are only two roles on the wire. assistant means “tokens this model generated.” The tool output came from outside the model, exactly like a human’s message does, so it rides in on the user role. user here does not mean “a person typed this”. It means “input from the world, which the model must now read.” Your harness is speaking on the world’s behalf. That is also why anything you put in a tool_result arrives with the same standing as text a user typed, which matters for the security argument later.

The loop, in one line

Definitions in, one tool_use out, one tool_result back in, repeat. That is the entire mechanism. The rest of this lesson is consequences: where those definitions sit in the prompt and what that costs, why the description field decides whether the tool ever fires, and how the picture changes at fifty tools instead of one.

What a tool actually is, mechanically

There is no function-calling engine. No separate service, no callback registry, no special channel. It is text in and text out the whole way down, the mechanism derived in structured output is a guarantee, not a request. Five steps:

  1. Your tool definitions are serialized into the prompt. They are converted to text and placed ahead of the system prompt (the standing instructions you set once for the whole conversation), in front of everything else. They become ordinary tokens, the sub-word chunks a model reads and is billed by, roughly four characters of English each, and you pay for them on every turn.
  2. The model emits a structure. It is trained to produce a particular block shape when it wants to act, instead of answering in prose.
  3. Constrained decoding can keep that structure valid, but you have to ask for it. The provider’s runtime (its own code wrapped around the model) compiles your schema into a grammar: a rule set that says which characters may legally come next. At each step the model produces one raw score, a logit, per possible next token. The runtime can set the logit of any token that would break your schema to negative infinity, forcing its probability to zero. That is logit masking, and it is what strict: true turns on. It is off by default.
  4. The runtime parses the block out of the generated text and sets stop_reason: "tool_use".
  5. You execute the tool and append a tool_result.

The path a tool call takes through your code

flowchart LR
    D["Tool definitions<br/>serialized into the prompt"] --> M((Model))
    M -->|tool_use block| H[Harness]
    H --> V{Validate + authorize}
    V -->|ok| E[Execute]
    V -->|deny| R1["tool_result<br/>is_error: true"]
    E -->|ok| R2[tool_result]
    E -->|throw| R1
    R1 --> M
    R2 --> M

    style M fill:#1d3557,color:#fff
    style V fill:#bc6c25,color:#fff
    style R1 fill:#9d0208,color:#fff

Everything after the tool_use edge is your code. The model asking to act is the only part the model does. Validate + authorize is yours: before Execute runs, the harness checks the arguments against your schema and checks the caller against your policy.

A clean run passes validation and returns a plain tool_result. The two failure paths are the part people miss. If policy denies the call, or if execution throws, both converge on a single tool_result with is_error: true. A denial and an exception are not raised into your loop and do not end the turn, each comes back as an ordinary block appended to messages, exactly like a success would be.

So the model sees the failure on its next turn and can react: ask for confirmation, pick a different tool, or fix the argument it got wrong. That is why “make errors actionable” is a design rule, not a nicety, the error string is the next prompt.

Three consequences of step 1

Step 1 said tool definitions render first, as ordinary tokens, on every turn. Three consequences follow, and they explain most of the tool-design rules below:

  • Tools live in the cacheable prefix. They render first, ahead of system. That matters because of prompt caching: the API can skip re-processing a request’s opening stretch of tokens if those bytes are identical to a previous request’s. A cached read bills at 0.1× the base input rate; the write that creates the entry costs 1.25×. So 10,000 tokens of tools that would normally cost 10,000 cost the equivalent of 1,000 once cached. The catch: the match is a prefix match, so one changed byte invalidates everything from that byte onward. Since tools sit at position zero, a tool list that varies per user invalidates the cache for everyone. (Prompt caching derived explains why from the way attention works.)

  • Tool count costs tokens on every turn. Fifty tools at roughly 150 tokens of schema each is 50 × 150 = 7,500 tokens of standing overhead, resent on every request for the life of the conversation. Treat 150 as an order-of-magnitude stand-in: a two-field tool lands well under it, a nested schema with six described properties well over. If you are sizing a real tool set, count your own with a token counter.

  • The description is prompt text, not metadata. It is not a label sitting in a registry. It is instructions the model reads on every turn, competing for attention with your system prompt and the whole conversation.

The description is the highest-leverage text in your system

The description field (not the schema, not the executor) decides whether a tool ever fires, and prose alone can resolve the same user turn two different ways.

The three fields do different jobs for different readers. The middle row is the only one that decides whether the tool fires at all:

FieldRead byJob
nameModelDisambiguate from siblings
descriptionModelDecide when the tool fires
input_schemaModel + your validatorConstrain arguments

Modern models under-trigger

A description that only says what a tool does will under-trigger: the model reads it, judges that it already knows the answer, and answers from memory. So state the trigger condition, the boundary, and the return shape, be prescriptive about when to call it, not just what it does.

The reason to design this way: modern models call tools conservatively. They reason more and reach for tools less than earlier generations did. Providers’ model-migration notes tell you to expect a drop in tool-call rate when a working prompt moves onto a newer model, and to add explicit trigger conditions to recover it. The magnitude depends on the model pair and your prompt, so treat the direction as reliable and check the migration guidance for the specific model you move to.

Two descriptions of the same tool

Same name, same schema object. Only the English changes:

SCHEMA = {
    "type": "object",
    "properties": {"query": {"type": "string"}},
    "required": ["query"],
}

# Weak — states what, not when. Under-triggers.
weak = {
    "name": "search_docs",
    "description": "Searches the knowledge base.",
    "input_schema": SCHEMA,
}

# Strong — four parts, annotated.
strong = {
    "name": "search_docs",
    "description": (
        "Search internal product documentation. "                    # 1 what
        "Call this whenever the user asks about product behavior, "  # 2 when
        "pricing, or policy — do not answer those from memory, "
        "since docs change weekly. "
        "Do not call it for general programming questions, or for "  # 3 when not
        "anything already answered earlier in this conversation. "
        "Returns the top 5 passages, each with a source id."         # 4 returns
    ),
    "input_schema": SCHEMA,
}

The strong description has four parts:

  1. What it does, in one clause. Every description already has this.
  2. When to call it: the positive trigger, phrased as a condition, with the reason attached. “Docs change weekly” lets the model generalize the rule to a question you did not anticipate, instead of pattern-matching the three nouns you listed.
  3. When not to call it: the boundary. This clause stops sibling tools from overlapping, and it is the one most often missing.
  4. What comes back, so the model can plan the next step instead of guessing at the result’s shape.

The same user turn, both ways

One user message, run twice against one model with one schema. Only the description differs:

user: What's your refund window?

── tools=[weak] ────────────────────────────────────────────────────────
assistant: Refund windows are typically 30 days from delivery, though this
           can vary by product category.
           stop_reason: "end_turn"     ← no tool_use block was ever emitted

── tools=[strong] ──────────────────────────────────────────────────────
assistant: (tool_use) search_docs({"query": "refund window"})
           stop_reason: "tool_use"
user:      (tool_result) [{"source": "policy-14", "text": "Refunds are
             accepted within 45 days of delivery for unopened items…"}]
assistant: 45 days from delivery, for unopened items (policy-14).
           stop_reason: "end_turn"

The weak run did not error and did not look wrong. It produced a fluent, confident answer from the model’s training data, and the real policy is 45 days, so it was wrong. Nothing in your logs distinguishes it from a correct answer; the only observable difference is a missing tool_use block and a stop_reason of "end_turn" where you expected "tool_use". A description that omits the trigger condition is an outage you cannot see. The schema, the executor, and the retrieval index were all fine in both runs.

Sibling overlap

The boundary clause matters most when two tools could plausibly answer the same question:

# Overlapping — both descriptions are true, and neither excludes the other.
# "What's your refund window?" matches both. The model guesses.
overlapping = [
    {"name": "search_web",  "description": "Searches the web."},
    {"name": "search_docs", "description": "Searches documentation."},
]

# Disambiguated — each description names its sibling and rules itself out.
disambiguated = [
    {"name": "search_web", "description": (
        "Search the public web for third-party services, current events, or "
        "anything not specific to this product. Do not use it for questions "
        "about our own behavior, pricing, or policy — use search_docs."
    )},
    {"name": "search_docs", "description": (
        "Search this product's internal documentation. Do not use it for "
        "general programming questions or third-party services — use "
        "search_web."
    )},
]

The fix is not a longer description of what each tool does. That is what the overlapping pair already has. It is a negative clause in each description that names the other tool by its exact name. The model reads both descriptions in the same prefix on the same turn, so a boundary stated in one is visible while it considers the other. Two tools with no mutual exclusion are a coin flip, and a coin flip shows up as flakiness in your test suite instead of as a clear bug.

When an agent has the right tool but never uses it, debug in this order: check the description’s trigger condition, then check whether a sibling’s description overlaps, then check whether the system prompt implies the model already knows the answer. Prompt-level nudging is the last fix, not the first.

Designing the tool surface

A well-described tool still has to be the right kind of tool. The question is not “what can the agent do” but “what shape should each action have”, one broad shell tool, or many typed ones the harness can see into.

The decision comes down to three yes/no questions. Any single yes means a dedicated tool; only three noes route to a general-purpose bash tool.

flowchart TD
    A[An action the agent needs] --> Q1{Needs a permission gate?}
    Q1 -->|Yes| DED[Dedicated tool]
    Q1 -->|No| Q2{Needs custom UI,<br/>an audit trail, or a<br/>staleness check?}
    Q2 -->|Yes| DED
    Q2 -->|No| Q3{Must the harness know<br/>it is parallel-safe?}
    Q3 -->|Yes| DED
    Q3 -->|No| BASH[Give it bash]

    style DED fill:#2d6a4f,color:#fff
    style BASH fill:#40916c,color:#fff
  • Permission gate: a checkpoint where your code, or a human, approves the action before it runs, because it is destructive, expensive, or governed by policy.
  • Custom UI: a rendered widget instead of a line of log text. Audit trail: a durable record of who did what. Staleness check: a guarantee that the thing being written has not changed since it was read.
  • Parallel-safe: two of them can run at once without corrupting each other.

Three noes is the common case, because most of what an agent does is read-only inspection your harness has no reason to intercept. The questions are ordered by the cost of getting them wrong: a missing permission gate is a security incident, a missing audit trail is a compliance finding, a missing parallel-safety hint is only a latency regression.

Why bash blocks all five capabilities

A call like bash("curl -X POST ...") hands your harness an opaque string: shell text with no structure your code can inspect. Every action has that same shape, so the harness cannot tell a grep from a git push. It therefore cannot:

  • Gate it. You would have to parse arbitrary shell to know if it is destructive.
  • Render it, there are no typed arguments to build a UI from.
  • Audit it, your log says “ran a command.”
  • Parallelize it, a read-only grep and a git push look identical, so everything serializes.
  • Enforce invariants: a dedicated edit tool can reject a write if the file changed since the model read it, because it receives the content the model thought it was editing and can compare it against disk. sed -i cannot.

Start with bash for breadth; promote an action to its own tool the moment you need to gate, render, audit, parallelize, or check an invariant. (How Claude Code Works shows how a real coding agent gates its tools this way.)

Practical rules

  1. 5–15 tools is comfortable. Past roughly 20, selection accuracy degrades from schema volume alone (see the section on tool selection at scale). This is a practitioner rule of thumb, not a hard threshold, nothing breaks at exactly 20; treat it as the point to start measuring selection accuracy.
  2. One tool, one job. A combined manage_user(action="create"|"delete") moves the decision out of the schema, where a gate can see it and a validator can check it, and into a string, where neither can.
  3. Use enum for closed sets. An enum is a JSON Schema keyword listing the only values a field may take. It is the cheapest accuracy win available, and once you pair it with strict: true the logit masking above makes any other value unemittable.
  4. Return structure, not prose. Tool results are context you pay for on every later turn. {"rows": 3, "total_cents": 4120} beats a paragraph saying the same thing.
  5. Truncate loudly. Say "...truncated; 4,812 rows total, call again with a filter". Never silently cut, the model cannot ask for what it does not know is missing.
  6. Make errors actionable. "Error: city 'Pariss' not found. Did you mean 'Paris'?" gets fixed in one turn, because that string is the next prompt.

Strict schemas, and what they do not buy

The strict flag is misjudged in both directions: people assume they have its guarantees when they don’t, and assume it checks things it never will. The line to hold onto runs between the shape of an argument, which the model can guarantee, and its meaning, which only your code can check.

By default, the schema is serialized into the prompt as text, instructions the model has been trained to follow, and usually does. Nothing masks a token on your schema’s behalf. You will reliably get back a parseable JSON object in input, but the API promises nothing about whether it matches what you declared. All three of these are possible: a field you typed integer arrives as the string "2"; a field you listed in required is missing; a field you never declared shows up.

Setting strict: true adds more: the runtime compiles the schema into a grammar and applies logit masking at every decode step, so an argument that violates the schema has probability zero. That gives you three things the default does not: declared types are enforced (2, never "2"); every field in required is present; no undeclared field appears. The tool name is guaranteed to be one you declared, too. In exchange the schema must include additionalProperties: false (forbids undeclared fields) and a complete required list.

The strict flag sits at the top level of the tool object, next to name, not inside input_schema:

TOOLS = [{
    "name": "refund_order",
    "description": (
        "Issue a refund for an order. Call only after confirming the order ID "
        "with the customer and checking it is within the 30-day window."
    ),
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string", "description": "Format: ORD-12345"},
            "amount_cents": {"type": "integer", "description": "Must be > 0"},
            "reason": {
                "type": "string",
                "enum": ["damaged", "not_received", "wrong_item", "other"],
            },
        },
        "required": ["order_id", "amount_cents", "reason"],
        "additionalProperties": False,
    },
}]

Strict fixes shape, never semantics. Take {"order_id": "ORD-99999", "amount_cents": 500000, "reason": "other"}: every field is the right type, present, and drawn from the right set, so it passes strict cleanly. It would also refund $5,000 for an order that does not exist. Business validation (does this order exist, does this amount match, is this customer entitled) stays in your harness, always. That is the boundary between what the model guarantees and what you must.

Two different failures with two different fixes

Strict or not, tool calls still go wrong, and the first diagnostic question is which way. A selection failure means the model picked the wrong tool; an extraction failure means the right tool with wrong arguments. Each has its own fixes, and they do not transfer.

Each row below starts from something you would see in a log, names the failure, and gives the fix:

SymptomDiagnosisFix
Calls search_web when it should call search_docsSelection — overlapping descriptionsSharpen triggers; state each tool’s boundary (“do not use for X”)
Right tool, date="next Tuesday"Extraction — under-specified schema"format": "date", example in the property description, strict: true
Invents a tool nameHallucinated capabilityReturn is_error listing real names; check whether the system prompt promises something the tool set lacks
null for a required fieldAmbiguity about optionality, and no strict so required is only adviceMake it genuinely optional with a default, or say in the description what to do when unknown (usually: ask). strict: true stops the omission; it cannot invent the value

The extraction fix ladder

For extraction failures, try these in order, each rung is cheaper and more reliable than the one below it:

  1. A description on the property, not just the tool, with a literal example of the value you want.
  2. An enum or a format (a named string pattern such as date).
  3. strict: true.
  4. A tool-use example inside the definition showing a correctly-shaped call.
  5. Prompt engineering, last.

Only rungs 2 and 3 are structural: they reach the constrained decoding that sets illegal tokens to probability zero. Rung 2 declares the constraint and rung 3 arms it, an enum with no strict is a strong hint, an enum under strict: true is unemittable-if-wrong.

Rungs 1 and 4 are prose the model reads; there is no grammar to compile from them. They still outrank plain prompt engineering because of where they sit. Prose inside the schema renders at position zero, ahead of system, re-read on every turn, right next to the field it constrains. A prompt instruction is a single line somewhere in a conversation that keeps growing around it, and retrieval accuracy is measurably worst for material in the middle of a long window (why quality degrades in long contexts). So a schema constraint at turn 40 is as strong as at turn 1; a property description nearly so; a prompt instruction neither.

Make the error message teach

import re

def parse_date(value: str) -> str:
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
        return value
    raise ValueError(
        f"date must be YYYY-MM-DD, got {value!r}. "
        "Today is 2026-07-30 — resolve relative dates before calling."
    )

Providing today’s date in the error is what closes the loop. Without it, the model cannot resolve “next Tuesday” no matter how often you restate the format, because the missing piece is a fact it does not have, not a rule it did not follow.

Tool selection at scale

Everything so far assumed a tool set small enough for the model to weigh every description against the others. At 50+ tools two things degrade at once: cost, because every schema sits in context on every turn, and quality, because selection accuracy drops as the model works through more near-identical descriptions.

There are three ways out, and they are not equals:

flowchart TD
    P[50+ tools] --> A["Router<br/>classify, then load a tool subset"]
    P --> B["Tool search<br/>model queries the catalog,<br/>schemas appended not swapped"]
    P --> C["Namespacing<br/>fewer, broader tools"]

    style B fill:#2d6a4f,color:#fff

Tool search is the only one of the three that leaves the cached prefix intact, its schemas are appended to the tool list, never swapped into position zero, so bytes already cached stay identical. The router and namespacing win on different axes:

BranchWho narrows the setCost you payEffect on the cached prefix
RouterA cheap classifier model, before the main callYou maintain the routing table by handClean — the subset is fixed before the request is built
Tool searchThe model itself, mid-turnOne extra round tripPreserved — schemas are appended, never swapped
NamespacingYou, at design timeArguments move out of the schemaClean — but the surface is smaller than the real action space

Router

A small, fast model (Haiku-tier; the general pattern is routing) reads the user turn and emits a label such as billing or technical. Your code looks that label up in a table mapping labels to tool subsets. The upside is determinism and low cost: the same request always loads the same subset. The cost is the table. You own that mapping and it rots silently. Add a tool, forget the entry, and the tool never loads: no error, no warning, and the symptom is “the agent has the right tool but never uses it” with a cause that is not in any description.

Namespacing

Namespacing collapses six GitHub tools into one github tool with an action enum:

github(action="create_issue"|"add_comment"|"merge_pr", …)

That is exactly the shape rule 2 above forbids. Both rules are right; they optimize for different things:

rule 2 optimizes for:   per-action checkability — a gate can see "merge_pr"
                        in the schema, not buried in a string
namespacing optimizes:  prefix size — 6 schemas at ~150 tokens becomes 1

The trade flips when schema volume becomes the dominant failure. At 15 tools you are nowhere near the accuracy cliff, so keep the checkability and pay the tokens. At 200 tools the tokens are the problem, so you buy prefix back by giving up what rule 2 protected. If you namespace, reintroduce the gate by hand: dispatch on action inside the handler before doing anything destructive, because your generic permission layer can no longer see what is about to happen. Broader tools also force the non-action properties to be optional, since only some actions use them, which drops the required constraint that strict schemas rely on.

Why tool search preserves the cache

Tool search is a built-in tool whose job is finding other tools. The model queries a catalog mid-turn, and the schemas it finds are appended to the tool list instead of swapping it out. That word “appended” is the whole argument: swapping in the relevant tools would change the front of the prefix and invalidate everything behind it, while appending leaves the existing prefix byte-identical, so the cache survives.

A working tool list has three kinds of entry:

tools = [
    # Never deferred — this is the tool that does the searching.
    {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
    # Always loaded: the two or three you know every request needs.
    {"name": "get_time", "description": "...", "input_schema": {...}},
    # Deferred: name + description stay searchable, schema stays out of context.
    {"name": "get_weather", "description": "...", "input_schema": {...},
     "defer_loading": True},
    # ...47 more, all deferred
]

Two details. First, tool_search_tool_regex_20251119 names a dated variant of the search tool, not a version. The date labels a frozen behavior contract, so a later date does not mean “newer, prefer this one”. It is a name, not a rank. Second, defer_loading: True keeps a tool’s schema out of context until needed. The search tool itself can never carry it, and at least one ordinary tool must stay non-deferred, which is why get_time stays loaded instead of deferring everything.

One round trip

A round trip here means one extra request-and-response with the model before the real work starts. The regex variant means the model emits a search pattern, not a sentence:

assistant: (server_tool_use) tool_search_tool_regex({"pattern": "weather|forecast"})
result:    2 matches — get_weather, get_hourly_forecast
           → their full input_schema blocks are APPENDED to the tool list
assistant: (tool_use) get_weather({"city": "Paris"})

server_tool_use is a different block type from the ordinary tool_use, and the difference is who runs it. A tool_use block is a request to you: your loop stops, executes, and sends a tool_result back. A server_tool_use block is the provider telling you it already ran something on its own infrastructure, the search happened inside the same API call, no tool_result is owed, which is why no turn of yours appears between the search and its result. Only the last line, the ordinary tool_use for get_weather, is work your harness does.

The two recovered schemas land after everything already in the prefix, so the bytes ahead of them stay cache-readable. You pay one extra model call and roughly the cost of the two schemas you needed, instead of 50 schemas on every turn of the session.

MCP — Model Context Protocol

Where the sections above described tool definitions you write by hand, MCP is about tool definitions somebody else wrote and published. MCP (Model Context Protocol) is a published convention for how a tool provider describes and exposes its tools, so that one Slack integration works with any agent that speaks the protocol. The same mechanism that makes that possible is what puts a stranger’s text in your prompt.

N×M versus N+M

Below are two agents and two providers, without MCP and with it:

flowchart LR
    subgraph Before["Without MCP — N×M"]
        A1[Agent A] -->|custom code| T1[Slack]
        A1 -->|custom code| T2[GitHub]
        A2[Agent B] -->|custom code| T1
        A2 -->|custom code| T2
    end
    subgraph After["With MCP — N+M"]
        B1["Agent A<br/>MCP client"] -->|MCP| S1[Slack server]
        B1 -->|MCP| S2[GitHub server]
        B2["Agent B<br/>MCP client"] -->|MCP| S1
        B2 -->|MCP| S2
    end

There are still four connections on the right. MCP is a wire format, not a broker you deploy, no hub in the middle, and Agent A still opens its own connection to each server. What drops from N×M (agents times providers) to N+M (agents plus providers) is the number of integrations someone has to write. Without MCP, every agent writes custom code per provider: 2 agents × 2 providers = 4 adapters. With MCP, each agent implements the client spec once and each provider implements the server spec once: 2 + 2 = 4. Four either way in this tiny picture; the saving only shows at scale, 20 agents and 20 providers is 40 instead of 400. Connections stay one per pair in both worlds.

The moving parts

Three nouns, none of them a hosted service:

  • A server is a separate process that advertises tools, resources, and prompts, usually a package you install and run (npx @acme/slack-mcp, or a Python entry point), owned by whoever owns the API it wraps.
  • A client is the code inside your harness that speaks MCP to exactly one server. One client per server. That is what the boxes on the right of the diagram are.
  • The host is your agent application, holding one client per connected server and merging what they all report.

At connect time the client sends a message called initialize, then calls tools/list. It gets back a name, description, and input_schema for every tool the server offers, the same three fields you would write by hand. The host splices those entries into the very same tools array that renders first, ahead of system, and is billed on every turn. From the model’s point of view an MCP tool and a hand-written tool are the same JSON in the same block. When the model emits a tool_use for one, the host routes it to the owning client, which issues tools/call and turns the response back into a tool_result. So the security stakes are mechanical, not a vibe: “its descriptions are injected into your prompt” is just tools/list output landing at prefix position 0.

Transport is a trust boundary

A transport is how the client’s bytes reach the server. There are two, on opposite sides of a trust boundary:

  • stdio. The client spawns the server as a child process on your machine and talks to it over standard input and output. No network, no ports, no authentication, because there is nothing to authenticate to. The process runs as you, with your filesystem and your credentials. The “auth” is that you chose to run it.

  • HTTP/SSE. SSE (Server-Sent Events) is a one-way server-to-client stream over plain HTTP. Here the server is a remote endpoint someone else operates, so you get real authentication, real multi-tenancy (one server safely serving many customers), a real network dependency, and the ordinary questions about who sees your arguments.

A local stdio server is configured as a command and its arguments, in JSON that is a config file on your own disk, owned by the host application: not sent to the API, not part of any request. The host reads it at startup to learn which servers to spawn. The mcpServers key is standardized by convention; each host picks its own path and filename.

{
  "mcpServers": {
    "slack": {
      "command": "npx",
      "args": ["-y", "@acme/[email protected]"],
      "env": { "SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}" }
    }
  }
}

The version pin matters. A local server is a package that updates, and an update ships new tool descriptions straight into your prefix, which is why you pin.

The three primitives

MCP servers expose three kinds of thing, distinguished by who controls them, really a question about who can initiate an action:

PrimitiveControlled byAnalogyWhat one looks like
ToolsThe modelFunctions it may callsend_message(channel, text) — the model decides when
ResourcesThe applicationFiles/data it may readA URI such as file:///repo/README.md; the server lists it, and your host decides whether to read it in — it does not enter context on its own
PromptsThe userSlash-command templates/review-pr expanding to a filled-in instruction the user invokes deliberately

Only tools let the model act unprompted. Resources are pull-only and gated by your code; prompts fire only when a human types them. That, not a data-versus-action distinction, is how a resource differs from a tool, who holds the trigger.

What MCP does not solve

Selection accuracy at scale, authorization, and cost all remain yours. The fifty-tool problem is unaffected by whether the tools came from a protocol or from your own code.

Security

A third-party MCP server is an untrusted input source sitting in your prefix. Its tool descriptions arrive via tools/list and are spliced into your prompt, so a malicious or compromised server can place instruction-like text directly into the highest-attention region of your context. That is prompt injection: attacker-controlled text reaching the model where it reads as instruction, not as data (full treatment).

The usual defense, content-wrapping, putting untrusted text inside delimiters and telling the model everything within them is data, not orders, does not apply here, because an MCP tool description never travels through your content pipeline. It is a first-class field of the tool block. So pin server versions, diff descriptions on update, and do not grant an MCP-sourced tool more authority than the server deserves. And if you own both sides of the integration, plain functions are simpler than MCP; the protocol earns its keep on integration economics and a shared discovery/permission model, not otherwise.

Sandboxed code execution

Sometimes the right tool surface is not a longer tool list at all. Giving an agent an interpreter is often better than giving it 30 tools: it can compose, loop, and filter without one round trip to the model per step. A sandbox is the isolated environment that code runs in, a container with its own filesystem and limits, so nothing the model writes can touch your machine or your data.

flowchart TD
    M((Model)) -->|code| S[Sandbox]
    S --> N[No network<br/>or allowlist only]
    S --> F[Ephemeral FS]
    S --> R[CPU / mem / wall-clock caps]
    S --> NR[Non-root, dropped caps]
    S -->|stdout / stderr / exit code| M

    style S fill:#2d6a4f,color:#fff
    style M fill:#1d3557,color:#fff

Each constraint closes a specific escape:

  • No network, allowlist only: the default is no outbound traffic. A sandbox with outbound network is an exfiltration channel for anything the code can read and a download channel for a second attack stage. Open specific hosts when you must; never open “the internet.”
  • Ephemeral FS: the filesystem the container sees is its own writable layer, with none of your directories mounted in, thrown away when the session ends. A poisoned file cannot become the next session’s input.
  • CPU / mem / wall-clock caps: limits on processor time, memory, and elapsed real time. The cheapest denial-of-service attack against your own product is while True: pass; without a wall-clock cap it bills until something notices.
  • Non-root, dropped caps: the code runs as an unprivileged user, and Linux capabilities (the fine-grained privileges that make up root, such as “may mount filesystems”) are dropped. Even a container breakout then lands on a process that cannot do much with the escape.
  • stdout / stderr / exit code: the only things that come back to the model. That narrow return channel is itself a control: the model reads results, not the container’s state.

Two design choices matter. Fresh container per session, not per call: per-call is stricter but throws away the working directory between steps, breaking the composition that made the interpreter worth having (the model can no longer write data.csv in one step and read it in the next). Per-session keeps that continuity while still guaranteeing nothing crosses between users, the boundary that actually matters.

Never run model-generated code with exec() inside your own process, and do not reach for RestrictedPython. Sandboxing by language subset, allowing only a safe-looking slice of Python, is escapable. Introspection chains like ().__class__.__bases__[0].__subclasses__() walk from a harmless empty tuple up to every class the interpreter has loaded, and out of any pure-Python jail. The boundary has to be at the operating-system or hypervisor level, where escaping requires a kernel bug, not cleverness.

Declaring the tool

resp = client.messages.create(
    model="claude-opus-5",
    max_tokens=8192,
    tools=[{"type": "code_execution_20260521", "name": "code_execution"}],
    messages=[{"role": "user", "content": "Std dev of the values in sales.csv"}],
)
for block in resp.content:
    if block.type == "bash_code_execution_tool_result":
        print(block.content.stdout, block.content.return_code)

The block type bash_code_execution_tool_result decomposes into three parts: bash (the sandbox runs the code through a shell), code_execution (the tool), and tool_result (the same result-block shape as everywhere else). Its content carries the narrow return channel: stdout, stderr, and return_code. Like the tool-search result, this is a server result, the container ran inside the API call, so there is nothing for your loop to execute and no tool_result to send back.

Programmatic tool calling

Standard tool use is one round trip per action, and every intermediate result lands in context and is resent on every following turn. Three data-heavy lookups:

read profile   → 8k tokens into context
lookup orders  → 40k tokens into context
check stock    → 12k tokens into context
                 60k tokens carried for the rest of the session

With programmatic tool calling (PTC), the model writes a script that calls your tools as ordinary functions:

# Emitted by the model, executed inside the sandbox.
profile = read_profile(uid)                 # 8k of JSON — stays in the sandbox
orders  = lookup_orders(uid, limit=500)     # 40k of JSON — stays in the sandbox
recent  = [o for o in orders if o["ts"] > profile["signup_ts"]]
stock   = check_stock([o["sku"] for o in recent])   # 12k — stays in the sandbox
print(f"{len(recent)} orders since signup; {sum(stock.values())} units in stock")

read_profile, lookup_orders, and check_stock are your tools, exposed to the sandbox as callable functions. That exposure is explicit: you declare the code-execution tool and mark each custom tool callable by listing that tool’s version in an allowed_callers field.

PTC needs a specific variant of the code-execution tool, the one that adds a persistent interpreter session and lets your tools be called from inside the container:

tools = [
    {"type": "code_execution_20260120", "name": "code_execution"},
    {"name": "read_profile", "description": "...", "input_schema": {...},
     "allowed_callers": ["code_execution_20260120"]},
]

As before, the dated type is a name, not a rank: code_execution_20260120 and code_execution_20260521 are two variants that share a prefix, and the earlier date happens to carry the PTC capability. The date stamps when a variant’s behavior was frozen, not when it shipped, so you look up which variant has the feature instead of picking the larger number. The two strings must match: allowed_callers names the caller by its exact tool type, so pairing a tool callable from 20260120 with a declared 20260521 container exposes nothing.

When read_profile runs, the container pauses, the call routes back to your harness by the same path a normal tool_use takes, your code executes it, and the return value is handed to the running script, not appended to messages. The list comprehension on line 3 is the point: the model’s reasoning runs where the data is, instead of shipping the data to the model first. Only the print crosses back.

one round trip, 60k tokens processed inside the sandbox, ~200 tokens returned

Token cost scales with the final answer, not the intermediate data. Hold the number of steps fixed and grow the intermediate payloads: standard tool use adds tokens to context linearly in that size; PTC adds none. Context cost is O(1) in payload size, not O(D), and it does not grow with the number of calls inside the script, three calls and thirty both return one print. What PTC does not change is the number of model turns overall; it makes an agent’s context independent of how large its tool results are. So it is the right lever whenever an agent chains several data-heavy lookups, and no lever at all when the results were small to begin with.

Computer use

Computer use is a tool whose action space is a screen: screenshot, click(x, y), type, key, scroll. The model looks at a picture of a desktop and issues mouse and keyboard actions against it.

The consequence that dominates the design: every turn ships an image. Images are incompressible in a way text is not. You cannot summarize a screenshot and keep it useful, because the next click depends on exact pixels. Cost per step is therefore high and roughly constant.

Image tokens

The model does not see pixels. It sees an image cut into a fixed grid of small square patches, and each patch becomes one visual token, the way a few characters of English become one text token. The patch is 28 × 28 pixels, so tokens scale with area:

image tokens  =  ceil(width / 28)  ×  ceil(height / 28)

The ceilings are there because a partial patch along the right or bottom edge still costs a whole one. Before this applies, the API downscales anything that exceeds your vision tier: the resolution regime a model family belongs to, which fixes two caps:

TierLong-edge capMax tokens per image1080p (1920×1080)
Standard (pre-Opus 4.7, pre-Sonnet 5)1,568 px1,568downscaled to 1456×819 → 1,560 tok
Current (Opus 4.7 and later, incl. Opus 5, Sonnet 5)2,576 px4,784not downscaled → 2,691 tok

A 1080p frame on the current tier costs ceil(1920/28) × ceil(1080/28) = 69 × 39 = 2,691 tokens. Its 1920-px long edge is under the 2,576-px cap and 2,691 is under the 4,784-token cap, so nothing is thrown away.

The token cap is the one that actually bounds your bill, and it is the one people omit. A long-edge cap constrains one dimension only, so a square image and a tall thin one both at the pixel cap cost wildly different amounts. The two caps are not redundant, and the token one bites first: on the standard tier, scaling 1080p to its 1,568-px long edge gives 1568×882 = 56 × 32 = 1,792 tokens, over the 1,568-token cap, so that size is unreachable there. It lands at 1456×819 instead. A resolution that satisfies the pixel limit can still violate the token limit.

The old pairing “~1.5k at 1080p, up to ~4.8k at maximum” mixes the two tiers: the low figure is the standard regime, the high one the current regime, never both true of the same model. This is not a rounding error, on the current tier a 1080p step costs 2691 / 1560 ≈ 1.7× what it cost on the standard one, and that multiplier lands on every turn of every run. (The computer-use case study works the full cost model against a single stated tier.)

Which cost lever to reach for

The obvious lever is trimming old screenshots out of the conversation. It is the wrong first lever once caching is on: a sliding window advances the point where this request’s prefix diverges from the last one’s every turn, forcing the surviving images to be re-processed at full rate instead of the cached tenth. Resolution shrinks every image without touching the prefix, reach for that first (case study 01). Coordinates map 1:1 to pixels on current models, so there is no scale-factor math to get wrong.

If an API exists, use the API. Computer use is on the order of 10–50× the cost of an equivalent API call and far less reliable. The mechanism is the arithmetic above: an API call sends a few hundred tokens of JSON once, while the same task through a screen sends a multi-thousand-token image on every step across several steps. The width of the band is mostly how many steps your task takes.

Agent Skills

Tools give an agent actions. Skills give it know-how, loaded only when a task calls for it. A skill is a folder containing a SKILL.md file, instructions, plus optional scripts and reference files. Only the skill’s short description sits in context; the model reads the full file when the task calls for it.

“Reads the full file” is a tool call, not magic. The skill’s name and one-line description are injected into the prompt; the body is a file on disk the agent opens with an ordinary file-read tool when it decides the description matches the task. So the cost profile is exactly the defer_loading profile from tool search: a few tokens of pointer on every turn, the full payload only when needed, and one extra round trip to fetch it. That is progressive disclosure for instructions: load detail on demand instead of up front, because context is billed on every turn.

Use skills for “how we do X here” knowledge that is too long for the system prompt and too procedural for retrieval. A deploy runbook is a skill: the model needs all of it, in order, exactly once. An API reference is a retrieval problem: the model needs three paragraphs out of thousands, chosen by similarity to the question.

OKF — Open Knowledge Format

OKF (Open Knowledge Format) is an emerging specification for packaging domain knowledge, facts, ontologies (formal descriptions of the things in a domain and how they relate), and procedures, in a portable, model-agnostic file, instead of baking it into a prompt or a vendor-specific store. It has the same motivation as MCP one layer up: MCP standardizes actions, OKF aims at knowledge. It is early, know the one-liner and do not over-claim.

Conclusion

  • A tool call is text in, text out. Definitions render first in the prompt, the model emits a tool_use block, you run it and append a tool_result. Everything after the tool_use edge (validation, authorization, execution, error handling) is your code.
  • The description is the highest-leverage field: it decides when a tool fires. State the trigger, the boundary, and the return shape, and name a sibling explicitly when two tools overlap.
  • strict: true guarantees an argument’s shape, never its meaning. Business validation stays in your harness, always.
  • Keep tool sets small (5–15). Past ~20, scale with a router, namespacing, or tool search, and prefer tool search when preserving the cached prefix matters, because it appends schemas instead of swapping them.
  • MCP is a wire format, not a service you deploy. It turns N×M integrations into N+M, but a third-party server’s descriptions land in your prefix as untrusted, un-wrappable input, pin versions and diff on update.
  • Sandboxes isolate at the OS or hypervisor level; programmatic tool calling keeps large intermediate results out of context. Computer use is expensive and unreliable, use an API whenever one exists.

One line to remember: the model only ever emits text asking to act; every guarantee you care about lives in the code you write after the tool_use block, not in the model.

Further reading

Cheat sheet

Each row starts from something you would observe, names the mechanism underneath, and gives the first thing to try:

SymptomMechanismFirst fix
Tool never firesDescription states what, not whenAdd the trigger condition
Wrong tool chosenTwo descriptions overlapState each tool’s boundary
Right tool, bad argsNo structural constraintProperty descriptions → enum/format → strict
Invented tool namePrompt promises a capability the tool set lacksReturn real names in is_error; audit the system prompt
Cache hit rate dropped after a releaseTool list changed — it’s at prefix position 0Sort tools deterministically; never build per-user
Context explodesTool returns raw payloadsTruncate at source; programmatic tool calling
Accuracy falls past ~20 toolsSchema volume in every turnTool search (defer_loading) or a router
MCP server update changed behaviorIts tools/list descriptions land in your prefixPin versions; diff descriptions on update
Computer-use cost estimate is ~1.7× offImage figures quoted from the wrong vision tierState the tier: 1080p is 2,691 tok current, 1,560 standard

Next: 04 — Memory & Context.

Report a bug