Write Software, Well

How Claude Uses Tools to Get Data and Run Code in Your App

November 09, 2026

In the previous post, we saw how a large language model like Claude works. Your application can use Claude by sending HTTP requests to the Claude API, which Anthropic runs. The model can't query your database, call your APIs, or run any of your code. All it can do is read the text you send it and write text back. Yet you've used AI features that look up records, search the web, or edit your code in a local repo. They're all built on a feature of the Claude API called tool use. With tool use, Claude doesn't run your code. It asks your app to run it, and your app decides whether to run it or not.

In this post, we'll build that tool exchange to learn what goes back and forth between your app and the Claude API so it can execute your code and access your data that it can't see otherwise. We'll start with server tools like web search, which Anthropic runs for you. Then we'll define a client tool that looks up a customer in our database, run it when Claude asks, and send the result back. Once that exchange makes sense, we'll build the loop that AI agents are built upon. At the end, we'll look at where MCP fits in that lets an agent like Claude Code run tools on your behalf.

The Problem Tools Solve

Let's say we're building a support inbox app called Helpdesk. Its users are support reps: they read tickets from customers and reply to them. We want to add a chat sidebar where a support rep can ask questions in plain English, and our Rails app (Helpdesk) answers them using Claude. For example, a support rep John, is looking at a ticket from a customer named Jane. He types this into the sidebar:

What plan is jane@example.com on, and does she have any other open tickets?

Our Rails app sends John's question to the Claude API and shows him the reply. But Claude can't answer it. It has never seen our application database. It knows a lot about the world up to its training cutoff, but nothing about Jane, who is a customer record in your Helpdesk database.

To see why, it helps to remember what a language model is. It takes text in and predicts text out, one token at a time, and the API is stateless, so every request has to carry everything the model should know. The model has no access to anything outside that request: no database, no network, etc.

The simplest fix is to put the data in the request. Load Jane's record, format it as text, and send it along with John's question. That works well when you know ahead of time what the model will need, and it's the right approach for a lot of features. But here, we don't know which customer John will ask about until he asks it. We can't send every customer in the account with every request.

What we need is for the model to tell us what it needs, so we can fetch it from our database. Before the APIs had built-in support for this, people did it with a plain text convention. You'd write something like this in the system prompt for LLM:

If you need details about a customer, reply with exactly one line in this format,
and nothing else:

LOOKUP <email>

You'll receive the customer's details in the next message.

Then you'd check every reply for that pattern:

if (match = reply.match(/\ALOOKUP (\S+@\S+)\z/))
customer = Customer.find_by(email: match[1])
# ...send the details back as the next message
end

The model is predicting probable text, not following a protocol. Sometimes it replies Sure! LOOKUP jane@example.com, and the regex doesn't match. Sometimes it adds a trailing period, which becomes part of the email. Sometimes it writes the lookup line and then keeps going with a guess at the answer. And it just complicates the whole regex parsing workflow.

Tool use is this same idea, built into the model and the API. The model is trained to request tools in a specific format. The API recognizes that format, parses it, and hands it to you as JSON, with the tool's name and its arguments already separated. You don't have to write the regex to extract meaningful information, like the customer's name.

What is a Tool?

A tool is a function that you describe to a language model, so that the model can ask for it to be run if and when needed. The tool description has three parts: a name, a sentence or two about what the function does and when to use it, and the type of the arguments it takes.

As mentioned earlier, the model can't run anything itself. When it decides it needs a tool, it replies with a request: "please call find_customer with the email jane@example.com." Something outside the model, like your application runs the function and sends the result back in the next request. The model then uses that result to write its answer.

Here's that sequence for John's question, from the moment he asks to the moment he sees the answer:

 John               Our Rails app                       Claude (via the API)
| | |
| "What plan is Jane | |
| on?" | |
|---------------------->| |
| | 1. John's question |
| | + a description of find_customer |
| |------------------------------------->|
| | | Doesn't know
| | | Jane, so it
| | 2. "Please call find_customer | asks for the
| | with jane@example.com" | tool
| |<-------------------------------------|
| | |
| |--+ |
| | | 3. Runs find_customer, |
| | | which queries the database |
| |<-+ |
| | |
| | 4. John's question, the model's |
| | request, and Jane's details |
| |------------------------------------->|
| | | Reads Jane's
| | 5. "Jane is on the Business plan..." | details and
| |<-------------------------------------| writes the
| | | answer
| "Jane is on the | |
| Business plan..." | |
|<----------------------| |

