Building an MCP Server in TypeScript

Building an MCP Server in TypeScript

#ai-engineering#typescript#mcp#nodejs

A support agent asks your assistant, “What is the status of order 4417?” The model does not know. It has never seen your database, and it never will. So it guesses, invents a plausible tracking number, and the agent reads it out loud to a paying customer.

The fix is not a bigger prompt. It is a tool the model can actually call: a function that hits your order service, returns real data, and lets the model answer from facts instead of vibes. The Model Context Protocol (MCP) is the standard way to expose that function so any MCP-capable client (Claude Desktop, IDE assistants, agent frameworks) can call it without bespoke glue. Here is a real MCP server in TypeScript, verified against the current SDK.

What MCP actually is

MCP is an open protocol from Anthropic that standardizes how applications feed context to LLMs. Think of it as a contract between a host (the LLM app), one or more clients it spawns, and your server (the process that owns the capability). The client and server speak JSON-RPC 2.0 over a transport, most commonly stdio for local servers.

A server can expose three kinds of capability:

  • Tools: functions the model can invoke, with side effects, subject to user approval. “Look up order 4417.”
  • Resources: read-only data the client can load into context, addressed by URI. Think files or API responses.
  • Prompts: reusable prompt templates the user can trigger deliberately.

This post focuses on tools, because they are where most real work happens. If you have ever hand-rolled OpenAI-style function calling, you know the pain: every client reinvents the schema format, the invocation loop, and the result plumbing. MCP standardizes all of it, so you write the tool once and it works everywhere. That is the same “stop shipping the prompt, ship the capability” idea I argue in The prompt is not the product.

The client/server dance

sequenceDiagram
    participant H as Host (Claude Desktop)
    participant C as MCP Client
    participant S as Your MCP Server
    H->>C: spawn server process
    C->>S: initialize (JSON-RPC)
    S-->>C: capabilities { tools }
    C->>S: tools/list
    S-->>C: [get_order_status]
    Note over H: user asks about order 4417
    H->>C: model wants get_order_status
    C->>S: tools/call { name, arguments }
    S-->>C: content: [{ type:"text", text }]
    C-->>H: tool result -> model answers

The host launches your server, negotiates capabilities during initialize, lists the tools, and from then on forwards each tools/call the model decides to make. Every message is JSON-RPC over stdin/stdout. You never write that wire format by hand; the SDK does.

Setting up

You need Node 18+ and two dependencies. This post is written against the stable @modelcontextprotocol/sdk 1.x line, which pairs with Zod v3 for input schemas. (The 2.0 line is in alpha, changing the import paths and moving to Zod v4, so pin the major to avoid surprises.)

mkdir order-mcp && cd order-mcp
npm init -y
npm install @modelcontextprotocol/sdk@^1 zod@3
npm install -D typescript @types/node

Set "type": "module" in package.json (the SDK ships ESM) and add a minimal tsconfig.json targeting ES2022 / NodeNext, emitting to build/. Then build with a "build": "tsc" script.

A minimal server with one tool

Here is a complete src/index.ts. It exposes a single get_order_status tool over stdio. I use an in-memory map so the file runs as-is; swap the lookup for a real query when you wire it to your service.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Pretend this is your orders service / database.
const ORDERS: Record<string, { status: string; eta: string }> = {
  "4417": { status: "shipped", eta: "2026-07-24" },
  "4418": { status: "processing", eta: "2026-07-28" },
};

const server = new McpServer({
  name: "order-status",
  version: "1.0.0",
});

server.registerTool(
  "get_order_status",
  {
    description: "Look up the current status and ETA for an order by its ID.",
    inputSchema: {
      orderId: z
        .string()
        .regex(/^\d+$/)
        .describe("Numeric order ID, e.g. 4417"),
    },
  },
  async ({ orderId }) => {
    const order = ORDERS[orderId];
    if (!order) {
      return {
        content: [
          { type: "text", text: `No order found with ID ${orderId}.` },
        ],
        isError: true,
      };
    }
    return {
      content: [
        {
          type: "text",
          text: `Order ${orderId}: ${order.status}, ETA ${order.eta}.`,
        },
      ],
    };
  },
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // stdout is the JSON-RPC channel, so logs MUST go to stderr.
  console.error("order-status MCP server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

Three details that matter, all confirmed against the SDK docs:

  1. inputSchema is a raw Zod shape, an object of validators, not z.object(...). The SDK wraps it, derives the JSON Schema the client advertises, and hands your handler a typed, already-validated { orderId }. Bad input is rejected before your code runs.
  2. The handler returns { content: [...] }. Each item has a type ("text" here) and its payload. Set isError: true to signal a tool-level failure the model can reason about, rather than throwing.
  3. Never write to stdout. On the stdio transport, stdout is the JSON-RPC pipe. A stray console.log corrupts a message and kills the connection. Log to stderr with console.error. This is the single most common way a first MCP server “mysteriously” fails.

Running and wiring it into a client

Build it, then point a client at the compiled entrypoint.

npm run build   # tsc -> build/index.js

For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json (create it if missing) and register the server under mcpServers. The client launches your process with this exact command:

{
  "mcpServers": {
    "order-status": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/order-mcp/build/index.js"]
    }
  }
}

Use an absolute path, save, and restart the client. It spawns node build/index.js, runs the initialize handshake, discovers get_order_status, and the model can now call it. Ask “what is the status of order 4417?” and the answer comes from your data, with a user-approval step in front of the call.

Because the transport is just a subprocess speaking JSON-RPC on stdio, the same binary works in any MCP host, an IDE assistant, an agent runtime, or your own client built on the SDK. That portability is the entire point.

Why a standard protocol beats bespoke function calling

You could wire the model directly to your function with a provider-specific tool-calling API. But then the schema format, the call loop, and the result shape are locked to one vendor, and every new client re-implements the integration. MCP decouples the capability from the consumer: one server, many hosts, one wire format. The same discipline you apply to any external interface still applies here. Validate every argument (Zod does the first pass), and remember that a tool the model can call is an attack surface, so keep secrets out of tool output and off the model’s context, as I cover in using AI without leaking secrets. Treating tools as real production endpoints, not demo toys, is also how I keep AI-assisted work shippable, which I wrote up in how I use AI to ship code.

Takeaway

An MCP server is a small, boring, honest program: register a tool with a Zod schema, return a content array, talk JSON-RPC over stdio, and log to stderr. That boring server is what turns a model that guesses into one that looks things up. Write the tool once against the standard, and every MCP client you will ever use can call it. Start with one tool that answers one real question, ship it, then grow.

References

Get new posts by email

Backend, auth, and shipping compliant systems. No spam, unsubscribe anytime.