How to Build Your First MCP Server in TypeScript

Build a working MCP server in TypeScript in about 30 lines: register a typed tool, connect it over stdio, and run it inside a real MCP client.

MMahzaib MirzaJuly 13, 20267 min read0 comments
How to Build Your First MCP Server in TypeScript

Building your first MCP server in TypeScript takes about 30 lines: install the SDK, create a server, register one tool with a typed input schema, and connect it over stdio. This guide walks the whole thing end to end with working code, then shows you how to run it inside a real MCP client. By the end you'll have a server that exposes a tool any MCP-compatible app (Claude, Cursor, Windsurf) can call.

If you're fuzzy on what MCP is or why you'd build a server, start with the MCP developer's guide. This piece assumes you know the shape and just want to ship one.

Set up the project

You need Node.js and the official MCP SDK. Zod handles the input schema so the model's arguments are validated before your handler runs.

mkdir greeting-mcp && cd greeting-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Set "type": "module" in your package.json so the ES module imports work, and you're ready to write the server.

Write the server

Three moves: create an McpServer, register a tool with registerTool, and connect a transport. Here's a complete server that exposes a single greet tool.

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: "greeting-server",
  version: "1.0.0",
});

server.registerTool(
  "greet",
  {
    description: "Greet someone by name",
    inputSchema: { name: z.string().describe("The person's name") },
  },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}!` }],
  }),
);

const transport = new StdioServerTransport();
await server.connect(transport);

That's the entire server. Three things are worth understanding.

The input schema is the contract. The Zod schema you pass to registerTool becomes the tool's typed interface. The model reads it, fills in the arguments, and the SDK validates them before your handler runs. A clear schema with .describe() on each field is what makes the model call your tool correctly, so treat it as documentation the model actually reads.

The handler returns content blocks. Your tool returns an object with a content array. Each block has a type (usually text) and the payload. This is what gets handed back to the model.

stdio is the transport. StdioServerTransport means the client will spawn this server as a subprocess and talk to it over standard input and output. That's the right choice for a local tool. For a remote server you'd swap in Streamable HTTP instead, covered in the transports guide.

Add a tool that does real work

A greeting is a nice hello-world, but tools earn their keep by doing something. Here's one that calls an external API. The pattern is identical: describe the inputs, do the work in the handler, return content.

server.registerTool(
  "get_weather",
  {
    description: "Get the current weather for a city",
    inputSchema: { city: z.string().describe("City name, e.g. Lahore") },
  },
  async ({ city }) => {
    const res = await fetch(
      `https://api.example.com/weather?q=${encodeURIComponent(city)}`,
    );
    if (!res.ok) {
      return {
        content: [{ type: "text", text: `Couldn't fetch weather for ${city}.` }],
        isError: true,
      };
    }
    const data = await res.json();
    return {
      content: [{ type: "text", text: `${city}: ${data.tempC}C, ${data.summary}` }],
    };
  },
);

Note the isError: true on the failure path. Returning an error result (rather than throwing) lets the model see what went wrong and decide what to do next, ask the user, try a different city, or give up gracefully. Make every tool fail this way.

Run it inside a client

A stdio server doesn't run on its own, a client spawns it. To use it in a desktop MCP client, you point the client's config at your built server. The shape is the same across clients: a command to run and the args to pass.

{
  "mcpServers": {
    "greeting": {
      "command": "node",
      "args": ["/absolute/path/to/greeting-mcp/dist/index.js"]
    }
  }
}

Compile your TypeScript to dist/, drop that config into your client, restart it, and the greet and get_weather tools show up. The model can now call them.

The mistakes that slow people down

  • Vague input schemas. If the model calls your tool with the wrong arguments, the schema is usually too thin. Add .describe() to every field and be specific.
  • Logging to stdout. On stdio, stdout is the protocol channel. A stray console.log corrupts the JSON-RPC stream. Log to stderr instead (console.error).
  • Throwing instead of returning errors. Return { content: [...], isError: true } so the model can react, don't let the handler throw.
  • Forgetting to build. The client runs your compiled JavaScript, not the .ts source. Rebuild after every change.

Where to go next

You've got a working stdio server. The natural next steps: expose it remotely over Streamable HTTP so hosted clients can use it (transports guide), and see how a real platform ships MCP by looking at GoHighLevel's official MCP server. For the big picture, the MCP developer's guide ties it together.

Frequently asked questions

Which package do I install?

The official SDK is @modelcontextprotocol/sdk. Install it plus zod for input schemas. Check the SDK's docs for the exact version and import paths, since the SDK tracks the evolving spec.

Why is my server producing garbled output in the client?

Almost always a stray write to stdout. On stdio, stdout carries the JSON-RPC protocol, so any console.log corrupts it. Send logs to stderr with console.error.

How does the model know how to call my tool?

From the tool's description and input schema. The client lists your server's tools, the model reads the typed schema, and it fills in the arguments. Clear descriptions and per-field .describe() calls are what make this reliable.

Can the same server be used by different AI apps?

Yes, that's the point of MCP. Any MCP-compatible client (Claude, Cursor, Windsurf, others) can spawn and use the same server without any client-specific code.

Share:
M

Written by

Mahzaib Mirza

Software developer & Founder of Coders Vibe.

Related Posts

Liked this post?

Get the next one in your inbox the moment it's published. No spam, unsubscribe anytime.

0 Comments

Leave a comment