ScreenshotNeo

BlogAI agents

How to Build a Node.js MCP Server

Build a production-ready Node.js MCP server with TypeScript, tools, resources, prompts, stdio, Streamable HTTP, security, and deployment guidance.

By the ScreenshotNeo team1 October 20267 min read

Direct answer: Build a Node.js MCP server by creating an McpServer, registering tools, resources, and prompts, selecting a transport, and calling server.connect(transport). Use stdio when a local MCP client launches your process. Use Streamable HTTP for a remote service. The current TypeScript SDK v2 uses @modelcontextprotocol/server; older v1 projects use @modelcontextprotocol/sdk.

This guide follows the official MCP server guide and the TypeScript SDK. The API surface is version-sensitive, so check the SDK migration guide when maintaining an existing server.

1. Choose the server shape first

Decision Use this Why
Client starts your Node process stdio Smallest setup and no network listener
Clients connect over a network Streamable HTTP Modern remote transport with request/response, optional SSE notifications, sessions, and resumability
Very old client compatibility HTTP+SSE Documented as a compatibility path; new servers should prefer Streamable HTTP

For HTTP, decide whether the server is stateless or stateful. Stateless mode fits API-style calls and does not create session IDs. Stateful mode adds session identity and resumability, but requires session storage and cleanup.

2. Create a TypeScript project

mkdir weather-mcp
cd weather-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript tsx @types/node
npx tsc --init

