The Model Context Protocol (MCP) is a standard way for an AI assistant to call tools and read resources from an external server — your codebase, your internal API, a database — instead of being limited to what it already knows. This walks through building a minimal server and pointing an MCP-compatible client at it.
Prerequisites
Node.js (see the Node environment setup guide if you haven't already), and a package to hold your server:
mkdir mcp-demo && cd mcp-demo
npm init -y
npm install @modelcontextprotocol/sdk zodA minimal server
MCP servers expose tools (actions the assistant can call) and resources (data it can read). Here's a server with one tool:
// server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "demo-server",
version: "1.0.0",
});
server.registerTool(
"get_time",
{
title: "Get current time",
description: "Returns the current server time in ISO 8601 format",
inputSchema: {},
},
async () => ({
content: [{ type: "text", text: new Date().toISOString() }],
}),
);
server.registerTool(
"add_numbers",
{
title: "Add two numbers",
description: "Returns the sum of two numbers",
inputSchema: {
a: z.number(),
b: z.number(),
},
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);This uses stdio transport — the client launches your server as a subprocess and talks to it over stdin/stdout. That's the right choice for a local tool like this; MCP also supports HTTP-based transport for servers that run remotely, which you'd reach for once this needs to be shared rather than run locally per-user.
zod schemas on tool inputs aren't just validation — the client uses them
to know what arguments a tool accepts and generate the right call. Take
the time to write accurate description fields too; that's literally how
the assistant decides which tool to call and when.
Connect it to Claude Desktop
Edit the config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"demo-server": {
"command": "node",
"args": ["/absolute/path/to/mcp-demo/server.js"]
}
}
}Restart Claude Desktop. Your tools should appear in the tool list —
ask it something that requires calling add_numbers and watch it use
the tool instead of computing the answer itself.
Connect it to Claude Code
claude mcp add demo-server -- node /absolute/path/to/mcp-demo/server.jsOr drop a .mcp.json in your project root with the same shape as the
Desktop config above, if you want it checked into the repo so the whole
team gets it automatically.
Test without a full client
The MCP Inspector is a standalone debugging UI — point it at your server without needing Claude Desktop or Code at all:
npx @modelcontextprotocol/inspector node server.jsIt opens a browser UI listing your tools, lets you call them manually with test arguments, and shows the raw request/response — the fastest feedback loop while you're building a server, before wiring up a real client.
Adding a resource
Tools are actions; resources are readable data, addressed by URI:
server.registerResource(
"readme",
"file:///project/README.md",
{ title: "Project README", mimeType: "text/markdown" },
async () => ({
contents: [
{
uri: "file:///project/README.md",
text: await import("node:fs/promises").then((fs) =>
fs.readFile("./README.md", "utf8"),
),
},
],
}),
);Common gotchas
Server doesn't show up in Claude Desktop/Code at all. Almost always a
config file path issue — confirm you edited the exact file the client
reads (the macOS/Windows paths above are easy to get slightly wrong), and
that the command/args use an absolute path, not a relative one.
Server crashes on launch with no clear error. Run it directly first —
node server.js — before wiring it into a client. Stdio transport means
any stray console.log in your server code corrupts the protocol stream,
since stdout is the communication channel; log to console.error (stderr)
instead if you need to debug.
The SDK's exact API shifts between versions — check the
TypeScript SDK's README
against the version npm install actually gave you if something here
doesn't quite match.
Once one tool works end to end — registered, callable, showing up in a
real client — adding more is just repeating registerTool with a new
name, schema, and handler.