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.
@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 │
└────────────────┘
- Host: The AI application that coordinates the model, user interaction, and one or more MCP clients.
- MCP Client: The protocol component inside the host that maintains a connection to an MCP server.
- Server: A program exposing capabilities such as tools, resources, and prompts through MCP.
- LLM: The model that can reason about available capabilities and, where the host permits it, request tool calls.
MCP defines three primary server-side primitives:
- Tools: Actions that a model can request, such as calling an API, querying data, or performing a calculation.
- Resources: Data identified by URIs that a client can read as context, such as documents, configuration, logs, or generated diagnostics.
- Prompts: Reusable prompt templates and workflows exposed by a server.
What We'll Build
Our example server will expose:
- A Live Weather Tool (
get_weather) that queries the Open-Meteo API for current conditions. - A Currency Conversion Tool (
convert_currency) using clearly labelled reference rates for demonstration. - A System Diagnostics Resource (
system://diagnostics) exposing runtime information such as Node.js version, memory usage, and uptime.
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 useconsole.log()for application logs in a stdio MCP server. Useconsole.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:
- Inspect the tools exposed by the server and their generated schemas.
- Call
get_weatherandconvert_currencywith test arguments. - Inspect JSON-RPC traffic and tool results.
- Read the
system://diagnosticsresource. - Verify input and output validation before connecting the server to a real host.
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.
- Precise Tool Descriptions: Write clear, unambiguous descriptions and parameter documentation. Models rely on this metadata when deciding how to use a tool.
- Runtime Validation: Validate all model-controlled input at the server boundary. Zod schemas make this explicit.
- Authorization: Do not assume that because a tool is callable by an AI host, the caller should have unrestricted access to the underlying system.
- Least Privilege: Give the server only the filesystem, network, database, and API permissions it actually needs.
- Safe External Calls: Use timeouts, bounded retries, and appropriate error handling around upstream APIs.
- Secrets: Keep credentials and API keys outside source code and outside tool descriptions or prompts.
- Observability: Add structured logging, metrics, tracing, and request correlation where the environment requires them.
- Network Security: For remote HTTP deployments, use TLS, authentication/authorization, origin and host validation where applicable, and appropriate network controls.
- Command Execution: Never pass model-controlled strings directly to a shell or arbitrary code evaluator.
- Reproducible Builds: Commit your package lockfile and review dependency updates rather than relying on floating versions in production.
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 →