Build Your First MCP Server
Tools, resources, and prompts: a working server from scratch, explained step by step.
Building an MCP server means writing a small program that offers tools, resources and prompts to an AI app. The official SDKs (software development kits) handle the protocol for you: the JSON-RPC messages, the version checks and the input validation. Your job is to describe what you offer and write the code behind it.
In Part 2 we plugged existing servers into an assistant. Now we build our own for the same personal assistant: a calendar server with two tools, one resource and one prompt. By the end you will have a server you have tested yourself and connected to Claude Desktop, and you will know how to serve the same code over HTTP. You need Node.js 20 or later, a terminal and a little TypeScript. Python readers get the same server near the end.
What does an MCP server actually do?
An MCP server waits for requests from an MCP client and answers them. It describes what it offers when asked, runs a tool when asked, and returns data or a prompt template when asked. It never talks to the model and draws no user interface. The host decides what the model sees and what the user approves.
Think of a restaurant kitchen with a standard order window. The waiter, who is the host, passes orders through the window without caring whether the kitchen cooks with gas or induction. MCP is the shape of the window and of the order slips. Your server is the kitchen.
You can offer three kinds of thing, and each has a different owner. Tools are functions the model decides to call. Resources are data the app decides to read and attach to the conversation, each with an address called a URI. Prompts are templates the user picks, for example from a slash command menu. We will build all three.
- Callable functions
- The model decides to call them
- Can have side effects
- Example: add_calendar_event
- Read-only data with a URI
- The host decides to attach them
- No side effects
- Example: calendar://today
How do I set up the project?
We use version 2 of the official TypeScript SDK, the stable line since July 2026. It ships as @modelcontextprotocol/server and implements the current 2026-07-28 revision of the spec, while still answering apps that speak the older handshake. Run these commands one at a time in a terminal:
type=module matters because the SDK ships only modern JavaScript modules. Zod is the library you describe tool inputs with. Then create tsconfig.json in the project folder:
Two lines here are easy to miss. Without rootDir, recent TypeScript versions stop this project with error TS5011 (we tested TypeScript 7.0), and since TypeScript 6 the @types packages are no longer included on their own, which is why "types": ["node"] is listed, as the SDK's install notes say.
How do I define a tool?
A tool has a name, a description, an input schema and a handler. The description and schema are what the model reads when it decides whether and how to call the tool, so write them for a reader who knows nothing about your code. The handler is your logic.
Create src/calendar.ts. It holds everything the server offers, inside one function, createServer, that builds and returns the server:
Step by step: McpServer is the server object, named so that apps can show it. registerTool takes the tool's name, a config and the handler. From the one Zod schema in inputSchema, the SDK produces the JSON Schema the model sees (including the pattern and the description), checks every call against it before your handler runs, and gives the handler typed arguments. If the model sends { "date": "tomorrow" }, your handler never runs; the SDK returns a tool result marked isError: true with an input validation error that ends in "Use YYYY-MM-DD", which the model can read and correct.
The handler returns content, a list of typed blocks; here one text block. The annotations are hints for the host: readOnlyHint: true says this tool changes nothing, so a host may let it run with less ceremony. Hints guide the app; they never change how your code runs.
Now add a second tool, one that writes. Put it inside createServer, just before return server;:
The schema does real work here. trim() removes stray spaces, min(1) refuses an empty title, and max(200) stops a runaway one. The annotations say this tool changes data, does not destroy anything, and should not be repeated blindly, so a careful host will ask the user before running it.
How do resources differ from tools?
A resource is data with an address. The app, not the model, decides when to read it and attach it to the conversation, for example when the user opens a menu and picks "Today's calendar". Add this inside createServer, before return server;:
registerResource takes four things, in this order: a name, the URI, a metadata object and the read handler. Getting that order wrong is the classic mistake with this API, and TypeScript will refuse to compile it. The handler returns contents, each item echoing the URI it answers for. resources/list now advertises calendar://today, and resources/read on that URI runs your handler. A resource can also be a template with a variable part, such as calendar://{date}, which the SDK's resources guide covers.
How do prompts fit in?
A prompt is a template the user starts on purpose. The host lists your prompts, the user picks one and fills in its arguments, and the host puts the resulting messages into the conversation. Add the last piece inside createServer:
When the user runs this prompt, the model receives that message and can call get_calendar_events to fulfil it. Prompts are a good way to package a workflow you want people to run the same way every time.
How do I run and test it?
The server needs an entry point that says how to serve it. Create src/server.ts:
serveStdio reads requests on standard input, writes answers on standard output, and calls createServer to build the server for the connection. Note the log line uses console.error. Standard output belongs to the protocol, and a single console.log would corrupt the messages, as the SDK's stdio guide warns.
Build it, then open it in the MCP Inspector, a test app from the MCP project that starts your server and lets you call it by hand:
The Inspector opens in your browser. Click Connect, then use the Tools tab to call get_calendar_events with today's date, the Resources tab to read calendar://today, and the Prompts tab to fetch morning_briefing. Try a bad date too, and you will see the validation error the model would see. If you prefer the terminal, the Inspector has a command-line mode:
To use the server in Claude Desktop, add it to claude_desktop_config.json (Settings, Developer, Edit Config) with the full path to the built file, then quit and restart the app:
On Windows the path looks like "C:\\Users\\you\\personal-assistant\\build\\server.js". The path must be absolute, because the app does not start your server from the project folder. In Claude Code the same server is one command: claude mcp add personal-assistant -- node /ABSOLUTE/PATH/TO/personal-assistant/build/server.js. Ask the assistant "What is on my calendar today?" and it will call your tool, after asking your permission.
How does a client find out what your server can do?
You never write these messages, because the SDK handles them, but it helps to know what crosses the wire. Under the current revision, 2026-07-28, there is no opening handshake. Every request carries the protocol version and the client's capabilities in a _meta field. A client may first send server/discover, which the SDK answers for you with your server's name, its supported versions and its capabilities (tools, resources, prompts). Then the client lists what you offer and calls it.
Older clients, those built on spec revisions up to 2025-11-25 such as the version 1 SDKs, open with an initialize request and an initialized notification instead. serveStdio answers both kinds from the same createServer, as the SDK's legacy clients guide explains, so your server works with new and old apps alike. Part 1's section on what changed in the 2026-07-28 revision lists the other differences.
There is no shutdown message in either version. When the app is done, it closes your server's standard input, and the process exits.
How do I choose between stdio and Streamable HTTP?
stdio is right when the app starts your server on the same computer, which is how Claude Desktop, Claude Code and VS Code run local servers. There is no network port and nothing to secure beyond your own machine.
Streamable HTTP is right when one server should serve many clients, or run somewhere else. Each request is an HTTP POST to one endpoint, and the server answers with a single JSON reply or with a short stream of updates that ends in the reply. It replaced the older HTTP+SSE transport, which is deprecated. Because you kept everything in createServer, serving over HTTP is a second entry file. Install the SDK's Node adapter with npm install @modelcontextprotocol/node, then create src/http.ts:
Build again and run node build/http.js, and the endpoint is http://127.0.0.1:3000/mcp. createMcpHandler builds a fresh server from your factory for every request, so nothing is kept between requests and the server can scale out, as the SDK's HTTP guide explains. The two checks in front of it refuse requests whose Host or Origin header is not local, which stops a web page in your browser from reaching your local server through a trick called DNS rebinding. The spec's Streamable HTTP rules ask servers to check the Origin header and, when running locally, to listen only on 127.0.0.1, as this code does. Your tools, resource and prompt did not change at all.
This server listens only on your own machine. Putting it on the internet takes more: HTTPS, and authorization so only the right people can call it. The spec's authorization section describes the OAuth 2.1 based flow, the SDK's authorization guide shows how to require a token, and MCP Security: The Attack Surface You Just Installed covers what can go wrong.
What does the same server look like in Python?
The official Python SDK is the mcp package, version 2 since July 2026, and it needs Python 3.11 or later. Its server class is MCPServer, which was called FastMCP before version 2; old tutorials that import mcp.server.fastmcp now fail with an error that points to the new name. With uv installed, set up a project:
Then save this as server.py:
The Python SDK reads the name from the function, the description from the docstring and the input schema from the type hints, so there is less to declare. mcp.run() serves over stdio by default. uv run mcp dev server.py opens it in the Inspector, and in Claude Desktop the entry is "command": "uv" with "args": ["--directory", "/ABSOLUTE/PATH/TO/personal-assistant-py", "run", "server.py"].
Quick Reference
| Property | Value |
|---|---|
| TypeScript SDK | @modelcontextprotocol/server 2.x, the stable line; implements spec 2026-07-28 and still serves older clients |
| Older TypeScript SDK | @modelcontextprotocol/sdk 1.x, maintenance only, spec up to 2025-11-25 |
| Python SDK | mcp 2.x, from mcp.server import MCPServer (FastMCP before version 2) |
| Registering | registerTool, registerResource (name, URI, metadata, handler), registerPrompt |
| Input validation | a Zod schema (TypeScript) or type hints (Python), checked before your handler runs |
| Transports | stdio (serveStdio) and Streamable HTTP (createMcpHandler); HTTP+SSE deprecated |
| Testing | npx @modelcontextprotocol/inspector node build/server.js |
| Specification | 2026-07-28, current as of October 2026 |
| Official SDKs | TypeScript, Python, C#, Go, Rust, Ruby, Java, Swift, PHP and Kotlin, as of October 2026 (SDK list) |
Frequently Asked Questions
Q: Can I use MCP with any LLM provider? Yes. Your server never meets the model. The host holds the model and the MCP client, so any host whose model can call tools can use your server, whichever company made the model.
Q: Do I need to handle JSON-RPC myself? No. The SDK handles the messages, the version checks, listing and validation. You register what you offer and write the handlers.
Q: I learned the older @modelcontextprotocol/sdk package. Is that code wrong?
It still works with apps that speak the older protocol, and version 1 gets fixes for at least six months after version 2's release in July 2026. Version 1 has the same registerTool, registerResource and registerPrompt methods, imported from @modelcontextprotocol/sdk/server/mcp.js, with a StdioServerTransport from .../server/stdio.js. The older tool(), resource() and prompt() methods are deprecated there and gone in version 2. The SDK's upgrade guide includes a codemod that rewrites most of it for you.
Q: How do I secure a server that changes things? Validate every input in the schema, then check business rules in the handler. Allow only the paths, accounts or records the server needs. Mark write tools honestly in their annotations so hosts ask before running them, and give the server the narrowest credentials that work. Over HTTP, add HTTPS and authorization.
Q: What happens if my server crashes mid-operation? With stdio, the app sees your process exit and the call fails. The spec says the client should restart a server that exits unexpectedly, and because the protocol is stateless the call can be retried against the new process. Make handlers safe to retry, and log to standard error so the app's log shows what went wrong.
Q: Can I test my server without Claude Desktop? Yes. The Inspector, shown above, calls your tools, reads your resources and fetches your prompts by hand. For automated tests, the SDK's testing guide connects a real client to your server inside one process, with no host and no approval clicks.
Test yourself
While testing your calendar server with an assistant, you notice that the model sometimes calls get_calendar_events with { "date": "2026-10-19 " }, with a trailing space. The call fails with the error "Use YYYY-MM-DD", the model usually retries correctly, but each failure costs a round trip. What should you change, and what should you keep strict?
Answer: Normalize harmless noise at the boundary and keep rejecting real mistakes. Change the date schema to z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"). Zod trims the value first and then checks the pattern, so "2026-10-19 " becomes "2026-10-19" before your handler runs, while "19/10/2026" is still refused with a clear message the model can act on. Do not loosen the pattern to accept any text: a vague schema moves the guessing into your handler, where bugs hide. The wider lesson is to treat tool arguments as untrusted input. The schema handles shape and format, the handler checks business rules such as whether the date is in range, and the error text is written for the model, since the model is the one who reads it and tries again.
Next: MCP Security: The Attack Surface You Just Installed. It looks at what can go wrong once servers like this one are connected: prompt injection through tool results, malicious local servers, over-broad sign in permissions and supply chain risk.
If you want this kind of breakdown every week, how real systems actually work under the hood, subscribe to Internals Decoded at internalsdecoded.com.
Sources
- MCP TypeScript SDK v2 documentation
- @modelcontextprotocol/server on npm
- TypeScript SDK: Resources
- TypeScript SDK: Serve over stdio
- TypeScript SDK: Serve over HTTP
- TypeScript SDK: Support legacy clients
- TypeScript SDK: Authorization
- TypeScript SDK: Testing
- TypeScript SDK: Upgrade to v2
- MCP Python SDK
- uv
- MCP specification 2026-07-28: Streamable HTTP
- MCP specification 2026-07-28: Authorization
- MCP SDKs