Use Node types explicitly in tsconfig.json when needed; TypeScript 6 no longer automatically includes every @types/* package. A practical configuration is:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}

Add a script so the server can be launched by an MCP host:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "tsx src/server.ts"
  }
}

3. Build a minimal stdio server

The v2 SDK documents serveStdio as a compact way to create and run a server. Save this as src/server.ts:

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

serveStdio(() => {
  const server = new McpServer({
    name: 'weather-tools',
    version: '1.0.0'
  });

  server.registerTool(
    'convert-temperature',
    {
      title: 'Convert temperature',
      description: 'Convert a Celsius value to Fahrenheit.',
      inputSchema: {
        celsius: z.number().finite()
      },
      outputSchema: {
        fahrenheit: z.number()
      }
    },
    async ({ celsius }) => {
      const output = { fahrenheit: (celsius * 9) / 5 + 32 };
      return {
        content: [{ type: 'text', text: JSON.stringify(output) }],
        structuredContent: output
      };
    }
  );

  return server;
});

Run it with npm run dev. The process waits for JSON-RPC messages on stdin. Keep protocol traffic on stdout; send diagnostic logs to stderr or an application logger.

Why the schema matters

Tool schemas let clients validate arguments before execution and help models choose the correct tool. Use finite numbers, bounded strings, enums, and explicit optional fields. Return normal content for people and structuredContent when clients need machine-readable fields.

4. Register tools, resources, and prompts

Tools: callable actions

server.registerTool(
  'lookup-order',
  {
    title: 'Look up an order',
    description: 'Return the current status for one order ID.',
    inputSchema: {
      orderId: z.string().trim().min(1).max(64)
    },
    outputSchema: {
      orderId: z.string(),
      status: z.enum(['pending', 'shipped', 'delivered'])
    }
  },
  async ({ orderId }) => {
    const output = { orderId, status: 'pending' as const };
    return {
      content: [{ type: 'text', text: `Order ${orderId}: ${output.status}` }],
      structuredContent: output
    };
  }
);

Resources: read-only context

Resources expose data a client can read or subscribe to. Use URI templates for parameterized content, and keep resource handlers read-only. The exact registration helper can vary by SDK release, so follow the version-matched resource examples in the SDK documentation.

Prompts: reusable user-invoked workflows

Prompts package a repeatable interaction such as “summarize this incident.” They are invoked explicitly by a user or client. Add argument completion with the SDK’s completable helper where a client benefits from suggestions.

5. Run over Streamable HTTP

For a remote server, use the Node Streamable HTTP transport. This stateful outline creates a session ID:

import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';

const server = new McpServer({
  name: 'remote-tools',
  version: '1.0.0'
});

const transport = new NodeStreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID()
});

await server.connect(transport);

In a real application, attach the transport to your HTTP framework’s request handler, configure JSON-only responses when you do not need an SSE stream, and store state externally when multiple instances share traffic. For stateless operation, omit the session generator.

HTTP request testing with cURL

curl -i https://your-host.example/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'

HTTP request testing with Python

import requests

payload = {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "protocolVersion": "2026-07-28",
        "capabilities": {},
        "clientInfo": {"name": "python-client", "version": "1.0"}
    }
}
response = requests.post(
    "https://your-host.example/mcp",
    json=payload,
    headers={"Accept": "application/json, text/event-stream"},
    timeout=30,
)
response.raise_for_status()
print(response.text)

HTTP request testing with Node.js

const payload = {
  jsonrpc: '2.0',
  id: 1,
  method: 'initialize',
  params: {
    protocolVersion: '2026-07-28',
    capabilities: {},
    clientInfo: { name: 'node-client', version: '1.0' }
  }
};

const response = await fetch('https://your-host.example/mcp', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    accept: 'application/json, text/event-stream'
  },
  body: JSON.stringify(payload)
});

if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
console.log(await response.text());

6. Secure an HTTP deployment

  1. Validate the Host and Origin headers. The SDK documents localhost DNS-rebinding protection and custom host validation.
  2. Require authentication before exposing the endpoint publicly.
  3. Authorize each tool separately; do not grant every caller every capability.
  4. Use TLS, rate limits, request size limits, and timeouts.
  5. Keep secrets in environment variables or a secret manager, never tool arguments or source control.
  6. Log request IDs, tool names, durations, and failure categories without logging tokens or private payloads.

7. Production checklist

  • Pin the SDK major version and review the migration guide before upgrading.
  • Give the server and every tool a stable, precise name and description.
  • Validate all tool inputs and define output schemas for automation.
  • Set timeouts around network and database calls.
  • Make retries idempotent; avoid repeating payments, mutations, or destructive actions automatically.
  • Choose stateless or stateful HTTP deliberately and test reconnect behavior.
  • Keep stdout clean for stdio JSON-RPC.
  • Exercise initialization, tool listing, tool calls, malformed arguments, unauthorized calls, and disconnects with an MCP client.

8. Troubleshooting

Symptom Likely cause Fix
Client reports invalid JSON Logs were written to stdout Write diagnostics to stderr or your logger.
“Cannot find module” for the SDK v1 and v2 imports were mixed Use @modelcontextprotocol/server consistently for v2, or follow the v1 migration guide.
Tool arguments fail validation Schema does not match the client payload Inspect the declared input schema; coerce or normalize only where safe.
HTTP client hangs Transport and framework handler are not connected, or the client expects SSE Use the SDK’s Streamable HTTP integration and advertise the correct Accept header.
Sessions disappear after a restart Session state is held only in process memory Use stateless mode or an external session store and stable routing.
Remote calls are rejected Host/origin validation or authentication failed Inspect headers and server logs, then configure an explicit allowlist.
AI chooses the wrong tool Descriptions overlap or are vague Describe purpose, required inputs, side effects, and output fields distinctly.

9. Performance, reliability, and cost

stdio avoids network overhead and is usually the simplest choice for desktop assistants and private automation. Streamable HTTP supports shared deployments but adds connection management, authentication, session handling, and load-balancer concerns. Keep tool handlers short, enforce upstream timeouts, cache read-only resources where appropriate, and make retries safe.

MCP itself does not set a hosting price. Your cost comes from the Node runtime, network, databases, third-party APIs, logs, and any model or service invoked by a tool. Measure per-tool latency and error rates before adding concurrency. For stateful HTTP, account for memory or external storage per active session.

10. Or skip the browser setup

If one of your MCP tools needs website screenshots, ScreenshotNeo provides a website screenshot API and MCP server. Its capture pipeline accepts cookie and consent banners, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and lets you turn each cleanup step off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; responses identify the page verdict and billing status with X-Page-Verdict and X-Billed headers.

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

See the ScreenshotNeo API documentation for the other 63 options, including full-page capture, CSS selectors, dark mode, device presets, custom JavaScript, request blocking, headers and cookies, PDFs, caching, signed links, asynchronous jobs, webhooks, bulk capture, and usage reporting. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

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)

Free accounts include 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Should a new server use v1 or v2?

Use the v2 package, @modelcontextprotocol/server, for new work. Existing v1 projects use @modelcontextprotocol/sdk; migrate deliberately rather than mixing imports.

Can one server expose both stdio and HTTP?

Yes, but run each transport with an explicit entry point and configuration. Do not write protocol data from one transport into another process’s stdout.

When do I need stateful sessions?

Use stateful sessions when you need session identity, resumability, or server-side conversation state. Stateless mode is simpler for independent API calls.

Are resources and prompts required?

No. Start with the smallest useful tool set, then add resources for read-only context and prompts for reusable, user-invoked workflows.

What should a tool return?

Return readable content for clients and add structuredContent with an output schema when downstream code needs typed fields.