[{"data":1,"prerenderedAt":1051},["ShallowReactive",2],{"article/build-mcp-server-typescript":3},{"_path":4,"_draft":5,"_partial":5,"_locale":6,"title":7,"description":8,"featured":5,"author":9,"categories":10,"slug":11,"image":12,"imageAlt":22,"published":23,"draft":5,"createdAt":24,"updatedAt":24,"faqs":25,"body":41,"_type":1050,"isInteractive":5,"interactiveConfig":-1},"/articles/react/build-mcp-server-typescript",false,"","Build an MCP Server in TypeScript: A Production Guide","Build a production MCP server with the TypeScript SDK: define tools, resources, prompts, pick a transport, and add OAuth authorization.","Thomas Findlay","Typescript, Javascript, React","build-mcp-server-typescript",[13,14,15,16,17,18,19,20,21],"/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-640w.avif","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1024w.avif","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1920w.avif","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-640w.webp","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1024w.webp","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1920w.webp","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-640w.png","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1024w.png","/images/articles/build-mcp-server-typescript/build-mcp-server-typescript-1920w.png","Diagram of an MCP server exposing tools, resources, and prompts to an AI client over a transport",true,"2026-08-01T00:00:00",[26,29,32,35,38],{"question":27,"answer":28},"What is an MCP server?","An MCP server is a program that exposes tools, resources, and prompts to AI clients through the Model Context Protocol, a JSON-RPC standard. The client (such as Claude Desktop or an IDE agent) connects over a transport like stdio or streamable HTTP, then calls your tools and reads your resources during a model conversation.",{"question":30,"answer":31},"How do you build an MCP server in TypeScript?","Install @modelcontextprotocol/sdk, create an McpServer instance with a name and version, register tools with server.registerTool, register resources with server.registerResource, and register prompts with server.registerPrompt. Then connect the server to a transport, either StdioServerTransport for local clients or StreamableHTTPServerTransport for remote HTTP clients.",{"question":33,"answer":34},"What is the difference between stdio and streamable HTTP transports in MCP?","Stdio runs your server as a local subprocess that exchanges newline-delimited JSON-RPC over stdin and stdout, which suits desktop clients and CLI tools. Streamable HTTP exposes a single HTTP endpoint that accepts POST requests and can stream responses over Server-Sent Events, which suits remote and multi-user servers that need network access and authorization.",{"question":36,"answer":37},"Does MCP support OAuth authorization?","Yes. Since the 2025-06-18 revision, the spec classifies HTTP-based MCP servers as OAuth 2.1 resource servers, and the current 2025-11-25 revision keeps that model. The server validates bearer tokens, advertises its authorization server through protected resource metadata (RFC 9728), and returns a 401 with a WWW-Authenticate header when a token is missing or invalid. Stdio servers do not use OAuth and rely on environment credentials instead.",{"question":39,"answer":40},"What MCP specification version should I target?","Target the current revision, 2025-11-25, and use the latest @modelcontextprotocol/sdk (1.29.x), which implements it. MCP uses date-based versioning, and clients and servers negotiate a shared version during initialization. HTTP clients must send the negotiated version in the MCP-Protocol-Version header on every request after the handshake.",{"type":42,"children":43,"toc":1030},"root",[44,52,59,129,135,163,203,209,214,219,234,246,299,306,318,329,349,355,376,388,397,416,422,443,455,464,476,482,495,507,516,521,527,540,552,561,582,588,593,615,620,626,638,647,660,665,676,688,694,707,719,728,757,763,775,810,822,831,896,901,922,928,933,945,972,977,983,995],{"type":45,"tag":46,"props":47,"children":48},"element","p",{},[49],{"type":50,"value":51},"text","The Model Context Protocol (MCP) is an open standard that lets AI applications connect to your tools, data, and prompts through a single JSON-RPC interface. Instead of writing a bespoke plugin for each assistant, you build one MCP server and any compatible client can use it. This guide builds a production server in TypeScript with the official SDK, covering tools, resources, prompts, both transports, and OAuth authorization.",{"type":45,"tag":53,"props":54,"children":56},"h2",{"id":55},"tldr",[57],{"type":50,"value":58},"TL;DR",{"type":45,"tag":46,"props":60,"children":61},{},[62,64,71,73,79,81,87,89,95,97,103,105,111,113,119,121,127],{"type":50,"value":63},"MCP gives AI clients a uniform way to call your code. Build a server with ",{"type":45,"tag":65,"props":66,"children":68},"code",{"className":67},[],[69],{"type":50,"value":70},"@modelcontextprotocol/sdk",{"type":50,"value":72}," (version 1.29.x, implementing spec revision 2025-11-25): create an ",{"type":45,"tag":65,"props":74,"children":76},{"className":75},[],[77],{"type":50,"value":78},"McpServer",{"type":50,"value":80},", register tools, resources, and prompts, then connect a transport. Use ",{"type":45,"tag":65,"props":82,"children":84},{"className":83},[],[85],{"type":50,"value":86},"StdioServerTransport",{"type":50,"value":88}," for local desktop clients and ",{"type":45,"tag":65,"props":90,"children":92},{"className":91},[],[93],{"type":50,"value":94},"StreamableHTTPServerTransport",{"type":50,"value":96}," for remote ones. For HTTP servers exposed to a network, treat the server as an OAuth 2.1 resource server: validate bearer tokens with ",{"type":45,"tag":65,"props":98,"children":100},{"className":99},[],[101],{"type":50,"value":102},"requireBearerAuth",{"type":50,"value":104},", advertise your auth server through protected resource metadata, and return ",{"type":45,"tag":65,"props":106,"children":108},{"className":107},[],[109],{"type":50,"value":110},"401",{"type":50,"value":112}," with ",{"type":45,"tag":65,"props":114,"children":116},{"className":115},[],[117],{"type":50,"value":118},"WWW-Authenticate",{"type":50,"value":120}," on failure. Return tool errors as ",{"type":45,"tag":65,"props":122,"children":124},{"className":123},[],[125],{"type":50,"value":126},"{ isError: true }",{"type":50,"value":128}," rather than throwing across the protocol boundary.",{"type":45,"tag":53,"props":130,"children":132},{"id":131},"versions-and-the-spec-revision-this-guide-targets",[133],{"type":50,"value":134},"Versions and the spec revision this guide targets",{"type":45,"tag":46,"props":136,"children":137},{},[138,140,146,148,154,156,161],{"type":50,"value":139},"MCP uses date-based versioning, and the current revision is ",{"type":45,"tag":65,"props":141,"children":143},{"className":142},[],[144],{"type":50,"value":145},"2025-11-25",{"type":50,"value":147},". That string is not a marketing label. It is the protocol version that clients and servers negotiate during the initialization handshake, and HTTP clients must echo it back in the ",{"type":45,"tag":65,"props":149,"children":151},{"className":150},[],[152],{"type":50,"value":153},"MCP-Protocol-Version",{"type":50,"value":155}," header on every later request. The matching official TypeScript SDK at the time of writing is ",{"type":45,"tag":65,"props":157,"children":159},{"className":158},[],[160],{"type":50,"value":70},{"type":50,"value":162}," version 1.29.x, which the maintainers flag as the production line while a v2 rewrite is still pre-release.",{"type":45,"tag":46,"props":164,"children":165},{},[166,168,174,176,181,183,192,194,201],{"type":50,"value":167},"The authorization model that this guide leans on landed one revision earlier, in ",{"type":45,"tag":65,"props":169,"children":171},{"className":170},[],[172],{"type":50,"value":173},"2025-06-18",{"type":50,"value":175},", and the current revision keeps it. That earlier revision changed three things you still feel directly. It classified HTTP-based MCP servers as OAuth 2.1 resource servers, added Resource Indicators (RFC 8707) so a token issued for one server cannot be replayed against another, and removed JSON-RPC batching. The ",{"type":45,"tag":65,"props":177,"children":179},{"className":178},[],[180],{"type":50,"value":145},{"type":50,"value":182}," revision builds on top of that with additions like asynchronous Tasks for long-running work (currently experimental in the spec) and richer authorization flows, none of which break the server shape below. If you read older tutorials that describe the server as its own OAuth authorization server, or that send arrays of batched requests, they predate this line. Pin the ",{"type":45,"tag":184,"props":185,"children":189},"a",{"href":186,"rel":187},"https://github.com/modelcontextprotocol/typescript-sdk",[188],"nofollow",[190],{"type":50,"value":191},"TypeScript SDK",{"type":50,"value":193}," and check the ",{"type":45,"tag":184,"props":195,"children":198},{"href":196,"rel":197},"https://modelcontextprotocol.io/specification/2025-11-25",[188],[199],{"type":50,"value":200},"current specification",{"type":50,"value":202}," before copying any auth code.",{"type":45,"tag":53,"props":204,"children":206},{"id":205},"what-an-mcp-server-actually-exposes-tools-resources-and-prompts",[207],{"type":50,"value":208},"What an MCP server actually exposes: tools, resources, and prompts",{"type":45,"tag":46,"props":210,"children":211},{},[212],{"type":50,"value":213},"An MCP server offers three kinds of capability, and the distinction drives your whole design. Tools are functions the model can call to take an action or fetch live data, like running a query or creating a ticket. Resources are addressable, read-only context the client can load, like a file or a database record exposed under a URI. Prompts are reusable message templates a user can invoke deliberately, like a \"review this code\" workflow. Tools are model-controlled, resources are application-controlled, and prompts are user-controlled, and keeping those lanes separate is what makes a server predictable.",{"type":45,"tag":46,"props":215,"children":216},{},[217],{"type":50,"value":218},"Start with the project skeleton. Install the SDK and Zod, which the SDK uses to describe and validate tool inputs.",{"type":45,"tag":46,"props":220,"children":221},{},[222],{"type":45,"tag":223,"props":224,"children":225},"strong",{},[226,232],{"type":45,"tag":65,"props":227,"children":229},{"className":228},[],[230],{"type":50,"value":231},"package.json",{"type":50,"value":233}," (dependencies)",{"type":45,"tag":235,"props":236,"children":241},"pre",{"className":237,"code":239,"language":240,"meta":6},[238],"language-json","{\n  \"name\": \"my-mcp-server\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.29.0\",\n    \"express\": \"^5.0.0\",\n    \"zod\": \"^3.25.0\"\n  }\n}\n","json",[242],{"type":45,"tag":65,"props":243,"children":244},{"__ignoreMap":6},[245],{"type":50,"value":239},{"type":45,"tag":46,"props":247,"children":248},{},[249,251,257,259,265,267,273,275,281,283,289,291,297],{"type":50,"value":250},"The ",{"type":45,"tag":65,"props":252,"children":254},{"className":253},[],[255],{"type":50,"value":256},"\"type\": \"module\"",{"type":50,"value":258}," line is not optional cosmetics. Because the SDK ships as ESM and its import paths end in ",{"type":45,"tag":65,"props":260,"children":262},{"className":261},[],[263],{"type":50,"value":264},".js",{"type":50,"value":266},", the package has to be a module or Node will reject the imports at runtime. One note on the ",{"type":45,"tag":65,"props":268,"children":270},{"className":269},[],[271],{"type":50,"value":272},"zod",{"type":50,"value":274}," range: the SDK's peer dependency accepts both ",{"type":45,"tag":65,"props":276,"children":278},{"className":277},[],[279],{"type":50,"value":280},"^3.25",{"type":50,"value":282}," and ",{"type":45,"tag":65,"props":284,"children":286},{"className":285},[],[287],{"type":50,"value":288},"^4.0",{"type":50,"value":290},", but a fresh ",{"type":45,"tag":65,"props":292,"children":294},{"className":293},[],[295],{"type":50,"value":296},"npm install zod",{"type":50,"value":298}," now pulls v4, so pin the version deliberately rather than assuming a new project lands on v3.",{"type":45,"tag":300,"props":301,"children":303},"h3",{"id":302},"creating-the-server-instance",[304],{"type":50,"value":305},"Creating the server instance",{"type":45,"tag":46,"props":307,"children":308},{},[309],{"type":45,"tag":223,"props":310,"children":311},{},[312],{"type":45,"tag":65,"props":313,"children":315},{"className":314},[],[316],{"type":50,"value":317},"src/server.ts",{"type":45,"tag":235,"props":319,"children":324},{"className":320,"code":322,"language":323,"meta":6},[321],"language-typescript","import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n\nexport function createServer(): McpServer {\n  return new McpServer({\n    name: \"my-mcp-server\",\n    version: \"1.0.0\",\n  });\n}\n","typescript",[325],{"type":45,"tag":65,"props":326,"children":327},{"__ignoreMap":6},[328],{"type":50,"value":322},{"type":45,"tag":46,"props":330,"children":331},{},[332,334,340,341,347],{"type":50,"value":333},"A factory function rather than a single shared instance is a deliberate choice. When you later run over HTTP with per-session transports, each session gets its own server object, and a factory keeps that clean. The ",{"type":45,"tag":65,"props":335,"children":337},{"className":336},[],[338],{"type":50,"value":339},"name",{"type":50,"value":282},{"type":45,"tag":65,"props":342,"children":344},{"className":343},[],[345],{"type":50,"value":346},"version",{"type":50,"value":348}," are sent to the client during initialization and show up in client UIs, so use a stable, recognizable name.",{"type":45,"tag":300,"props":350,"children":352},{"id":351},"registering-a-tool-with-a-typed-input-schema",[353],{"type":50,"value":354},"Registering a tool with a typed input schema",{"type":45,"tag":46,"props":356,"children":357},{},[358,360,366,368,374],{"type":50,"value":359},"Tools are where most of the work lives. ",{"type":45,"tag":65,"props":361,"children":363},{"className":362},[],[364],{"type":50,"value":365},"server.registerTool",{"type":50,"value":367}," takes a name, a config object with a Zod ",{"type":45,"tag":65,"props":369,"children":371},{"className":370},[],[372],{"type":50,"value":373},"inputSchema",{"type":50,"value":375},", and an async handler. The schema is the contract: the client uses it to validate arguments before they reach you, and the model uses it to understand what to pass.",{"type":45,"tag":46,"props":377,"children":378},{},[379],{"type":45,"tag":223,"props":380,"children":381},{},[382],{"type":45,"tag":65,"props":383,"children":385},{"className":384},[],[386],{"type":50,"value":387},"src/tools/weather.ts",{"type":45,"tag":235,"props":389,"children":392},{"className":390,"code":391,"language":323,"meta":6},[321],"import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n\nexport function registerWeatherTool(server: McpServer): void {\n  server.registerTool(\n    \"get_weather\",\n    {\n      title: \"Weather Information Provider\",\n      description: \"Get current weather data for a city\",\n      inputSchema: { city: z.string() },\n    },\n    async ({ city }) => {\n      const response = await fetch(\n        `https://api.example.com/weather?city=${encodeURIComponent(city)}`\n      );\n\n      if (!response.ok) {\n        // throws: nothing; the protocol expects errors as data, not exceptions\n        return {\n          content: [{ type: \"text\", text: `Weather lookup failed: ${response.status}` }],\n          isError: true,\n        };\n      }\n\n      const data = await response.text();\n      return {\n        content: [{ type: \"text\", text: data }],\n      };\n    }\n  );\n}\n",[393],{"type":45,"tag":65,"props":394,"children":395},{"__ignoreMap":6},[396],{"type":50,"value":391},{"type":45,"tag":46,"props":398,"children":399},{},[400,401,407,409,414],{"type":50,"value":250},{"type":45,"tag":65,"props":402,"children":404},{"className":403},[],[405],{"type":50,"value":406},"isError",{"type":50,"value":408}," flag is the single most important production detail in this file. A tool failure is not a transport failure. If a city is invalid or an upstream API is down, the model should see that as a result it can reason about and retry, not as a broken connection. Returning ",{"type":45,"tag":65,"props":410,"children":412},{"className":411},[],[413],{"type":50,"value":126},{"type":50,"value":415}," keeps the error inside the tool result. Throwing an unhandled exception, by contrast, surfaces as a protocol-level error and gives the model nothing useful to work with. Validate, catch, and return.",{"type":45,"tag":300,"props":417,"children":419},{"id":418},"returning-structured-output-the-client-can-parse",[420],{"type":50,"value":421},"Returning structured output the client can parse",{"type":45,"tag":46,"props":423,"children":424},{},[425,427,433,435,441],{"type":50,"value":426},"Text content works for prose, but agents increasingly want machine-readable results. The spec supports structured output: declare an ",{"type":45,"tag":65,"props":428,"children":430},{"className":429},[],[431],{"type":50,"value":432},"outputSchema",{"type":50,"value":434}," and return a ",{"type":45,"tag":65,"props":436,"children":438},{"className":437},[],[439],{"type":50,"value":440},"structuredContent",{"type":50,"value":442}," object alongside the text.",{"type":45,"tag":46,"props":444,"children":445},{},[446],{"type":45,"tag":223,"props":447,"children":448},{},[449],{"type":45,"tag":65,"props":450,"children":452},{"className":451},[],[453],{"type":50,"value":454},"src/tools/weather-structured.ts",{"type":45,"tag":235,"props":456,"children":459},{"className":457,"code":458,"language":323,"meta":6},[321],"import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n\nexport function registerStructuredWeatherTool(server: McpServer): void {\n  server.registerTool(\n    \"get_weather_structured\",\n    {\n      title: \"Structured Weather\",\n      description: \"Get current weather as structured data\",\n      inputSchema: { city: z.string() },\n      outputSchema: {\n        temperature: z.number(),\n        conditions: z.string(),\n      },\n    },\n    async ({ city }) => {\n      const data = { temperature: 22.5, conditions: \"Partly cloudy\" };\n      return {\n        content: [{ type: \"text\", text: JSON.stringify(data) }],\n        structuredContent: data,\n      };\n    }\n  );\n}\n",[460],{"type":45,"tag":65,"props":461,"children":462},{"__ignoreMap":6},[463],{"type":50,"value":458},{"type":45,"tag":46,"props":465,"children":466},{},[467,469,474],{"type":50,"value":468},"Keep the text content as a fallback even when you return structured data. Not every client renders ",{"type":45,"tag":65,"props":470,"children":472},{"className":471},[],[473],{"type":50,"value":440},{"type":50,"value":475},", and the text branch is what older clients fall back to. This same discipline of typing the boundary applies whenever you let a model drive code, the same idea behind type-safe LLM calls in TypeScript.",{"type":45,"tag":300,"props":477,"children":479},{"id":478},"exposing-data-as-resources",[480],{"type":50,"value":481},"Exposing data as resources",{"type":45,"tag":46,"props":483,"children":484},{},[485,487,493],{"type":50,"value":486},"Resources are read-only context addressed by URI. For dynamic resources you register a ",{"type":45,"tag":65,"props":488,"children":490},{"className":489},[],[491],{"type":50,"value":492},"ResourceTemplate",{"type":50,"value":494}," with a URI pattern, and the handler receives the parsed variables. The template can also supply completions, so a client can autocomplete the variable values.",{"type":45,"tag":46,"props":496,"children":497},{},[498],{"type":45,"tag":223,"props":499,"children":500},{},[501],{"type":45,"tag":65,"props":502,"children":504},{"className":503},[],[505],{"type":50,"value":506},"src/resources/repos.ts",{"type":45,"tag":235,"props":508,"children":511},{"className":509,"code":510,"language":323,"meta":6},[321],"import {\n  type McpServer,\n  ResourceTemplate,\n} from \"@modelcontextprotocol/sdk/server/mcp.js\";\n\nexport function registerRepoResource(server: McpServer): void {\n  server.registerResource(\n    \"github-repos\",\n    new ResourceTemplate(\"github://repos/{owner}/{repo}\", {\n      list: undefined,\n      complete: {\n        repo: (value, context) => {\n          if (context?.arguments?.[\"owner\"] === \"org1\") {\n            return [\"project1\", \"project2\"];\n          }\n          return [\"default-repo\"];\n        },\n      },\n    }),\n    {\n      title: \"GitHub Repository\",\n      description: \"Repository information by owner and repo name\",\n    },\n    async (uri, { owner, repo }) => ({\n      contents: [\n        {\n          uri: uri.href,\n          text: `Repository: ${owner}/${repo}`,\n        },\n      ],\n    })\n  );\n}\n",[512],{"type":45,"tag":65,"props":513,"children":514},{"__ignoreMap":6},[515],{"type":50,"value":510},{"type":45,"tag":46,"props":517,"children":518},{},[519],{"type":50,"value":520},"The reason resources are read-only is worth internalizing. A client may load a resource into context automatically or on a user's command, without the model deciding to. That makes resources the wrong place for anything with side effects. If an operation changes state, it belongs in a tool, where the model has to choose to call it and the user can see the action in the conversation.",{"type":45,"tag":300,"props":522,"children":524},{"id":523},"registering-a-prompt-as-a-reusable-workflow",[525],{"type":50,"value":526},"Registering a prompt as a reusable workflow",{"type":45,"tag":46,"props":528,"children":529},{},[530,532,538],{"type":50,"value":531},"Prompts are user-invoked templates. They take an ",{"type":45,"tag":65,"props":533,"children":535},{"className":534},[],[536],{"type":50,"value":537},"argsSchema",{"type":50,"value":539}," and return the messages that seed a conversation.",{"type":45,"tag":46,"props":541,"children":542},{},[543],{"type":45,"tag":223,"props":544,"children":545},{},[546],{"type":45,"tag":65,"props":547,"children":549},{"className":548},[],[550],{"type":50,"value":551},"src/prompts/review.ts",{"type":45,"tag":235,"props":553,"children":556},{"className":554,"code":555,"language":323,"meta":6},[321],"import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n\nexport function registerReviewPrompt(server: McpServer): void {\n  server.registerPrompt(\n    \"review-code\",\n    {\n      title: \"Code Review\",\n      description: \"Review code for best practices and potential issues\",\n      argsSchema: { code: z.string() },\n    },\n    ({ code }) => ({\n      messages: [\n        {\n          role: \"user\",\n          content: {\n            type: \"text\",\n            text: `Please review this code:\\n\\n${code}`,\n          },\n        },\n      ],\n    })\n  );\n}\n",[557],{"type":45,"tag":65,"props":558,"children":559},{"__ignoreMap":6},[560],{"type":50,"value":555},{"type":45,"tag":46,"props":562,"children":563},{},[564,566,572,574,580],{"type":50,"value":565},"Prompts are the most overlooked of the three primitives, and they are the cheapest way to encode an opinion. A good ",{"type":45,"tag":65,"props":567,"children":569},{"className":568},[],[570],{"type":50,"value":571},"review-code",{"type":50,"value":573}," or ",{"type":45,"tag":65,"props":575,"children":577},{"className":576},[],[578],{"type":50,"value":579},"summarize-incident",{"type":50,"value":581}," prompt turns a vague request into a consistent workflow your whole team triggers the same way. They surface in clients as slash commands or menu items, so users reach for them on purpose.",{"type":45,"tag":53,"props":583,"children":585},{"id":584},"which-transport-should-you-use-stdio-or-streamable-http",[586],{"type":50,"value":587},"Which transport should you use, stdio or streamable HTTP?",{"type":45,"tag":46,"props":589,"children":590},{},[591],{"type":50,"value":592},"A transport is how bytes move between client and server, and MCP defines two. The choice is not a detail you defer. It decides how the server is deployed, who can reach it, and whether you need authorization at all.",{"type":45,"tag":46,"props":594,"children":595},{},[596,598,605,607,613],{"type":50,"value":597},"Stdio runs your server as a subprocess of the client. The client spawns the process, then sends newline-delimited JSON-RPC over the server's standard input and reads replies from standard output. It is the right transport for a server that runs on the same machine as the client, like a desktop assistant or a local CLI agent. There is no network surface, no port, and no auth handshake, because access is governed by the fact that the user already launched the process. One firm rule comes straight from the ",{"type":45,"tag":184,"props":599,"children":602},{"href":600,"rel":601},"https://modelcontextprotocol.io/specification/2025-11-25/basic/transports",[188],[603],{"type":50,"value":604},"transports specification",{"type":50,"value":606},": a stdio server must never write anything to stdout that is not a valid MCP message. Stray ",{"type":45,"tag":65,"props":608,"children":610},{"className":609},[],[611],{"type":50,"value":612},"console.log",{"type":50,"value":614}," output corrupts the JSON-RPC stream and breaks the client. Log to stderr instead.",{"type":45,"tag":46,"props":616,"children":617},{},[618],{"type":50,"value":619},"Streamable HTTP exposes a single HTTP endpoint that accepts POST requests for client-to-server messages and can stream server-to-client messages over Server-Sent Events. This is the transport for remote servers, multi-user deployments, and anything that needs to live behind a load balancer, and it replaces the older HTTP plus SSE transport from earlier revisions. With a network surface comes responsibility: an HTTP MCP server is reachable by anything that can hit the URL, so authorization stops being optional.",{"type":45,"tag":300,"props":621,"children":623},{"id":622},"wiring-up-the-stdio-transport",[624],{"type":50,"value":625},"Wiring up the stdio transport",{"type":45,"tag":46,"props":627,"children":628},{},[629],{"type":45,"tag":223,"props":630,"children":631},{},[632],{"type":45,"tag":65,"props":633,"children":635},{"className":634},[],[636],{"type":50,"value":637},"src/stdio.ts",{"type":45,"tag":235,"props":639,"children":642},{"className":640,"code":641,"language":323,"meta":6},[321],"import { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { createServer } from \"./server.js\";\nimport { registerWeatherTool } from \"./tools/weather.js\";\nimport { registerRepoResource } from \"./resources/repos.js\";\nimport { registerReviewPrompt } from \"./prompts/review.js\";\n\nasync function main(): Promise\u003Cvoid> {\n  const server = createServer();\n  registerWeatherTool(server);\n  registerRepoResource(server);\n  registerReviewPrompt(server);\n\n  const transport = new StdioServerTransport();\n  await server.connect(transport);\n}\n\nmain().catch((error) => {\n  // throws: writes to stderr, never stdout, then exits non-zero\n  console.error(\"Fatal error starting MCP server:\", error);\n  process.exit(1);\n});\n",[643],{"type":45,"tag":65,"props":644,"children":645},{"__ignoreMap":6},[646],{"type":50,"value":641},{"type":45,"tag":46,"props":648,"children":649},{},[650,652,658],{"type":50,"value":651},"Notice that the only error reporting in the entry point goes to ",{"type":45,"tag":65,"props":653,"children":655},{"className":654},[],[656],{"type":50,"value":657},"console.error",{"type":50,"value":659},", which writes to stderr. That is the stdout rule in practice. The process registers its capabilities, connects the transport, and then blocks on the stream until the client disconnects.",{"type":45,"tag":46,"props":661,"children":662},{},[663],{"type":50,"value":664},"You can run this server against the official MCP Inspector to exercise it before any client integration:",{"type":45,"tag":235,"props":666,"children":671},{"className":667,"code":669,"language":670,"meta":6},[668],"language-bash","npx @modelcontextprotocol/inspector node build/stdio.js\n","bash",[672],{"type":45,"tag":65,"props":673,"children":674},{"__ignoreMap":6},[675],{"type":50,"value":669},{"type":45,"tag":46,"props":677,"children":678},{},[679,681,686],{"type":50,"value":680},"The Inspector gives you a visual interface to call tools, read resources, and trigger prompts, which is the fastest way to catch a broken schema or a tool that throws instead of returning ",{"type":45,"tag":65,"props":682,"children":684},{"className":683},[],[685],{"type":50,"value":406},{"type":50,"value":687},".",{"type":45,"tag":300,"props":689,"children":691},{"id":690},"wiring-up-streamable-http-with-session-management",[692],{"type":50,"value":693},"Wiring up streamable HTTP with session management",{"type":45,"tag":46,"props":695,"children":696},{},[697,699,705],{"type":50,"value":698},"The HTTP transport carries more moving parts because it manages sessions. Below is the pattern the SDK README uses: keep a map of transports keyed by session ID, create a new transport on the initialization request, and reuse it for follow-up requests carrying the ",{"type":45,"tag":65,"props":700,"children":702},{"className":701},[],[703],{"type":50,"value":704},"mcp-session-id",{"type":50,"value":706}," header.",{"type":45,"tag":46,"props":708,"children":709},{},[710],{"type":45,"tag":223,"props":711,"children":712},{},[713],{"type":45,"tag":65,"props":714,"children":716},{"className":715},[],[717],{"type":50,"value":718},"src/http.ts",{"type":45,"tag":235,"props":720,"children":723},{"className":721,"code":722,"language":323,"meta":6},[321],"import express from \"express\";\nimport { randomUUID } from \"node:crypto\";\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport { isInitializeRequest } from \"@modelcontextprotocol/sdk/types.js\";\nimport { createServer } from \"./server.js\";\nimport { registerWeatherTool } from \"./tools/weather.js\";\n\nconst app = express();\napp.use(express.json());\n\nconst transports: Record\u003Cstring, StreamableHTTPServerTransport> = {};\n\napp.post(\"/mcp\", async (req, res) => {\n  const sessionId = req.headers[\"mcp-session-id\"] as string | undefined;\n  let transport: StreamableHTTPServerTransport;\n\n  if (sessionId && transports[sessionId]) {\n    transport = transports[sessionId];\n  } else if (!sessionId && isInitializeRequest(req.body)) {\n    transport = new StreamableHTTPServerTransport({\n      sessionIdGenerator: () => randomUUID(),\n      onsessioninitialized: (newSessionId) => {\n        transports[newSessionId] = transport;\n      },\n      enableDnsRebindingProtection: true,\n      allowedHosts: [\"127.0.0.1\", \"localhost\"],\n    });\n\n    transport.onclose = () => {\n      if (transport.sessionId) {\n        delete transports[transport.sessionId];\n      }\n    };\n\n    const server = createServer();\n    registerWeatherTool(server);\n    await server.connect(transport);\n  } else {\n    res.status(400).json({\n      jsonrpc: \"2.0\",\n      error: { code: -32000, message: \"Bad Request: No valid session ID provided\" },\n      id: null,\n    });\n    return;\n  }\n\n  await transport.handleRequest(req, res, req.body);\n});\n\nconst handleSessionRequest = async (\n  req: express.Request,\n  res: express.Response\n): Promise\u003Cvoid> => {\n  const sessionId = req.headers[\"mcp-session-id\"] as string | undefined;\n  if (!sessionId || !transports[sessionId]) {\n    res.status(400).send(\"Invalid or missing session ID\");\n    return;\n  }\n  await transports[sessionId].handleRequest(req, res);\n};\n\napp.get(\"/mcp\", handleSessionRequest);\napp.delete(\"/mcp\", handleSessionRequest);\n\napp.listen(3000);\n",[724],{"type":45,"tag":65,"props":725,"children":726},{"__ignoreMap":6},[727],{"type":50,"value":722},{"type":45,"tag":46,"props":729,"children":730},{},[731,733,739,741,747,749,755],{"type":50,"value":732},"Two production details deserve attention. The GET handler exists so the server can push notifications to the client over SSE on the same endpoint, and the DELETE handler lets a client end its session cleanly so you free the transport. Next is ",{"type":45,"tag":65,"props":734,"children":736},{"className":735},[],[737],{"type":50,"value":738},"enableDnsRebindingProtection",{"type":50,"value":740},", which the SDK leaves off by default for backward compatibility. A local HTTP server with it disabled can be reached by a malicious web page through a DNS rebinding attack. Turn it on and set ",{"type":45,"tag":65,"props":742,"children":744},{"className":743},[],[745],{"type":50,"value":746},"allowedHosts",{"type":50,"value":748}," explicitly. If you would rather run without sessions at all, for a stateless function-style deployment, construct the transport with ",{"type":45,"tag":65,"props":750,"children":752},{"className":751},[],[753],{"type":50,"value":754},"sessionIdGenerator: undefined",{"type":50,"value":756}," and skip the map.",{"type":45,"tag":53,"props":758,"children":760},{"id":759},"securing-an-http-server-mcp-as-an-oauth-21-resource-server",[761],{"type":50,"value":762},"Securing an HTTP server: MCP as an OAuth 2.1 resource server",{"type":45,"tag":46,"props":764,"children":765},{},[766,768,773],{"type":50,"value":767},"This is the part that the ",{"type":45,"tag":65,"props":769,"children":771},{"className":770},[],[772],{"type":50,"value":173},{"type":50,"value":774}," revision rewrote and the current revision carries forward, and getting it wrong is the difference between a demo and something you can put on the internet. Under the current spec, an HTTP MCP server is an OAuth 2.1 resource server. It does not issue tokens. Instead, it validates them. A separate authorization server (your existing identity provider, Auth0, Keycloak, or similar) issues access tokens, and your MCP server checks each request's bearer token and the scopes it carries.",{"type":45,"tag":46,"props":776,"children":777},{},[778,780,786,788,793,795,801,803,808],{"type":50,"value":779},"The flow has a discovery step that is easy to miss. When a client hits your server without a valid token, you return ",{"type":45,"tag":65,"props":781,"children":783},{"className":782},[],[784],{"type":50,"value":785},"401 Unauthorized",{"type":50,"value":787}," with a ",{"type":45,"tag":65,"props":789,"children":791},{"className":790},[],[792],{"type":50,"value":118},{"type":50,"value":794}," header that points to your protected resource metadata (RFC 9728). From that metadata the client learns which authorization server to talk to, completes an OAuth flow there, and retries with a token. To cover both halves, the SDK gives you middleware: ",{"type":45,"tag":65,"props":796,"children":798},{"className":797},[],[799],{"type":50,"value":800},"mcpAuthMetadataRouter",{"type":50,"value":802}," to publish the metadata and ",{"type":45,"tag":65,"props":804,"children":806},{"className":805},[],[807],{"type":50,"value":102},{"type":50,"value":809}," to enforce tokens on your MCP routes.",{"type":45,"tag":46,"props":811,"children":812},{},[813],{"type":45,"tag":223,"props":814,"children":815},{},[816],{"type":45,"tag":65,"props":817,"children":819},{"className":818},[],[820],{"type":50,"value":821},"src/auth.ts",{"type":45,"tag":235,"props":823,"children":826},{"className":824,"code":825,"language":323,"meta":6},[321],"import express from \"express\";\nimport { requireBearerAuth } from \"@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js\";\nimport { mcpAuthMetadataRouter } from \"@modelcontextprotocol/sdk/server/auth/router.js\";\nimport { getOAuthProtectedResourceMetadataUrl } from \"@modelcontextprotocol/sdk/shared/auth-utils.js\";\n\nconst tokenVerifier = {\n  verifyAccessToken: async (token: string) => {\n    const response = await fetch(\"https://auth.example.com/introspect\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Authorization: `Bearer ${token}`,\n      },\n      body: new URLSearchParams({ token }).toString(),\n    });\n\n    if (!response.ok) {\n      // throws: rejects the request; requireBearerAuth turns this into a 401\n      throw new Error(`Invalid token: ${response.status}`);\n    }\n\n    const data = await response.json();\n    return {\n      token,\n      clientId: data.client_id,\n      scopes: data.scope ? data.scope.split(\" \") : [],\n      expiresAt: data.exp,\n    };\n  },\n};\n\nexport function applyAuth(app: express.Express): void {\n  app.use(\n    mcpAuthMetadataRouter({\n      oauthMetadata: {\n        issuer: \"https://auth.example.com\",\n        authorization_endpoint: \"https://auth.example.com/authorize\",\n        token_endpoint: \"https://auth.example.com/token\",\n      },\n      resourceServerUrl: new URL(\"http://localhost:3000/mcp\"),\n      scopesSupported: [\"mcp:tools\"],\n      resourceName: \"MCP Demo Server\",\n    })\n  );\n\n  app.use(\n    \"/mcp\",\n    requireBearerAuth({\n      verifier: tokenVerifier,\n      requiredScopes: [\"mcp:tools\"],\n      resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(\n        new URL(\"http://localhost:3000/mcp\")\n      ),\n    })\n  );\n}\n",[827],{"type":45,"tag":65,"props":828,"children":829},{"__ignoreMap":6},[830],{"type":50,"value":825},{"type":45,"tag":46,"props":832,"children":833},{},[834,835,841,843,849,851,857,858,864,866,872,874,879,881,886,888,894],{"type":50,"value":250},{"type":45,"tag":65,"props":836,"children":838},{"className":837},[],[839],{"type":50,"value":840},"verifyAccessToken",{"type":50,"value":842}," function is the heart of it, and the shape of its return value is the contract the middleware depends on. It must resolve to an object with ",{"type":45,"tag":65,"props":844,"children":846},{"className":845},[],[847],{"type":50,"value":848},"token",{"type":50,"value":850},", ",{"type":45,"tag":65,"props":852,"children":854},{"className":853},[],[855],{"type":50,"value":856},"clientId",{"type":50,"value":850},{"type":45,"tag":65,"props":859,"children":861},{"className":860},[],[862],{"type":50,"value":863},"scopes",{"type":50,"value":865},", and ",{"type":45,"tag":65,"props":867,"children":869},{"className":868},[],[870],{"type":50,"value":871},"expiresAt",{"type":50,"value":873},", because ",{"type":45,"tag":65,"props":875,"children":877},{"className":876},[],[878],{"type":50,"value":102},{"type":50,"value":880}," uses ",{"type":45,"tag":65,"props":882,"children":884},{"className":883},[],[885],{"type":50,"value":863},{"type":50,"value":887}," to enforce ",{"type":45,"tag":65,"props":889,"children":891},{"className":890},[],[892],{"type":50,"value":893},"requiredScopes",{"type":50,"value":895}," and rejects the request if the token lacks them. The example calls a token introspection endpoint, which is the safe default when tokens are opaque. If your authorization server issues JWTs you can verify the signature locally instead and skip the network round trip, but introspection has the advantage that revocation takes effect immediately.",{"type":45,"tag":46,"props":897,"children":898},{},[899],{"type":50,"value":900},"A word on why the resource-server model is stricter than it looks. RFC 8707 Resource Indicators bind a token to the specific resource it was issued for. That stops a malicious or compromised MCP server from taking a token a client sent it and replaying that token against a different, more sensitive server. Token audience matters, and the spec now treats it as a hard requirement rather than a nicety. If you are weighing identity providers for the authorization-server half of this, the trade-offs between Better Auth, Clerk, and Auth.js feed directly into this setup.",{"type":45,"tag":46,"props":902,"children":903},{},[904,906,912,914,920],{"type":50,"value":905},"For the simpler case where you want to delegate the entire OAuth dance to an upstream provider, the SDK also ships ",{"type":45,"tag":65,"props":907,"children":909},{"className":908},[],[910],{"type":50,"value":911},"ProxyOAuthServerProvider",{"type":50,"value":913}," together with ",{"type":45,"tag":65,"props":915,"children":917},{"className":916},[],[918],{"type":50,"value":919},"mcpAuthRouter",{"type":50,"value":921},", which proxies authorization and token requests to an external provider's endpoints. It is a faster path for a prototype, but for production I prefer the explicit resource-server setup above because it keeps token validation in your control.",{"type":45,"tag":53,"props":923,"children":925},{"id":924},"production-concerns-errors-input-safety-and-what-the-model-can-reach",[926],{"type":50,"value":927},"Production concerns: errors, input safety, and what the model can reach",{"type":45,"tag":46,"props":929,"children":930},{},[931],{"type":50,"value":932},"Two failure modes separate a server that survives contact with real traffic from one that does not, and both come back to the same idea: a tool boundary is a trust boundary.",{"type":45,"tag":46,"props":934,"children":935},{},[936,938,943],{"type":50,"value":937},"The first is error handling, which I have stressed throughout but which is worth stating as a rule. Errors that originate inside a tool should be returned as ",{"type":45,"tag":65,"props":939,"children":941},{"className":940},[],[942],{"type":50,"value":126},{"type":50,"value":944}," content. Errors that represent a broken protocol contract, like a malformed initialization request, should be real protocol errors. Mixing these up is the most common bug I see: servers that throw on a bad API response and leave the model staring at a dead connection, or servers that swallow a genuine protocol violation and confuse the client. Catch upstream failures, shape them into tool results, and let the protocol layer handle only protocol problems.",{"type":45,"tag":46,"props":946,"children":947},{},[948,950,956,958,963,965,971],{"type":50,"value":949},"The second is input safety. Tool arguments come from a model that is itself reading untrusted text, which means a tool input can be an injection vector. A ",{"type":45,"tag":65,"props":951,"children":953},{"className":952},[],[954],{"type":50,"value":955},"run_query",{"type":50,"value":957}," tool that interpolates its argument straight into SQL is a vulnerability whether the caller is a human or an LLM. Validate every input with the Zod schema, treat the validated value as data and never as code, and apply the same authorization checks inside the tool that you would on any API endpoint. The scopes you enforced with ",{"type":45,"tag":65,"props":959,"children":961},{"className":960},[],[962],{"type":50,"value":102},{"type":50,"value":964}," should gate which tools a given token can even call. Prompt injection in production AI features is its own deep attack surface, and the broader habit of hardening AI-driven code is covered in ",{"type":45,"tag":184,"props":966,"children":968},{"href":967},"/blog/hardening-ai-generated-react-app-for-production",[969],{"type":50,"value":970},"hardening an AI-generated React app for production",{"type":50,"value":687},{"type":45,"tag":46,"props":973,"children":974},{},[975],{"type":50,"value":976},"There is also a deployment decision hiding in the transport choice. A stdio server inherits the privileges of whatever launched it, so its blast radius is the user's machine. An HTTP server is a network service with all that implies: rate limiting, observability, and the OAuth layer above. Pick the transport that matches the trust model you actually have, not the one that is quickest to stand up.",{"type":45,"tag":53,"props":978,"children":980},{"id":979},"conclusion-ship-stdio-first-then-earn-http",[981],{"type":50,"value":982},"Conclusion: ship stdio first, then earn HTTP",{"type":45,"tag":46,"props":984,"children":985},{},[986,988,993],{"type":50,"value":987},"If you are building your first MCP server, start with stdio and a handful of well-typed tools. It has the smallest surface, no auth to get wrong, and the MCP Inspector makes iteration fast. Get the primitives right first: tools for actions, resources for read-only context, prompts for repeatable workflows, and ",{"type":45,"tag":65,"props":989,"children":991},{"className":990},[],[992],{"type":50,"value":406},{"type":50,"value":994}," on every tool failure. That foundation is identical no matter where the server ends up running.",{"type":45,"tag":46,"props":996,"children":997},{},[998,1000,1005,1007,1012,1014,1020,1022,1028],{"type":50,"value":999},"Move to streamable HTTP when you genuinely need a remote or multi-user server, and when you do, treat the OAuth resource-server model as mandatory rather than a later milestone. Target spec revision ",{"type":45,"tag":65,"props":1001,"children":1003},{"className":1002},[],[1004],{"type":50,"value":145},{"type":50,"value":1006},", pin ",{"type":45,"tag":65,"props":1008,"children":1010},{"className":1009},[],[1011],{"type":50,"value":70},{"type":50,"value":1013}," to 1.29.x, turn on DNS rebinding protection, and validate tokens and scopes on every request. The protocol is young and moving quickly, so verify the ",{"type":45,"tag":184,"props":1015,"children":1017},{"href":186,"rel":1016},[188],[1018],{"type":50,"value":1019},"TypeScript SDK README",{"type":50,"value":1021}," and the ",{"type":45,"tag":184,"props":1023,"children":1025},{"href":196,"rel":1024},[188],[1026],{"type":50,"value":1027},"specification",{"type":50,"value":1029}," against your installed version before you ship. Build the small thing well, and the production version is mostly the same code with a stricter door.",{"title":6,"searchDepth":1031,"depth":1031,"links":1032},2,[1033,1034,1035,1043,1047,1048,1049],{"id":55,"depth":1031,"text":58},{"id":131,"depth":1031,"text":134},{"id":205,"depth":1031,"text":208,"children":1036},[1037,1039,1040,1041,1042],{"id":302,"depth":1038,"text":305},3,{"id":351,"depth":1038,"text":354},{"id":418,"depth":1038,"text":421},{"id":478,"depth":1038,"text":481},{"id":523,"depth":1038,"text":526},{"id":584,"depth":1031,"text":587,"children":1044},[1045,1046],{"id":622,"depth":1038,"text":625},{"id":690,"depth":1038,"text":693},{"id":759,"depth":1031,"text":762},{"id":924,"depth":1031,"text":927},{"id":979,"depth":1031,"text":982},"markdown",1785621731734]