Skip to content

How to design tools an LLM actually uses well

By Chris Artmann ·

Building an MCP server is the easy part. You wrap a few endpoints, register them as tools, and a model can call them. Then you watch it in a real chat. The model picks the wrong tool. It makes up a parameter value. It reads a giant result and forgets what you asked. Or it hits an error and tries the exact same call three more times.

None of that is the model being dumb. It's doing its best with what you gave it. And what you gave it is a list of names, a few descriptions and some JSON. That's the whole interface. So it's worth designing with care.

We run a public MCP server for recipe data, and we got a few of these wrong before we got them right. Here are eight rules we now follow. Most come with a real change from our own history. If you're new to MCP, start with What is MCP? first.

The eight rules

1. Name tools after what the user wants, not after your endpoint.

The model picks a tool by matching your user's words to your tool names and descriptions. Your user says "what can I make with chicken and rice?" They never say "pantry". So our /pantry endpoint is a tool called cook_from_pantry. Our /popular endpoint is popular_recipes, and /shopping-list is build_shopping_list.

Verbs help a lot. get_recipe, find_substitutions and search_recipes each read like something a person would ask for. Compare that with names like recipes_v2_query or pantry_handler. Those make sense to the person who wrote the API, but they don't help a model match a request to a tool.

2. Say when not to use a tool.

Once you have two tools that do similar things, the model has to choose. If the descriptions don't explain the difference, it guesses. We have two ways to find recipes. find_recipes filters by fields like cuisine, diet and time. search_recipes searches by meaning, so "creamy pasta" also finds carbonara. They overlap, so the description of the first one points at the second:

List recipes newest first, filtered by any combination of cuisine, course, category, diet, ingredients, allergens, equipment, time and nutrition. Use search_recipes instead to search by meaning.

That last sentence does more work than you'd think. It turns a guess into a rule the model can follow.

Descriptions should also explain anything in the result that might surprise someone. Here's a real before and after. Our first cook_from_pantry description was:

Find what you can cook from the ingredients you have, best covered first. Each recipe lists the pantry ingredients it uses and the ones you are missing; maxMissing: 0 returns what you can cook right now.

Then a data audit showed us that salt and oil counted as "missing". That's technically true and useless in practice. We changed the behaviour so staples are assumed to be on hand, and we explained it in the description:

Common staples — salt, pepper, water, oil, sugar, flour — are assumed to be on hand: they are left out of the coverage and of missingIngredients and returned in assumedIngredients instead. Send staples: false to count them as ingredients you need.

Without that sentence, a model could easily tell your user they don't need salt. With it, the model knows what the response means. It also knows how to switch the behaviour off.

3. Use closed lists, and say what's allowed when the model gets it wrong.

If a parameter only accepts certain values, the model needs a way to learn them. Otherwise it invents something plausible. It might send "Italian food" or "veggie". A lot of APIs quietly return an empty list for an unknown value. That's the worst thing you can do to a model. It reads the empty list as "there are no Italian recipes" and tells your user so.

We do two things. First, a list_vocabularies tool returns the real lists of cuisines, courses, diets, allergens and units. The parameter descriptions point to it: "A cuisine from list_vocabularies, such as "italian"." Second, an unknown value is an error, not an empty page. The error says where the allowed values are:

Unknown cuisine "Italian food". The allowed values are listed by GET /vocabularies.

When a model gets that error, it can look up the right value and try again. It usually gets it right on the second call.

4. Keep results small, and page them.

Everything a tool returns goes into the model's context window. Big results are slow and expensive. They push out the conversation, and at some point the client simply refuses them.

We learned this the hard way. In our first version, asking for ten popular recipes returned about 85,000 characters, and Claude Code refused to show it inline. Ten of the richer recipes came to about 97,000 characters, which is over Claude Code's 25,000-token limit for a tool result. A full recipe can run to 10,000 characters. It has the ingredients, the steps, nutrition and media, and all of it is useful to an app. Most of it isn't needed for an answer in a chat.

