Skip to content
Guides/Detail

Your MCP server's instructions are a product surface, not a README

19/08/2026 (edited)
Tech
1251 Words
6Min read

An MCP server can hand the model a block of text at initialisation that governs every tool call for the rest of the session. Most servers ship it empty.

The result is predictable. You fix the same behaviour thirty times in thirty tool descriptions, and the thirty-first tool gets it wrong.

This is about that block, not about individual tool design. If you want tool granularity, naming and per-tool errors, that is the other guide. This is the layer above it, and it is cheaper than all of it.

The session that made the case

The first real organiser session on our events server produced a wall of raw ids and JSON in reply to “how did my conference do”.

Every tool was working correctly. Every response was accurate. It was unusable, and no single tool was at fault.

Nothing about that is fixable one tool at a time. It is a property of the whole surface, so it needs a fix that applies to the whole surface.

Instructions reach the model once

You pass them next to the server name and version:

const mcp = new McpServer(
  { name: "presso-events", version: "0.0.1" },
  {
    instructions: [
      "You are acting as an event organiser's assistant on Presso Events.",
      // ...
    ].join("\n"),
  },
);

That text is read once and applies to everything. It is the highest-leverage paragraph in the codebase, and it costs nothing to change.

What follows is what earned its place in ours.

Never show a human an internal id

NEVER show the organiser a numeric id. Event ids, ticket type ids and organiser ids are for your tool calls only. They are internal database keys, they leak how many events the platform has ever created, and they mean nothing to a human.

Two separate reasons, and the second one is the one people miss.

Ids are meaningless to the reader. “Your Early Bird tickets” is what a person says. “Ticket type 54” is what a database says.

Sequential ids also leak. A user who sees id 54 knows roughly how many records the platform has ever created, which is business information you did not intend to publish and cannot retract.

So give the model the disambiguation rule too, or it will reach for the id the moment two things share a name. Use the date or the price instead.

Why should a refusal carry the next question?

Because a refusal is where the work either continues or stops, and a validation error stops it.

The publish path on our server is the clearest case. It refuses on three missing fields, and the refusal is written to be relayed:

throw new Error(
  `"${event.name}" is not ready to publish. Still needed: ` +
    `${checklist.blocking.join(", ")}. ${checklist.nextQuestion ?? ""} ` +
    `Publishing is the moment this event becomes payable, so it is never ` +
    `a side effect of anything else.`,
);

Three things in one string: what is missing, the next question already phrased, and why the rule exists.

The model does not have to invent a recovery, choose a tone, or decide which missing field to raise first. It relays a sentence.

Then say so at the server level, so it holds for every tool that ever refuses:

When a tool refuses, the refusal usually carries the next action. Relay that action as a plain instruction, not as an error.

Why does an interview beat a form?

Because the model will batch your questions otherwise, and a batched question gets a partial answer.

Return the state of the interview as data, not prose:

export type EventChecklist = {
  ready: boolean;
  /** Fields that refuse publish right now. */
  blocking: string[];
  /** Answered or declined - do NOT ask these again. */
  asked: string[];
  /** The single next thing to ask the organiser, already phrased. */
  nextQuestion: string | null;
};

asked is the field that is easy to leave out and expensive to omit. Without it a model re-asks a question the user already declined, which reads as not listening.

nextQuestion is singular on purpose, and the server-level rule enforces what that means:

When a tool returns a checklist, it is telling you the interview is not finished. Ask its nextQuestion before doing anything else, and do not batch the remaining questions into one message.

Phrase the questions in the server, not in the model. Ours carry the reassurance with them, because the answer changes if the user thinks a number will be enforced against them:

expectedAttendance:
  "Roughly how many people are you expecting? This is for your planning only - " +
  "Presso never caps your door.",

Say what inventing means, or it will invent politely

“Do not make things up” does not work, because the model does not classify what it is doing as making things up. It classifies it as being helpful.

So define it, with an example:

NEVER invent a price, a capacity, a venue, a refund policy or an event description, and never complete a detail the organiser gave only in part. Recording “Barbican Centre” as “Barbican Centre, Silk Street, London EC2Y 8DS” is inventing, even when the expansion is correct. You are guessing, and the same reflex on a venue you know less well puts a wrong postcode in front of everyone who bought a ticket.

The example does the work. Completing an address is exactly the behaviour a helpful assistant produces and exactly the behaviour that puts a wrong postcode on a ticket.

Then give the instruction teeth. Ours ends by telling the model the tools refuse guessed values server-side, so guessing costs it a turn. An instruction with a consequence behind it survives; an instruction on its own is a preference.

Never let money happen as a side effect

The last rule is the one I would keep if I could keep only one.

Publishing is the moment an event becomes payable. Never publish as a side effect of another request: read the ticket names, prices and quantities back to the organiser, get their explicit agreement, and only then call publish_event with the confirmation.

Behind it, a parameter the caller cannot fill in without doing the reading:

throw new Error(
  `Publishing "${event.name}" needs an explicit confirmation. Read the ` +
    `ticket names, prices and quantities back to the organiser, get their ` +
    `agreement, then call publish_event again with confirm.tickets ` +
    `listing every ticket type exactly as it stands.`,
);

The instruction states the norm. The parameter enforces it. Neither works alone: an instruction with no gate is advice, and a gate with no instruction produces a model that confirms without ever showing the user what it is confirming.

Write it before the tools

Server instructions are the first thing to write and usually the last thing anybody writes.

They cost one paragraph, they change every response, and unlike tool descriptions they do not multiply. If a behaviour is true of your whole surface, it belongs here, once.

Back to guides
End of Post