AI assistants like Claude are great at reasoning, but on their own they can't see your database, your files, or your internal APIs. The Model Context Protocol (MCP) fixes that. It is an open standard that lets any AI client talk to any tool server in the same way, a bit like USB-C for AI integrations.

In this guide you will build a working MCP server with Node.js and TypeScript using the official v2 SDK, test it with a real MCP client, and connect it to Claude. Every code sample here was run against @modelcontextprotocol/server 2.3.1.

What is an MCP Server?

An MCP server is a small program that exposes capabilities to an AI client through a standard JSON-RPC protocol. The client (Claude Desktop, Claude Code, Cursor, VS Code and others) discovers what the server offers and lets the model use it.

A server can expose three kinds of things:

  • Tools: functions the model can call, such as search_orders or create_ticket. This is what most servers are built for.
  • Resources: read-only data the client can load into context, such as a file, a config, or a database record, addressed by a URI.
  • Prompts: reusable prompt templates the user can pick from a menu.
AI Client (Claude, Cursor, IDE)
        ↕  MCP over stdio or HTTP
MCP Server (Node.js + TypeScript)
        ↕  your code
Database · APIs · Files

The big win is that you write the integration once. Every MCP-compatible client can use it without custom plugins.

Prerequisites

  • Node.js 20 or newer
  • Basic TypeScript knowledge
  • Claude Desktop or Claude Code (optional, for the final step)

Step 1: Set Up the Project

Create a new folder and install the server SDK, Zod for input validation, and TypeScript:

mkdir notes-mcp-server && cd notes-mcp-server
npm init -y
npm pkg set type=module scripts.build="tsc"
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node

In v2, the old single @modelcontextprotocol/sdk package is split into @modelcontextprotocol/server and @modelcontextprotocol/client. If you follow an older tutorial, that is why the imports look different.

Add a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "types": ["node"]
  },
  "include": ["src"]
}