Here's what we changed:

  • Every tool that returns recipes now answers at most five per call.
  • Each response says how many recipes match in total, so the model knows there's more and can ask for the next page with offset.
  • We stopped sending every result twice. We had been returning the JSON as text and again as structuredContent. Most clients only pass the text to the model, so the copy doubled the size for nothing.
  • search_recipes takes a fields parameter, so the model can ask for just title, url and times.

Five is still a lot. While writing this post, we asked for just two pantry matches and got about 10 KB of JSON back. Most of it was image variants, video captions and nutrition. So if you design one thing carefully, make it the result. Ask what the model needs to answer, and return that.

5. Write error messages the model can act on.

A model reads your error message and decides what to do next. "Bad request" gives it nothing to work with, so it retries or gives up. A good error says what went wrong, why, and what to do about it. Our maxMissing error looks like this:

The "maxMissing" parameter must be a whole number between 0 and 20. Send how many ingredients you are willing to shop for, such as 1.

When the free allowance runs out, the error explains what happened and when it will work again:

Daily allowance of 50 requests used. It resets at midnight UTC.

The model can pass that straight on to the user, and it knows not to keep trying. When we added a weekly limit next to the daily one, each limit got its own message for exactly this reason. "It resets at midnight" is wrong if the weekly limit is the one you hit.

Status codes matter too. Our first MCP version answered a spent allowance with a 429, not a 401. That was on purpose. In MCP, a 401 tells the client to start the sign-in flow. If you send it for a rate limit, your user gets sent through OAuth again for nothing, and then hits the same limit. So pick the status that leads the client to do the right thing.

The errors page in our docs lists them all, if you want more examples.

6. Build tools around jobs, not around every endpoint.

It's tempting to make one tool per endpoint and call it done. Sometimes that works. Ours maps closely to our endpoints, because they were already built around jobs like "cook from my pantry" or "build a shopping list". But we left some out on purpose. /status and /health are for us, not for anyone cooking dinner, so they have no tool. Every tool you add is something the model has to read and consider on every turn.

The bigger point is where the work happens. build_shopping_list takes up to 20 recipe ids. It scales each recipe, adds up the amounts across recipes and groups the list by aisle. The model could try to do that itself from five recipe results. But adding up "1 cup + 3 tbsp" across recipes is exactly the kind of maths models get wrong. If a job has a correct answer, do it on the server and give the model one tool for it.

7. Make tools safe to call twice.

Models retry. Clients retry. Users click "regenerate". If your tool creates an order every time it's called, someone will end up with three orders.

All our tools only read data, and we say so. MCP has tool annotations for this, and every tool we register has readOnlyHint: true. Clients can use that to skip confirmation prompts for safe calls. If your tools do write data, take an idempotency key, or make the second identical call a no-op. And mark them as destructive so the client asks your user first.

8. Test with a real model in a real client.

Unit tests tell you the tool returns the right JSON. They don't tell you whether a model can use it. We found the 85,000-character problem by using our server in Claude Code, not from a test suite. The staples problem came from checking real answers against the source recipes.

Logs are worth watching too. A while ago our API logged 131 "task timed out after 300 seconds" errors on the MCP endpoint in a single week. Newer MCP clients had started opening a subscriptions/listen stream to hear when tools change. Our tools never change, but the stream stayed open until the serverless function was killed. One config line fixed it (maxSubscriptions: 0). We'd never have spotted it without real clients talking to the real server.

So set aside an hour. Connect your server to two or three clients and ask the questions your users would ask. Watch which tools get called and with what arguments. Every time the model does something odd, ask what in your names, descriptions or results led it there.

The short version

Write your tool definitions for a reader who is smart, in a hurry, and can only see what you tell them. Name tools after jobs. Point to the right tool when two of them overlap. Return allowed values instead of empty lists. Keep results small. Write errors that say what to do next. Do the maths on the server. Make retries safe. And test with a real model.

You can see how all of this looks in practice in our MCP docs, which list every tool with its description and parameters. If you want to build your own server, Build your own MCP server in TypeScript walks through it step by step. And if you just want to try a well-behaved server, Give your agent a recipe database shows how to connect ours.