Building Your First MCP Server: A Practical Walkthrough
A hands-on guide to writing a Model Context Protocol server in TypeScript — exposing tools an AI assistant can actually call, and wiring it into Claude and other MCP clients.
On this page
The Model Context Protocol (MCP) is the plumbing that lets an AI assistant reach outside its own context window — to your database, your file system, an internal API, or any tool you care to expose. Once you have written one MCP server, the mental model clicks: you are just publishing a small set of typed functions, and the client (Claude Desktop, an IDE, or your own app) decides when to call them.
This walkthrough builds a minimal but real server in TypeScript. No toy add(a, b) — we will expose a tool that reads structured data and returns it in a shape the model can use.
What an MCP server actually is
An MCP server is a long-running process that speaks the MCP protocol over a transport (stdio for local tools, HTTP/SSE for remote ones). It advertises three kinds of capabilities:
- Tools — functions the model can invoke (this is what you will use most).
- Resources — read-only data the client can pull into context.
- Prompts — reusable prompt templates.
For a first server, tools are where the value is. Everything else can wait.
Project setup
Start with a clean TypeScript project and the official SDK:
mkdir bytes-mcp && cd bytes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --initzod is not optional in practice — the SDK uses it to describe and validate tool inputs, and that schema is exactly what the model sees when it decides how to call your tool.
Writing the server
Here is a complete server that exposes a single get_project_status tool. Imagine it reads from an internal deployment API; here we stub the data so the example runs anywhere.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "bytes-mcp",
version: "1.0.0",
});
const deployments: Record<string, { env: string; status: string; sha: string }> = {
"web-app": { env: "production", status: "healthy", sha: "a1b2c3d" },
"api": { env: "production", status: "degraded", sha: "9f8e7d6" },
};
server.registerTool(
"get_project_status",
{
title: "Get project status",
description: "Return the current deployment status for a given project.",
inputSchema: { project: z.string().describe("The project slug, e.g. 'web-app'") },
},
async ({ project }) => {
const record = deployments[project];
if (!record) {
return {
content: [{ type: "text", text: `No project found named "${project}".` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Project ${project} is ${record.status} on ${record.env} (sha ${record.sha}).`,
},
],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);A few things worth noticing:
- The
descriptionfields are not documentation — they are the interface. The model reads them to decide when and how to call the tool. Vague descriptions produce vague tool use. - Returning
isError: truelets the client surface a failure to the model without crashing the session. StdioServerTransportmeans this server communicates over standard input/output, which is exactly what local clients like Claude Desktop expect.
Running and connecting it
Add a script and run it:
{
"scripts": {
"start": "tsx src/server.ts"
}
}To connect it to Claude Desktop, add an entry to its MCP config file:
{
"mcpServers": {
"bytes-mcp": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/bytes-mcp/src/server.ts"]
}
}
}Restart the client, and the tool shows up. Ask it something like "What's the status of the api project?" and you will watch it call get_project_status with { project: "api" } and answer from the result.
What I learned shipping a real one
The gap between the demo above and a production server is mostly discipline, not complexity:
- Keep tools narrow. One tool that does one thing is far easier for the model to use correctly than one tool with a
modeparameter that changes its behavior. - Validate at the boundary. The
zodschema is your only guarantee about what the model sends. Treat every input as untrusted — because functionally, it is. - Return text the model can reason about, not raw JSON blobs. A one-line summary plus structured detail beats dumping an entire API response.
Once this loop feels natural, the same pattern scales to database queries, ticket systems, and internal dashboards. The protocol is small on purpose — the leverage comes from what you choose to expose.