Steps 1 and 2 are one HTTP request and its response, and steps 4 and 5 are another. Step 3 happens entirely inside our app: Claude never touches the database. It only sees what our code chooses to send back in step number 4.

That's the purpose of a tool: it gives the model a way to get information it doesn't have, or to take an action it can't take, by asking our code to do it. Every AI feature that does more than generate text is built on this. When Claude searches the web, when Claude Code or Codex edits a file in your codebase, or when our sidebar looks up Jane's plan, the model is asking for a tool call and some code is running it.

The name makes it sound like the model reaches out to the tool which does things in the world. It doesn't. The model writes a structured request, and some code decides whether and how to carry it out. That code can live in two places, which gives us two kinds of tools:

  • Server tools are tools that Anthropic runs on its own servers, like web search. You mention the server tool you want in the API call, and Anthropic handles everything else.
  • Client tools are tools that your app runs. You describe them, and when the model asks for one, your application code runs it and sends back the result.

Jane's details live in our database, which Anthropic can't reach, so the customer lookup has to be a client tool. But server tools are the simpler kind, and they show the whole idea with the least code, so we'll start there.

Before we do, one piece of terminology. In the Claude API, every message in a conversation has a role, and there are only two: user and assistant. Messages the model writes have the role assistant. Everything else has the role user. These are roles in the API, not people. In our example, John is the person using our Rails app typing the question, our Rails app is what sends the requests to Anthropic API, and Claude is the model writing the assistant messages. John never talks to the API directly.

Server Tools

Say a business customer in Germany writes in. They've been charged VAT on their invoice, and they say they shouldn't be, because they gave us their VAT ID. John isn't a tax expert, and he doesn't want to guess. He asks the sidebar to find out whether the customer is right and what we'd need from them. Claude may know the general rules from its training data, but tax rules change, its knowledge stops at its training cutoff, and John needs current sources he can check before replying to a paying customer. This is what Anthropic's web search tool is for.

Here's a request that turns on web search:

client = Anthropic::Client.new

WEB_SEARCH = { type: "web_search_20250305", name: "web_search", max_uses: 3 }

messages = [
{ role: "user", content: "A customer in Germany says we should not charge them VAT because they gave us their VAT ID. Are they right, and what do we need from them?" }
]

reply = client.messages.create(
model: "claude-sonnet-5-5", max_tokens: 16_000, tools: [ WEB_SEARCH ], messages: messages
)

model picks which Claude model answers, max_tokens caps the length of its reply, and messages is the conversation so far. The only new thing compared to a plain request is tools, an array of tool definitions. You can pass as many as you like, and the model chooses among them, or decides it doesn't need any. Here, the array has a single entry: the web search tool.

A server tool is defined by its type, not by a description and a schema. Anthropic has already written those, and the model has been trained on them. The date in the type is a version: web_search_20250305 is the basic web search tool, and newer versions (web_search_20260209 and web_search_20260318, as of November 2026) add features like letting the model filter results with code before reading them. max_uses caps how many searches the model can run while answering this one message.

The response is a single assistant message, and its content array tells what happened, in order:

  • A short text block, in which the model says it's going to search.
  • A server_tool_use block. This is the model's request: it wants the web_search tool, with a search query it wrote itself.
  • A web_search_tool_result block, holding the results Anthropic got back: each page's URL, its title, and its content.
  • One or more text blocks with the answer.

Here's what that looks like, with the long values shortened:

{
"role": "assistant",
"stop_reason": "end_turn",
"content": [
{ "type": "text", "text": "I'll look up the current EU VAT rules for business customers." },
{
"type": "server_tool_use",
"id": "srvtoolu_01WYG3...",
"name": "web_search",
"input": { "query": "EU VAT reverse charge B2B digital services VAT ID" }
},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01WYG3...",
"content": [
{
"type": "web_search_result",
"url": "https://europa.eu/youreurope/business/taxation/vat/cross-border-vat/index_en.htm",
"title": "Cross-border VAT - Your Europe"
}
]
},
{
"type": "text",
"text": "Most likely, yes. For services sold to a business with a valid VAT ID, the reverse charge usually applies, ...",
"citations": [
{
"type": "web_search_result_location",
"url": "https://europa.eu/youreurope/business/taxation/vat/cross-border-vat/index_en.htm",
"title": "Cross-border VAT - Your Europe"
}
]
}
],
"usage": { "input_tokens": 6039, "output_tokens": 931, "server_tool_use": { "web_search_requests": 1 } }
}

