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.
TL;DR
MCP gives AI clients a uniform way to call your code. Build a server with @modelcontextprotocol/sdk (version 1.29.x, implementing spec revision 2025-11-25): create an McpServer, register tools, resources, and prompts, then connect a transport. Use StdioServerTransport for local desktop clients and StreamableHTTPServerTransport for remote ones. For HTTP servers exposed to a network, treat the server as an OAuth 2.1 resource server: validate bearer tokens with requireBearerAuth, advertise your auth server through protected resource metadata, and return 401 with WWW-Authenticate on failure. Return tool errors as { isError: true } rather than throwing across the protocol boundary.
Versions and the spec revision this guide targets
MCP uses date-based versioning, and the current revision is 2025-11-25. 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 MCP-Protocol-Version header on every later request. The matching official TypeScript SDK at the time of writing is @modelcontextprotocol/sdk version 1.29.x, which the maintainers flag as the production line while a v2 rewrite is still pre-release.
The authorization model that this guide leans on landed one revision earlier, in 2025-06-18, 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 2025-11-25 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 TypeScript SDK and check the current specification before copying any auth code.
What an MCP server actually exposes: tools, resources, and prompts
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.
Start with the project skeleton. Install the SDK and Zod, which the SDK uses to describe and validate tool inputs.
package.json (dependencies)
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"express": "^5.0.0",
"zod": "^3.25.0"
}
}
The "type": "module" line is not optional cosmetics. Because the SDK ships as ESM and its import paths end in .js, the package has to be a module or Node will reject the imports at runtime. One note on the zod range: the SDK's peer dependency accepts both ^3.25 and ^4.0, but a fresh npm install zod now pulls v4, so pin the version deliberately rather than assuming a new project lands on v3.
Creating the server instance
src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function createServer(): McpServer {
return new McpServer({
name: "my-mcp-server",
version: "1.0.0",
});
}
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 name and version are sent to the client during initialization and show up in client UIs, so use a stable, recognizable name.
Registering a tool with a typed input schema
Tools are where most of the work lives. server.registerTool takes a name, a config object with a Zod inputSchema, 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.
src/tools/weather.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerWeatherTool(server: McpServer): void {
server.registerTool(
"get_weather",
{
title: "Weather Information Provider",
description: "Get current weather data for a city",
inputSchema: { city: z.string() },
},
async ({ city }) => {
const response = await fetch(
`https://api.example.com/weather?city=${encodeURIComponent(city)}`
);
if (!response.ok) {
// throws: nothing; the protocol expects errors as data, not exceptions
return {
content: [{ type: "text", text: `Weather lookup failed: ${response.status}` }],
isError: true,
};
}
const data = await response.text();
return {
content: [{ type: "text", text: data }],
};
}
);
}
The isError 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 { isError: true } 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.
Returning structured output the client can parse
Text content works for prose, but agents increasingly want machine-readable results. The spec supports structured output: declare an outputSchema and return a structuredContent object alongside the text.
src/tools/weather-structured.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerStructuredWeatherTool(server: McpServer): void {
server.registerTool(
"get_weather_structured",
{
title: "Structured Weather",
description: "Get current weather as structured data",
inputSchema: { city: z.string() },
outputSchema: {
temperature: z.number(),
conditions: z.string(),
},
},
async ({ city }) => {
const data = { temperature: 22.5, conditions: "Partly cloudy" };
return {
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
}
);
}
Keep the text content as a fallback even when you return structured data. Not every client renders structuredContent, 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.
Exposing data as resources
Resources are read-only context addressed by URI. For dynamic resources you register a ResourceTemplate 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.
src/resources/repos.ts
import {
type McpServer,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerRepoResource(server: McpServer): void {
server.registerResource(
"github-repos",
new ResourceTemplate("github://repos/{owner}/{repo}", {
list: undefined,
complete: {
repo: (value, context) => {
if (context?.arguments?.["owner"] === "org1") {
return ["project1", "project2"];
}
return ["default-repo"];
},
},
}),
{
title: "GitHub Repository",
description: "Repository information by owner and repo name",
},
async (uri, { owner, repo }) => ({
contents: [
{
uri: uri.href,
text: `Repository: ${owner}/${repo}`,
},
],
})
);
}
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.
Registering a prompt as a reusable workflow
Prompts are user-invoked templates. They take an argsSchema and return the messages that seed a conversation.
src/prompts/review.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerReviewPrompt(server: McpServer): void {
server.registerPrompt(
"review-code",
{
title: "Code Review",
description: "Review code for best practices and potential issues",
argsSchema: { code: z.string() },
},
({ code }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Please review this code:\n\n${code}`,
},
},
],
})
);
}
Prompts are the most overlooked of the three primitives, and they are the cheapest way to encode an opinion. A good review-code or summarize-incident 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.
Which transport should you use, stdio or streamable HTTP?
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.
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 transports specification: a stdio server must never write anything to stdout that is not a valid MCP message. Stray console.log output corrupts the JSON-RPC stream and breaks the client. Log to stderr instead.
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.
Wiring up the stdio transport
src/stdio.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";
import { registerWeatherTool } from "./tools/weather.js";
import { registerRepoResource } from "./resources/repos.js";
import { registerReviewPrompt } from "./prompts/review.js";
async function main(): Promise<void> {
const server = createServer();
registerWeatherTool(server);
registerRepoResource(server);
registerReviewPrompt(server);
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
// throws: writes to stderr, never stdout, then exits non-zero
console.error("Fatal error starting MCP server:", error);
process.exit(1);
});
Notice that the only error reporting in the entry point goes to console.error, 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.
You can run this server against the official MCP Inspector to exercise it before any client integration:
npx @modelcontextprotocol/inspector node build/stdio.js
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 isError.
Wiring up streamable HTTP with session management
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 mcp-session-id header.
src/http.ts
import express from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createServer } from "./server.js";
import { registerWeatherTool } from "./tools/weather.js";
const app = express();
app.use(express.json());
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports[newSessionId] = transport;
},
enableDnsRebindingProtection: true,
allowedHosts: ["127.0.0.1", "localhost"],
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
const server = createServer();
registerWeatherTool(server);
await server.connect(transport);
} else {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
const handleSessionRequest = async (
req: express.Request,
res: express.Response
): Promise<void> => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}
await transports[sessionId].handleRequest(req, res);
};
app.get("/mcp", handleSessionRequest);
app.delete("/mcp", handleSessionRequest);
app.listen(3000);
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 enableDnsRebindingProtection, 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 allowedHosts explicitly. If you would rather run without sessions at all, for a stateless function-style deployment, construct the transport with sessionIdGenerator: undefined and skip the map.
Securing an HTTP server: MCP as an OAuth 2.1 resource server
This is the part that the 2025-06-18 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.
The flow has a discovery step that is easy to miss. When a client hits your server without a valid token, you return 401 Unauthorized with a WWW-Authenticate 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: mcpAuthMetadataRouter to publish the metadata and requireBearerAuth to enforce tokens on your MCP routes.
src/auth.ts
import express from "express";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const response = await fetch("https://auth.example.com/introspect", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${token}`,
},
body: new URLSearchParams({ token }).toString(),
});
if (!response.ok) {
// throws: rejects the request; requireBearerAuth turns this into a 401
throw new Error(`Invalid token: ${response.status}`);
}
const data = await response.json();
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(" ") : [],
expiresAt: data.exp,
};
},
};
export function applyAuth(app: express.Express): void {
app.use(
mcpAuthMetadataRouter({
oauthMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
},
resourceServerUrl: new URL("http://localhost:3000/mcp"),
scopesSupported: ["mcp:tools"],
resourceName: "MCP Demo Server",
})
);
app.use(
"/mcp",
requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: ["mcp:tools"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(
new URL("http://localhost:3000/mcp")
),
})
);
}
The verifyAccessToken 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 token, clientId, scopes, and expiresAt, because requireBearerAuth uses scopes to enforce requiredScopes 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.
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.
For the simpler case where you want to delegate the entire OAuth dance to an upstream provider, the SDK also ships ProxyOAuthServerProvider together with mcpAuthRouter, 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.
Production concerns: errors, input safety, and what the model can reach
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.
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 { isError: true } 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.
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 run_query 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 requireBearerAuth 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 hardening an AI-generated React app for production.
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.
Conclusion: ship stdio first, then earn HTTP
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 isError on every tool failure. That foundation is identical no matter where the server ends up running.
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 2025-11-25, pin @modelcontextprotocol/sdk 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 TypeScript SDK README and the specification 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.


