Designing MCP Tools an AI Assistant Gets Right: Notes From a Production Server A developer who built a remote MCP server with 24 tools for ChatGPT and Claude reports that tool descriptions function more like a system prompt than API documentation, because the model reads them every turn and acts on them. The server's instructions open by telling the assistant to call list_topics once and reuse matching topic ids, repeat that rule inside list_topics and create_topic descriptions, store each topic in a single unit (water in ml, weight in kg, durations in minutes), and require an explicit direction field (at_least, at_most, between) so a 2,000 calorie goal is treated as a ceiling rather than a target. The developer also advises leaving genuinely ambiguous fields without a default and stating in the tool text when the model should ask a clarifying question. I drafted this with help from an AI assistant and edited it myself. For the past few months I’ve been building a remote MCP server that ChatGPT and Claude call while someone talks to them. A person says “had two glasses of water and a protein bar”, and the assistant is supposed to turn that into entries on the right topics, in the right units, so a dashboard can draw progress against goals. The server has 24 tools. The model decides when to call each one, and with what arguments, based almost entirely on text I wrote. That took me a while to absorb. I had been writing tool descriptions the way I’d write API docs for a developer, who can read the source when something is unclear. The model reads them on every turn and acts on them, so they behave more like a system prompt. A lot of what decides whether the server works well lives in that text. Here is how I approached it. MCP lets a server send an instructions string when a client connects, and many hosts pass it to the model. It's tempting to use it to describe the product. A description tells the model what the server is for, though, and says nothing about what to do. Without a rule about the first move, an assistant will happily create a topic called "Water Intake" next to an existing one called "Water". So the instructions open with that move: Start by calling list topics once and remember the ids. Reuse a topic that matcheswhat the person says; create a new one only when nothing fits. I repeat the same rule in the descriptions of list topics and create topic. That felt redundant at first, but hosts differ in what they forward, and the moment a rule applies is the moment the model is reading one particular tool. If a rule matters, I put it where the model is looking when it needs it. I also decided the server would do no fuzzy matching of topic names. Merging “water” and “Water intake” on the server is tempting, but the model has the conversation and the server doesn’t. It knows whether the person meant the same thing. So the server makes reuse easy every list result carries ids and offers a merge topics tool for when the model gets it wrong anyway. Every topic stores values in one unit: water in ml, weight in kg, durations always in minutes. The model converts before it writes. The description of the value field says so with a number in it: value: 'The amount in the topic\'s unit convert first: 2 litres is 2000 for a "ml" topic . ...' The worked example does more than the rule. Without it, an assistant can log the 2 from “2 litres” straight into a millilitre topic, and the tile shows someone who has barely drunk anything. The server instructions cover the vaguer case too: “a glass of water is about 250 ml if the unit is ml”. Models estimate that kind of thing well once you tell them estimating is expected. When a unit has to change, update topic takes a unitFactor that multiplies every stored value and the goal litres to millilitres: 1000 . This is the part I find most interesting. Someone says “my goal is 2,000” about calories. For water, 2,000 ml is a target to reach. For calories, 2,000 kcal is usually a ceiling. If the model guesses wrong, the tile turns green on exactly the days it should warn. So a goal carries an explicit direction, and the description of the field is blunt about it: Always set direction . at least is a target to reach water 2000 ml, protein 140 g,steps . at most is a limit to stay under calories 2000 kcal, spending 400 USD,drinks . between is a range with min and max sleep 7 to 9 h . ...If the person gives a number without saying whether it is a limit or a target, ask them. Two things here carry over to other servers. A field that is genuinely ambiguous should have no default, so the model can’t skip past it. And if you want the model to ask a clarifying question, say so in the tool text. In tool-heavy flows models lean toward acting, so a description that makes asking part of the job gives the question a fair chance of being asked. The same goal object handles one-off goals with a date: by replaces per, so "75 kg by 1 February" becomes at most 75 with a by date. I folded that into the existing field instead of adding a second goal tool. Some things people track because they want fewer of them: cigarettes, takeaway, migraines. Model these as plain counts with an at most goal and they break quietly. A day with nothing logged shows as “not logged”, which reads like a missed day, when it was the best day the person could have had. The fix was a polarity field on the topic, build or avoid. With avoid, an empty day counts as a good one, each entry counts against the day, the streak becomes days without, and the goal can only be a limit. The description tells the model to set it whenever the topic is something to avoid and to ask if unsure. When someone tells Claude "track my headaches", it creates an avoid topic, and a week with two headaches shows five good days. The general point is that when the absence of data means something in your domain, the model needs a field to say what it means. It has no way to express that through entries it never writes. People get sick, go on holiday, take rest days. Without a tool for it, an assistant has only odd ways to protect a streak, like logging a zero or suggesting the person lower their goal. So there is skip day for one topic and pause days for everything over a range of dates. The skip day description opens with a phrase a person would actually say: "skip stretching today, my back hurts" . Quoting the user's words is a better routing hint than describing the operation in the abstract. There is also an unskip, and the model is told to confirm with the person before calling it. A meal is several foods, and someone catching up after a weekend might list a dozen things. With only a single-entry tool, an assistant makes ten log entry calls in a row. That is slow, it clutters the host's UI, and if call six fails, five entries are already written. log entries takes up to 50 entries in one call. Its description says: "All or nothing: if one entry is invalid, none are written and the error says which." Atomicity helps the model as much as the data. After a partial failure, a model has to work out which entries landed, and a wrong guess means duplicates. With one failure point, it fixes a field and resends the batch. The server instructions point to it as well: "When the person mentions several things at once, send them together with log entries instead of many log entry calls." Every failure comes back as a tool result with isError: true, never as a JSON-RPC error, because the model can see a tool error and can't do much with a protocol one. The text names a code and lists each bad field with its allowed values: js const lines = err.details ?? .map d = \n${d.path}: ${d.message}${d.allowed?.length ? allowed: ${d.allowed.join ', ' } : ''} ;return ${err.code} ${err.message}${lines.join '' } ; The last line of the server instructions tells the model this is coming: “When a tool returns an error, it names the field and the allowed values. Correct the input and try again.” That sets the model up to fix the input on the next call. Successful results follow a similar idea. Each one starts with a plain sentence “Logged 500 ml to Water. Today: 1,450 ml.” followed by the same data as JSON in the text block, because some hosts forward only content and drop structuredContent. Without the JSON, the model would lose the ids it needs for the next call. MCP tools can declare readOnlyHint, destructiveHint, idempotentHint and openWorldHint. Hosts use them to decide what to confirm with the person, and directory reviewers check them against what the tool really does. For this server I keep all four hints, set explicitly, in one table with a comment for every judgment call: js const READ = hints true, false, false, true ;const ADD SHOWN = hints false, false, true, false ;const CHANGE SHOWN = hints false, true, true, true ; log entry: ADD SHOWN,// Asking again for a day already skipped returns the same skip.skip day: hints false, false, true, true ,// A new dashboard is empty and seen by nobody else until it is shared or paired.create dashboard: hints false, false, false, false , Some calls were less obvious than I expected. A dashboard can be showing on a paired TV or at a public link, so writing an entry changes what someone else sees, which makes nearly every write tool open-world. skip day is idempotent and pause days isn't, since each call adds another pause. A read that rebuilds cached daily summaries still counts as read-only, because nothing anyone sees changes. A contract test registers every tool and fails if one is missing an annotation, an output schema or a scope, so a new tool can't ship without them. ChatGPT’s directory review flagged health, tobacco and plan wording in the example text. Rather than weakening the examples for every client, the server keeps a list of string rewrites applied only to what ChatGPT receives, covering the instructions, tool descriptions and schemas. “ headaches, panic attacks, cigarettes ” becomes “ late-night snacking, doomscrolling, impulse buys ” there, and Claude keeps the original wording, which helps it pick the right topic type. A test fails if a rewrite stops matching anything, so the two versions can’t drift apart silently. Twenty-four tools is about as many as I’d want. Every description is context the model carries on every turn, and two tools with overlapping jobs make it hesitate between them. Where tools do overlap, the descriptions say which to use when: summarize topic is for "how was my week", and query entries is for finding a specific entry. The long catalog of widget kinds, card sizes and visuals sits behind a single get capabilities call, which the instructions tell the model to make once per conversation and reuse, instead of being spelled out in every dashboard tool. If I were starting again, I’d write the server instructions and the three or four most-used tool descriptions before the code, then watch real conversations and edit the text first whenever the model does something odd. Vasanth is the founder of Dashly dashly.si , a dashboard that ChatGPT and Claude keep up over MCP. Designing MCP Tools an AI Assistant Gets Right: Notes From a Production Server https://pub.towardsai.net/designing-mcp-tools-an-ai-assistant-gets-right-notes-from-a-production-server-d89d8e43429a was originally published in Towards AI https://pub.towardsai.net on Medium, where people are continuing the conversation by highlighting and responding to this story.