The tool_use_id on the result matches the id of the server_tool_use block, which is how you can tell which result answers which request.

The response's stop_reason is "end_turn", which means the model finished its answer and there's nothing left for your code to do. You take the text and show it to John. Everything, including the search, happened inside one HTTP request.

Look at what happened between the request and the response. The model started writing, decided it needed to search, and wrote a request for the tool. Anthropic's servers saw that request, paused the model, ran the search, added the results to the model's input, and let the model continue. The model read the results and wrote the answer. If the first search wasn't good enough, it could have searched again, up to the max_uses limit, all before the response came back. Anthropic's docs call this the server-side loop. Keep it in mind, because later in this post we'll build the same loop ourselves.

Web search isn't the only server tool. As of November 2026, Anthropic also runs web fetch (read the full content of a specific URL), code execution (run Python and Bash in a sandbox, useful for analyzing a CSV a customer attached), and tool search (find the right tool among hundreds without putting all their definitions in every request). The tool reference lists them all. There's also a middle category: tools like bash and the text editor, where Anthropic defines the tool and has trained the model on it, but your app runs it. Coding agents use those.

Server tools are convenient, but they can only reach what Anthropic can reach: the public internet and a sandbox. They can't reach your database. For that, we need a tool of our own.

Client Tools: Allow Claude to Access Your App Data and Code

A client tool is a function that you define in your code and your app runs. When Claude asks for one, the API doesn't run it. It stops and hands the request back to your Rails app, and your Ruby code decides what to do. That code can do anything the rest of your app can: query Active Record models, call an internal service, or update a record. Claude never sees that code. It sees only the tool's description, and later, whatever result your code sends back.

That's what John's question needs. Jane's plan and her open tickets are rows in our database, and only our app can read them. So we'll give Claude a find_customer tool, and our app will run the query whenever Claude asks for it.

Using a client tool takes two steps. First, we describe the tool, so Claude knows it exists and when to use it. Then we handle the exchange: notice when Claude asks for the tool, run it, and send the result back. This section covers the first step, and the next section covers the second.

A client tool definition has the three parts from earlier: a name, a description, and a schema for its input. Here's one for our customer lookup:

FIND_CUSTOMER = {
name: "find_customer",
description: <<~TEXT.squish,
Looks up a customer of this support team by their email address.
Returns the customer's name, their plan, and the subjects of their open tickets.
Use it whenever a question is about a specific customer and their details
aren't already in the conversation. It doesn't return billing history or payments.
TEXT
input_schema: {
type: "object",
properties: {
email: { type: "string", description: "The customer's email address, e.g. jane@example.com" }
},
required: [ "email" ]
}
}

The name is how the model will refer to the tool when it asks for it. It has to be letters, numbers, underscores, or hyphens. Name tools after what they do, the same way you'd name a method.

The description tells the model what the tool does or when to use it. Anthropic's docs call detailed descriptions "by far the most important factor in tool performance" and suggest at least three to four sentences per tool. Notice that the description above says what the tool returns, when to use it, and what it doesn't do. Without it, if John asked about Jane's last payment, the model might call find_customer and hope the answer is in there.

The input_schema describes the arguments. This one says the input is an object with a single required string, email.

What The Model Actually Sees

The model only ever sees text. When you send tools with a request, the API turns them into text and adds it to the system prompt. Anthropic documents the template it uses. It starts like this:

In this environment you have access to a set of tools you can use to answer the user's question.
...
Here are the functions available in JSONSchema format:
{{ TOOL DEFINITIONS IN JSON SCHEMA }}
{{ USER SYSTEM PROMPT }}

Your tool definitions are pasted in as JSON, followed by your own system prompt. From the model's point of view, a tool is a paragraph of instructions describing a function it's allowed to ask for. (Server tools work the same way. The only difference is that Anthropic wrote their definitions.)

