createmcps.com

Guide

How to build an MCP server in TypeScript

Last verified: 10 August 2026 · spec 2026-07-28 · ruleset 1.4.2

Under 2026-07-28 an MCP server is one 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

@modelcontextprotocol/sdk@1.30.0 tops out at 2025-11-25: Verified on 10 August 2026 by reading the published package. It declares LATEST_PROTOCOL_VERSION = "2025-11-25", and 2026-07-28 does not appear in its SUPPORTED_PROTOCOL_VERSIONS list.
Check it yourself — one command, no install
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.
Our recommendation, not a spec quote: we recommend the second for anything new. The work the SDK saves you is session management and handshake negotiation, and 2026-07-28 deleted both. What remains is JSON-RPC dispatch and header validation, which is not where a dependency earns its place. Re-check the SDK before you commit — this is a snapshot, and it will change.

1. Set up

A server with no MCP dependency
mkdir mcp-weather && cd mcp-weather
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
npx tsc --init
src/server.ts — the skeleton
import 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.

2. Validate the headers — before dispatch

MCP-HDR-003MCP-HDR-006
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.
MCP-HDR-003 MUST · spec 2026-07-28
src/headers.ts
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
}
Wire it in first, before the method switch
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
})
Our recommendation, not a spec quote: the ordering — validate, then dispatch — is our inference from the MUST-reject requirement rather than an explicit spec sentence. It is the difference between returning -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.

3. Implement server/discover

MCP-DSC-001MCP-DSC-002
Servers MUST implement server/discover.
MCP-DSC-001 MUST · spec 2026-07-28
It replaces the handshake as the way clients learn about you
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.
MCP-DSC-005 SHOULD · spec 2026-07-28

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.

4. tools/list — with the caching metadata

MCP-CAC-001MCP-RES-001
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.
MCP-CAC-001 MUST · spec 2026-07-28
Three fields beyond the tools array
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 },
  }
}
cacheScope: public is a claim about your data: "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-001
Every result carries resultType
async 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.

Our recommendation, not a spec quote: if a tool needs to continue across calls, return an explicit handle in 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.

6. Errors — the codes that changed

MCP-ERR-002MCP-ERR-003
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).
MCP-ERR-002 MUST · spec 2026-07-28
Dispatch and error handling together
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.
MCP-ERR-003 MUST_NOT · spec 2026-07-28

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:

No handshake, no session, brand-new connection
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": {}
      }
    }
  }'
Then prove header validation actually rejects
# 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 invalid Origin with 403 to 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.

Validate a server →

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.