ionTrails IT Consulting
EN
AI & MCP

How to Build an MCP Server in JavaScript: A Practical Step-by-Step Guide

Published on May 12, 2026 by Viliam Simko · Updated for MCP TypeScript SDK v2 · 12 min read

Large Language Models (LLMs) are good at reasoning, writing code, and answering questions. By themselves, however, they do not automatically have access to your databases, local files, private APIs, or internal business systems.

The Model Context Protocol (MCP) is an open standard for connecting AI applications to the systems where data and tools live. Instead of building a separate integration for every AI application, you can expose capabilities through an MCP server and let compatible hosts connect to them.

In this guide, we'll build a practical MCP server in modern JavaScript (Node.js) using the MCP TypeScript SDK v2 and Zod. The example exposes two tools and one resource, runs locally over stdio, and can be tested with MCP Inspector.

SDK version note: This article targets the v2 MCP TypeScript SDK and the 2026-07-28 MCP protocol era. The v2 SDK replaced the monolithic @modelcontextprotocol/sdk v1 package with focused packages such as @modelcontextprotocol/server.

What is the Model Context Protocol?

Think of MCP as a standardized interface between AI applications and external capabilities. The protocol defines how a host and its MCP clients discover and use capabilities exposed by MCP servers.

A useful mental model is:

┌────────────────────────────────────┐
│             MCP Host               │
│                                    │
│   ┌─────────┐       ┌────────────┐ │
│   │   LLM   │◄─────►│ MCP Client │ │
│   └─────────┘       └─────┬──────┘ │
└───────────────────────────┼────────┘
                            │ MCP
                            ▼
                    ┌────────────────┐
                    │   MCP Server   │
                    ├────────────────┤
                    │ Tools          │
                    │ Resources      │
                    │ Prompts        │
                    └────────────────┘

MCP defines three primary server-side primitives:

  1. Tools: Actions that a model can request, such as calling an API, querying data, or performing a calculation.
  2. Resources: Data identified by URIs that a client can read as context, such as documents, configuration, logs, or generated diagnostics.
  3. Prompts: Reusable prompt templates and workflows exposed by a server.

What We'll Build

Our example server will expose:

The example intentionally uses stdio, which is a convenient transport when a local MCP host launches your server as a child process. For remote deployments, MCP's modern direction is Streamable HTTP, discussed later in this article.

Prerequisites

You need Node.js 20 or later and npm. The current MCP TypeScript SDK is distributed as ES modules, so this example uses Node.js ESM support.

Project Initialization

Create a clean Node.js project:

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod

The important point is the package name: SDK v2 uses the focused @modelcontextprotocol/server package rather than the v1 monolithic @modelcontextprotocol/sdk package.

Your resulting package.json can be kept simple:

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/server": "...",
    "zod": "..."
  }
}

The exact dependency versions are intentionally left to npm rather than hard-coded in this article. Run npm install to resolve the current compatible releases, and commit the generated lockfile for reproducible builds.

Building the Server: Step-by-Step

Create a file named index.js. The v2 SDK provides the high-level McpServer abstraction, while Zod supplies runtime input validation.

1. Server Core and Imports

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

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

Notice that SDK v2 uses registerTool() and registerResource() rather than the older v1 server.tool() and server.resource() APIs.

2. Adding a Practical Tool

Tools are registered with server.registerTool(). The configuration object contains the description and input schema, and the handler receives already-validated arguments.

// Weather Tool using the Open-Meteo API
server.registerTool(
  "get_weather",
  {
    description:
      "Fetch current weather conditions for geographic coordinates",
    inputSchema: z.object({
      latitude: z.number()
        .min(-90)
        .max(90)
        .describe("Latitude coordinate, for example 49.0069 for Karlsruhe"),
      longitude: z.number()
        .min(-180)
        .max(180)
        .describe("Longitude coordinate, for example 8.4037 for Karlsruhe"),
      cityName: z.string()
        .optional()
        .describe("Optional friendly city name")
    })
  },
  async ({ latitude, longitude, cityName }) => {
    const url =
      "https://api.open-meteo.com/v1/forecast" +
      `?latitude=${encodeURIComponent(latitude)}` +
      `&longitude=${encodeURIComponent(longitude)}` +
      "¤t=temperature_2m,relative_humidity_2m," +
      "apparent_temperature,precipitation,wind_speed_10m";

    const response = await fetch(url);

    if (!response.ok) {
      return {
        isError: true,
        content: [{
          type: "text",
          text: `Weather service returned HTTP ${response.status}`
        }]
      };
    }

    const data = await response.json();
    const current = data.current;

    const label = cityName
      ? `${cityName} (${latitude}, ${longitude})`
      : `coordinates (${latitude}, ${longitude})`;

    const report = [
      `Weather Report for ${label}:`,
      `- Temperature: ${current.temperature_2m}°C`,
      `- Feels like: ${current.apparent_temperature}°C`,
      `- Relative Humidity: ${current.relative_humidity_2m}%`,
      `- Wind Speed: ${current.wind_speed_10m} km/h`,
      `- Precipitation: ${current.precipitation} mm`
    ].join("\\n");

    return {
      content: [{ type: "text", text: report }]
    };
  }
);