When the model decides to use a tool, it's doing the same thing it always does: predicting the next token. Through fine-tuning and reinforcement learning, it has learned that when a question matches a tool's description, the most probable continuation is a tool request in a specific format. The Claude API spots that format in the output, stops generation, and converts it into JSON for you.

This has two consequences:

Tools cost tokens on every request. The definitions are part of the prompt, so you pay for them on every call, whether the model uses them or not. On top of your definitions, the API adds its own tool-use instructions, which Anthropic lists at 286 tokens for Claude Sonnet 5.5 (as of November 2026). Ten tools with good descriptions can easily add a couple of thousand tokens to every request.

The model can get it wrong. It picks tools and fills in arguments by prediction, the same way it writes everything else. It might pick the wrong tool, call a tool it doesn't need, or invent an argument value. We'll come back to this.

The Tool Use Exchange

With a server tool, the whole exchange happened inside one request. With a client tool, Anthropic can't run the tool, which resides in your application, so the API stops and hands the request back to you. Here's the full exchange, from the API's point of view:

  1. Request 1: John's question, plus the tool definitions.
  2. Response 1: a tool_use block asking for find_customer.
  3. Your code runs find_customer.
  4. Request 2: John's question, the model's tool_use block, and your tool_result, plus the tool definitions again.
  5. Response 2: the answer.

Let's go through each step.

The First Request

We send John's question the same way, with our tool in the tools array:

messages = [
{ role: "user", content: "What plan is jane@example.com on, and does she have any other open tickets?" }
]

reply = client.messages.create(
model: "claude-sonnet-5-5", max_tokens: 16_000, tools: [ FIND_CUSTOMER ], messages: messages
)

Here's the kind of response you get back:

{
"id": "msg_01Aq9w938a90dw8q",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"stop_reason": "tool_use",
"content": [
{
"type": "text",
"text": "I'll look up that customer."
},
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "find_customer",
"input": { "email": "jane@example.com" }
}
]
}

Compare this with the web search response. There are three differences.

stop_reason is "tool_use", not "end_turn". The model stopped halfway because it's waiting on you. This is the field your code checks to decide what to do next.

The request is a tool_use block, not a server_tool_use block. The block holds the request itself: name is the tool the model wants, input is a hash matching the tool's schema, and id identifies this particular call. You'll need that id in a minute.

There's no result block after it, because we have not run the tool yet and no query is executed. The model hasn't looked up Jane. It has written down, in a format your code can parse, that it would like you to run the tool. If you ignored this response, Jane's record would never be touched.

As before, the first text block is just the model saying what it's doing. Your code shouldn't depend on it being there or saying anything in particular. Sometimes the model says nothing before calling a tool, and sometimes it explains at length.

That JSON is what travels over the wire. The SDK hands it to you as Ruby objects instead of a hash. reply.stop_reason returns the symbol :tool_use, and reply.content is an array, and so on.

Running The Tool

Running the tool is ordinary Rails code. For this example, the Helpdesk app has accounts, and each account has customers. A customer belongs to a plan and has many tickets, and tickets have an open scope:

def run_tool(name, input)
case name
when "find_customer"
customer = Current.account.customers.find_by(email: input[:email])
return "No customer with the email #{input[:email]} exists in this account." unless customer

{
name: customer.name,
plan: customer.plan.name,
open_tickets: customer.tickets.open.pluck(:subject)
}.to_json
else
raise ArgumentError, "Unknown tool: #{name}"
end
end

The case maps tool names to code. With just one tool to find customers, it's a single branch. With ten tools, you'd want something more organized than a case statement, which is why I'm planning to write another post that will show code organization with tools.

Look at Current.account.customers.find_by. It would be easy to write Customer.find_by(email: input[:email]) here. But the email came from the model, and the model got it from text that John typed. Treat tool input exactly like params from a form. Scope every lookup to what the current user is allowed to see. If a support rep from one account could get the sidebar to look up an email belonging to another account, Customer.find_by would hand over that customer's details. This is standard multi-tenant practice in Rails. But important to remember that the model is not an authorization layer.

When the customer isn't found, the method returns a plain sentence saying so. The result goes back to the model as text, and a clear sentence gives it something useful to tell John. Returning nil, or an empty string, would leave it guessing.

