The fastest way to understand MCP is to build a server yourself. This guide walks through a complete, dependency-free HTTP MCP server in a single Node file. By the end you'll have a working server you can test with curl and register with Claude Code.
What we're building
A minimal MCP server that handles the three required interactions:
- initialize — confirm protocol version, introduce the server.
- tools/list — advertise one tool:
echo. - tools/call — run the echo tool, return the result.
No npm, no TypeScript compilation, no framework. Just node:http and JSON-RPC.
The server: mcp-server.mjs
import { createServer } from "node:http";
const PORT = 8765;
const TOOLS = [
{
name: "echo",
description: "Return the text you send.",
inputSchema: {
type: "object",
properties: {
text: { type: "string", description: "The text to echo." },
},
required: ["text"],
},
},
];
function handleRequest(body) {
const { jsonrpc, id, method, params } = body;
if (method === "initialize") {
return {
jsonrpc,
id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: {} },
serverInfo: { name: "my-first-mcp", version: "0.1.0" },
instructions: "A minimal echo server for learning MCP.",
},
};
}
if (method === "tools/list") {
return { jsonrpc, id, result: { tools: TOOLS } };
}
if (method === "tools/call") {
const { name, arguments: args } = params;
if (name !== "echo") {
return {
jsonrpc,
id,
error: { code: -32601, message: `Unknown tool: ${name}` },
};
}
return {
jsonrpc,
id,
result: {
content: [{ type: "text", text: args.text }],
},
};
}
return {
jsonrpc,
id,
error: { code: -32601, message: `Unknown method: ${method}` },
};
}
const server = createServer((req, res) => {
if (req.method !== "POST") {
res.writeHead(405).end("Method Not Allowed");
return;
}
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
let body;
try {
body = JSON.parse(raw);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
const response = handleRequest(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(response));
});
});
server.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}`);
});Save this as mcp-server.mjs anywhere on your machine. Run it:
node mcp-server.mjs
# MCP server listening on http://localhost:8765Test with curl
Verify each method before touching Claude Code. Open a second terminal.
Initialize:
curl -s -X POST http://localhost:8765 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}'Expected response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": { "tools": {} },
"serverInfo": { "name": "my-first-mcp", "version": "0.1.0" },
"instructions": "A minimal echo server for learning MCP."
}
}List tools:
curl -s -X POST http://localhost:8765 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'Call the echo tool:
curl -s -X POST http://localhost:8765 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello from curl"}}}'Expected:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{ "type": "text", "text": "hello from curl" }]
}
}Test an unknown method to confirm error handling:
curl -s -X POST http://localhost:8765 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"nonexistent","params":{}}'Expected:
{ "jsonrpc": "2.0", "id": 4, "error": { "code": -32601, "message": "Unknown method: nonexistent" } }Register with Claude Code
With the server running, register it using the HTTP transport:
claude mcp add --transport http my-first-mcp http://localhost:8765Confirm it's registered:
claude mcp listStart a new Claude Code session. Claude Code will handshake with your server at startup and make the echotool available. In the session, ask Claude to use it: "use the echo tool to repeat: hello world". Claude Code will call tools/call with { "name": "echo", "arguments": { "text": "hello world" } } and display the result.
JSON-RPC error codes
The spec defines standard error codes. The one you'll use most often:
| Code | Meaning |
|---|---|
-32700 | Parse error — invalid JSON. |
-32600 | Invalid request — missing required fields. |
-32601 | Method not found — unknown method or tool name. |
-32602 | Invalid params — wrong argument types or missing required args. |
-32603 | Internal error — unexpected server exception. |
Always return structured JSON-RPC errors rather than HTTP 4xx/5xx for application-level failures. HTTP status codes are for transport-level problems (wrong method, auth failure). The JSON-RPC error field handles everything inside a valid request.
What to add next
This server is intentionally stripped down. The natural next steps:
- Authentication. Check an Authorization header in the HTTP request handler before routing to
handleRequest. Return HTTP 401 if it's missing or invalid. Store hashed tokens server-side — never the raw value. See HTTP MCP servers with bearer tokens for the full security model. - More tools. Add entries to the
TOOLSarray and handle their names in thetools/callbranch. Each tool needs a name, description, and a JSON Schema for its inputs. - Input validation. The server above trusts that
args.textis a string. Add a check and return a-32602error if it's missing or the wrong type. - Deploy it.Once you're happy locally, run it on a server, put it behind HTTPS, and register the public URL with
--transport http. At that point it works from any machine, not just localhost.
When you're ready to see what a production HTTP MCP server looks like, read Inside the promenow-tools MCP server for real transcripts from a live deployment. To use it directly, visit the Terminal Pro builder.