The URL above uses Open-Meteo's current query parameter. The handler also converts an HTTP failure into an MCP tool error that the model can read instead of allowing an expected upstream failure to become an unhandled process exception.

3. Adding a Currency Conversion Tool

For simplicity, this example uses static reference rates. These values are demonstration data, not live financial rates, and should not be used for financial decisions.

server.registerTool(
  "convert_currency",
  {
    description:
      "Convert an amount between supported fiat currencies using reference rates",
    inputSchema: z.object({
      amount: z.number()
        .positive()
        .describe("Amount to convert"),
      from: z.string()
        .length(3)
        .transform(value => value.toUpperCase())
        .describe("Three-letter source currency code, e.g. EUR"),
      to: z.string()
        .length(3)
        .transform(value => value.toUpperCase())
        .describe("Three-letter target currency code, e.g. USD")
    }),
    outputSchema: z.object({
      amount: z.number(),
      from: z.string(),
      to: z.string(),
      convertedAmount: z.number(),
      rate: z.number()
    })
  },
  async ({ amount, from, to }) => {
    // Static reference rates against EUR for demonstration only.
    const ratesAgainstEUR = {
      EUR: 1.0,
      USD: 1.08,
      GBP: 0.85,
      CHF: 0.96,
      CZK: 25.2,
      PLN: 4.31,
      JPY: 165.5
    };

    if (!(from in ratesAgainstEUR) || !(to in ratesAgainstEUR)) {
      return {
        isError: true,
        content: [{
          type: "text",
          text:
            `Unsupported currency. Available currencies: ` +
            Object.keys(ratesAgainstEUR).join(", ")
        }]
      };
    }

    const amountInEUR = amount / ratesAgainstEUR[from];
    const convertedAmount = amountInEUR * ratesAgainstEUR[to];
    const rate = ratesAgainstEUR[to] / ratesAgainstEUR[from];

    const output = {
      amount,
      from,
      to,
      convertedAmount: Number(convertedAmount.toFixed(2)),
      rate: Number(rate.toFixed(6))
    };

    return {
      content: [{
        type: "text",
        text: `${amount} ${from} = ${output.convertedAmount} ${to} (reference rate: ${output.rate})`
      }],
      structuredContent: output
    };
  }
);

The outputSchema demonstrates an important v2 capability: a tool can return both human-readable content and structured data. The SDK can validate the structured result against the declared schema.

4. Adding a Context Resource

Resources are data identified by URIs. They are different from tools: a tool represents an action, while a resource represents information that a client can read.

server.registerResource(
  "diagnostics",
  "system://diagnostics",
  {
    title: "System Diagnostics",
    description: "Runtime information about the MCP server",
    mimeType: "application/json"
  },
  async uri => {
    const memory = process.memoryUsage();

    const info = {
      platform: process.platform,
      nodeVersion: process.version,
      uptimeSeconds: Math.floor(process.uptime()),
      heapUsedMB: Number((memory.heapUsed / 1024 / 1024).toFixed(2)),
      heapTotalMB: Number((memory.heapTotal / 1024 / 1024).toFixed(2))
    };

    return {
      contents: [{
        uri: uri.href,
        text: JSON.stringify(info, null, 2),
        mimeType: "application/json"
      }]
    };
  }
);

5. Serving the Server over stdio

For the modern 2026 MCP protocol era, use the SDK's serveStdio() entry point. It creates a server instance per connection and handles protocol-era negotiation.

serveStdio(() => server).catch(error => {
  console.error("Fatal error starting MCP server:", error);
  process.exit(1);
});

For a more reusable application, you can put server construction into a factory function and return a fresh McpServer instance from the callback. That pattern becomes particularly useful when you add per-connection state or move to HTTP.

The Golden Rule of MCP stdio Servers

When building an MCP server that communicates over stdio:

CRITICAL: Keep diagnostic logging away from the protocol stream. Do not use console.log() for application logs in a stdio MCP server. Use console.error() or another logging mechanism that writes to stderr. stdout is the protocol channel.

Testing Your Server with MCP Inspector

MCP Inspector is a development tool for testing and debugging MCP servers without first integrating them into a full AI application.

npx @modelcontextprotocol/inspector node index.js

The Inspector starts a local web interface and prints the connection URL in the terminal. Do not assume a fixed port or URL: use the URL shown by the Inspector for your session.

Depending on the version and mode you use, Inspector can also be driven from the command line or terminal UI. For example:

# Web interface
npx @modelcontextprotocol/inspector node index.js

# CLI mode
npx @modelcontextprotocol/inspector --cli node index.js

# Terminal UI
npx @modelcontextprotocol/inspector --tui node index.js

In the Inspector you can:

Connecting to Claude Desktop or an IDE

An MCP-compatible host can launch a local stdio server as a child process. The exact configuration format and location depend on the host and its current version, so consult the host's documentation for the supported MCP configuration.

