Guide

How to build a Node.js MCP server for your existing API

By Aman Singh · Published · Updated · 14 min read

Most teams that want Claude, Cursor or ChatGPT to use their product already have the hard part: a REST API. A custom MCP server is a thin layer on top of it that turns a few of those endpoints into tools an AI assistant can call.

This guide builds one in Node.js with TypeScript. It exposes a small orders API as three tools: two that only read, and one that cancels an order. Along the way it covers input validation, a confirmation step for the write action, error handling, a test you can run without any AI app, and an HTTP version a whole team can connect to.

Tested in October 2026 with Node.js 20, version 1.32.1 of the official @modelcontextprotocol/sdk package and Zod 4. The code below is copied from the files that were run, not retyped.

Want it built and tested for your API instead? Custom MCP server development →

What a custom MCP server is

MCP (Model Context Protocol) is an open standard for connecting AI applications to outside systems. An MCP server is a small program that advertises a list of tools, each with a name, a description and an input schema, and runs a tool when the AI app asks.

The MCP server sits between the assistant and your API. Your API does not change; the rules about what the assistant may do live in the server.

Your API does not change. The server sits next to it, translates between what the assistant sends and what your API expects, and decides what the assistant is allowed to do. That last part is the reason to build it yourself: the tool list is the permission list.

Design the tools first

Do this before writing code, because the tool list is the product. These are the rules I follow:

  • One tool per goal, not per endpoint. "Look up an order" is a goal. Mirroring every route of a large API gives the model dozens of near-identical tools to choose from.
  • Keep the list short. Start with three to eight tools. Add more when you see the assistant lacking one.
  • Write descriptions for the model. Say what the tool does, when to use it, and what to use instead when it is the wrong one.
  • Return small results. Everything a tool returns goes into the model's context. Send the fields it needs, not the raw API response.
  • Separate reads from writes. Read tools can be generous. Every write tool needs its own guard.

For the orders example that gives three tools: get_order, list_orders (optionally filtered by status) and cancel_order, the only write.

Set up the project

Terminal
mkdir orders-mcp-server && cd orders-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node

Then set the project to ES modules and add two scripts. In package.json:

package.json (added fields)
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "start:http": "node dist/http.js"
  }
}

And create tsconfig.json, with source in src/ and output in dist/:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

A small client for your API

Put all calls to your existing API in one file. The tools then stay short, and the rules about timeouts and retries live in one place.

src/api.ts
const BASE_URL = process.env.ORDERS_API_URL ?? "http://localhost:4000";
const API_KEY = process.env.ORDERS_API_KEY ?? "dev-key";

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
  }
}

type Options = { method?: "GET" | "POST"; retries?: number };

// One place that talks to the existing API: auth header, timeout, and a
// retry for reads only. Writes are never retried automatically.
export async function api<T>(
  path: string,
  { method = "GET", retries = 0 }: Options = {},
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetch(`${BASE_URL}${path}`, {
        method,
        headers: { authorization: `Bearer ${API_KEY}` },
        signal: AbortSignal.timeout(8000),
      });
      const body = (await res.json().catch(() => ({}))) as { error?: string };
      if (!res.ok) {
        if (res.status >= 500 && attempt < retries) continue;
        throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
      }
      return body as T;
    } catch (err) {
      if (err instanceof ApiError) throw err;
      if (attempt < retries) continue; // network error or timeout
      throw new ApiError(0, "The orders API did not respond in time.");
    }
  }
}

Three decisions in that file matter:

  • Timeout. An assistant waiting on a hung request looks broken. Eight seconds here; pick a number that fits your API.
  • Retries for reads only. A cancel request that times out may have succeeded. Retrying could run it twice, or report a failure that did not happen. For writes, return the error and let a person check.
  • A typed error. ApiError carries the status and your API's own message, so the tools can pass a useful reason back to the model.

Define the tools

The tools live in one file, inside a buildServer() function. Each one is registered with registerTool: a name, a config object with a description and an input schema, and a handler. Keeping them in a function means the same tools can run locally or over HTTP later.

src/tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { api, ApiError } from "./api.js";

type Order = {
  id: string;
  status: string;
  customerEmail: string;
  totalCents: number;
  items: string[];
};

const orderId = z
  .string()
  .regex(/^ord_[a-z0-9]{6,}$/, "Order IDs look like ord_1a2b3c");

// Tool results are text the model reads. Keep them short and structured.
const ok = (data: unknown) => ({
  content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});

