Friday, 7 August 2026

Tool Schemas (MCP)

 With tool-use, you’re not steering language toward a good answer anymore, you’re handing Claude a set of actions and trusting it to pick the right one; that pick is driven almost entirely by what you wrote in the schema.


A developer registers two tools, including search_knowledge_base and get_cached_result. The tool names are distinct, but Claude’s tool selection weighs descriptions (tools->schema->description) heavily; when descriptions overlap, name alone is not sufficient to disambiguate. 


Handles well

Routing Claude to the right tool reliably when descriptions are specific and exclusion conditions are stated.

Poor fit.
Two tools that do similar things and need ever-longer descriptions to keep apart: at that point, merge them into one tool with a type parameter instead.


**Description should be of two sentences, one to say when to use it, one to say when not to. That's the whole fix.**


The context cost problem with MCP

Every tool definition you load from an MCP server eats into your context window — even if that tool is never called in the current conversation. Connect three MCP servers with 20 tools each, and 60 tool definitions are sitting in your context before the first user message even arrives.

Two ways to control this:

defer_loading — delays loading a tool definition until Claude actually needs it. Reduces upfront context cost when a server has a large tool list.

enabled — lets you register a server but selectively expose only the tools you want Claude to see. Others are hidden entirely.


Communication Protocols

How the client actually talks to the server — two transports:

stdio — for local servers. Your app spawns the MCP server as a subprocess and communicates over standard input/output. Simple, no network needed.

Streamable HTTP — for remote servers. Your app connects over the network, uses HTTP POST for sending messages to the server, and an optional SSE stream for server-initiated messages. This is the current standard — an older SSE-only transport exists but is deprecated, don't use it for new integrations.

The two sides of MCP

MCP has a server side and a client side. You wrote the server side.

Your Java Code          MCP Client (your app)        Claude
─────────────────       ──────────────────────       ──────
You define tools   →    ListToolsRequest fired   →   Receives tool
schemas manually        to your server               definitions
in buildToolsSchema()   automatically                automatically

What you automated vs what you didn't

You still wrote the tool schemas — that part is manual and always will be on the server side. Someone has to define what get_weather accepts as input.

What MCP automates is the delivery of those schemas to Claude. Without MCP, you would write the schemas twice:

  • Once in your server/tool implementation
  • Once again in your Claude API call's tools array

With MCP, you write them once in buildToolsSchema(), and the MCP client fetches them via ListToolsRequest and passes them to Claude automatically. Claude never needs you to manually copy-paste those definitions into the API call.


In your code specifically

java
case "tools/list" -> {
    ObjectNode result = mapper.createObjectNode();
    result.set("tools", buildToolsSchema());  // ← you defined these once
    send(out, successResponse(id, result));
}

When a MCP client connects to your server and fires ListToolsRequest, it gets these definitions back and forwards them to Claude — without you writing them again in the API call.


example : 