For a host that supports the familiar mcpServers stdio configuration, the entry looks like this:

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

After restarting the host, it should discover the server's tools and resources. The same server can be used by different MCP-compatible hosts without changing the server implementation; the host is responsible for the model interaction and the MCP client connection.

"What is the current temperature in Karlsruhe, Germany, and convert 150 EUR to USD using the server's reference rates."

A compatible host can decide to call get_weather and convert_currency, then present the results to the user. Tool selection is performed by the host/model; the MCP server provides the capabilities and enforces their input contracts.

stdio vs. Streamable HTTP

stdio is a good fit when an application launches your MCP server locally. It requires no listening HTTP port and makes process isolation straightforward.

For remote or service-oriented deployments, the modern MCP SDK provides Streamable HTTP. It is the transport to consider when an MCP server needs to be reachable over a network rather than spawned as a local process.

Transport Typical use
stdio Local MCP servers launched by a desktop app, IDE, or agent.
Streamable HTTP Remote, shared, or service-oriented MCP servers.
Legacy HTTP+SSE Compatibility with older MCP deployments; new implementations should prefer Streamable HTTP.

The transport is independent of the capabilities you expose. A well-designed server can keep its tool/resource registration separate from the code that serves it over stdio or HTTP.

Security and Production Considerations

A tutorial server is not automatically a production service. Before exposing real systems to an LLM, treat the MCP server as an application security boundary.

Common v1-to-v2 Differences

If you find older MCP JavaScript tutorials online, you may see code such as:

// Older v1 style
server.tool("get_weather", schema, handler);
server.resource("diagnostics", "system://diagnostics", handler);

The v2 API uses explicit registration methods and configuration objects:

// Current v2 style
server.registerTool("get_weather", {
  description: "Get current weather",
  inputSchema: z.object({ /* ... */ })
}, handler);

server.registerResource(
  "diagnostics",
  "system://diagnostics",
  { mimeType: "application/json" },
  handler
);

This is a breaking API change, so copying code from a v1 tutorial into a v2 project can result in confusing import or method errors. When starting a new project, use the current v2 documentation and package names.

Compact Server Example

Putting the pieces together, a compact version of the server looks like this:

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

function createServer() {
  const server = new McpServer({
    name: "utility-mcp-server",
    version: "1.0.0"
  });

  server.registerTool(
    "get_weather",
    {
      description: "Fetch current weather for geographic coordinates",
      inputSchema: z.object({
        latitude: z.number().min(-90).max(90),
        longitude: z.number().min(-180).max(180),
        cityName: z.string().optional()
      })
    },
    async ({ latitude, longitude, cityName }) => {
      const url =
        "https://api.open-meteo.com/v1/forecast" +
        `?latitude=${encodeURIComponent(latitude)}` +
        `&longitude=${encodeURIComponent(longitude)}` +
        "¤t=temperature_2m,relative_humidity_2m," +
        "apparent_temperature,precipitation,wind_speed_10m";

      const response = await fetch(url);

      if (!response.ok) {
        return {
          isError: true,
          content: [{
            type: "text",
            text: `Weather service returned HTTP ${response.status}`
          }]
        };
      }

      const data = await response.json();
      const current = data.current;
      const label = cityName ?? `${latitude}, ${longitude}`;

      return {
        content: [{
          type: "text",
          text: [
            `Weather for ${label}:`,
            `Temperature: ${current.temperature_2m}°C`,
            `Feels like: ${current.apparent_temperature}°C`,
            `Humidity: ${current.relative_humidity_2m}%`,
            `Wind: ${current.wind_speed_10m} km/h`
          ].join("\\n")
        }]
      };
    }
  );

  server.registerResource(
    "diagnostics",
    "system://diagnostics",
    {
      title: "System Diagnostics",
      mimeType: "application/json"
    },
    async uri => {
      const memory = process.memoryUsage();

      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            platform: process.platform,
            nodeVersion: process.version,
            uptimeSeconds: Math.floor(process.uptime()),
            heapUsedMB: Number(
              (memory.heapUsed / 1024 / 1024).toFixed(2)
            )
          }, null, 2)
        }]
      };
    }
  );

  return server;
}

serveStdio(createServer).catch(error => {
  console.error("Fatal error starting MCP server:", error);
  process.exit(1);
});

This is intentionally small. In a real application, keep the MCP registration layer thin and move business logic, API clients, authentication, configuration, and observability into separate modules.

Conclusion

MCP provides a standard boundary between AI applications and external capabilities. With the current MCP TypeScript SDK v2, a JavaScript/Node.js developer can expose validated tools and readable resources without implementing the protocol from scratch.

The example in this article uses stdio because it is simple to run locally. As your integration grows, the same architectural ideas can be applied to remote Streamable HTTP servers, internal APIs, databases, developer tools, and enterprise workflows.

Need Help Integrating AI Workflows into Your Tech Stack?

At ionTrails, we specialize in high-performance software engineering, distributed systems, and modern AI agent integrations. Contact us to explore how we can empower your engineering team.

Get in Touch →