How to Build an MCP Chat Server with Node.js
Build a Node.js MCP server that exposes tools to chat hosts, with stdio setup, testing, remote deployment guidance, security, and troubleshooting.
Direct answer: an MCP chat server is a Node.js process that exposes tools, resources, or prompts to an MCP-compatible AI host. MCP does not provide the chat interface, model, or conversation manager. Your server supplies capabilities; the host decides how to present and call them.
This guide uses the current MCP TypeScript SDK v2 package, @modelcontextprotocol/server, for the 2026-07-28 specification revision. The official SDK supports Node.js, Bun, and Deno. The local example uses stdio, which is appropriate when a host launches your server as a child process.
1. Choose the server boundary
Separate the responsibilities before writing code:
- Chat host: provides the model, conversation UI, permissions, and tool-calling loop.
- MCP server: validates inputs and performs actions such as API calls, database queries, or file operations.
- Transport: carries MCP JSON-RPC messages between the host and server.
For a local integration, the host starts your Node.js process and communicates over stdin/stdout. For a shared remote service, expose the current v2 HTTP serving path documented by the SDK and authenticate clients at the application boundary.
2. Create the Node.js project
The current first-server walkthrough assumes Node.js 20 or later, an ES module project, zod for schemas, and tsx for running TypeScript directly.
mkdir weather-mcp-server
cd weather-mcp-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install --save-dev tsx typescript @types/node
mkdir src
Add the module and scripts to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"start": "tsx src/index.ts"
}
}
If your TypeScript configuration does not include Node declarations, add types: ["node"] to compilerOptions. The v2 package ships ES modules only, so do not copy imports from the older monolithic v1 package.
3. Register a useful tool
The following server exposes a weather_alerts tool. It accepts a two-letter US state code, calls the public weather-alert endpoint, and returns structured text the chat host can display.
import { createServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
function createMcpServer() {
const server = createServer({
name: "weather-mcp-server",
version: "1.0.0"
});
server.registerTool(
"weather_alerts",
{
title: "US weather alerts",
description: "Return active weather alerts for a two-letter US state code.",
inputSchema: {
state: z.string().length(2).regex(/^[A-Za-z]{2}$/).describe("US state code, for example CA")
}
},
async ({ state }) => {
const code = state.toUpperCase();
const response = await fetch(
`https://api.weather.gov/alerts/active?area=${encodeURIComponent(code)}`,
{
headers: {
"User-Agent": "weather-mcp-server/1.0 (contact@example.com)",
"Accept": "application/geo+json"
}
}
);
if (!response.ok) {
throw new Error(`Weather API returned HTTP ${response.status}`);
}
const data = await response.json();
const alerts = (data.features ?? []).map((feature) => {
const p = feature.properties ?? {};
return {
event: p.event,
headline: p.headline,
severity: p.severity,
area: p.areaDesc,
effective: p.effective,
expires: p.expires,
instruction: p.instruction
};
});
return {
content: [
{
type: "text",
text: JSON.stringify({ state: code, count: alerts.length, alerts }, null, 2)
}
]
};
}
);
return server;
}
await serveStdio(createMcpServer());
The SDK validates the tool call against the schema before your handler runs. Keep schemas narrow: validation errors are easier for a model and a user to understand than an API failure several steps later.
4. Keep stdout clean
stdio is the protocol channel. Any diagnostic written with console.log can corrupt the JSON-RPC stream. Use stderr instead:
console.error("weather-mcp-server started");
The official documentation summarizes this operational rule as: “stdout is the protocol channel. Log with console.error — one console.log corrupts the JSON-RPC stream.” Never print banners, progress messages, stack traces, or debug objects to stdout.
5. Run and inspect the server
Start it locally:
npm run dev
To inspect the tool interactively, launch the MCP Inspector with the same command:
npx @modelcontextprotocol/inspector npx tsx src/index.ts
- Open the Inspector interface.
- Connect to the spawned server.
- Open Tools.
- Select
weather_alerts. - Submit a state such as
CA. - Check that the result appears as a text content item.
This verifies the protocol boundary without requiring a particular chat application. A host integration uses the same executable command and starts the process when it needs the server.
6. Add resources and prompts deliberately
MCP servers can expose three capability types:
| Capability | Use it for | Example |
|---|---|---|
| Tools | Actions with side effects or computation | Look up alerts, create a ticket, query an API |
| Resources | Addressable context that a host can read | Documents, schemas, records, generated reports |
| Prompts | Reusable interaction templates | Code-review or incident-investigation instructions |
Use the current v2 server documentation for resource and prompt registration signatures. Older v1 examples use different package names and APIs; do not mix those imports into this v2 project.
7. Decide between stdio and HTTP
| Requirement | Recommended shape |
|---|---|
| A desktop or CLI host launches one local process | stdio with serveStdio |
| Several clients need one hosted endpoint | Current v2 Node-compatible Streamable HTTP serving |
| An older client requires legacy compatibility | Use HTTP+SSE only when that compatibility requirement is real; the explicit legacy guidance is from v1 documentation |
For v2 remote serving, follow the SDK’s current Node HTTP transport and migration guidance, including the documented createMcpHandler entry where applicable. Keep the HTTP adapter, authentication, origin checks, and request lifecycle in the web application layer. Do not paste a v1 Express helper into a v2 server and assume the behavior is unchanged.
8. Secure a remote deployment
- Bind local development servers only where the host expects them.
- For a network endpoint, validate the request origin and host according to the current v2 deployment guidance.
- Require authentication before exposing tools that read private data or perform mutations.
- Allow-list outbound destinations when tools fetch URLs or call internal services.
- Set timeouts and response-size limits on every external request.
- Redact tokens, cookies, authorization headers, and personal data from stderr logs.
- Return stable, model-readable errors instead of leaking stack traces.
Older SDK documentation discusses DNS rebinding and host-header validation for localhost servers. Treat that as a security consideration and verify the exact current v2 implementation before deployment.
9. Reliability and performance
Bound every external call
Use an AbortController timeout around slow APIs. A tool that never resolves blocks the host’s tool-calling turn.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(url, { signal: controller.signal });
// parse and validate the response
} finally {
clearTimeout(timer);
}
Return compact, useful data
Large payloads increase model context and latency. Select fields, truncate unbounded descriptions, and include timestamps or identifiers so the host can explain what it received.
Make retries safe
Retry only idempotent reads, use exponential backoff, and avoid repeating mutations unless the operation has an idempotency key. The dossier provides no benchmark or SLA, so choose limits from your API’s documented behavior and measure your own workload.
Control concurrency
Limit simultaneous calls if the upstream API has quotas. Cache data only when its freshness requirements allow it, and expose the cache age in the result when that affects decisions.
10. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Inspector shows a protocol parse error | Something wrote to stdout | Replace console.log with console.error; remove startup banners. |
ERR_MODULE_NOT_FOUND |
Old v1 import or missing dependency | Install @modelcontextprotocol/server and use ESM imports. |
| Tool input is rejected | Input does not match the Zod schema | Send a two-letter state code such as CA; improve the description if the model frequently guesses incorrectly. |
| Weather request returns 403 | Missing or unsuitable User-Agent | Send an identifiable User-Agent and follow the upstream API’s access policy. |
| Tool hangs | Upstream request has no timeout | Use AbortController and return a clear timeout error. |
| Host cannot start the server | Wrong working directory or command | Use an absolute project path in the host configuration and verify npx tsx src/index.ts from a shell first. |
| Remote clients fail to connect | Transport mismatch | Confirm that both sides support the same v2 HTTP transport; use legacy HTTP+SSE only for a documented compatibility need. |
11. Or skip the browser setup
If your chat workflow mainly needs website screenshots, ScreenshotNeo gives an MCP server that AI agents such as Claude, Cursor, and other MCP clients can call. It also provides a direct HTTP API, so your Node.js tool can request an image without managing Playwright or Chromium.
One request returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result.
See the ScreenshotNeo API documentation for all options.
import { z } from "zod";
server.registerTool(
"capture_screenshot",
{
title: "Capture a website screenshot",
description: "Capture a clean screenshot of a public URL.",
inputSchema: {
url: z.string().url(),
format: z.enum(["png", "jpeg", "webp", "pdf"]).optional()
}
},
async ({ url, format = "webp" }) => {
const query = new URLSearchParams({
access_key: process.env.SCREENSHOTNEO_ACCESS_KEY,
url,
format
});
const response = await fetch(`https://api.screenshotneo.com/v1/shot?${query}`);
if (!response.ok) throw new Error(`ScreenshotNeo returned HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
return {
content: [{ type: "text", text: `Captured ${url} (${bytes.length} bytes)` }]
};
}
);
For a direct call:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo includes full-page and element capture, dark mode, device presets, retina scale, custom CSS and JavaScript, clicks, waits, request blocking, headers, cookies, user agents, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, webhooks, bulk capture, usage reporting, and an OpenAPI specification. It accepts the parameter names used by other screenshot APIs, which can simplify migration.
There are 1,000 free screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots, and every feature is available on every plan. Create a free ScreenshotNeo account.
12. FAQ
Does MCP include a chat UI?
No. MCP connects an AI application to tools, resources, and prompts. The host supplies the model and conversation experience.
Can I write the server in plain JavaScript?
Yes. The SDK is an ES module package; TypeScript with tsx is used here for typed schemas, but the same v2 imports can be used from JavaScript.
Why use stdio instead of HTTP?
stdio is simplest when a local host starts one process. HTTP is appropriate when multiple clients need a shared endpoint.
Should every capability be a tool?
No. Use tools for actions, resources for addressable context, and prompts for reusable interaction templates.
How do I keep a tool from exposing secrets?
Keep credentials in server-side environment variables, validate inputs, redact logs, and return only the fields the model needs.


