n4nAI

How to build an MCP server in TypeScript

Step-by-step tutorial to build MCP server TypeScript from scratch: set up SDK, define tools, handle requests, and run a compliant Model Context Protocol server.

n4n Team3 min read588 words

Audio narration

Coming soon — every post will get a voice note here.

If you want to build mcp server typescript that speaks the Model Context Protocol and exposes tools to any compliant client, the official SDK removes most of the protocol boilerplate. This tutorial walks through a runnable stdio server that lists and executes a simple arithmetic tool, then verifies it with the inspector and a small client.

Prerequisites

  • Node.js 20+ (stdio transport needs async local storage and top-level await)
  • npm 10+
  • TypeScript 5.4+ with module: "nodenext"
  • Familiarity with JSON-RPC and async TypeScript

No API keys required for the core server. If you later add LLM-backed tools, you’ll supply your own credentials.

Project setup

Create the project and install the SDK plus a schema validator. The MCP TS SDK uses Zod-compatible JSON schemas for tool inputs, but you can hand-write JSON Schema if you prefer.

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

Write a minimal tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Create src/index.ts. The server will run over stdio, which means it must never write to stdout except via the MCP transport.

Define the server skeleton

Instantiate Server with a name, version, and the tools capability. Then attach a StdioServerTransport.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server(
  { name: "ts-demo", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

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

That’s a compliant server that does nothing. Run npx tsc && node dist/index.ts and it will hang waiting for JSON-RPC on stdin. Good.

Implement tool discovery

Clients call tools/list to learn what your server offers. Register a handler for ListToolsRequestSchema and return a JSON-Schema-described tool.

import {
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "add",
        inputSchema: {
          type: "object",
          properties: {
            a: { type: "number", description: "First operand" },
            b: { type: "number", description: "Second operand" },
          },
          required: ["a", "b"],
        },
      },
    ],
  };
});

The inputSchema is plain JSON Schema. Keep property types narrow; the client uses this to build prompts and validate arguments before calling.

Handle tool calls

Register CallToolRequestSchema. The request carries params.name and params.arguments. Return a content array with at least one typed block.

import {
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== "add") {
    throw new Error(`Unknown tool: ${request.params.name}`);
  }
  const args = request.params.arguments as { a: number; b: number };
  if (typeof args.a !== "number" || typeof args.b !== "number") {
    throw new Error("Invalid arguments: expected numbers");
  }
  const sum = args.a + args.b;
  return {
    content: [{ type: "text", text: String(sum) }],
  };
});

MCP expects you to throw on error; the SDK maps the exception to a proper JSON-RPC error response. Do not console.log the result—it corrupts the stdio stream.

Run and verify with the inspector

The fastest manual check is the official inspector. Build and launch:

npx tsc
npx @modelcontextprotocol/inspector node dist/index.ts

The inspector opens a local web UI. Connect, then you should see:

Connected to MCP server: ts-demo 1.0.0
Tools:
- add: Add two numbers and return the sum

Call add with {"a": 2, "b": 3}. Expected output in the inspector:

{
  "content": [{ "type": "text", "text": "5" }]
}

Programmatic client test

For CI or local assertions, write a short client script. This confirms the full round trip without the UI.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "node",
  args: ["dist/index.ts"],
});
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);

const listed = await client.listTools();
console.log("Tools:", listed.tools.map((t) => t.name));
// Tools: [ 'add' ]

const result = await client.callTool({
  name: "add",
  arguments: { a: 7, b: 8 },
});
console.log("Result:", result.content);
// Result: [ { type: 'text', text: '15' } ]

await client.close();

Run with npx tsx src/client.ts (install tsx for TS execution). If you see the two log lines, your server is spec-compliant.

Adding a second tool with side effects

Real MCP servers wrap databases, shells, or HTTP APIs. Add a echo tool that returns its input unchanged to show the pattern for non-numeric schemas.

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "add",
        inputSchema: {
          type: "object",
          properties: { a: { type: "number" }, b: { type: "number" } },
          required: ["a", "b"],
        },
      },
      {
        name: "echo",
        inputSchema: {
          type: "object",
          properties: { msg: { type: "string" } },
          required: ["msg"],
        },
      },
    ],
  };
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "add") {
    const { a, b } = request.params.arguments as { a: number; b: number };
    return { content: [{ type: "text", text: String(a + b) }] };
  }
  if (request.params.name === "echo") {
    const { msg } = request.params.arguments as { msg: string };
    return { content: [{ type: "text", text: msg }] };
  }
  throw new Error(`Unknown tool: ${request.params.name}`);
});

Rebuild and re-run the inspector. Both tools appear; echo with {"msg":"hello"} returns "hello".

Calling LLMs from a tool

If a tool needs model inference, keep the server thin and delegate to a gateway. For example, n4n.ai exposes one OpenAI-compatible endpoint across 240+ models with automatic fallback when a provider is degraded and per-token metering, so your MCP server only forwards prompts and streams completions. Your tool handler would POST to that endpoint and return the text block.

// inside CallToolRequestSchema handler for a "summarize" tool
const resp = await fetch("https://api.n4n.ai/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.N4N_KEY}`,
  },
  body: JSON.stringify({
    model: "anthropic/claude-3.5-sonnet",
    messages: [{ role: "user", content: args.text }],
  }),
});
const data = await resp.json();
return { content: [{ type: "text", text: data.choices[0].message.content }] };

This keeps protocol concerns separate from model routing.

Common pitfalls

Mixing logs and stdio. Any console.log to stdout breaks the JSON-RPC framing. Use console.error for diagnostics; stderr is safe.

Loose input schemas. Clients validate against your inputSchema. If you accept any, you shift burden to the model and get erratic calls.

Missing capability declaration. If you omit capabilities: { tools: {} }, clients skip tools/list and assume an empty server.

Top-level await without nodenext. The SDK examples use top-level await server.connect(). Set module: "nodenext" or wrap in an async main().

Where to go next

You now know how to build mcp server typescript that is inspectable and client-agnostic. Extend it with resource endpoints (resources/list, resources/read) or prompt templates if your agent needs structured context. The protocol is stable; the value is in the tools you wrap.

Tagsmcptypescriptmcp-servertutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All model context protocol (mcp) deep dives posts →