MCP Explainers
What Are MCP Server Instructions?
MCP server instructions are one optional string from the handshake. The SDK stores it and never reads it, so whether the model sees it is the host's call.
Mark
Head of Marketing, MCPOrbit
- Published
- Updated
- · Updated
- Read time
- · 9 min read
MCP server instructions are a single optional string your server returns during the handshake, meant to tell the model how to use the server as a whole. It is the only place a Model Context Protocol (MCP) server speaks to the model outside a tool description. The catch is that it does nothing on its own: the client SDK stores the string and never reads it again.
That gap is the whole story. The protocol guarantees delivery of instructions to the client. It guarantees nothing about the model ever seeing it. We built a server and a client on the 2.0.0 SDKs, drove them over real stdio, and read the SDK source to find out exactly where the string goes and where it stops.
What are MCP server instructions?
Instructions are server-level guidance for the model. A tool description explains one tool. Instructions explain the server: which tool to call first, what an identifier means, what the model should not assume. You set the string once, when you construct the server.
Here is the full server we tested. It has two tools with a real ordering constraint between them, which is exactly the kind of thing a tool description cannot express on its own.
// server.mjs
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
serveStdio(() => {
const server = new McpServer(
{ name: "invoice-server", version: "1.0.0" },
{
capabilities: { tools: {} },
instructions:
"Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods.",
}
);
server.registerTool(
"list_periods",
{ description: "List billing periods.", inputSchema: {} },
async () => ({ content: [{ type: "text", text: "2026-07, 2026-08" }] })
);
server.registerTool(
"get_invoice",
{ description: "Get an invoice by id.", inputSchema: { id: z.string() } },
async ({ id }) => ({ content: [{ type: "text", text: `invoice ${id}` }] })
);
return server;
});Note that serveStdio takes a factory function, not a server instance. It calls the factory once per connection and pins that instance for the connection's lifetime. Passing a constructed server instead of a factory is a quiet failure: the handshake answers -32603 Internal server error with nothing on stderr.
The factory shape matters for instructions specifically. Because the string is read from the constructor options each time the factory runs, per-connection instructions are possible. Building the string inside the factory is the only supported hook for varying it.
What does an MCP client actually do with the instructions string?
It stores it. That is the entire behavior of the SDK. We traced every reference to the private _instructions field in @modelcontextprotocol/client 2.0.0 and found six: one declaration, one reset to undefined on close, three writes (one per handshake path), and one read.
// the only read of _instructions in the client SDK
getInstructions() {
return this._instructions;
}Nothing in the SDK feeds that string into a prompt, a tool list, or a system message. It cannot, because a protocol SDK does not own the model call. So the honest answer to "what does the client do with instructions" is: it hands them to the host application and stops.
Reading the value back takes three lines. This client connects to the server above over stdio and prints what it received.
// client.mjs
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const client = new Client({ name: "probe", version: "1.0.0" });
await client.connect(new StdioClientTransport({ command: "node", args: ["server.mjs"] }));
console.log("era: ", client.getProtocolEra());
console.log("negotiated: ", client.getNegotiatedProtocolVersion());
console.log("getInstructions():", JSON.stringify(client.getInstructions()));
await client.close();era: legacy
negotiated: 2025-11-25
getInstructions(): "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods."Which handshake carries the instructions field?
Two of them, and which one you get depends on a client option most people never set. The 2025 era uses an initialize request. The 2026-07-28 revision uses server/discover. The instructions field is byte-identical across both, but the envelope around it is not.
The 2025 era handshake looks like this on the wire. This is the raw response from the server above.
{
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "invoice-server", "version": "1.0.0" },
"instructions": "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods."
},
"jsonrpc": "2.0",
"id": 1
}The 2026-07-28 handshake moves the client's identity into a _meta envelope and returns a richer result. Send server/discover without that envelope and the stdio entry treats the message as claim-less, routes it to a 2025 era instance, and answers -32601 Method not found.
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "wire", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}{
"result": {
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true } },
"instructions": "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods.",
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "invoice-server", "version": "1.0.0" }
}
},
"jsonrpc": "2.0",
"id": 1
}The new fields are the interesting part. ttlMs and cacheScope mean the modern handshake result is a cacheable artifact, so your instructions string can be stored and replayed on later connections rather than re-fetched. The SDK default is ttlMs: 0 with cacheScope: "private", which tells the client not to cache it at all.
The client defaults to the older handshake
versionNegotiation.mode defaults to 'legacy' in client 2.0.0. A plain new Client(...) never attempts server/discover, even against a server that supports it. You opt in with 'auto' to probe, or pin a revision outright.
// probe for a modern revision, fall back to legacy
const client = new Client(
{ name: "probe", version: "1.0.0" },
{ versionNegotiation: { mode: "auto" } }
);
// or pin it and fail loudly if the server cannot meet it
const pinned = new Client(
{ name: "probe", version: "1.0.0" },
{ versionNegotiation: { mode: { pin: "2026-07-28" } } }
);Running the same probe in all three modes against the same server gives the result below. The instructions string survives every path unchanged, which is the reassuring half. The era and the surrounding metadata do not, which is the half that affects your caching and your debugging.
default era=legacy negotiated=2025-11-25
getInstructions() = "Always call list_periods before get_invoice..."
mode:auto era=modern negotiated=2026-07-28
getInstructions() = "Always call list_periods before get_invoice..."
discover.ttlMs=0 cacheScope=private
pin:2026-07-28 era=modern negotiated=2026-07-28
getInstructions() = "Always call list_periods before get_invoice..."
discover.ttlMs=0 cacheScope=privateWhy does an empty instructions string disappear?
Because the server guards on truthiness, not on undefined. The line that builds the handshake result is a conditional spread, so any falsy value drops the key entirely.
// @modelcontextprotocol/server 2.0.0, handshake result assembly
...this._instructions && { instructions: this._instructions }We sent four values through a real handshake and read the key off the wire each time. An empty string and an unset field are indistinguishable to the client. A single space is not.
server sets "" -> key on wire: false value: undefined
server sets " " -> key on wire: true value: " "
server sets "Call list_periods first." -> key on wire: true value: "Call list_periods first."
server sets undefined (not set) -> key on wire: false value: undefinedThis matters if you template your instructions. A string built from config that renders empty vanishes with no warning, no log line, and a completely successful handshake. A string that renders as whitespace ships and occupies context for nothing. Neither shows up in a health check that only asserts the server connected.
Can you update instructions after the handshake?
Not through a push. Every other listable thing in MCP has a change notification. Instructions have none. Here is the complete notification vocabulary in server 2.0.0:
- `notifications/tools/list_changed`
- `notifications/prompts/list_changed`
- `notifications/resources/list_changed`
- `notifications/resources/updated`
- `notifications/roots/list_changed`
- `notifications/progress`, `notifications/cancelled`, `notifications/message`, and the rest of the transport-level set
No entry for instructions. The string is a handshake artifact, and the protocol has no way to tell a connected client that it went stale.
A client can re-issue the handshake request and get a fresh copy. We sent initialize twice on one connection and server/discover twice on another, and both answered successfully both times with instructions intact. But a pull nobody knows to make is not a refresh mechanism. Treat instructions as fixed for the life of a connection, and put anything that genuinely changes into a resource or a tool result instead.
What should you put in the instructions field?
Write what a tool description structurally cannot say. A tool description is scoped to one tool, so anything about the relationship between tools has nowhere else to live. That is the field's real job.
- Call ordering: which tool has to run before another, and why
- Identifier semantics: what an ID is scoped to and when it stops being valid
- Scope limits: what this server does not cover, so the model stops guessing
- Cost or rate warnings that apply to the server as a whole
Keep it short. The string is prepended to context on every connection that uses it, so it competes directly with the tool descriptions it is supposed to support. Two or three sentences of constraint beat a paragraph of description. Do not restate what your tool descriptions already say, and do not put secrets in it: instructions are handed to the client before any authorization decision the model makes.
Frequently asked questions
Frequently asked questions
- What are MCP server instructions?
- They are a single optional string an MCP server returns during the handshake, describing how to use the server as a whole. Unlike a tool description, which covers one tool, instructions cover the relationship between tools, such as required call ordering or what an identifier is scoped to.
- Does the model automatically see MCP server instructions?
- No. The client SDK stores the string and exposes it through
getInstructions(), and that is all it does with it. Whether the string is injected into the model's context is a decision the host application makes, and hosts differ. - How do I set instructions on an MCP server?
- Pass an
instructionsstring in the options object of theMcpServerconstructor, alongsidecapabilities. In@modelcontextprotocol/server2.0.0 withserveStdio, construct the server inside the factory function so the value is computed once per connection. - Why are my MCP server instructions not showing up?
- The most common cause is an empty string. The server builds the handshake result with a truthiness guard, so
instructions: ""drops the key from the response entirely and looks identical to never setting it. Check the raw handshake response rather than your config. - Can an MCP server change its instructions while a client is connected?
- Not by pushing an update. There is no instructions-changed notification, even though tools, prompts, resources, and roots all have one. A client can re-issue the handshake request to pull a fresh copy, but nothing tells it to, so treat instructions as fixed for the connection.
- Is the instructions field different in the 2026-07-28 MCP revision?
- The field itself is unchanged, but it arrives on
server/discoverinstead ofinitialize, and the result carriesttlMsandcacheScopeso it can be cached. Client 2.0.0 defaults to the older handshake, so you have to opt in withversionNegotiationto reach the newer path.
Tested end to end on Node 25.8.1 with @modelcontextprotocol/server 2.0.0, @modelcontextprotocol/client 2.0.0, and zod 4.4.3. Every wire payload above is copied from a real run, not reconstructed from the spec.
About the author
Mark
Head of Marketing, MCPOrbit
Mark leads marketing at MCPOrbit, the free desktop client for the Model Context Protocol. He writes the build-it and reliability guides, and the code in them is run before it ships.