The "types": ["node"] line matters. TypeScript 6 no longer includes @types/* automatically, and the SDK's type definitions reference Node's Buffer.

Step 2: Write the MCP Server

We will build a small notes server with two tools (add_note and search_notes) and one resource (notes://all). Create src/index.ts:

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

type Note = { id: number; title: string; body: string; createdAt: string };
const notes: Note[] = [];

function createServer() {
  const server = new McpServer({ name: "notes-server", version: "1.0.0" });

  server.registerTool(
    "add_note",
    {
      title: "Add note",
      description: "Save a short note with a title and body.",
      inputSchema: z.object({
        title: z.string().min(1).describe("Short title for the note"),
        body: z.string().min(1).describe("The note text"),
      }),
    },
    async ({ title, body }) => {
      const note = { id: notes.length + 1, title, body, createdAt: new Date().toISOString() };
      notes.push(note);
      return { content: [{ type: "text", text: `Saved note #${note.id}: ${title}` }] };
    }
  );

  server.registerTool(
    "search_notes",
    {
      title: "Search notes",
      description: "Find notes whose title or body contains the query.",
      inputSchema: z.object({ query: z.string().min(1) }),
      outputSchema: z.object({
        results: z.array(z.object({ id: z.number(), title: z.string(), body: z.string() })),
      }),
      annotations: { readOnlyHint: true },
    },
    async ({ query }) => {
      const q = query.toLowerCase();
      const results = notes
        .filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q))
        .map(({ id, title, body }) => ({ id, title, body }));
      return {
        content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
        structuredContent: { results },
      };
    }
  );

  server.registerResource(
    "all-notes",
    "notes://all",
    { title: "All notes", description: "Every saved note as JSON", mimeType: "application/json" },
    async (uri) => ({
      contents: [{ uri: uri.href, text: JSON.stringify(notes, null, 2) }],
    })
  );

  return server;
}

serveStdio(createServer);
console.error("notes-server running on stdio");

Let's go through the important parts.

McpServer and serveStdio

McpServer holds your tools, resources and prompts. serveStdio takes a factory function that returns a server and runs it over standard input and output, which is how desktop clients launch local servers. It also handles both the current and the older 2025-era protocol handshake for you, so older clients keep working.

registerTool

Each tool has a name, a description, and an inputSchema written with Zod. The model reads the description to decide when to call the tool, so write it like documentation for a teammate. The SDK validates arguments against the schema before your handler runs, so the handler always gets clean, typed input.

search_notes also declares an outputSchema and returns structuredContent. Clients that support structured output get typed JSON, and the plain-text content remains as a fallback. The readOnlyHint annotation tells clients the tool doesn't change anything, so they can skip asking the user for confirmation.

registerResource

Resources are addressed by URI. Here, notes://all returns every note as JSON so a client can attach it to the conversation as context.

Never log to stdout

With the stdio transport, stdout carries the protocol messages. A stray console.log corrupts the stream and the client disconnects. Always log with console.error, which writes to stderr.

Step 3: Build and Test with a Client

Build the project:

npm run build

The quickest visual check is the official MCP Inspector, which opens a browser UI where you can list and call your tools:

npx @modelcontextprotocol/inspector node build/index.js

You can also test from code. Install the client package with npm install @modelcontextprotocol/client, then create src/test-client.ts:

import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

const transport = new StdioClientTransport({ command: "node", args: ["build/index.js"] });
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);

const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name));

console.log(await client.callTool({
  name: "add_note",
  arguments: { title: "MCP", body: "Learn the Model Context Protocol" },
}));
console.log(await client.callTool({ name: "search_notes", arguments: { query: "protocol" } }));

await client.close();

Run npm run build && node build/test-client.js and you should see:

notes-server running on stdio
tools: [ 'add_note', 'search_notes' ]
{ content: [ { type: 'text', text: 'Saved note #1: MCP' } ] }

Try calling add_note with an empty title. The SDK rejects it before your handler runs and returns an Input validation error to the model, which lets the model correct its arguments and try again.

Step 4: Connect Your MCP Server to Claude

Claude Code

Register the server with one command. Use the absolute path to your build output:

claude mcp add notes -- node /absolute/path/to/notes-mcp-server/build/index.js

Start Claude Code and run /mcp to check the connection. Then just ask: "Save a note titled Deploy checklist" or "Search my notes for Redis".

Claude Desktop

Open Settings → Developer → Edit Config and add the server to claude_desktop_config.json:

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/to/notes-mcp-server/build/index.js"]
    }
  }
}

Restart Claude Desktop and your tools appear in the tools menu. Other clients such as Cursor and VS Code use the same command / args shape in their own MCP settings.

Best Practices for Production MCP Servers

  • Write tool descriptions for the model. Say what the tool does, when to use it, and what each argument means. Vague descriptions lead to wrong or missed tool calls.
  • Keep tools focused. Several small tools like get_order and refund_order work better than one generic run_query tool.
  • Validate everything with Zod. Use .min(), .max() and .enum() so bad input fails early with a clear message.
  • Return useful errors. A message like "Order 42 not found" helps the model recover. A stack trace doesn't.
  • Mark read-only tools with readOnlyHint: true, and never expose destructive actions without an explicit confirmation step.
  • Keep secrets in environment variables, never in tool output or source code.
  • Use HTTP for remote servers. stdio is right for local tools. For a server shared by a team, use the Streamable HTTP transport with the @modelcontextprotocol/express, hono or fastify adapters, and add authentication.

Frequently Asked Questions (FAQs)

What is the Model Context Protocol (MCP)?

MCP is an open standard, introduced by Anthropic, that defines how AI applications connect to external tools and data. A server built once works with every MCP-compatible client, including Claude, Cursor and VS Code.

What is the difference between MCP tools and resources?

Tools are actions the model decides to call, like saving a note or querying an API. Resources are read-only data, addressed by a URI, that the client loads into the conversation as context.

Should I use @modelcontextprotocol/sdk or @modelcontextprotocol/server?

For new projects, use the v2 packages: @modelcontextprotocol/server for servers and @modelcontextprotocol/client for clients. The single @modelcontextprotocol/sdk package is the v1 line, and the official migration guide covers moving existing code to v2.

Can I build an MCP server in JavaScript instead of TypeScript?

Yes. The SDK works in plain JavaScript with ES modules. TypeScript is recommended because Zod schemas then give you fully typed tool arguments.

Why does my MCP server disconnect right after starting?

The most common cause is writing to stdout with console.log, which corrupts the stdio protocol stream. Log with console.error instead, and make sure the client config uses the absolute path to your built file.

Conclusion

An MCP server is one of the highest-leverage things you can build as a developer in 2026. In about 80 lines of TypeScript you have a server with validated tools, structured output and a resource, and it works in Claude and every other MCP client. From here, swap the in-memory array for MongoDB or PostgreSQL, wrap your company's REST API, or deploy it over HTTP for your whole team.

If you are building MCP workflows or AI tooling for your product and want help, email me or see my projects.