When a customer is found, the result is a JSON string. It doesn't have to be. The model reads the result as text, so you could also return a few formatted lines. Structured JSON data is preferred because models read it well and to_json is one call. Whatever format you choose, include only the fields the model needs. Every field you return is more tokens in the context, and a customer record with 40 columns makes the actually relevant fields harder to find.

Sending The Result Back

Now we need to tell the model what we found. Since the API is stateless, that means sending the whole conversation again, with two new messages on the end:

# Add the model's reply to the conversation, exactly as we received it.
# It contains the tool_use block, so the model will see its own request.
messages << { role: "assistant", content: reply.content }

# Find every tool request in the reply (there can be more than one),
# run each tool, and wrap its output in a tool_result block.
tool_results = reply.content.select { it.type == :tool_use }.map do |tool_use|
{
type: "tool_result",
# Ties this result to the request it answers.
tool_use_id: tool_use.id,
# Our Ruby code runs here. For find_customer, this is Jane's details as JSON.
content: run_tool(tool_use.name, tool_use.input)
}
end

# Send the results back in a user message. "user" is the API role for
# anything the model didn't write, not John.
messages << { role: "user", content: tool_results }

# The second request: the whole conversation so far, and the same tools.
final = client.messages.create(
model: "claude-sonnet-5-5", max_tokens: 16_000, tools: [ FIND_CUSTOMER ], messages: messages
)

# This time the stop_reason is :end_turn, and the answer is in a text block.
final.content.find { it.type == :text }.text
# => "Jane is on the Business plan. She has two other open tickets: ..."

First, we append the model's reply to the conversation as an assistant message. We pass reply.content back exactly as we received it, every block, untouched. That includes the tool_use block, so the model can see its own request in the next turn. It can also include thinking blocks on models that reason before answering, and the API requires those to come back unmodified alongside tool results.

Next, we build a tool_result block for each tool_use block. The model can ask for several tools at once (more on that soon), so we select all of them rather than assuming there's one. Each result has a tool_use_id that matches the id of the request it answers. That's how the model knows which result belongs to which request.

Then we send the results in a user message. Remember that user is a role in the API, not John. The API has only two roles, and anything the model didn't write itself goes on the user side. The type: "tool_result" is what tells the model this is a tool's output and not something John typed.

There are a couple of rules here, and the API returns an error if you break them. The tool result message has to come immediately after the assistant message that requested the tools, with nothing in between. And within that user message, the tool_result blocks have to come first. If you want to add a text note, it goes after the results.

Finally, we call client.messages.create again with the longer conversation. This time the model has Jane's details in its context, and it writes the answer. The stop_reason is "end_turn", and our app shows the answer to John.

The Exchange, Step By Step

Here's the same five-step exchange again, now with what actually travels in each step.

  1. Request 1. Our app sends messages with one user message holding John's question, and tools holding the find_customer definition. The API turns the definition into text in the system prompt, and the model reads both.
  2. Response 1. The model predicts that the best continuation is a request for find_customer. The API sees it, stops generation, and returns a tool_use block with an id, the tool name, and the input hash. The stop_reason is "tool_use". Our app doesn't show anything to John yet.
  3. Our code runs the tool. We read name and input from the tool_use block, run Current.account.customers.find_by, and turn the result into a string. Nothing about this step involves the API. It's a plain database query, and it's the only step where anything actually happens to our data.
  4. Request 2. Our app sends messages again, now with three entries: John's question as a user message, the model's reply (including the tool_use block) as an assistant message, and a user message holding a tool_result block whose tool_use_id matches the request's id. We send the same tools again, since the API remembers nothing between requests.
  5. Response 2. The model reads the whole conversation, including Jane's details from the tool result, and writes the answer. The stop_reason is "end_turn". Our app shows the text to John.

John asked one question, and our app made two API calls. Because request 2 contains everything from request 1, you pay for those input tokens twice. That's worth knowing when you estimate the cost of a feature that uses tools.

Notice also what the model never did: it did not touch the database, and it never saw our code. It saw a description of a function, asked for it in a specific format, and later saw a string that our code chose to send. Your Rails app made every decision about what to actually run.

Letting Model Ask Again with Agent Loop

