MCP Protocol Server Implementation Overview
An MCP server answers JSON-RPC and tells a language model what it can read and what it can run. Over HTTP it is one endpoint; local servers also run over stdio. The specification is terse about the parts you hit first, so this covers those in the order you meet them, across the revisions a server has to answer.
Nothing here is language specific. It assumes you have read the spec and want the shape of a working server.
What A Server Exposes
A server publishes three kinds of thing. Resources are data the model can read, addressed by URI. Tools are functions the model can call, each with a name, a description and a JSON Schema for its arguments. Prompts are templated messages a user can pick.
The description fields are not documentation. They are the only thing the model reads when deciding which tool to call, so they carry the same weight as the code.
MCP servers are stateless between calls unless you add sessions, and on the handshake-era revisions the transport below can carry a session to hold that state.
Which Revision You Are Serving
The protocol is dated, and clients still arrive on every date. 2024-11-05 used HTTP plus a separate SSE stream. 2025-03-26 moved to Streamable HTTP, where one endpoint takes every POST and sessions are optional. 2025-06-18 put the protocol version in a header on every HTTP request. 2026-07-28 removed sessions and the handshake altogether: every request carries its own version and capabilities, and a server answers server/discover to say what it supports.
A server that serves real clients answers more than one of these. The sections below describe the handshake-era shape first, because that is what most clients still send, then what changes under the stateless revision.
Messages Are JSON-RPC
Every message is JSON-RPC 2.0: a method, a params object, and an id that pairs a response to its request. A message with no id is a notification, and the client does not expect an answer.
On the handshake-era revisions a server answers initialize, notifications/initialized and ping, then tools/list and tools/call for its tools and resources/list and resources/read for its resources. Add resources/templates/list and prompts/list when you have templates or prompts to hand back. Under 2026-07-28 the handshake and ping are gone and server/discover takes their place.
Protocol versions are strings like 2025-03-26. A server declares which it supports and answers with the newest one both sides know.
The Initialize Handshake
The client POSTs initialize with its protocol version and capabilities. The server replies with its own version, its capabilities and its server info.
Pick the version here. A server supporting 2025-03-26 and 2024-11-05 returns the newest one the client named. When the client asks for a revision the server does not have, the server answers with the latest it does support, which may be older than what was asked for, and the client decides whether to continue.
The client then sends the notifications/initialized notification. Until that arrives, this server declines tool and resource calls; the spec only says that neither side should send anything but pings and logging before it. It has no id and gets no reply.
Create the session during initialize and return it in an Mcp-Session-Id response header. Every later request carries that header back.
POST And GET Do Different Jobs
POST is the client talking. Requests and notifications arrive as JSON-RPC in the body, and the server answers with JSON or opens a stream.
GET is the server talking. It opens a Server-Sent Events stream on text/event-stream for messages the server originates, and it needs a session that already exists. On this server a GET with no Mcp-Session-Id is an error, not a new session, because the only thing that mints a session here is initialize. A server that never assigns sessions accepts the GET, or answers 405 when it has nothing to push.
Getting this backwards is the common mistake. GET is not how responses come back from a POST.
OPTIONS needs answering too, with the CORS headers naming Mcp-Session-Id and MCP-Protocol-Version among the allowed request headers, or a browser client never reaches the POST.
Reads And Writes Differ
Reads generally run without asking. Writes should not, and the client is what asks, which means your tool descriptions have to make the difference visible.
Transport security is the underlying protocol's job: TLS for HTTP. Authentication above that is bearer tokens, API keys, or OAuth when each user gets their own view of the data.
SEOLinkMap gives every account a private MCP URL behind OAuth, so a request resolves to that user's projects rather than to a shared dataset, through a conversational AI interface.
Scope the token, not the server. A server that can do everything is a server whose blast radius is everything.
Resources Versus Tools
A resource is a read: resources/list returns entries with a uri, a name and a mimeType, and resources/read returns the content. There is no side effect, so a client can fetch one without asking permission.
A tool is a call: tools/list returns name, description and inputSchema, and tools/call runs it. Validate arguments against that schema before executing, because the schema is a promise the model relies on.
{
"name": "get_page_metrics",
"description": "Returns measured metrics for one page URL.",
"inputSchema": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "Absolute page URL" }
},
"required": ["url"]
}
}List methods page with a cursor. Return nextCursor while more remains and omit it on the last page, so a client walks a large catalog without asking for it whole.
Errors The Client Can Use
Protocol failures use the JSON-RPC codes: -32700 for unparseable JSON, -32600 for an invalid request, -32601 for an unknown method, -32602 for bad parameters, -32603 for anything that threw. Application conditions of your own go in -32000 to -32019; the band below that is reserved for the protocol.
A tool that fails is different. It returns a result marked as an error rather than a protocol error, because the call itself succeeded and the model needs to read what went wrong.
Write those messages for the model, not for your log file. "Missing required parameter: url" gets a retry with the parameter. "Internal error" gets the same failing call again.
Where To Start
Implement the handshake-era trio, initialize, notifications/initialized and ping, and confirm a real client connects before you expose anything. A 2026-07-28 client will call server/discover instead, so answer that too.
Add read-only resources next, then tools, then writes. Each step is testable against a client you already have, and connecting one takes a minute.
Version the server from the first release. Clients outlive the assumptions you made on day one.
What Does the Math Say
About Your Site?
Finding out is free and takes minutes.