Server Instructions in Practice: Spec Versions and Testing

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 revisionDelivered inHow the client gets itWording in the schema
2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25InitializeResult.instructionsinitialize request, then the notifications/initialized notificationA “hint” to the model; it “MAY be added to the system prompt”
2026-07-28DiscoverResult.instructionsserver/discover request carrying per-request _metaShould 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 calls server/discover never sees your instructions, so they cannot be your only safeguard.
  • The result is cacheable. Servers MUST put ttlMs (an integer >= 0) and cacheScope on a complete server/discover result. 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/updated and 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/discoverServer eraWhat the client does
A DiscoverResultModernPick a version from supportedVersions; read instructions from the result
Error -32022 (unsupported protocol version) with data.supportedModern, different versionRetry with a listed version. Do not fall back to initialize
Any other error (commonly -32601 or -32602), or no answer in timeLegacySend 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:

ModelWith instructionsWithoutFisher p
GPT-5-Mini8/10 (95% CI 49-94%)2/10 (6-51%)0.023
Claude Sonnet-49/10 (60-98%)10/10 (72-100%)1.000
Pooled17/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 knowPut it inWhy
What one tool does, its arguments and resultsThe tool description and schemaThe model reads it when choosing that tool
Order and dependencies between tools (“authenticate before search or read”), cross-server hintsinstructionsRelationships are what no single description conveys
Limits that span tools (a shared rate limit, a workspace boundary)instructionsSame reason
A workflow the user starts on purposeA promptPrompts are user-controlled; the user must select them
Large or per-task reference materialA resourceResources 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 hooksThe official post warns against relying on instructions for critical actions
Tone, persona, marketingNowhereThese are anti-patterns in the official post

5. What breaks in practice

SymptomLikely causeHow to detectFix
The model behaves as if no instructions existedThe host does not inject them; the schema only says they MAY be added to the system promptRead the host’s chat or request log for the injected textTest each host; never make correctness depend on the field
The model calls tools that no longer exist, or skips renamed onesTool names drifted away from the textScript finding unknown-referenceRun the checker in CI
Instructions restate each toolCopy-paste from descriptions; wasted tokens and diluted signalScript finding duplicates-tool-description (heuristic)Keep per-tool detail in descriptions
Instructions grew into a manualEvery new rule was appendedScript 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 upserver/discover is cacheable and ttlMs is only a hintCompare ttlMs with how soon you need changes visibleChoose ttlMs deliberately; expect lag up to the TTL
A modern client shows no instructionsIt never called server/discoverCapture the client’s first requestsDo not rely on the field alone
A legacy client cannot connectThe server is modern-only and rejects initializeRun a legacy handshake against itServe both eras, or tell users to upgrade
Handshake fails on stdioSomething other than MCP messages is printed to stdoutScript finding stdout-noiseLog 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 stdout

Notes 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

  1. Write the pass/fail oracle as a tool-call sequence, as the official post does. Subjective grading does not scale.
  2. Run the same tasks with and without the instructions, on every host and model you care about.
  3. Keep the host’s request log for a few sessions to prove the text was actually injected.
  4. Report counts with an interval or a test, not a bare percentage, and size the sample using section 3.
  5. 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 ttlMs on purpose, and you know how you serve legacy clients.

Sources