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.

MCP Resources: Giving the Model Read Access to Your Data
MCP
Tools get all the attention in MCP tutorials, but Resources are where you give the model read access to live data — files, databases, configs — without making it call a tool every time.
3 min read
Adding Authentication to Your MCP Server
MCP
Exposing an MCP server beyond localhost means thinking about who can call it. This walkthrough adds API key authentication to a TypeScript MCP server without over-engineering it.
4 min read
Getting More From ChatGPT With Projects
ChatGPT
ChatGPT Projects changed how I use the tool daily — persistent instructions, shared context across conversations, and a much cleaner way to separate different kinds of work.
4 min read