BytesAI
MCP

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.

Abid Zaidi4 min read
#mcp#typescript#claude#ai-tools#tutorial
Building Your First MCP Server: A Practical Walkthrough

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:

bash
mkdir bytes-mcp && cd bytes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init

zod 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.

typescript
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:

  1. The description fields 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.
  2. Returning isError: true lets the client surface a failure to the model without crashing the session.
  3. StdioServerTransport means 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:

json
{
  "scripts": {
    "start": "tsx src/server.ts"
  }
}

To connect it to Claude Desktop, add an entry to its MCP config file:

json
{
  "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 mode parameter that changes its behavior.
  • Validate at the boundary. The zod schema 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.

How I Actually Use LLMs Day to Day as a Developer
AI Coding
A grounded look at where large language models earn their keep in a working developer's day — the tasks worth delegating, the ones worth keeping, and the workflow that ties them together.
3 min read
ChatGPT for Developers: Getting Real Work Done
ChatGPT
Beyond autocomplete — how to use ChatGPT as a genuine engineering tool for debugging, design review, and grinding through the tedious parts of a project without shipping subtle bugs.
3 min read
Deploying a Next.js 15 App to AWS ECS with Docker
Tutorials
A production-minded guide to containerizing a Next.js 15 app with standalone output and running it on AWS ECS Fargate — Dockerfile, task definition, and the gotchas that bite in production.
4 min read