Model Context Protocol
MCP tool calling without the SDK
The Model Context Protocol is JSON-RPC over a pipe, and since the 2026-07-28 revision there is no handshake at all. A whole server fits on one screen, and seeing the wire makes the security surface obvious.
· 11 min read
The Model Context Protocol is usually met through an SDK: a decorator, a server object, a transport you never look inside. That framing makes it feel like a platform. It is a wire format, and a small one.
There is no handshake
Until the 2025-11-25 revision, a client opened with an initialize request, waited for the server to answer with its capabilities, and sent a notifications/initialized back. That exchange, and the session it established, were removed in 2026-07-28. Requests now stand alone:
// Every request carries its own version, identity and capabilities.
// There is no session, and nothing to open first.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"read_note",
"arguments":{"id":"42"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{}}}}The three _meta fields carry what the handshake used to negotiate. On stdio they travel in the body, as above. Over Streamable HTTP the same values are mirrored into MCP-Protocol-Version, Mcp-Method and Mcp-Name headers so a gateway can route without parsing the body, and a server must reject the request if the header and the body disagree.
Discovery, if you want it
Servers must implement server/discover. Clients may skip it entirely and call whatever they like, handling an unsupported-version error if one comes back.
// client -> server (optional for the client, mandatory for the server)
{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"my-client","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{}}}}
// server -> client
{"jsonrpc":"2.0","id":"d1","result":{
"resultType":"complete",
"supportedVersions":["2026-07-28"],
"capabilities":{"tools":{}},
"instructions":"Notes utilities.",
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes","version":"1.0.0"}}}}Listing and calling
These two did not change. A tool definition is a name, a description, and a JSON Schema, which is the same shape every model provider already wants for function calling. That is why the protocol works at all.
// client -> server
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{ ... }}}
// server -> client
{"jsonrpc":"2.0","id":2,"result":{"tools":[{
"name":"read_note",
"description":"Read a note by its id.",
"inputSchema":{
"type":"object",
"properties":{"id":{"type":"string"}},
"required":["id"]}}]}}
// server -> client, answering the tools/call above
{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"the note body"}],
"isError":false}}A whole server
Put it together and a complete stdio server fits on one screen, with no dependencies.
#!/usr/bin/env node
// A complete stdio MCP server. No SDK, no dependencies.
import { createInterface } from "node:readline";
const VERSION = "2026-07-28";
const TOOLS = {
read_note: {
schema: {
name: "read_note",
description: "Read a note by its id.",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
},
},
run: async ({ id }) => `the body of note ${id}`,
},
};
// stdout IS the protocol channel: the spec says a server MUST NOT write
// anything there that is not a valid MCP message. Diagnostics go to stderr,
// which the client is explicitly allowed to ignore.
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
const log = (...args) => console.error(...args);
const handlers = {
// Servers MUST implement this. Clients may skip it and call anything
// directly, handling a version error if one comes back.
"server/discover": () => ({
resultType: "complete",
supportedVersions: [VERSION],
capabilities: { tools: {} },
_meta: { "io.modelcontextprotocol/serverInfo": { name: "notes", version: "1.0.0" } },
}),
"tools/list": () => ({ tools: Object.values(TOOLS).map((t) => t.schema) }),
"tools/call": async ({ name, arguments: args }) => {
const tool = TOOLS[name];
if (!tool) return { content: [{ type: "text", text: `no tool ${name}` }], isError: true };
try {
return { content: [{ type: "text", text: await tool.run(args ?? {}) }], isError: false };
} catch (error) {
// Errors come back as content, not as JSON-RPC errors: the model reads
// them and can retry. A transport error would just kill the call.
return { content: [{ type: "text", text: String(error) }], isError: true };
}
},
};
const versionOf = (params) =>
params?._meta?.["io.modelcontextprotocol/protocolVersion"];
createInterface({ input: process.stdin }).on("line", async (line) => {
if (!line.trim()) return;
let request;
try {
request = JSON.parse(line);
} catch {
return log("dropped unparseable line");
}
if (request.id === undefined) return; // a notification never gets a reply
const requested = versionOf(request.params);
if (requested && requested !== VERSION) {
return send({
jsonrpc: "2.0",
id: request.id,
error: {
code: -32022,
message: "Unsupported protocol version",
data: { supported: [VERSION], requested },
},
});
}
const handler = handlers[request.method];
if (!handler) {
return send({
jsonrpc: "2.0",
id: request.id,
error: { code: -32601, message: `Method not found: ${request.method}` },
});
}
try {
send({ jsonrpc: "2.0", id: request.id, result: await handler(request.params ?? {}) });
} catch (error) {
send({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message: String(error) } });
}
});
// Closing stdin is the portable shutdown signal. Exit promptly on it.
process.stdin.on("end", () => process.exit(0));What reading the wire tells you that the SDK does not
stdout is the protocol. A single print or console.log in your tool code injects garbage into the message stream and the client disconnects with a parse error that names nothing useful. The spec makes this a MUST for exactly this reason, and it is still the most common way a hand-written server breaks.
Three fields are model-facing, and none of them are trustworthy. A tool description, its annotations, and the instructions string returned by discovery are all read by a model. A server you did not write can put instructions in any of them. The specification says as much: descriptions of tool behaviour are to be considered untrusted unless the server itself is trusted. A tool called get_weather whose description ends with an instruction to also read a file and post it somewhere is prompt injection with a delivery mechanism.
serverInfo proves nothing. The name and version a server reports are self-declared and unverified, and the spec explicitly tells clients not to make security decisions from them. It is a display string, not an identity.
Arguments come from a model, not a caller. The schema tells the model what to send. It does not make the model send it. Validate arguments in the tool as if they came off the network, because in the ways that matter they did.
A stdio server is a local process with your privileges. There is no sandbox in the protocol. Installing a third-party MCP server is running that code with your filesystem and your credentials, and it is the same trust decision as any other dependency, usually made faster and with less scrutiny.
What this revision actually teaches
It would be neat to end on "protocols outlive libraries", and that is only half true. The 2026-07-28 revision deleted the handshake, the session id, the resumable stream and the server-initiated request. Anyone who had learned the ceremony learned something with a shelf life.
What did not move is the part worth knowing: tools/list returns name plus description plus JSON Schema, tools/call takes a name and arguments, and the result is content blocks with an error flag. Every revision so far has kept that, because it is the shape function calling already had.
So the lesson is narrower and more useful than the slogan: the mechanism held, the ceremony around it did not. Learn the mechanism, read the revision notes for the ceremony, and keep the two apart in your head.
The same mechanism, with its tradeoffs
Every pattern page runs its code on the page, in TypeScript, Python and Rust.