{
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"tools": [
{
"name": "get_current_time",
"description": "Returns the current time for a given timezone",
"input_schema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "IANA timezone e.g. Asia/Kolkata, America/New_York"
}
},
"required": [
"timezone"
]
}
},
{
"name": "get_weather",
"description": "Returns current weather for a given city",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name e.g. Mumbai, New York"
}
},
"required": [
"city"
]
}
},
{
"name": "get_traffic",
"description": "Returns current traffic for a given city",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name e.g. Mumbai, New York"
}
},
"required": [
"city"
]
}
},
{
"name": "convert_currency",
"description": "Converts an amount from one currency to another",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "Amount to convert"
},
"from": {
"type": "string",
"description": "Source currency e.g. INR"
},
"to": {
"type": "string",
"description": "Target currency e.g. USD"
}
},
"required": [
"amount",
"from",
"to"
]
}
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "I'm flying from Mumbai to New York tomorrow. What's the weather there, what time is it currently in New York, and how much is 5000 INR in USD? and what's the traffic like in New York at the same time."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Sure! Let me fetch all of that information for you simultaneously right away!"
},
{
"type": "tool_use",
"id": "toolu_01HySPSs563HzdhK84sE8xEA",
"name": "get_weather",
"input": {
"city": "New York"
},
"caller": {
"type": "direct"
}
},
{
"type": "tool_use",
"id": "toolu_01VD6agYVE8nAWVqohwrr8Pk",
"name": "get_current_time",
"input": {
"timezone": "America/New_York"
},
"caller": {
"type": "direct"
}
},
{
"type": "tool_use",
"id": "toolu_01BE92qZfTnz7NrWu4DCK8S9",
"name": "convert_currency",
"input": {
"amount": 5000,
"from": "INR",
"to": "USD"
},
"caller": {
"type": "direct"
}
},
{
"type": "tool_use",
"id": "toolu_01JHtuSTZzdHjUXBeULddVFQ",
"name": "get_traffic",
"input": {
"city": "New York"
},
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01HySPSs563HzdhK84sE8xEA",
"content": "28°C, Sunny"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01VD6agYVE8nAWVqohwrr8Pk",
"content": "12:15 PM, Tuesday 28 Jul 2026"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01BE92qZfTnz7NrWu4DCK8S9",
"content": "5000.00 INR = 60.00 USD"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01JHtuSTZzdHjUXBeULddVFQ",
"content": "6am to 12pm, moderate traffic, 12pm-4pm light traffic, after 4pm heavy traffic due to office hours"
}
]
}
]
}


Extended Thinking

 Without thinking blocks carried back, Claude is re-reasoning from scratch each turn. On a complex plan it usually reaches the same conclusion — but not always. The thinking block carry-back ensures Claude is continuing the same reasoning thread, not starting a fresh one that happens to look similar.

Think of it like this:

Without carry-back:  Claude re-derives the plan each turn
                     → Usually same result, occasionally diverges

With carry-back:     Claude continues its original plan each turn
                     → Deterministically consistent

When does that difference actually matter?

Honestly, for simple 2-3 step tool loops — it probably doesn't. Your turns example would work fine.

It matters when:

  • The plan has 6-8 dependent steps
  • Claude made a conditional decision early on ("if A returns X, skip C")
  • That condition is not visible in the assistant message, only in the thinking block
  • By turn 5, Claude has forgotten its own conditional and executes C anyway

What will happen if we don't send the thinking block back in the Turn?

Without thinking block carry-back:

Turn 1: Claude reasons A→B→C→D→E→F  (full reasoning)
Turn 2: Claude reasons A→B→C→D→E→F  (full reasoning again)
Turn 3: Claude reasons A→B→C→D→E→F  (full reasoning again)
...repeats every turn

Wasteful, and risks diverging on complex plans.


With thinking block carry-back:

Turn 1: Claude reasons A→B→C→D→E→F  (full reasoning)
Turn 2: Claude continues from where Turn 1 left off  (picks up at B)
Turn 3: Claude continues from where Turn 2 left off  (picks up at C)
...progresses forward each turn

Two benefits this gives you:

Efficiency — Claude isn't re-deriving the same plan repeatedly. It already knows where it is in the plan.

Consistency — Any conditional decision Claude made in Turn 1 ("if A returns X, skip D") is preserved in the thinking block. Without carry-back, that condition might be re-derived differently in Turn 4.

System prompts - Four techniques

Four techniques that give Claude a reliable output shape

 1. Wrong shape of output (got a sentence, wanted JSON)

You didn't tell Claude how to format the answer — only what to answer. Claude fills that gap with whatever looks reasonable. Fix: add an explicit output constraint ("respond only in JSON with these fields: ...").

A developer wants Claude to label support tickets as BILLING, TECHNICAL, or ESCALATION. The first attempt is just:

"You are a support classifier. Classify the ticket."

