If you want an application with a model inside to use a tool of yours (read a folder, query a management system, do a calculation), you can write the glue by hand, and you will rewrite it for every application and every tool. The Model Context Protocol (MCP) is the attempt to remove that multiplication: an open protocol, based on JSON-RPC 2.0 messages, with which an application that hosts the model talks to services that offer tools, data and prompt templates. Learned once, the same server serves different applications.
A warning that changes what you will read around. The current revision of the specification, the one dated 2026-07-28 that I read on 2026-11-13, has removed the initialize handshake and the protocol-level sessions: every request carries its own version and capabilities. Many tutorials still describe the old start-up, and for that reason the article builds a miniature server according to the current revision, runs it and measures its behaviour. Before this you read the agent loop, which is the side that consumes tools; here we look at the side that exposes them. Basic Python is enough and no model is called.
What MCP standardises, and what it does not
The specification states that the protocol connects three actors and that it takes inspiration from the Language Server Protocol, the one that lets an editor support many languages without rewriting everything for each.
- The host is the application with the model, which starts the connections.
- The client is the connector inside the host, which talks to a server.
- The server is the service that offers context and capabilities, in the form of tools (functions the model can have executed), resources (data) and prompts (predefined messages for the user).
The distinction that matters in practice is between host and client: the host is the application, the clients are its connectors to the servers. That is why the user's consent is a duty of the host, not of the server: the specification writes it among the security principles, and you find it again in the dedicated section. The protocol does not establish how the model expresses the request to use a tool (text, JSON, a proprietary format): it describes the messages between client and server, and what happens before, inside the agent, remains the host's business.
A miniature MCP server
The messages are JSON-RPC 2.0 requests, responses and notifications, with two clarifications from the specification: the id of a request cannot be null (unlike pure JSON-RPC) nor coincide with that of another request still without a response, and every result carries a resultType field (complete for an ordinary result). On the stdio channel, in which the client starts the server as a subprocess, one message per line applies, with no newline inside the message, and nothing but valid messages may appear on stdout: logs go to stderr.
The listing puts everything in one file. The "transport" is simulated (a line goes in, a line comes out) and the server lives in the same process: I did not start subprocesses or open ports, so what I test is the logic of the protocol, not a real exchange over stdio or HTTP. The folder with the test files is created by the program, and that "secret" is a file of mine.
"""A miniature MCP server, in-process, for revision 2026-07-28.
The "transport" is simulated: one JSON line in, one line out
(the stdio rules: one message per line, no newline inside the message)."""
import json
import os
import pathlib
import tempfile
VERSION = "2026-07-28"
SUPPORTED = [VERSION]
TYPES = {"string": str, "integer": int}
TOOLS = [ # fixed order: the list must be deterministic
{"name": "read_file", "description": "Reads a text file from the public folder",
"inputSchema": {"type": "object", "required": ["path"],
"properties": {"path": {"type": "string"}}}},
{"name": "add", "description": "Adds two integers",
"inputSchema": {"type": "object", "required": ["a", "b"],
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}}},
]
def error_response(id_, code, message, data=None):
e = {"code": code, "message": message}
if data is not None:
e["data"] = data
return {"jsonrpc": "2.0", "id": id_, "error": e}
def validate(schema, args):
"""Minimal check of required and type; a real validator reads the whole schema."""
for field in schema.get("required", []):
if field not in args:
return f"missing argument: {field}"
for field, value in args.items():
expected = schema["properties"].get(field, {}).get("type")
if expected and (not isinstance(value, TYPES[expected]) or isinstance(value, bool)):
return f"{field} must be {expected}"
return None
class Server:
def __init__(self, public_dir, safe):
self.root = pathlib.Path(public_dir)
self.safe = safe
def read_file(self, path):
target = self.root / path
if self.safe:
target = target.resolve() # also follows symbolic links
if not target.is_relative_to(self.root.resolve()):
raise PermissionError("path outside the public folder")
return target.read_text()
def call(self, name, args):
if name == "add":
return str(args["a"] + args["b"])
return self.read_file(args["path"])
def respond(self, msg):
id_, method = msg["id"], msg["method"]
meta = (msg.get("params") or {}).get("_meta") or {}
if method == "initialize": # handshake of the earlier revisions
return error_response(id_, -32601, "initialize no longer exists", {"supported": SUPPORTED})
for key in ("io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientCapabilities"):
if key not in meta:
return error_response(id_, -32602, f"_meta incomplete: missing {key}")
requested = meta["io.modelcontextprotocol/protocolVersion"]
if requested not in SUPPORTED:
return error_response(id_, -32022, "Unsupported protocol version",
{"supported": SUPPORTED, "requested": requested})
if method == "server/discover":
return {"jsonrpc": "2.0", "id": id_, "result": {
"resultType": "complete", "supportedVersions": SUPPORTED,
"capabilities": {"tools": {}}, "ttlMs": 3600000, "cacheScope": "public",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "mini", "version": "0.1"}}}}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": id_, "result": {
"resultType": "complete", "tools": TOOLS, "ttlMs": 300000, "cacheScope": "public"}}
if method == "tools/call":
p = msg["params"]
tool = next((t for t in TOOLS if t["name"] == p.get("name")), None)
if tool is None:
return error_response(id_, -32602, f"Unknown tool: {p.get('name')}")
problem = validate(tool["inputSchema"], p.get("arguments", {}))
try:
if problem:
raise ValueError(problem)
text, is_error = self.call(tool["name"], p["arguments"]), False
except (ValueError, OSError) as e:
text, is_error = f"{type(e).__name__}: {e}", True
return {"jsonrpc": "2.0", "id": id_, "result": {
"resultType": "complete", "isError": is_error,
"content": [{"type": "text", "text": text}]}}
return error_response(id_, -32601, "Method not found")
def handle(self, line):
try:
msg = json.loads(line)
except json.JSONDecodeError:
return json.dumps(error_response(None, -32700, "Parse error"))
if "id" not in msg:
return None # notification: no response
r = self.respond(msg)
assert ("result" in r) != ("error" in r) and r["id"] == msg["id"]
if "result" in r:
assert r["result"]["resultType"] == "complete"
line_out = json.dumps(r, ensure_ascii=False)
assert "\n" not in line_out
return line_out
def ask(server, id_, method, params=None, version=VERSION, with_meta=True):
p = dict(params or {})
if with_meta:
p["_meta"] = {"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientCapabilities": {}}
line = json.dumps({"jsonrpc": "2.0", "id": id_, "method": method, "params": p})
out = server.handle(line)
return json.loads(out) if out else None
def outcome(r):
if "error" in r:
return f"error {r['error']['code']}: {r['error']['message']}"
if r["result"].get("isError"):
return "isError: " + r["result"]["content"][0]["text"]
return "ok"
root = pathlib.Path(tempfile.mkdtemp()) # test target, created here
(root / "public").mkdir()
(root / "public" / "note.txt").write_text("public note")
(root / "secret.txt").write_text("TEST-SECRET")
os.symlink(root / "secret.txt", root / "public" / "shortcut.txt")
srv = Server(root / "public", safe=True)
d = ask(srv, 1, "server/discover")["result"]
print("discover:", d["supportedVersions"], d["capabilities"], d["ttlMs"])
names = [t["name"] for t in ask(srv, 2, "tools/list")["result"]["tools"]]
print("tools/list:", names, "| same list on the second request:",
names == [t["name"] for t in ask(srv, 3, "tools/list")["result"]["tools"]])
cases = [
("add 2+3", "tools/call", {"name": "add", "arguments": {"a": 2, "b": 3}}, {}),
("add with text", "tools/call", {"name": "add", "arguments": {"a": 2, "b": "3"}}, {}),
("unknown tool", "tools/call", {"name": "delete", "arguments": {}}, {}),
("without _meta", "tools/list", None, {"with_meta": False}),
("version 1900-01-01", "tools/list", None, {"version": "1900-01-01"}),
("initialize", "initialize", {}, {"with_meta": False}),
]
for i, (name, method, params, extra) in enumerate(cases, start=10):
r = ask(srv, i, method, params, **extra)
detail = ""
if "result" in r and not r["result"].get("isError"):
detail = " " + r["result"]["content"][0]["text"]
print(f"{name:20s} -> {outcome(r)}{detail}")
print("notification without id -> response:", srv.handle('{"jsonrpc":"2.0","method":"notifications/cancelled"}'))
print("--- public folder: naive versus safe")
for name, safe in [("naive", False), ("safe", True)]:
s = Server(root / "public", safe)
for label, p in [("note.txt", "note.txt"), ("../secret.txt", "../secret.txt"),
("shortcut.txt", "shortcut.txt"),
("(absolute path)", str(root / "secret.txt"))]:
r = ask(s, 20, "tools/call", {"name": "read_file", "arguments": {"path": p}})
print(f"{name:8s} {label:20s} {r['result']['content'][0]['text']}")
# second, independent calculation: commonpath on the normalised path with realpath
for p in ["note.txt", "../secret.txt", "shortcut.txt", "./note.txt", "a/../note.txt"]:
real = os.path.realpath(root / "public" / p)
base = os.path.realpath(root / "public")
inside = os.path.commonpath([real, base]) == base
s = Server(root / "public", True)
r = ask(s, 30, "tools/call", {"name": "read_file", "arguments": {"path": p}})
denied = r["result"]["isError"] and "PermissionError" in r["result"]["content"][0]["text"]
assert (not denied) == inside, p
print("second check (commonpath + realpath) agrees on 5 paths")
# Output:
# discover: ['2026-07-28'] {'tools': {}} 3600000
# tools/list: ['read_file', 'add'] | same list on the second request: True
# add 2+3 -> ok 5
# add with text -> isError: ValueError: b must be integer
# unknown tool -> error -32602: Unknown tool: delete
# without _meta -> error -32602: _meta incomplete: missing io.modelcontextprotocol/protocolVersion
# version 1900-01-01 -> error -32022: Unsupported protocol version
# initialize -> error -32601: initialize no longer exists
# notification without id -> response: None
# --- public folder: naive versus safe
# naive note.txt public note
# naive ../secret.txt TEST-SECRET
# naive shortcut.txt TEST-SECRET
# naive (absolute path) TEST-SECRET
# safe note.txt public note
# safe ../secret.txt PermissionError: path outside the public folder
# safe shortcut.txt PermissionError: path outside the public folder
# safe (absolute path) PermissionError: path outside the public folder
# second check (commonpath + realpath) agrees on 5 paths
The heart is the respond method. It checks _meta first (version and capabilities), then dispatches to server/discover, tools/list and tools/call. In tools/call two distinct things happen: if the tool does not exist the response is a JSON-RPC error; if the argument does not respect the schema (validate) or the tool fails, the response is a normal result with isError set to true. The assertion in handle checks for every response the basic invariants: exactly one of result and error, the same id as the request, resultType present, no newline. Without TOOLS in a fixed order, the list would change from one call to the next, and the specification recommends a deterministic order to help the client's cache and the prompt cache.
The output begins with discovery, which returns the supported version and the capabilities, and with the list of tools, identical on the second request; then the sum of 2 and 3 gives 5. Five error paths follow: argument of the wrong type (result with isError), unknown tool (-32602), missing _meta (-32602), unknown version (-32022) and the old initialize (-32601). The notification without id receives no response, as the protocol requires. The next section explains why the error map is built this way.
No handshake: every request carries its own context
In the earlier revisions the client opened with initialize, the server answered with its capabilities and the client confirmed with a notification. Revision 2026-07-28 removes that step, and its list of main changes shows how many consequences follow from it.
- No
initializeand no protocol sessions: theMcp-Session-Idheader of the HTTP transport disappears. - Every request declares the client's version and capabilities in
_meta, and the server cannot deduce them from earlier requests. - The server must implement
server/discoverto announce versions, capabilities and identity; for the client it is optional. - An unsupported version produces a
-32022error that lists the accepted versions. - The requests the server used to make to the client (for example asking the user for a piece of information) become
input_requiredresults, to be retried.
These are five consequences of a single choice: a server that remembers nothing about the client can be put behind a load balancer, restarted or replicated without any request depending on which instance saw the previous one. In my server you see it in the fact that respond reads no state of the object to decide version or capabilities. For clients that still speak the old protocol the specification provides a probe: a client that supports both eras first sends server/discover and, if the response is not modern, falls back to initialize. A modern-only server should, if it receives initialize, name in the error the versions it supports; the -32601 code I use for that case is my own choice, the specification does not prescribe it.
Protocol errors and tool errors
The specification distinguishes two channels. Protocol errors concern the structure of the request (unknown tool, malformed request, server failure) and travel as standard JSON-RPC errors, because it is unlikely that the model knows how to correct them. Execution errors (an API that fails, a date in the wrong format, a domain rule) travel in the result with isError: true, because they contain an indication the model can use to try again with different arguments. In the listing, the "3" passed as text to add ends up in the second channel and delete, which does not exist, in the first.
Connected to the loop of the previous article: a client that passes the text of the execution error to the model gets the same effect as the ERROR: ... observation of our ReAct executor, but with a standard field in place of a text convention. The specification says that clients should provide execution errors to the model and that they may also provide protocol errors, with lower hopes of recovery.
Security: the public folder that publishes too much
Tools are code that someone executes at the request of a model, and the specification says so bluntly: they represent arbitrary code execution and must be treated with caution. It requires servers to validate inputs, to apply access controls, to rate-limit calls and to sanitise outputs. It asks hosts for the user's explicit consent before invoking a tool, and recommends a human in the loop with the ability to deny. Even a tool's annotations must be considered untrusted, unless the server is trusted.
The listing shows the most classic access violation, path traversal, against a target of mine: a temporary folder created by the program with a public subfolder, a "secret" file outside it and a symbolic link inside the public one that points to the secret. The read_file tool must serve only the subfolder. The naive server concatenates the root with the path and reads; the safe server calls resolve(), which normalises the .. and follows the links, and only afterwards checks with is_relative_to that the result lies within the root. In the output the naive one hands over the secret by three different routes, ../secret.txt, the link and the absolute path (with pathlib an absolute path replaces the base), while the safe one denies all three and still serves the legitimate file.
As a second, independent calculation, at the end of the script I compare the server's verdict with that of another method (realpath plus commonpath) on five paths, among them ./note.txt and a/../note.txt, which are legitimate: the two agree. The countermeasure must also go in the tool, not in the prompt, because it is the model that chooses the argument and a model can be induced to choose any argument. My server remains incomplete with respect to the specification: it does not rate-limit calls, it has no authentication and it does not sanitise outputs.
What I did not verify
On 2026-11-13 I read the overview of the revision, the base protocol page, the one on versioning, discovery, tools, the stdio channel and the list of changes. I did not read the HTTP transport, authorization, resources or prompts, and I did not run any SDK or a real exchange between processes: the claims on those points do not appear here, except for their existence in the overview. Among the features deprecated in this revision the specification lists the roots list, sampling and logging, and it signals a minimum window of twelve months before removal; for a new server it is better not to rely on them. A more recent revision may have changed the details: the specification page is the source, this article does not replace it.
Frequently asked questions
Does MCP replace the models' function calling?
No, they sit on different levels. MCP describes the dialogue between the application's client and the tools server; how the model expresses the request to use one stays outside the specification. A host can receive a structured call from the model and forward it to the server with tools/call.
Do I still have to support initialize for older clients?
It depends on who uses your server. The specification describes servers that speak both eras and clients that probe with server/discover to fall back on the old handshake, and it indicates that a modern client against an old-only server fails. If your server is modern-only, the specification recommends naming the versions it supports in every error returned to initialize.
Why is a wrong argument not a JSON-RPC error?
Because the specification reserves JSON-RPC errors for problems in the structure of the request, which the model can hardly correct, and puts input validation errors in the result with isError, where the model can read them and try again with correct values.
What you can do now
You can say what an MCP message of the current revision looks like and why every request carries version and capabilities, you can tell a protocol error from an execution error and you can write a tool that does not hand over files outside its own folder, with a test that proves it against a target of yours. The tools you expose can also serve data for a retrieval system like the one described in the article on RAG, embeddings and vector stores. To understand how recent models devote more compute to reasoning, see the next article; for running a model on your own machine and using it as the engine behind a host, see the topic of the last article in the series.
Sources
- Model Context Protocol - Specification, revision 2026-07-28 - roles of host, client and server, inspiration from the Language Server Protocol, security and consent principles, features offered by servers and clients, consulted on 2026-11-13
- MCP - Base Protocol, Overview (revision 2026-07-28) - JSON-RPC 2.0 messages, rules on
idandresultType, error codes, stateless protocol, mandatory_metafields, consulted on 2026-11-13 - MCP - Versioning and Compatibility (revision 2026-07-28) - per-request negotiation, error -32022 with the supported versions, probe and fallback to
initialize, consulted on 2026-11-13 - MCP - Discovery (revision 2026-07-28) -
server/discovermandatory for the server, optional for the client, consulted on 2026-11-13 - MCP - Tools (revision 2026-07-28) -
tools/listandtools/call, deterministic order, protocol errors versus execution errors, security obligations of server and client, consulted on 2026-11-13 - MCP - stdio transport (revision 2026-07-28) - one message per line, no newlines, only valid messages on stdout, consulted on 2026-11-13
- MCP - Key Changes (revision 2026-07-28) - list of the changes with respect to revision 2025-11-25 and of the deprecated features, consulted on 2026-11-13
The listing was run with Python 3.14.4 (python3 -I), twice, with identical output. The transport is simulated in the same process and the target of the test is a temporary folder created by the program. The pages of the specification were downloaded and read in full; the details of the HTTP transport, authorization, resources and prompts were not read.
Commenti
Caricamento commenti...
Sign in to comment