What if answering needs more than one lookup? Say John asks "Which of Jane's open tickets has been waiting the longest, and what did she last say on it?" Let's give the model a second tool, get_ticket_messages, which takes a ticket ID and returns its messages. Now the model needs to look up Jane, find the oldest open ticket, then ask for that ticket's messages. It doesn't know the ticket ID until it has seen the first result, so it can't ask for both at once.

The fix is to keep going as long as the model keeps asking:

# The value of `break` becomes the value of `loop`, so `final`
# ends up holding the model's last reply.
final = loop do
# Send the whole conversation so far, along with every tool the model can use.
reply = client.messages.create(
model: "claude-sonnet-5-5", max_tokens: 16_000, tools: TOOLS, messages: messages
)

# If the model didn't ask for a tool, it has its answer. Stop here.
break reply unless reply.stop_reason == :tool_use

# Otherwise, add the model's tool request to the conversation, exactly as we received it...
messages << { role: "assistant", content: reply.content }

# ...run every tool it asked for, and add the results. Then go around again.
messages << { role: "user", content: tool_results_for(reply) }
end

TOOLS is the array of both tool definitions, and tool_results_for is the select and map from the previous section, pulled into a method. Each time around the loop, we send the conversation to Claude. If the model asked for tools, we run them, add the results to the conversation, and go around again. As soon as it stops with something other than "tool_use", which usually means it has its answer, break reply ends the loop and hands that last reply to final.

Here's how John's question would play out:

  1. Request 1 carries John's question. The model replies with a tool_use block for find_customer with Jane's email. Our loop runs it and gets Jane's plan and her open tickets, each with an ID and a creation date.
  2. Request 2 carries the conversation so far, including that result. The model reads the ticket list, works out that ticket 4812 is the oldest, and replies with a tool_use block for get_ticket_messages with ticket_id: 4812. Our loop runs that too.
  3. Request 3 carries everything, including the ticket's messages. The model now has what it needs, writes the answer, and stops with "end_turn". The while condition is false, and the loop ends.

Our code never decided to look up the customer first and the ticket second. It didn't know which ticket to fetch, or how many steps the question would take. The model made each of those decisions, one response at a time, based on the results it had seen so far. Our loop only did two things: ran whatever the model asked for, and sent the results back.

Those few lines are the core of every AI agent. An agent is a model, some tools, and a loop that runs until the model says it's done. The model decides what to do next, the loop carries it out, and the result goes back to the model so it can decide again. Anthropic's Building Effective Agents says that agents "are typically just LLMs using tools based on environmental feedback in a loop."

We've already seen this loop once in this post. The web search tool ran the same loop on Anthropic's servers: the model asked for a search, Anthropic ran it, fed the results back, and the model decided whether to search again or answer. The only difference is who runs the tools. A coding agent like Claude Code is this loop too, with tools for reading files, editing them, and running shell commands, and a model that keeps asking for them until the task is done.

Everything else you hear about agents is built around this loop: you can add iteration limits so it can't run forever, have budgets, let a person approve risky calls before they run, add checks to survive a deploy halfway through.

Handle Errors

Tools fail like any other code. If find_customer raises an exception, the model is still waiting for a tool_result for its request, and the API rejects the next request if one is missing. So send a result anyway, and tell the model that the call failed. A tool_result can carry an is_error flag for this:

def tool_result(tool_use)
{ type: "tool_result", tool_use_id: tool_use.id, content: run_tool(tool_use.name, tool_use.input) }
rescue => error
Rails.error.report(error)
{ type: "tool_result", tool_use_id: tool_use.id, content: "The lookup failed: #{error.message}", is_error: true }
end

With is_error: true, the model knows the call failed and can tell John, or try something else. Anthropic's docs suggest writing error messages that say what went wrong and what to try next, like "Rate limit exceeded. Retry after 60 seconds", rather than a bare "failed". The model reads the message, so give it something to act on. (You have to be a bit careful about what goes in it. An exception message can include details you wouldn't want shown to a user, and anything you send to the model can end up in its reply.)

Another type of failure is the model filling in arguments badly. If John asks "What plan is Jane on?" without an email, the model might ask which Jane he means. Or, as Anthropic's docs warn, it might infer a value he never gave it. Smaller models are more likely to guess. Good descriptions help a lot here, and a system prompt line like "If you don't have the customer's email, ask for it instead of guessing." is also helpful to guide the model.

