Server Instructions in Practice: Spec Versions and Testing
The official MCP blog post Server Instructions: Giving LLMs a user manual for your server makes the case for the instructions field. This note starts where that post stops. It answers five questions an implementer runs into: where the field is delivered in each spec revision, how much weight the published evidence can carry, where each kind of knowledge belongs, what breaks in practice, and how to check a live server with a script.
Spec facts below were checked against the schema files and spec pages of the official repository in October 2026. Experiments were run locally with the official Python SDK 2.3.0 and are labeled as such.
1. Where the field lives, by spec revision
| Spec revision | Delivered in | How the client gets it | Wording in the schema |
|---|---|---|---|
| 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 | InitializeResult.instructions | initialize request, then the notifications/initialized notification | A “hint” to the model; it “MAY be added to the system prompt” |
| 2026-07-28 | DiscoverResult.instructions | server/discover request carrying per-request _meta | Should focus on what helps the model use the server and “should not duplicate information already in tool descriptions” |
Two consequences follow from the 2026-07-28 text:
- Instructions only travel with
server/discover. Servers MUST implement it, but clients MAY skip it and call any method inline. A modern client that never callsserver/discovernever sees your instructions, so they cannot be your only safeguard. - The result is cacheable. Servers MUST put
ttlMs(an integer >= 0) andcacheScopeon a completeserver/discoverresult. The TTL is a freshness hint: a client may keep serving the old text until it expires. The schema defines no notification for instructions (the 2026-07-28 notifications are cancelled, message, progress, the prompts/tools/resources list changes,resources/updatedand subscriptions/acknowledged), so a changed text reaches caching clients only when the TTL runs out or the client chooses to re-fetch.
The anti-pattern the official post calls out, repeating tool descriptions, is now schema-level guidance rather than blog advice.
2. How a client finds out which era it is talking to
On stdio, a client that supports both eras sends server/discover first and decides from the answer. The spec lays out three outcomes (stdio: Backward Compatibility):
Reply to server/discover | Server era | What the client does |
|---|---|---|
A DiscoverResult | Modern | Pick a version from supportedVersions; read instructions from the result |
Error -32022 (unsupported protocol version) with data.supported | Modern, different version | Retry with a listed version. Do not fall back to initialize |
Any other error (commonly -32601 or -32602), or no answer in time | Legacy | Send initialize, then notifications/initialized; read instructions from InitializeResult |
The spec stresses that the fallback must not be keyed to one specific error code. The script in section 6 implements exactly this table, which makes it useful for servers of either era.
A legacy client talking to a modern-only server fails outright: initialize is an unknown method there, and the spec says such servers SHOULD name their supported versions in the error. In that case your instructions are never delivered at all.
3. How far the published evidence goes
The official post measured one task (reviewing a pull request with the GitHub MCP server in VS Code) with a clear oracle: success is the tool sequence create_pending_pull_request_review, add_comment_to_pending_review, submit_pending_pull_request_review. The counts are 10 sessions per cell. The table adds an exact two-sided Fisher test and 95% Wilson intervals, which I computed from those published counts:
| Model | With instructions | Without | Fisher p |
|---|---|---|---|
| GPT-5-Mini | 8/10 (95% CI 49-94%) | 2/10 (6-51%) | 0.023 |
| Claude Sonnet-4 | 9/10 (60-98%) | 10/10 (72-100%) | 1.000 |
| Pooled | 17/20 (64-95%) | 12/20 (39-78%) | 0.155 |
What this supports, and what it does not:
- The GPT-5-Mini gap is large enough to be unlikely under chance, but the intervals still overlap slightly. The Claude result cannot distinguish 9/10 from 10/10.
- The pooled “+25 points” mixes two models 50/50 and is not distinguishable from noise at the 5% level. Quoting it as a general lift overstates the evidence. The sound reading is the official post’s own: some models already follow the good path, others do not, so test per host and model.
- Sample size matters. From a 20% baseline, 10 sessions per arm only reaches p < 0.05 at 8/10 (7/10 gives p = 0.070). With 30 per arm, 16/30 against 6/30 already gives p = 0.015.
4. Where each kind of knowledge belongs
The spec’s control hierarchy (prompts are user-controlled, resources application-controlled, tools model-controlled) decides most placements:
| You want the model to know | Put it in | Why |
|---|---|---|
| What one tool does, its arguments and results | The tool description and schema | The model reads it when choosing that tool |
| Order and dependencies between tools (“authenticate before search or read”), cross-server hints | instructions | Relationships are what no single description conveys |
| Limits that span tools (a shared rate limit, a workspace boundary) | instructions | Same reason |
| A workflow the user starts on purpose | A prompt | Prompts are user-controlled; the user must select them |
| Large or per-task reference material | A resource | Resources are attached by the client; do not put them in every session’s system prompt |
| Anything that must never be skipped (security, privacy, protective ordering) | Server code or host hooks | The official post warns against relying on instructions for critical actions |
| Tone, persona, marketing | Nowhere | These are anti-patterns in the official post |
5. What breaks in practice
| Symptom | Likely cause | How to detect | Fix |
|---|---|---|---|
| The model behaves as if no instructions existed | The host does not inject them; the schema only says they MAY be added to the system prompt | Read the host’s chat or request log for the injected text | Test each host; never make correctness depend on the field |
| The model calls tools that no longer exist, or skips renamed ones | Tool names drifted away from the text | Script finding unknown-reference | Run the checker in CI |
| Instructions restate each tool | Copy-paste from descriptions; wasted tokens and diluted signal | Script finding duplicates-tool-description (heuristic) | Keep per-tool detail in descriptions |
| Instructions grew into a manual | Every new rule was appended | Script finding too-long (the 1500-character default is my heuristic, not a spec value) | Delete rules the model already follows |
| Edited instructions do not show up | server/discover is cacheable and ttlMs is only a hint | Compare ttlMs with how soon you need changes visible | Choose ttlMs deliberately; expect lag up to the TTL |
| A modern client shows no instructions | It never called server/discover | Capture the client’s first requests | Do not rely on the field alone |
| A legacy client cannot connect | The server is modern-only and rejects initialize | Run a legacy handshake against it | Serve both eras, or tell users to upgrade |
| Handshake fails on stdio | Something other than MCP messages is printed to stdout | Script finding stdout-noise | Log to stderr |
6. Check a live server
check_instructions.py uses only the Python standard library. It launches a stdio server, runs the probe from section 2, lists tools, and lints the instructions. The lint rules are engineering heuristics, not requirements of the spec. Exit code 0 means no findings, 1 warnings, 2 errors.
check_instructions.py
#!/usr/bin/env python3
"""Probe an MCP stdio server and lint its `instructions` field (stdlib only).
Era detection follows the 2026-07-28 stdio "Backward Compatibility" rules:
send `server/discover` first; a DiscoverResult or error -32022 means a modern
server, any other error or a timeout means a legacy server, so fall back to
the `initialize` handshake. The lint rules are engineering heuristics, not
requirements of the specification.
Usage: check_instructions.py [--timeout S] [--max-chars N] -- <server command>
Exit code: 0 = no findings, 1 = warnings, 2 = errors (cannot connect, bad field).
"""
import argparse
import json
import queue
import re
import subprocess
import sys
import threading
MODERN = "2026-07-28"
LEGACY = "2025-11-25"
UNSUPPORTED_VERSION = -32022
CLIENT = {"name": "check_instructions", "version": "0.1"}
STOP = {"the", "a", "an", "to", "for", "of", "by", "in", "on", "is", "are", "and",
"or", "this", "that", "with", "it", "tool", "tools", "server"}
class Peer:
"""A stdio JSON-RPC peer: one JSON message per line in both directions."""
def __init__(self, cmd, timeout):
self.timeout, self.next_id, self.noise = timeout, 0, 0
self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
self.lines = queue.Queue()
threading.Thread(target=self._pump, daemon=True).start()
def _pump(self):
for line in self.proc.stdout:
self.lines.put(line)
self.lines.put(None)
def send(self, method, params=None, notify=False):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if not notify:
self.next_id += 1
msg["id"] = self.next_id
try:
self.proc.stdin.write(json.dumps(msg) + "\n")
self.proc.stdin.flush()
except (BrokenPipeError, OSError):
pass
return msg.get("id")
def call(self, method, params=None):
rid = self.send(method, params)
while True:
try:
line = self.lines.get(timeout=self.timeout)
except queue.Empty:
return {"timeout": True}
if line is None:
return {"closed": True}
try:
msg = json.loads(line)
except ValueError:
self.noise += 1 # stdout must carry MCP messages only
continue
if isinstance(msg, dict) and msg.get("id") == rid and ("result" in msg or "error" in msg):
return msg
def close(self):
try:
self.proc.stdin.close()
self.proc.wait(timeout=3)
except Exception:
self.proc.kill()
def meta(version, capabilities=None):
return {"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientInfo": CLIENT,
"io.modelcontextprotocol/clientCapabilities": capabilities or {}}
def handshake(peer, capabilities=None):
"""Return (era, protocol version, result dict) or (None, None, error text)."""
version = MODERN
for _ in range(2):
reply = peer.call("server/discover", {"_meta": meta(version, capabilities)})
result = reply.get("result")
if isinstance(result, dict) and "supportedVersions" in result:
return "modern", version, result
error = reply.get("error") or {}
if error.get("code") == UNSUPPORTED_VERSION: # modern server: never fall back
usable = [v for v in (error.get("data") or {}).get("supported", []) if v >= MODERN]
if not usable:
return None, None, "modern server offers no modern protocol version"
version = max(usable)
continue
break # any other error, or a timeout: treat as legacy
reply = peer.call("initialize", {"protocolVersion": LEGACY, "capabilities": capabilities or {},
"clientInfo": CLIENT})
if not isinstance(reply.get("result"), dict):
return None, None, "initialize failed: %s" % json.dumps(reply)[:200]
peer.send("notifications/initialized", notify=True)
return "legacy", reply["result"].get("protocolVersion"), reply["result"]
def list_tools(peer, era, version, capabilities=None):
"""Return {name: tool object}, or None if tools/list is unavailable."""
tools, cursor = {}, None
for _ in range(20): # pagination guard
params = {"_meta": meta(version, capabilities)} if era == "modern" else {}
if cursor:
params["cursor"] = cursor
result = peer.call("tools/list", params).get("result")
if not isinstance(result, dict):
return tools or None
for tool in result.get("tools", []):
tools[tool["name"]] = tool
cursor = result.get("nextCursor")
if not cursor:
break
return tools
def stem(word):
"""Strip a plural ending: searches -> search, files -> file, reads -> read."""
if len(word) <= 3:
return word
if re.search(r"(ss|ch|sh|x|z)es$", word):
return word[:-2]
return word[:-1] if word.endswith("s") and not word.endswith("ss") else word
def words(text):
return {stem(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP}
def lint(text, tools, max_chars):
findings = []
if len(text) > max_chars:
findings.append(("WARN", "too-long", "%d chars > %d; instructions are not a manual" % (len(text), max_chars)))
if tools is None:
return findings
for ref in sorted(set(re.findall(r"""['"`]([A-Za-z][\w.\-]*\*?)['"`]""", text))):
known = any(n.startswith(ref[:-1]) for n in tools) if ref.endswith("*") else ref in tools
if not known:
findings.append(("WARN", "unknown-reference",
"'%s' is not in tools/list (%s)" % (ref, ", ".join(sorted(tools)) or "no tools")))
for sentence in re.split(r"(?<=[.!?])\s+|\n", text):
sw = words(sentence)
if len(sw) < 2:
continue
for name, tool in tools.items():
if len(sw & words(tool.get("description", ""))) / len(sw) >= 0.8:
findings.append(("WARN", "duplicates-tool-description",
"\"%s\" restates the description of '%s'" % (sentence.strip(), name)))
break
return findings
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--timeout", type=float, default=5.0)
ap.add_argument("--max-chars", type=int, default=1500)
ap.add_argument("cmd", nargs=argparse.REMAINDER)
args = ap.parse_args()
cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
if not cmd:
ap.error("missing server command after --")
peer = Peer(cmd, args.timeout)
try:
era, version, result = handshake(peer)
if era is None:
print("[ERROR] %s" % result)
return 2
tools = list_tools(peer, era, version)
finally:
peer.close()
extra = " ttlMs=%s cacheScope=%s" % (result.get("ttlMs"), result.get("cacheScope")) if era == "modern" else ""
print("era: %s (protocol %s)%s" % (era, version, extra))
print("tools: %s" % (", ".join(sorted(tools)) if tools is not None else "unavailable"))
findings = []
text = result.get("instructions")
if text is None:
print("instructions: absent (optional field)")
elif not isinstance(text, str):
findings.append(("ERROR", "bad-type", "instructions must be a string"))
else:
print("instructions: %d chars" % len(text))
findings += lint(text, tools, args.max_chars)
if peer.noise:
findings.append(("WARN", "stdout-noise", "%d non-JSON line(s) on stdout" % peer.noise))
for level, code, message in findings:
print("[%s] %s: %s" % (level, code, message))
if not findings:
print("[OK] no findings")
return 2 if any(f[0] == "ERROR" for f in findings) else 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())To have something to test, here is a small server on the official Python SDK (pip install mcp). The two environment switches simulate the failures from section 5.
demo_server.py
"""Demo server on the official Python SDK (pip install mcp) with server instructions.
DEMO_RENAME_READ=1 registers `read` as `read_file` while the instructions still
say 'read', which simulates tool-name drift after a refactor.
DEMO_PARROT=1 swaps in instructions that restate tool descriptions.
"""
import os
from mcp.server.mcpserver import MCPServer
INSTRUCTIONS = (
"Call 'authenticate' before 'search' or 'read'. "
"Use 'search' before 'read' to validate file paths. "
"Rate limit: 100 requests/minute across all tools."
)
if os.environ.get("DEMO_PARROT"):
INSTRUCTIONS = "The search tool searches for files. The read tool reads files."
server = MCPServer("demo-files", instructions=INSTRUCTIONS)
@server.tool()
def authenticate(token: str) -> str:
"""Authenticate the session with an API token."""
return "ok"
@server.tool()
def search(query: str) -> list[str]:
"""Search for files whose names match the query."""
return []
@server.tool(name="read_file" if os.environ.get("DEMO_RENAME_READ") else "read")
def read(path: str) -> str:
"""Read a file by path."""
return ""
if __name__ == "__main__":
server.run("stdio")The SDK server answers both eras, so the legacy fallback needs a stand-in that rejects server/discover. This stdlib stub does that; --silent makes it ignore unknown requests instead of rejecting them, and --noise prints a banner to stdout.
legacy_server.py
"""Minimal legacy-era (initialize handshake) stdio server, stdlib only.
Used to exercise the checker's fallback paths:
--silent never answer requests it does not know (the probe must time out)
--noise print a banner to stdout before serving (breaks stdio framing)
"""
import json
import sys
SILENT = "--silent" in sys.argv
if "--noise" in sys.argv:
print("demo-files 0.1 starting...", flush=True)
TOOLS = [{"name": "ping", "description": "Check connectivity.", "inputSchema": {"type": "object"}}]
def reply(rid, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": rid}
msg.update({"error": error} if error else {"result": result})
print(json.dumps(msg), flush=True)
for line in sys.stdin:
req = json.loads(line)
method, rid = req.get("method"), req.get("id")
if method == "initialize":
reply(rid, {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}},
"serverInfo": {"name": "legacy-demo", "version": "0.1"},
"instructions": "Call 'ping' once at startup."})
elif method == "tools/list":
reply(rid, {"tools": TOOLS})
elif rid is not None and not SILENT:
reply(rid, error={"code": -32601, "message": "Method not found"})Output from my runs (Python 3.14, mcp 2.3.0, macOS):
$ python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read, search
instructions: 147 chars
[OK] no findings
$ DEMO_RENAME_READ=1 python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read_file, search
instructions: 147 chars
[WARN] unknown-reference: 'read' is not in tools/list (authenticate, read_file, search)
$ DEMO_PARROT=1 python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read, search
instructions: 62 chars
[WARN] duplicates-tool-description: "The search tool searches for files." restates the description of 'search'
[WARN] duplicates-tool-description: "The read tool reads files." restates the description of 'read'
$ python3 check_instructions.py -- python3 legacy_server.py
era: legacy (protocol 2025-11-25)
tools: ping
instructions: 28 chars
[OK] no findings
$ python3 check_instructions.py -- python3 legacy_server.py --noise
era: legacy (protocol 2025-11-25)
tools: ping
instructions: 28 chars
[WARN] stdout-noise: 1 non-JSON line(s) on stdoutNotes on what these runs show. The SDK server returned ttlMs=0 by default, which means clients may re-fetch every time; set a longer value only when stale text is acceptable. The silent stub (--silent, with --timeout 1) also ends in the legacy path through the timeout branch. The -32022 retry branch is not covered by the SDK server; I exercised it only with a fake peer, so treat it as less tested.
7. A minimal evaluation protocol
- Write the pass/fail oracle as a tool-call sequence, as the official post does. Subjective grading does not scale.
- Run the same tasks with and without the instructions, on every host and model you care about.
- Keep the host’s request log for a few sessions to prove the text was actually injected.
- Report counts with an interval or a test, not a bare percentage, and size the sample using section 3.
- Re-run when the host, the model or the tool set changes.
8. Before you ship
- The text names only tools, prompts and resources that exist (run the checker).
- Nothing in it repeats a tool description, and it is short enough to read in a few seconds.
- No rule in it protects data or security by itself; those rules live in code.
- You chose
ttlMson purpose, and you know how you serve legacy clients.
Sources
- Server Instructions: Giving LLMs a user manual for your server (official MCP blog, source of the A/B counts)
- Discovery, Caching, stdio transport and Versioning in the 2026-07-28 specification
- The
schema.tsfile of each spec revision in the official repository - Related reading on this site: Mastering MCP Tool Development and MCP Apps in Practice, whose checker reuses the stdio probe from this script