Guide
How to build an MCP server in TypeScript
Last verified: 10 August 2026 · spec 2026-07-28 · ruleset 1.4.2
POST handler: validate three headers against the body, dispatch on the JSON-RPC method, return a result carrying resultType. No handshake, no session store, no framework required. Start with the SDK note below — it changes which route you take.Read this first: the official SDK and 2026-07-28
LATEST_PROTOCOL_VERSION = "2025-11-25", and 2026-07-28 does not appear in its SUPPORTED_PROTOCOL_VERSIONS list.curl -sL https://cdn.jsdelivr.net/npm/@modelcontextprotocol/sdk@latest/dist/esm/types.js \
| grep -E "LATEST_PROTOCOL_VERSION|SUPPORTED_PROTOCOL_VERSIONS"
# export const LATEST_PROTOCOL_VERSION = '2025-11-25';
# export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION,
# '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];This matters because nearly every “build an MCP server in TypeScript” tutorial starts with that SDK, and produces a server speaking the handshake-based protocol. That server works today with clients that also speak it — and cannot serve a client expecting the stateless protocol.
So there are two honest routes, and you should pick deliberately:
- Target 2025-11-25 with the SDK. Fine if your clients are on that revision. You are building against a revision the spec has already moved past, and the migration is ahead of you.
- Target 2026-07-28 with a plain handler. More code — perhaps 150 lines — but it is what this guide does, because statelessness removed most of what the SDK's transport layer existed to manage.
1. Set up
mkdir mcp-weather && cd mcp-weather
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
npx tsc --initimport express from "express"
const app = express()
app.use(express.json())
const PROTOCOL_VERSION = "2026-07-28"
const SERVER_INFO = { name: "weather", version: "1.0.0" }
app.post("/mcp", async (req, res) => {
const { id, method, params } = req.body ?? {}
// header validation and dispatch go here
})
// Statelessness: GET and DELETE have nothing to act on
app.get("/mcp", (_req, res) => res.status(405).end())
app.delete("/mcp", (_req, res) => res.status(405).end())
app.listen(3000)Those two 405 handlers are rules MCP-STL-005 and MCP-STL-006. They exist because the legacy transport used a standalone GET stream and a DELETE to tear down a session — with no sessions, neither has a meaning.
“Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.”
export const HEADER_MISMATCH = -32020
/** Header names are case-insensitive per RFC 9110 — never match exact case. */
export function validateHeaders(req: {
headers: Record<string, unknown>
body: { method?: string; params?: Record<string, unknown> }
}): string | null {
const h = (name: string) => req.headers[name.toLowerCase()] as string | undefined
const version = h("mcp-protocol-version")
const method = h("mcp-method")
const name = h("mcp-name")
if (!version) return "MCP-Protocol-Version is required on every POST"
if (!method) return "Mcp-Method is required on every request"
if (method !== req.body.method) {
return "Mcp-Method does not match the body method"
}
const meta = (req.body.params?._meta ?? {}) as Record<string, unknown>
const metaVersion = meta["io.modelcontextprotocol/protocolVersion"]
if (metaVersion && version !== metaVersion) {
return "MCP-Protocol-Version does not match _meta protocolVersion"
}
// Mcp-Name is required on these three, and must match the body
const NAMED = ["tools/call", "resources/read", "prompts/get"]
if (NAMED.includes(req.body.method ?? "")) {
const target = (req.body.params?.name ?? req.body.params?.uri) as string | undefined
if (!name) return "Mcp-Name is required on " + req.body.method
if (name !== target) return "Mcp-Name does not match the body"
}
return null
}app.post("/mcp", async (req, res) => {
const { id, method, params } = req.body ?? {}
const headerError = validateHeaders(req)
if (headerError) {
return res.status(400).json({
jsonrpc: "2.0", id,
error: { code: HEADER_MISMATCH, message: headerError },
})
}
// … dispatch
})-32020 and returning -32601 Method not found for a mismatched header, and it is the one header rule we grade as a warning because the spec does not state the ordering directly.“Servers MUST implement server/discover.”
function discover() {
return {
resultType: "complete",
supportedVersions: ["2026-07-28"],
capabilities: { tools: {} },
_meta: {
"io.modelcontextprotocol/serverInfo": SERVER_INFO,
},
}
}“Servers SHOULD include the following io.modelcontextprotocol/* field in every result's _meta, unless specifically configured not to do so, to identify themselves without relying on any prior connection state.”
The serverInfo block is SHOULD-level and belongs in every result, not just this one — it is how a server identifies itself without relying on connection state.
“Require ttlMs and cacheScope fields on results returned by tools/list, prompts/list, resources/list, resources/read, and resources/templates/list via a new CacheableResult interface.”
const TOOLS = [
{
name: "get_weather",
description: "Current conditions for a location",
inputSchema: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"],
},
},
]
function toolsList() {
return {
resultType: "complete", // MCP-RES-001
tools: TOOLS, // deterministic order — MCP-RES-007
ttlMs: 3600000, // MCP-CAC-001
cacheScope: "public", // MCP-CAC-002
_meta: { "io.modelcontextprotocol/serverInfo": SERVER_INFO },
}
}"public" asserts the response contains nothing user-specific and may be shared by intermediaries. The moment your tool list varies by caller — tools gated on permissions — it must become "private", or a shared cache can serve one user's list to another. A constant array like the one above is genuinely public.Returning tools in a stable order is MCP-RES-007: the spec asks for it so clients can cache and so LLM prompt caches hit. An array literal gives you that for free; a list built from an object's keys or a database query without ORDER BY does not.
5. tools/call
MCP-RES-001async function toolsCall(params: { name: string; arguments?: Record<string, unknown> }) {
if (params.name !== "get_weather") {
throw { code: -32602, message: `Unknown tool: ${params.name}` }
}
const location = params.arguments?.location
if (typeof location !== "string") {
throw { code: -32602, message: "location is required and must be a string" }
}
const conditions = await lookUpWeather(location)
return {
resultType: "complete",
content: [{ type: "text", text: conditions.summary }],
structuredContent: conditions,
_meta: { "io.modelcontextprotocol/serverInfo": SERVER_INFO },
}
}Note what is absent: no ttlMs, no cacheScope. Those five cacheable methods are list and read operations. A tool call is an action, and the spec does not invite intermediaries to cache one.
structuredContent and let the client pass it back as an ordinary argument. Do not reach for a session — there isn't one, and reintroducing one through a side channel is the failure MCP-STL-004 exists to catch.“If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).”
app.post("/mcp", async (req, res) => {
const { id, method, params } = req.body ?? {}
const headerError = validateHeaders(req)
if (headerError) {
return res.status(400).json({
jsonrpc: "2.0", id, error: { code: HEADER_MISMATCH, message: headerError },
})
}
try {
let result
switch (method) {
case "server/discover": result = discover(); break
case "tools/list": result = toolsList(); break
case "tools/call": result = await toolsCall(params); break
default:
// MCP-ERR-002: 404 *and* -32601, not just the JSON-RPC error
return res.status(404).json({
jsonrpc: "2.0", id,
error: { code: -32601, message: `Method not found: ${method}` },
})
}
return res.json({ jsonrpc: "2.0", id, result })
} catch (err: any) {
return res.status(err.code === -32602 ? 400 : 500).json({
jsonrpc: "2.0", id,
error: { code: err.code ?? -32603, message: err.message ?? "Internal error" },
})
}
})“-32002 — resource not found (2025-11-25 and earlier; replaced by -32602). Implementations of this protocol version MUST NOT emit these codes.”
Two easy mistakes here. MCP-ERR-002 wants the HTTP status and the JSON-RPC code — a 200 carrying -32601 is not conformant. And resource-not-found is now -32602: -32002 is retired and MUST NOT be emitted.
7. Test it cold
The single most important test, because it is the one thing a legacy server cannot do:
curl -i http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'# Mcp-Method says list, the body says call → expect 400 / -32020
curl -i http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather"}}'What to add next
- Origin validation.
MCP-SEC-001— reject an invalidOriginwith403to prevent DNS rebinding. Roughly five lines, and it is security-relevant, so failing it caps a compliance grade at D. - OAuth. If your server touches anything private — the resource-server guide.
- Publish it. To the official registry, so clients can find it.
- The remaining rules. This guide covers roughly a dozen of 79. Schema constraints, the rest of the error codes and the deprecation checks are all in the catalogue.
Check it against all 79 rules
A cold curl proves statelessness. It says nothing about the rules that do not break clients immediately — missing cacheScope, a retired error code, an unbounded schema. Run the endpoint through the validator before you publish it.
Frequently asked
How do I build an MCP server in TypeScript?
For the 2026-07-28 revision, a Streamable HTTP MCP server is a single POST handler. It reads the required headers, validates them against the request body, dispatches on the JSON-RPC method, and returns a result carrying resultType — plus ttlMs and cacheScope on list responses. No handshake, no session store, and no framework is required.
Does the official MCP TypeScript SDK support 2026-07-28?
Not as of version 1.30.0, verified 10 August 2026. The published package declares LATEST_PROTOCOL_VERSION as 2025-11-25, and its SUPPORTED_PROTOCOL_VERSIONS list does not include 2026-07-28. A server built on it will speak the handshake-based revisions, so it cannot serve a client that expects the stateless protocol.
Do I need a framework to build an MCP server?
No. Under 2026-07-28 the transport is one POST endpoint with no session state to manage, which is most of what a framework used to do for you. A plain handler in Express, Hono, Fastify or a serverless function is enough, and it is currently the most direct route to a conformant server.
What is the minimum a TypeScript MCP server must implement?
server/discover so clients can learn what you support, tools/list carrying resultType, ttlMs and cacheScope, and tools/call returning resultType. Validate the Mcp-Method, Mcp-Name and MCP-Protocol-Version headers against the body, and return 400 with -32020 when they disagree.
Should I use stdio or HTTP transport?
Use Streamable HTTP for anything a client connects to over a network, and stdio for a server a client launches as a local subprocess. The 2026-07-28 statelessness changes apply to the HTTP transport; the deprecated HTTP+SSE transport should not be adopted by new implementations.
How do I test my TypeScript MCP server?
Send a cold request with curl — one tools/list with no handshake — and confirm you get a list back rather than an initialization error. Then run the endpoint through a validator to check all 79 rules of the 2026-07-28 spec, which catches the ones that do not break clients immediately, such as missing ttlMs or cacheScope.