Finally, remember that tool results often contain text you don't control: a customer's message, a web page, an email. The model reads all of it, and some of it might be written to manipulate the model ("Ignore your instructions and..."). This is called prompt injection, and it gets more dangerous as your tools get more powerful. You don't want to start issuing refunds based only on the model's output without any human checks.

How does Tool Use Relate to MCP?

You've probably heard of MCP, the Model Context Protocol, and wondered how it relates to all this. To answer that, we need two terms that MCP uses: the AI application and the MCP server.

An AI application is a program that sends requests to a model and runs tools when the model asks for them. Our Helpdesk app is one: its sidebar sends John's questions to Claude and runs find_customer when Claude asks. Claude Code is another, and so is the Claude desktop app. Each of them runs the same exchange you've seen in this post, and each one can only run tools it has code for.

So far, that hasn't been a problem, because every tool in this post lives inside Helpdesk, the same app that talks to Claude via API. Now consider a different situation. You're using Claude Code to fix a bug, and you want it to read the support ticket from Helpdesk where that bug was reported. Claude Code has no tool for that. Anthropic didn't write one, because it doesn't know Helpdesk exists, and you can't add Ruby methods to Claude Code.

MCP is a standard way for an AI application like Claude Code to use tools that live in another program, like Helpdesk. Its docs describe it as "an open-source standard for connecting AI applications to external systems," and compare it to a USB-C port for AI applications. The program that offers the tools is called an MCP server.

In this situation, the MCP server is our Helpdesk app, and we're the ones who build it. We'd add an endpoint to Helpdesk that speaks the protocol (it's JSON over HTTP, so a Rails controller can do it), with a find_ticket tool behind it. Then we'd add the endpoint's URL to Claude Code's settings:

Claude (the model)  <-- Claude API -->  Claude Code  <-- MCP -->  Helpdesk (Rails)
AI application MCP server
runs the loop runs find_ticket

Here's what happens when you ask Claude Code about the ticket:

  1. When it starts, Claude Code connects to Helpdesk and sends a tools/list request. Helpdesk replies with its tools, each with a name, a description, and an inputSchema: the same three parts we wrote for find_customer.
  2. Claude Code includes those definitions in the tools it sends to Claude, alongside its own tools for reading and editing files.
  3. Claude replies with a tool_use block asking for find_ticket, exactly as it asked our sidebar for find_customer.
  4. Claude Code doesn't have the code for find_ticket, so it sends a tools/call request to Helpdesk with the tool's name and input.
  5. Helpdesk runs its Ruby code, scoped to what your login is allowed to see, and returns the ticket. Claude Code sends it back to Claude as a tool_result, and the loop continues.

The model never knows MCP is involved. Claude sees tool definitions and asks for tool calls, exactly as in the rest of this post. MCP is the connection between Claude Code that runs the loop and the program (Helpdesk) where a tool's code lives.

Who builds MCP servers? Whoever owns the data or the actions. Sentry runs one so that Claude Code, or any other AI application, can look up errors in your Sentry account. Basecamp has one so you could work with your projects in Claude Code. GitHub runs one for repositories and pull requests. Helpdesk would run one for tickets.

So Helpdesk can play either role, depending on who's running the loop. In the sidebar, Helpdesk is the AI application. It runs the loop and its own tools, and it doesn't need MCP at all. When we want Claude Code, the Claude desktop app, or our customers' own AI tools to reach Helpdesk's data, Helpdesk is the MCP server. It could even be both: if we wanted the sidebar to look up errors in Sentry, Helpdesk could connect to Sentry's MCP server and pass Sentry's tools to Claude alongside find_customer. (The Claude API can do that connecting for you. Its MCP connector, in beta as of November 2026, is a server tool that connects to a remote MCP server on your behalf, the same way web search runs searches on your behalf.)

For a web developer, tool use is like calling a method in your own app. MCP is like publishing an API so that other people's apps (Claude Code) can call your code. (MCP has more in it than tools: servers can also expose resources and prompt templates. But tools are what most people mean when they talk about it.) If you're building AI features inside your own Rails app, you don't need MCP. You define tools and run them, the way we did here. MCP becomes useful when you want your app's data and actions available inside AI applications you don't control.

I will cover building an MCP server in Rails in a future post.

Subscribe to get future posts via email (or grab the RSS feed)

If you have a comment, please email it to me.

Subscribe