homeresume
 
   

Building an MCP server with Node.js

Published June 6, 2026Last updated June 6, 20267 min read

The Model Context Protocol (MCP) is an open standard for connecting AI hosts (Claude, ChatGPT, Cursor, VS Code, and others) to external context and actions through a structured protocol instead of ad-hoc plugins.

The host runs an MCP client. Your application is the MCP server, exposing tools (actions the model can invoke), resources (read-only data), and optionally prompts (reusable message templates). Communication uses JSON-RPC over a transport such as stdio or HTTP.

This post shows how to build a small todo MCP server with Node.js using the official @modelcontextprotocol/sdk package and Zod schemas.

Architecture at a glance

After the client connects, the server completes an initialization handshake (initializeinitialized). The host discovers capabilities via tools/list, resources/list, and prompts/list, then calls tools or reads resources at runtime.

Prerequisites

  • Node.js version 26
  • npm i @modelcontextprotocol/sdk zod
  • Optional for client testing: Claude Desktop and/or ChatGPT (Connectors / Apps)

The stable v1 SDK is @modelcontextprotocol/sdk. A v2 split (@modelcontextprotocol/server, @modelcontextprotocol/client) is in pre-release - this post uses v1, which matches current production tooling.

MCP capabilities - tools, resources, prompts

CapabilityPurposeDemo example
ToolsModel-invoked actions with typed inputsadd_todo, list_todos, mark_todo_done
ResourcesRead-only context the host can fetchtodo://all JSON snapshot
Prompts (optional)Named templates with argumentssummarize-open-todos

Tools are the main integration surface - the model calls them via tools/call. Resources are fetched with resources/read and should stay read-only. Prompts return pre-built messages via prompts/get.

Tool inputs need a schema so clients know parameters. With the TypeScript SDK, pass Zod fields in inputSchema:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => ({
content: [{ type: 'text', text: JSON.stringify({ title, done: false }) }],
})
);

Register a resource at a fixed URI:

server.registerResource(
'all-todos',
'todo://all',
{
title: 'All todos',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify([{ id: 1, title: 'Learn MCP', done: false }]),
},
],
})
);

Register an optional prompt:

server.registerPrompt(
'summarize-open-todos',
{
title: 'Summarize open todos',
description: 'Ask the model to summarize open todos',
},
() => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Summarize my open todos and suggest a priority order.',
},
},
],
})
);

Building the server

Use a factory so the same server definition works with multiple transports:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const todos = [
{ id: 1, title: 'Learn MCP basics', done: false },
{ id: 2, title: 'Ship demo server', done: false },
];
let nextId = 3;
export function createMcpServer() {
const server = new McpServer({ name: 'todo-mcp-server', version: '1.0.0' });
server.registerTool(
'add_todo',
{
description: 'Add a new todo item',
inputSchema: { title: z.string().min(1) },
},
async ({ title }) => {
const todo = { id: nextId++, title, done: false };
todos.push(todo);
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
server.registerTool('list_todos', { description: 'List all todo items' }, async () => ({
content: [{ type: 'text', text: JSON.stringify(todos, null, 2) }],
}));
server.registerTool(
'mark_todo_done',
{
description: 'Mark a todo item as done by id',
inputSchema: { id: z.number().int().positive() },
},
async ({ id }) => {
const todo = todos.find((item) => item.id === id);
if (!todo) {
return { content: [{ type: 'text', text: `Todo ${id} not found` }], isError: true };
}
todo.done = true;
return { content: [{ type: 'text', text: JSON.stringify(todo, null, 2) }] };
}
);
// register resource and prompt here (see snippets above)
return server;
}

Tool handlers return { content: [...] }. Set isError: true when a tool fails so the host can surface the error. Resource handlers return { contents: [...] }. Prompt handlers return { messages: [...] }.

The demo uses an in-memory store so you can run it without API keys or a database.

Transports - stdio vs SSE / Streamable HTTP

MCP separates protocol (JSON-RPC messages) from transport (how bytes move between client and server).

Stdio (StdioServerTransport)

The client spawns your server as a child process. JSON-RPC goes over stdin/stdout.

  • Use when: Claude Desktop, Cursor, VS Code, Claude Code, local CLI agents
  • Pros: simplest setup, no ports or firewall rules, no OAuth
  • Cons: one client per process; cloud hosts cannot spawn your local binary
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createMcpServer } from './create-server.js';
const server = createMcpServer();
await server.connect(new StdioServerTransport());

Write logs to stderr only - stdout is the protocol channel.

Remote HTTP - SSE (legacy) vs Streamable HTTP (current)

Early MCP remote servers used HTTP + SSE: POST for client→server requests, Server-Sent Events for server→client streaming. That transport is deprecated.

New servers should use Streamable HTTP (StreamableHTTPServerTransport). It supports POST request/response, optional SSE for notifications, and session management. The v2 SDK removes server-side SSE entirely; client-side SSE remains for legacy servers.

ScenarioTransport
Claude Desktop, local devstdio
Cursor / VS Code project MCPstdio
ChatGPT Apps / ConnectorsStreamable HTTP over public HTTPS
Legacy SSE-only clientsSSE client transport still exists; prefer Streamable HTTP for new servers

Streamable HTTP entry (stateless):

import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './create-server.js';
const app = createMcpExpressApp();
const PORT = Number(process.env.PORT) || 3000;
app.post('/mcp', async (req, res) => {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
});
app.listen(PORT, () => {
console.error(`MCP server listening on http://127.0.0.1:${PORT}/mcp`);
});

createMcpExpressApp() enables DNS rebinding protection when binding to localhost - recommended for local HTTP servers.

Deploy Streamable HTTP behind HTTPS before exposing it to cloud clients. ChatGPT Connectors also require OAuth 2.1 for production use.

Connecting MCP clients

Claude Desktop (stdio - primary demo path)

Config file on Windows: %APPDATA%\Claude\claude_desktop_config.json. Open it via Settings → Developer → Edit Config.

{
"mcpServers": {
"todo-mcp": {
"command": "node",
"args": ["C:/path/to/demos/mcp-server-nodejs-demo/src/stdio.js"]
}
}
}

Restart Claude Desktop after saving. Claude shows a tool approval UI before executing write operations.

Claude Desktop (remote HTTP)

claude_desktop_config.json validates stdio servers only - do not put a bare url field there expecting it to work. For a public HTTPS MCP server, use Settings → Connectors → Add custom connector. For local HTTP during development, bridge with mcp-remote as a stdio-launched proxy.

ChatGPT (Connectors / Apps)

ChatGPT has no local MCP config file. Register servers in Settings → Apps (or Connectors) with a name, description, and MCP server URL.

Requirements:

  • Public HTTPS endpoint (Streamable HTTP)
  • OAuth 2.1 for production connectors
  • Stdio-only servers need an HTTP wrapper or tunnel before ChatGPT can reach them

ChatGPT shows confirmation modals before write/modify tool calls.

Cursor

Cursor uses .cursor/mcp.json with the same stdio shape as Claude Desktop (command + args).

What else matters

  • Security - validate tool inputs, scope tools narrowly, and never expose secrets in resources.
  • Logging - stderr only for stdio servers; avoid console.log on stdout.
  • Testing - use @modelcontextprotocol/sdk client helpers with StdioClientTransport for smoke tests.
  • Spec evolution - prefer Streamable HTTP over legacy SSE; watch the v2 SDK split when upgrading.

Demo

Runnable scripts for this post live in the mcp-server-nodejs-demo folder in the private demos repository. Get access via code demos.