Claude returns "Billing" sometimes, "billing" other times, and occasionally a full sentence. The downstream code expects an exact label — so it breaks. This is exactly the first row explains : wrong output shape, missing an output constraint


2. Content drifts over a conversation

Your instructions were loose enough that Claude gradually shifted tone, scope, or focus across turns. Fix: write a proper system prompt that locks in the role, boundaries, and format rules upfront — those rules then apply to every single response, not just the first one.

Imagine you're building a customer support bot for a software product. Your prompt is just:

"You are a helpful assistant. Answer the user's questions."

Turn 1 — User asks about a billing issue. Claude answers correctly, stays on topic.

Turn 3 — User casually mentions they're stressed about work. Claude starts offering life advice and a sympathetic tone.

Turn 5 — User asks a vague question. Claude now answers it like a general-purpose assistant, not a support agent — recommending Google searches, going off-topic.

Nothing in the prompt said stay scoped to software support only, don't shift tone, don't answer questions outside this domain. So Claude drifted — not because it misunderstood, but because there was no standing rule to hold it in place across turns.

Fix: A system prompt that locks the contract:

"You are a support agent for Acme Software. Answer only questions about billing, accounts, and product features. Do not offer personal advice. Keep responses under 3 sentences."

Now those rules apply to every single response, regardless of where the conversation wanders.



3. Structure is right but invented (Few-shot)

Claude understood the task but made up a structure you never asked for. The problem is that describing a structure in words is imprecise — Claude interprets the description, not the actual shape you have in mind. Fix: give it one concrete example of an input and the exact output you want. Showing beats telling here.

Few-shot examples show Claude the exact label and casing to return, and XML tags keep those examples separate from the instruction so Claude does not read them as part of the task:

System: "You are a support classifier. Classify each ticket into exactly one of: BILLING, TECHNICAL, ESCALATION. Return only the label. No other text." <sample_input>My account shows two charges for April.</sample_input> <ideal_output>BILLING</ideal_output> <sample_input>The API keeps returning a 429 error.</sample_input> <ideal_output>TECHNICAL</ideal_output> User: <ticket>I was charged twice for the same month.</ticket>

XML tags — the examples are wrapped in <sample_input> / <ideal_output> tags so Claude can clearly see where the instruction ends and where each example starts and stops. Without that separation, Claude might treat the examples as part of the task description.

Wrap them with descriptive tag names like <my_code> and <docs> and the boundary becomes unambiguous. You do not need to use official XML tag names; descriptive names that match your content work best.


4. Works on your test cases, breaks on edge cases

You validated the prompt against inputs you thought of, so it handles those fine. But the prompt has no rule for anything outside that set. Fix: when you spot a breaking variant, name it explicitly in the prompt ("if the field is empty, return null") or add an example that covers it.

Back to the ticket classifier. You've added the output constraint and few-shot examples. You test it on 20 tickets — works perfectly. You ship it.

A week later the router starts breaking. The input coming in looks like this:

"I don't know, maybe it's a billing thing? Or maybe technical? I'm not sure."

Claude returns: BILLING / TECHNICAL — because nothing in the prompt said what to do when a ticket is ambiguous. Your router expects exactly one label, so it breaks.

Your prompt handled the happy path — clear, unambiguous tickets. But it had no rule for the case it never saw during testing.

Fix: Name the variant explicitly in the prompt:

"If the ticket is ambiguous or spans multiple categories, return ESCALATION."

Or add a few-shot example that covers it:

<sample_input>Not sure if this is a billing or account issue.</sample_input>
<ideal_output>ESCALATION</ideal_output>

The prompt wasn't wrong — it just had a gap that your test inputs never exposed. The fix is closing that specific gap, not rewriting the whole prompt.



The common thread: Claude can only follow rules you actually wrote down. Every gap in the prompt is a decision Claude makes for you — sometimes correctly, sometimes not.