Build Your First MCP Server
Tools, resources, and prompts: a working server from scratch, explained line by line.
Building an MCP server means writing a program that exposes tools, resources, and prompts to an LLM (large language model) host over JSON-RPC, using a stateful transport like stdio. Under the hood, you are implementing the server side of the Model Context Protocol: negotiating capabilities, registering handlers, and mapping your domain logic to standardized message formats.
The same server you build for local use with Claude Desktop can be deployed remotely over HTTP with zero code changes. MCP separates protocol from transport. That separation is what makes the standard feel like a USB-C port for AI tools, and it is the first thing you need to understand before writing a single line of code.
In Part 2, we configured existing servers. Now we build our own, continuing the personal assistant project. We will wire up a server that can read your calendar, play music, and control smart lights. By the end, you will have a working server that an LLM can actually call.
What does an MCP server actually do?
An MCP server is a JSON-RPC 2.0 server that advertises capabilities to an MCP client, then responds to requests for those capabilities. It does not know about the LLM. It does not render UI. It waits for messages and runs your code.
Think of it as a restaurant’s point-of-sale system. The kitchen exposes a standard API (application programming interface) for “place order,” “check inventory,” and “update menu.” The waiter (the LLM host) uses that API without caring whether the kitchen uses gas or induction. The POS system is the MCP protocol. Your server is the kitchen.
The protocol defines three categories of things you can expose: tools, resources, and prompts. Tools are callable functions. Resources are readable data addressed by URIs. Prompts are reusable interaction templates. We will build all three for our personal assistant.
- Callable functions
- Model invokes them
- Can have side effects
- Example: add_calendar_event
- Read only data
- Model pulls into context
- No side effects
- Example: calendar://today
How does the initialization handshake work?
Before any tool call, the client and server must agree on protocol version and capabilities. The client sends an initialize request with its own capabilities and the protocol version it supports. The server responds with its capabilities and metadata. This handshake is mandatory and must be the first interaction.
The TypeScript SDK (software development kit) handles this handshake for you. When you create a McpServer instance and connect a transport, the SDK registers the lifecycle handlers and builds the capability metadata from the tools, resources, and prompts you registered. You never write the JSON-RPC messages yourself.
The lifecycle then enters the operation phase. In this phase, the client can list tools, call them, read resources, and fetch prompts. The connection stays open. The server can push notifications, like resource updates, without a preceding request. Finally, a shutdown request triggers cleanup and closes the transport.
How do I define a tool?
A tool is a function with a name, a description, a JSON (JavaScript Object Notation) Schema for its arguments, and a handler that returns content. The schema tells the LLM what arguments to pass. The handler runs your domain logic.
In the TypeScript SDK, you use Zod to define the schema. You register the tool with server.tool(), giving it a name, a Zod schema, and an async handler. The SDK validates incoming arguments against the schema before calling your handler. If validation fails, the SDK returns a structured error to the client.
Here is a tool that returns today’s calendar events for our personal assistant. It accepts a date string and returns a list of events as text.
The handler returns a content array. Each item has a type (like "text") and the actual payload. The SDK wraps this into the MCP tool result format and sends it back over the transport.
How do resources differ from tools?
Resources are read-only data that the LLM can pull into its context. They are identified by URIs, like calendar://today or file:///home/user/notes.txt. Unlike tools, resources do not perform actions. They just provide data.
You register a resource with server.resource(), giving it a URI, a name, a description, and a handler that returns content. The handler receives the URI and can use it to decide what data to return. The SDK handles listing and reading automatically.
For our assistant, we can expose a resource that returns today’s calendar as a JSON blob. The LLM can then read this resource to get structured data without calling a tool.
Resources can also be dynamic. You can define URI templates with parameters, like calendar://{date}. The client can then request any date. The spec supports subscriptions too, so the server can push updates when data changes.
How do prompts fit in?
Prompts are user-controlled templates that guide the LLM through a multi-step interaction. They are not called automatically by the model. The user selects them from a list the host presents.
You register a prompt with server.prompt(), giving it a name, a description, and a list of arguments. The handler returns an array of messages with roles like "user" and "assistant". The host uses these messages to seed the conversation.
For our assistant, we can create a “morning briefing” prompt that asks the model to summarize the day’s events and suggest a playlist.
The host can then retrieve this prompt, fill in the date, and inject the messages into the conversation. The model will see the user message and can call the calendar tool and music tool to fulfill the request.
How do I choose between stdio and SSE transports?
Stdio is the default for local development. The host spawns your server as a child process and communicates through standard input and output. It is simple, secure, and requires no network configuration.
- Child process
- Standard input/output
- Simple and secure
- Default for Claude Desktop
- HTTP POST + event stream
- Long lived connection
- Needs auth and TLS
- For production deployments
SSE (Server-Sent Events) is for remote servers. The client sends requests over HTTP POST, and the server pushes responses over a long-lived event stream. This is useful when your server runs on a different machine or needs to handle multiple clients.
The SDK makes switching trivial. You instantiate a different transport class. For stdio:
For SSE, you would use an HTTP server with an SSE transport. The same server.connect(transport) call works regardless. Your tool and resource handlers never change.
In production, SSE requires authentication, TLS, and rate limiting. The spec notes that transport implementations must handle these concerns. When you deploy remotely, you add those layers outside the MCP protocol itself.
What does a complete server look like?
Putting it all together, here is the full server for our personal assistant. It exposes a tool, a resource, and a prompt. We run it over stdio, which is what Claude Desktop expects.
To run this, compile it and point Claude Desktop’s config at the built file. The host will spawn the process, initialize the connection, and then the LLM can use your tools, read your resources, and fetch your prompts.
Quick Reference
| Property | Value |
|---|---|
| Protocol | JSON-RPC 2.0 over stateful transport |
| Default transport | stdio (local), SSE (remote) |
| Server SDKs | TypeScript, Python, Kotlin,.NET, Go (community) |
| Capability categories | tools, resources, prompts |
| Tool argument validation | Zod (TS), type hints (Python), attributes (.NET) |
| Resource identification | URIs with optional templates |
| Lifecycle phases | initialize → operate → shutdown |
| Specification version | 2024-11-05 (current) |
Frequently Asked Questions
Q: Can I use MCP with any LLM provider? Yes. MCP is provider-agnostic. The host application embeds the MCP client and the LLM. Claude Desktop uses Anthropic’s models, but other hosts can use any model that supports tool calling.
Q: Do I need to handle JSON-RPC myself? No. The official SDKs handle serialization, deserialization, and routing. You register handlers with high-level APIs. The SDK translates your responses into the correct MCP message format.
Q: How do I secure a server that modifies files? Validate all inputs. Use allowlists for paths and URIs. Run the server with the least privilege necessary. When using stdio, the host process is local and trusted. For remote SSE, add authentication and TLS at the HTTP layer.
Q: What happens if my server crashes mid-operation? The transport connection breaks. The client will see an error or a closed stream. The host should surface the failure to the user. The protocol does not define automatic retry. You must make your handlers resilient and log errors for debugging.
Q: Can I test my server without Claude Desktop? Yes. The MCP Inspector (a debugging tool from the MCP team) lets you send raw JSON-RPC messages and inspect responses. You can also write integration tests that instantiate a client and server in the same process.
Test yourself
Your personal assistant server exposes a play_music tool that accepts a genre string. A user says, “Play something relaxing.” The LLM calls play_music with { genre: "relaxing" }. Your handler queries a music service and returns a playlist. During testing, you notice that the LLM sometimes passes { genre: "relaxing " } with a trailing space. What should your server do?
Answer: The Zod schema should validate and transform the input. You can add .trim() to the string schema: z.string().trim().describe(...). Zod will strip whitespace before the handler runs. If you cannot modify the schema, sanitize the argument inside the handler. Never trust the LLM to produce clean input. The SDK will reject invalid types, but it does not enforce business rules like trimming. Defensive coding at the boundary prevents downstream failures in your music service API. Always treat tool arguments as untrusted data.
If you want this kind of breakdown every week, how real systems actually work under the hood, subscribe to Internals Decoded at internalsdecoded.com.
In Part 4, we will take this server and connect it to real APIs: Google Calendar, Spotify, and Philips Hue. We will handle authentication, error recovery, and streaming responses.
Sources
- MCP Introduction
- MCP Architecture
- MCP Specification: Lifecycle
- MCP Specification: Transports
- MCP Specification: Tools
- MCP Specification: Resources
- MCP Specification: Prompts
- TypeScript SDK
- Python SDK
- Claude Desktop MCP configuration