// Errors go back as a result with isError, not a thrown exception, so the
// model can read the reason and recover or explain it to the user.
const fail = (err: unknown) => ({
  isError: true,
  content: [
    {
      type: "text" as const,
      text: err instanceof ApiError ? err.message : "Unexpected error.",
    },
  ],
});

// Never write to stdout in a stdio server: it carries the protocol itself.
const log = (...args: unknown[]) => console.error("[orders-mcp]", ...args);

// One function builds the server with all its tools, so the same tools can be
// served over stdio (server.ts) or over HTTP (http.ts).
export function buildServer(): McpServer {
  const server = new McpServer({ name: "orders", version: "1.0.0" });

  server.registerTool(
    "get_order",
    {
      title: "Get order",
      description:
        "Look up one order by its ID and return its status, items and total in cents. Use list_orders if you do not have the ID.",
      inputSchema: { orderId },
      annotations: { readOnlyHint: true },
    },
    async ({ orderId }) => {
      log("get_order", orderId);
      try {
        return ok(await api<Order>(`/orders/${orderId}`, { retries: 1 }));
      } catch (err) {
        return fail(err);
      }
    },
  );

  server.registerTool(
    "list_orders",
    {
      title: "List orders",
      description: "List orders, optionally filtered by status.",
      inputSchema: {
        status: z
          .enum(["processing", "shipped", "delivered", "cancelled"])
          .optional()
          .describe("Only return orders with this status"),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ status }) => {
      log("list_orders", status ?? "all");
      try {
        const query = status ? `?status=${status}` : "";
        return ok(await api<{ orders: Order[] }>(`/orders${query}`, { retries: 1 }));
      } catch (err) {
        return fail(err);
      }
    },
  );

  // The only write action. It needs an explicit confirm flag, and the API itself
  // refuses to cancel anything that has already shipped.
  server.registerTool(
    "cancel_order",
    {
      title: "Cancel order",
      description:
        "Cancel an order that is still processing. This cannot be undone. Ask the user to confirm first, then call with confirm set to true.",
      inputSchema: {
        orderId,
        confirm: z
          .literal(true)
          .describe("Must be true; set it only after the user agrees"),
      },
      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: false,
      },
    },
    async ({ orderId }) => {
      log("cancel_order", orderId);
      try {
        return ok(
          await api<Order>(`/orders/${orderId}/cancel`, { method: "POST" }),
        );
      } catch (err) {
        return fail(err);
      }
    },
  );

  return server;
}

The entry point for local use is three lines: build the server and connect it to standard input and output.

src/server.ts
// Local use: Claude Desktop, Cursor or Claude Code start this file and talk to
// it over stdin/stdout.
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./tools.js";

await buildServer().connect(new StdioServerTransport());
console.error("[orders-mcp] ready");

What to notice:

  • Schemas do the validating. The orderId pattern and the status enum are checked before your handler runs. The message you put in the schema is what the model sees when it gets something wrong, so write it to be useful.
  • Errors are results. fail() returns isError: true with readable text instead of throwing. The assistant can read the reason, fix its call or explain the problem to the user.
  • Log to stderr, not stdout. A server that runs over stdio uses stdout for protocol messages. Some clients tolerate a stray console.log and some do not, so never rely on it. The troubleshooting guide covers what that looks like.
  • Annotations are hints. readOnlyHint and destructiveHint let a client app show the right prompts. They are not security. Enforce real limits in your API.

Make the write action safe

cancel_order is the risky one. It has three layers, each doing a different job:

  • The description tells the model to ask the user first and says the action cannot be undone.
  • confirm: z.literal(true) makes the call invalid unless the model sends confirm: true, so it cannot cancel by accident with a bare order ID.
  • Your API still refuses to cancel anything that has shipped. This is the layer that counts: the first two shape the model's behaviour, but only server-side rules are guaranteed.

Test it without an AI app

You do not need Claude to know whether the server works. The SDK ships a client, so a short script can start the server, list its tools and call each one.

src/smoke-test.ts
// Starts the mock API and the built server, then calls each tool like a client would.
import { spawn } from "node:child_process";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const mock = spawn(process.execPath, ["--import", "tsx", "src/mock-api.ts"], {
  stdio: "inherit",
});
await new Promise((resolve) => setTimeout(resolve, 1000));

const client = new Client({ name: "smoke-test", version: "1.0.0" });
await client.connect(
  new StdioClientTransport({
    command: process.execPath,
    args: ["dist/server.js"],
  }),
);

function show(label: string, result: unknown) {
  console.log(`\n== ${label}\n${JSON.stringify(result, null, 1)}`);
}
async function call(name: string, args: Record<string, unknown>) {
  try {
    return await client.callTool({ name, arguments: args });
  } catch (err) {
    return { rejected: (err as Error).message };
  }
}

