Build an MCP Server with TypeScript: A Complete Tutorial

Building an MCP server in plain JavaScript is great for a quick prototype. But once you have more than two tools, or you start publishing the server for others to use, the lack of static types starts to hurt. Tool schemas drift, handler return types go undocumented, and refactors get scary.

TypeScript fixes all of that. This tutorial walks you through building a production-grade MCP server in TypeScript — strict typing, Zod-inferred argument types, clean error handling, and a build pipeline ready for npm publish.

The example server is a currency converter with two tools: one that lists supported currencies, and one that converts an amount between two currencies. By the end you will have a complete, type-safe, distributable MCP package.

What you need

  • Node 18 or higher
  • A code editor with TypeScript support (VS Code, WebStorm, etc.)
  • ~20 minutes

Step 1: Project initialization

mkdir currency-mcp
cd currency-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript @types/node tsx

The extra dev dependencies:

  • typescript — the compiler
  • @types/node — Node.js type definitions
  • tsx — runs TypeScript directly without a build step (handy for development)

Step 2: TypeScript configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

The critical bits:

  • "strict": true — turns on every type-safety check. Non-negotiable for production code.
  • "module": "NodeNext" — modern ES modules in Node.
  • "declaration": true — emits .d.ts files so consumers of your package get IntelliSense.

Update package.json:

{
  "name": "currency-mcp",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/server.js",
  "bin": { "currency-mcp": "dist/server.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/server.ts",
    "start": "node dist/server.js",
    "prepublishOnly": "npm run build"
  }
}

Step 3: The server skeleton

Create src/server.ts:

#!/usr/bin/env node
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: 'currency-mcp',
  version: '1.0.0',
});

// Tools go here

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

Run it in dev mode to confirm everything works:

npm run dev

The process should start and stay running (it is waiting for stdin messages). Press Ctrl+C to stop.

Step 4: Add a typed helper module

In larger servers, keep the tool logic separate from the protocol wiring. Create src/currency.ts:

export type CurrencyCode = string; // ISO 4217, e.g. "USD", "INR"

export interface ConversionResult {
  from: CurrencyCode;
  to: CurrencyCode;
  amount: number;
  converted: number;
  rate: number;
  timestamp: string;
}

const API_BASE = 'https://api.frankfurter.dev/v1';

export async function listCurrencies(): Promise<Record<CurrencyCode, string>> {
  const res = await fetch(`${API_BASE}/currencies`);
  if (!res.ok) throw new Error(`Currency list failed: ${res.status}`);
  return res.json() as Promise<Record<string, string>>;
}

export async function convert(
  from: CurrencyCode,
  to: CurrencyCode,
  amount: number
): Promise<ConversionResult> {
  const url = `${API_BASE}/latest?base=${from}&symbols=${to}&amount=${amount}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Conversion failed: ${res.status}`);
  const data = (await res.json()) as { rates: Record<string, number>; date: string };
  const converted = data.rates[to];
  if (converted === undefined) throw new Error(`Unknown target currency: ${to}`);
  return {
    from, to, amount, converted,
    rate: converted / amount,
    timestamp: data.date,
  };
}

The Frankfurter API is free, requires no key, and is perfect for examples.

Step 5: Define tools with Zod (and let TypeScript infer the types)

Replace the placeholder in server.ts with the two tools:

import { listCurrencies, convert } from './currency.js';

const CurrencyCodeSchema = z.string()
  .length(3)
  .toUpperCase()
  .describe('ISO 4217 currency code, e.g. "USD", "INR", "EUR"');

server.tool(
  'list_supported_currencies',
  'List every currency the converter supports, with full names.',
  {},
  async () => {
    const map = await listCurrencies();
    const lines = Object.entries(map)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([code, name]) => `${code} — ${name}`);
    return { content: [{ type: 'text', text: lines.join('\n') }] };
  }
);

server.tool(
  'convert_currency',
  'Convert an amount from one currency to another using the latest exchange rate.',
  {
    from: CurrencyCodeSchema,
    to: CurrencyCodeSchema,
    amount: z.number().positive().describe('Amount in the source currency'),
  },
  async ({ from, to, amount }) => {
    const r = await convert(from, to, amount);
    return {
      content: [{
        type: 'text',
        text: `${r.amount} ${r.from} = ${r.converted.toFixed(2)} ${r.to} (rate ${r.rate.toFixed(4)}, as of ${r.timestamp})`,
      }],
    };
  }
);

The magic of using Zod for both validation and JSON Schema generation: the handler's argument types are inferred from the schema. Inside async ({ from, to, amount }) => ..., from and to are string, amount is number. No any, no type assertions, no manual interface declarations. Change the schema and the handler types update automatically.

Step 6: Error handling pattern

MCP tools should return errors in the response, not throw them up the stack. A thrown error becomes a protocol-level failure that the LLM cannot reason about; an isError: true response is recoverable context.

A clean pattern:

function errorResponse(message: string) {
  return {
    content: [{ type: 'text' as const, text: `Error: ${message}` }],
    isError: true,
  };
}

server.tool(
  'convert_currency',
  '...',
  { from: CurrencyCodeSchema, to: CurrencyCodeSchema, amount: z.number().positive() },
  async ({ from, to, amount }) => {
    try {
      const r = await convert(from, to, amount);
      return { content: [{ type: 'text', text: `${r.amount} ${r.from} = ${r.converted.toFixed(2)} ${r.to}` }] };
    } catch (err) {
      return errorResponse(err instanceof Error ? err.message : String(err));
    }
  }
);

When the LLM sees isError: true, it typically apologizes to the user and either retries with different inputs or explains the failure. That is exactly the behavior you want.

Step 7: Testing

A simple test that boots the server in-process and exercises a tool, using the SDK's in-memory transport:

// tests/convert.test.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const server = /* build the same server as src/server.ts */;
const client = new Client({ name: 'test', version: '0.0.0' }, {});
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

const tools = await client.listTools();
console.log('Tools:', tools.tools.map(t => t.name));

const result = await client.callTool({
  name: 'convert_currency',
  arguments: { from: 'USD', to: 'INR', amount: 100 },
});
console.log('Result:', result.content);

Run it with tsx tests/convert.test.ts. No external process needed, no flaky network of subprocesses to start — just function calls.

Step 8: Build and publish

npm run build

This emits dist/server.js and dist/*.d.ts. Check the output: the JS files should have ES module syntax and the type declarations should match your Zod schemas.

Make the entry executable: chmod +x dist/server.js (the build keeps the shebang). Then:

npm publish

After publishing, users install with:

{
  "mcpServers": {
    "currency": { "command": "npx", "args": ["-y", "currency-mcp"] }
  }
}

TypeScript-specific gotchas

Issue Symptom Fix
.js extensions in imports Cannot find module TypeScript's NodeNext mode requires .js extensions in import paths even though source files are .ts
Strict mode complaints Lots of red squiggles Address them; do not lower strictness. strict: true catches real bugs.
Zod's .transform() and SDK type inference Mysterious type errors The SDK's tool helper expects a Zod raw shape object, not a full Zod schema. Use plain object shapes at the top level.
prepublishOnly did not run Published files are stale Ensure the script name is exact — prepublishOnly, not prepublish.

When to choose TypeScript over plain JS

Always for production. The extra ten minutes of setup pays for itself on the first refactor.

For throwaway prototypes (less than 50 lines, no plan to publish, never going to evolve), plain JS is faster. Everything else: TypeScript.

Conclusion

Building a TypeScript MCP server is barely more effort than JavaScript, and the payoff is huge: handler argument types inferred from Zod, public API documented by .d.ts files, refactors that are safe instead of scary.

The currency-converter pattern in this tutorial scales directly to anything else you want to wrap — REST APIs, databases, internal services, file systems. Replace the helper module, redefine the tool schemas, ship it.

If you have built anything in TypeScript before, you already know 90% of what is needed. The MCP SDK does the rest.

Try it yourself

Once currency-mcp is registered, you can convert across currencies in plain English:

YouHow much is 5,000 INR in USD?
Claude · used convert_currency5,000 INR = $60.02 USD (rate 0.01200, as of today’s mid-market FX).
YouWhat about in Euros, and what is the round-trip if I converted back?
Claude · used convert_currency twice5,000 INR ≈ €55.34 EUR. If you converted that €55.34 back to INR right now, you would end up with about 4,998 INR — so the mid-market round-trip is basically flat. In a real exchange you would lose a few percent to spread and fees.

Notice how the LLM autonomously decided to call the tool twice in a row — that is the kind of multi-step reasoning MCP makes trivial to enable.

Leave a Comment

Your email address will not be published. Required fields are marked *