If you have compared coding agents or picked one from our roundup, the next thing you hit is MCP: the protocol those agents use to reach tools you provide. Almost every explanation starts with "install the SDK and add this to your config file".

That teaches you neither what the protocol is nor how to debug it when the agent says server failed to start. So here is one built with no SDK, no framework and no dependencies — 62 lines of code — driven directly with the protocol so you can see the whole surface.

⚠️ Updated 2 September 2026 — this guide builds a server that speaks revision 2025-06-18, and the specification has moved. The revision dated 28 July 2026 removed the initialize handshake below, removed the session header, and replaced server-initiated requests; its own summary line is "make MCP stateless". Nothing here has been withdrawn, because an old client and an old server still talk to each other and no end-of-life date has been announced for the older revision — but the handshake this walks through is no longer how a current client opens a connection. What changed, and what replaced it, is set out in MCP deleted the handshake every tutorial teaches. Read the transcript below as an accurate record of the 2025-06-18 protocol, which is still what a great many deployed servers speak.

MCP is JSON-RPC over a pipe

That is the entire idea. Your server reads one JSON object per line on standard input and writes one per line on standard output. The agent launches your process and talks to it down that pipe.

Three methods make a server useful under 2025-06-18:

  • initialize — the handshake. Agree a protocol version, say what you support.
  • tools/list — what you can do, described as JSON Schema.
  • tools/call — do one of them.

The specification also covers resources, prompts and sampling, but a server implementing just those three methods is a complete, working MCP server. Everything else is additive.

⚠️ Two of those have since changed status. initialize was removed outright in the July 2026 revision — a current client sends its protocol version and capabilities on every request instead — and sampling is deprecated, with the suggested replacement being to call an LLM provider's API directly. tools/list and tools/call are untouched, which is why a server built this way still works.

The handshake

Send this on stdin:

{"jsonrpc":"2.0","id":1,"method":"initialize",
 "params":{"protocolVersion":"2025-06-18","capabilities":{},
           "clientInfo":{"name":"probe","version":"0"}}}

And the server answers:

{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{}},
  "serverInfo":{"name":"tiny-mcp","version":"0.1.0"}}}

The version string here is 2025-06-18. Both sides name a version; disagreement is how a client discovers it is talking to something older or newer than it expects. The capabilities field declares what the server can do. This one offers only tools, so that is all it announces.

Advertising a tool

tools/list returns the menu. Each entry is a name, a description and a JSON Schema for the arguments:

{"tools":[{
  "name":"word_count",
  "description":"Count the words in a piece of text.",
  "inputSchema":{"type":"object",
    "properties":{"text":{"type":"string"}},
    "required":["text"]}}]}

The description is functional, not decorative. It is the only thing the model reads when deciding whether your tool is the right one, so it must say what the tool does and when to reach for it, in a sentence. A vague description is the most common reason a tool is never called.

Calling it

{"jsonrpc":"2.0","id":3,"method":"tools/call",
 "params":{"name":"word_count","arguments":{"text":"the invoice is overdue"}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"4"}]}}

Results come back as a content array rather than a bare value, because a tool may return several things and they may not all be text. Four words, correctly counted, by a server with no dependencies.

The three ways it says no

This is the part the SDK hides and the part you will need when something breaks. Each of these was sent to the running server and the reply recorded:

What you sendWhat comes back
A tool name that does not existerror -32602 — "Unknown tool: nope"
word_count with no texterror -32602 — invalid params
resources/list, which this server does not implementerror -32601 — method not found

Those are JSON-RPC's standard codes, not MCP's: -32602 is invalid params and -32601 is method not found. Returning them is what lets a client distinguish method not found from the server is broken. A server that returns an empty result for an unknown tool looks identical to one that succeeded and did nothing.

And two things it must ignore

We also sent a notification — notifications/initialized, which carries no id — and a line of text that is not JSON at all. The server produced zero replies to both, which is correct in both cases and for different reasons.

A JSON-RPC notification has no id precisely because it expects no answer; replying to one puts a response on the wire the client is not waiting for. A malformed line is the client's bug, and the useful response is to skip it rather than to crash the process the agent is depending on.

If your server dies on unexpected input, the agent reports "server failed to start" and you get no more information than that. Tolerating junk on stdin is not politeness, it is debuggability.

Connecting it to an agent

Every MCP client takes the same three things: a command to run, its arguments, and optionally an environment. In practice that is a JSON block naming node and the absolute path to your file. The exact filename and location differ per client and change often enough that any path printed in a guide is a hostage to fortune, so check your client's current documentation for where its config lives.

What does not change is the debugging move: run your server by hand first and paste the three messages above into it. If it answers them at a terminal, the protocol side is sound and any remaining problem is the client's configuration. If it does not, no amount of editing config files will help.

What this is and is not

  • It is a real MCP server. It handshakes, advertises a tool, executes it, and returns correct errors.
  • It is stdio only. The specification also defines an HTTP transport, which is what you want for a server that is not launched by the client.
  • It has no authentication, because a stdio server inherits the trust of whoever launched it. That changes entirely over HTTP, and a network-reachable MCP server needs the same care as any other API.
  • Use an SDK once you have more than one tool. An SDK is a convenience over something small and readable, not a requirement, and knowing what is underneath is what lets you debug the SDK itself.

If you are giving an agent access to tools, it is worth knowing what that grant actually covers: what installing an agent toolkit actually grants measured it across a real toolkit.

What is measured here

The server is 62 lines of code excluding comments and blank lines, 2,784 bytes, with zero dependencies. Every exchange above was executed on 21 August 2026 against that file on Node v26.4.0, by a script in our repository that speaks the protocol on stdin and records what returns. The error codes and the zero-reply count are observations, not quotations from the specification.

The negotiated protocol version was 2025-06-18, which is what this server declares. MCP is young and its version string moves; a client expecting a different one may refuse the handshake, and that is the protocol working rather than failing.

That sentence has since been tested. Two revisions have shipped — 2025-11-25 and 2026-07-28 — and the second removed the handshake entirely, so a client built against it does not negotiate at all. The July revision also gave MCP its first deprecation policy: features pass through Active, Deprecated and Removed over a minimum of twelve months. That window is meant to prevent the kind of abrupt change this handshake's removal represents.

What is not tested here is any real client. We drove the protocol directly, which proves the server speaks it correctly but proves nothing about any agent's configuration format. That is exactly why the debugging advice above is to test the protocol first and the config second.