show("tools", (await client.listTools()).tools.map((t) => t.name));
show("get_order", await call("get_order", { orderId: "ord_1a2b3c" }));
show("list_orders shipped", await call("list_orders", { status: "shipped" }));
show("bad id", await call("get_order", { orderId: "nope" }));
show("cancel without confirm", await call("cancel_order", { orderId: "ord_1a2b3c" }));
show("cancel shipped", await call("cancel_order", { orderId: "ord_4d5e6f", confirm: true }));
show("cancel processing", await call("cancel_order", { orderId: "ord_1a2b3c", confirm: true }));

await client.close();
mock.kill();

The test also starts a stand-in for your real API: a mock of about 50 lines, so you can try everything without credentials.

Show the mock orders API (src/mock-api.ts)
src/mock-api.ts
// Stand-in for the REST API you already have. Run it with: npm run mock-api
import { createServer, type ServerResponse } from "node:http";

type Order = {
  id: string;
  status: "processing" | "shipped" | "delivered" | "cancelled";
  customerEmail: string;
  totalCents: number;
  items: string[];
};

const orders = new Map<string, Order>([
  ["ord_1a2b3c", { id: "ord_1a2b3c", status: "processing", customerEmail: "ana@example.com", totalCents: 4900, items: ["Standing desk mat"] }],
  ["ord_4d5e6f", { id: "ord_4d5e6f", status: "shipped", customerEmail: "ben@example.com", totalCents: 12900, items: ["Desk lamp", "USB hub"] }],
  ["ord_7g8h9i", { id: "ord_7g8h9i", status: "delivered", customerEmail: "cleo@example.com", totalCents: 2500, items: ["Cable set"] }],
]);

const API_KEY = process.env.ORDERS_API_KEY ?? "dev-key";

function send(res: ServerResponse, status: number, body: unknown) {
  res.writeHead(status, { "content-type": "application/json" });
  res.end(JSON.stringify(body));
}

createServer((req, res) => {
  if (req.headers.authorization !== `Bearer ${API_KEY}`) {
    return send(res, 401, { error: "bad api key" });
  }
  const url = new URL(req.url ?? "/", "http://localhost");
  const cancel = url.pathname.match(/^\/orders\/([^/]+)\/cancel$/);
  const one = url.pathname.match(/^\/orders\/([^/]+)$/);

  if (req.method === "GET" && url.pathname === "/orders") {
    const status = url.searchParams.get("status");
    const rows = [...orders.values()].filter((o) => !status || o.status === status);
    return send(res, 200, { orders: rows });
  }
  if (req.method === "GET" && one) {
    const order = orders.get(one[1]);
    return order ? send(res, 200, order) : send(res, 404, { error: "order not found" });
  }
  if (req.method === "POST" && cancel) {
    const order = orders.get(cancel[1]);
    if (!order) return send(res, 404, { error: "order not found" });
    if (order.status !== "processing") {
      return send(res, 409, { error: `cannot cancel an order that is ${order.status}` });
    }
    order.status = "cancelled";
    return send(res, 200, order);
  }
  send(res, 404, { error: "not found" });
}).listen(4000, () => console.error("mock orders API on http://localhost:4000"));

Build, then run the test:

Terminal
npm run build && npx tsx src/smoke-test.ts

It prints the following, trimmed here for space:

Smoke test output
tools: get_order, list_orders, cancel_order

get_order ord_1a2b3c            -> status: processing
get_order "nope"                -> isError: Order IDs look like ord_1a2b3c at orderId
cancel_order (no confirm)       -> isError: Invalid input: expected true at confirm
cancel_order ord_4d5e6f         -> isError: cannot cancel an order that is shipped
cancel_order ord_1a2b3c, true   -> status: cancelled

Two things in that output are worth knowing. A malformed call, such as a bad ID or a missing confirm, comes back as a normal tool result with isError set, carrying your schema message, so the model can correct itself. And the shipped order was refused by the API, not by the MCP server, which is the layering you want.

For interactive poking, the MCP Inspector (npx @modelcontextprotocol/inspector node dist/server.js) gives you a browser UI over the same server.

Connect Claude and Cursor

Build the server with npm run build, then register the compiled file. Use an absolute path, and put keys in the env block rather than in code.

Claude Desktop: add this to claude_desktop_config.json (on macOS under ~/Library/Application Support/Claude/, on Windows under %APPDATA%\Claude\), then restart the app.

claude_desktop_config.json
{
  "mcpServers": {
    "orders": {
      "command": "node",
      "args": ["/absolute/path/to/orders-mcp-server/dist/server.js"],
      "env": {
        "ORDERS_API_URL": "https://api.example.com",
        "ORDERS_API_KEY": "your-key-here"
      }
    }
  }
}

Cursor: the same mcpServers block goes in .cursor/mcp.json in your project, or ~/.cursor/mcp.json for all projects.

Claude Code: one command registers it.

Terminal
claude mcp add orders -e ORDERS_API_URL=https://api.example.com -e ORDERS_API_KEY=your-key-here -- node /absolute/path/to/orders-mcp-server/dist/server.js

Then ask the assistant something your API can answer, such as "Which of our orders are still processing?", and watch it call list_orders. If the server does not show up, see MCP server not showing up in Claude Desktop.

Run it over HTTP for a team

A local server works for one person on one machine. To let a whole team connect to one copy, serve the same tools over HTTP with the SDK's Streamable HTTP transport. Node's built-in http module is enough; no framework is needed.

src/http.ts
// Remote use: the same tools over HTTP, using the SDK's Streamable HTTP
// transport with Node's built-in http module (no framework needed).
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./tools.js";

const PORT = Number(process.env.PORT ?? 3333);
const TOKEN = process.env.MCP_TOKEN ?? "";
if (!TOKEN) throw new Error("Set MCP_TOKEN before exposing this server");

// A shared bearer token is enough for one team. For many users, use OAuth so
// each person's assistant acts with that person's own permissions.
function authorized(header: string | undefined): boolean {
  const given = Buffer.from(header ?? "");
  const expected = Buffer.from(`Bearer ${TOKEN}`);
  return given.length === expected.length && timingSafeEqual(given, expected);
}

createServer(async (req, res) => {
  if (req.url !== "/mcp") {
    res.writeHead(404).end();
    return;
  }
  if (!authorized(req.headers.authorization)) {
    res.writeHead(401, { "content-type": "application/json" }).end('{"error":"unauthorized"}');
    return;
  }

  // Stateless mode: a fresh server and transport per request, so any
  // instance behind a load balancer can answer.
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res);
}).listen(PORT, () => console.error(`[orders-mcp] http://localhost:${PORT}/mcp`));
  • Stateless mode. With sessionIdGenerator: undefined, every request gets a fresh server, so you can run several copies behind a load balancer.
  • A token check before anything else. Requests without the right bearer token get a 401 and never reach your tools. The comparison uses timingSafeEqual.
  • The same tools. buildServer() is shared with the local version, so behaviour cannot drift between the two.

Tested with the SDK's HTTP client against the mock API:

Output
[orders-mcp] http://localhost:3333/mcp
without token: 401
tools: get_order, list_orders, cancel_order
get_order: processing

Clients then connect to https://your-host/mcp with the token in an Authorization header; Claude Desktop adds remote servers through its connector settings rather than the JSON file.

What changes in production

Serving a team or customers changes the list of concerns, not the shape of the code:

  • Per-user sign-in. A shared token is fine for one team. For many users, use OAuth so each person's assistant acts with that person's permissions, never a shared key.
  • Idempotency for writes. Give write calls an idempotency key so a retry by the client or the model cannot do the same thing twice.
  • Audit logs. Record which tool ran, for whom, and with what outcome, without logging secrets or customer data.
  • Limits. Rate limits, result-size caps and timeouts keep one confused assistant from hammering your API or flooding its own context.
  • Tests with a real assistant. Schemas catch bad input, not bad judgement. Run your common tasks through Claude or Cursor and tune the tool descriptions until it picks the right tool.
  • Deployment and monitoring. Treat it like any service: environments, secrets handling, alerts and a README the next engineer can follow.

Common questions

Do I need to rewrite my API to add an MCP server?+
No. The server calls your existing API, so nothing behind it changes. You decide which endpoints become tools.
How many tools should a custom MCP server have?+
Start with three to eight tools shaped around user goals. Too many near-identical tools make it harder for the model to pick the right one.
Is it safe to let an AI assistant write data through my API?+
It can be, if every write has a confirmation step and your API enforces its own rules. Treat tool input as untrusted, the same as input from a browser.
Should I use stdio or HTTP?+
stdio is simplest for one user on one machine. A server shared with a team runs over HTTP with authentication; this guide includes both, built from the same tools.

If you would rather have it built

Wrapping an API is a few days of work for someone who has done it before, and the careful parts are the ones above: tool design, guarded writes, authentication and monitoring. If you want that done for your own API, see custom MCP server development, fixed-price packages from $90, or read how AI and LLM integration projects are scoped.

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides