You have a chat model that writes code and answers questions, but it cannot touch your data. It does not know what sits in your database, it cannot read your files, and it cannot run a query unless you hand it a script. That gap is what the Model Context Protocol (MCP) solves: it gives an agent tools, resources, and prompts in a standardized way, so one server works with any MCP-aware client (Claude Desktop, Claude Code, Cursor, and others) instead of a one-off integration per app.
This tutorial builds a small note server in TypeScript using the official MCP TypeScript SDK. You will register a typed tool, run the server over stdio, test it with the MCP Inspector, and wire it into a client. By the end you will have a working server that you built yourself, with the protocol not hidden behind a framework.
Prerequisites
- Node.js 20+ (the MCP Inspector now wants Node 22.19+; the SDK itself requires 18+)
- npm
- Basic TypeScript
- One MCP client to test against, like Claude Desktop or Claude Code
Step 1: Scaffold the project
mkdir note-server && cd note-server
npm init -y
npm install @modelcontextprotocol/server zod@3
npm install -D typescript @types/node
mkdir src
The official SDK ships split packages. @modelcontextprotocol/server exposes the high-level McpServer class that hides most of the JSON-RPC plumbing, and the stdio transport lives under a subpath (@modelcontextprotocol/server/stdio). zod gives us typed, self-describing tool schemas.
Next, tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"strict": true
}
}
Step 2: Write the server
Create src/index.ts with an in-memory note store:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const notes = new Map<string, string>();
const server = new McpServer({
name: "note-server",
version: "1.0.0",
});
server.registerTool(
"save_note",
{
title: "Save note",
description: "Store a note under a short id.",
inputSchema: {
id: z.string().min(1).max(64).describe("Unique id for the note"),
text: z.string().min(1).describe("Body of the note"),
},
},
async ({ id, text }) => {
notes.set(id, text);
return { ok: true, count: notes.size };
}
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Fetch a note by id.",
inputSchema: {
id: z.string().describe("Id of the note to fetch"),
},
},
async ({ id }) => {
const text = notes.get(id);
return { found: text !== undefined, text: text ?? null };
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Two details matter here. The describe(...) strings are not decoration: they end up in the tool schema that the model sees, so a good description directly improves how well the agent picks and fills arguments. And the return value is a plain object serialized to JSON, so keep it small and structured, because that exact object is fed back to the model on every call.
Build it:
npx tsc
Step 3: Test the server first, do not trust the model
Before you connect a real client, drive the server directly with the official MCP Inspector, the reference tool for testing MCP servers. It shows the JSON-RPC traffic between client and server, so you can confirm your tool returns what you expect:
npx @modelcontextprotocol/inspector node ./dist/index.js
The Inspector prints a URL with a one-time token. Open it in a browser, go to the Tools tab, and call save_note and get_note. It runs straight through npx with no install and needs Node 22.19+.
The same package has a headless CLI mode for CI or quick checks:
npx @modelcontextprotocol/inspector --cli node ./dist/index.js --method tools/list
Step 4: Connect it to a client
Add the server to Claude Desktop's config. On macOS the file lives at ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"note-server": {
"type": "stdio",
"command": "node",
"args": ["/abs/path/to/note-server/dist/index.js"]
}
}
}
Restart Claude Desktop, then ask it to "save a note that says buy oat milk, id groceries". It will call save_note with those arguments and confirm.
Exposing over HTTP
stdio only works for local, process-spawned servers. To serve a remote server that multiple clients can reach, switch to Streamable HTTP. The MCP TypeScript SDK docs cover both server.md and client.md with stateless and OAuth examples. Start local first, then move it over HTTP once the logic is right.
Common pitfalls
- Logging to stdout breaks the protocol. On stdio, stdout is the JSON-RPC channel. Log with
console.error(stderr) or a real logger. - Blurry schemas. Missing
describe()calls make the model guess what an argument means. Write them like documentation. - Skipping the Inspector. Plugging straight into a client hides failures. Inspect first, connect second.
- Unbounded output. Return small structured JSON, not a huge blob, or you burn tokens on every call.
Next steps
- Add a resource or prompts section and watch them appear in the Inspector's Resources and Prompts tabs.
- Swap the transport for Streamable HTTP and serve your server remotely.
- Read the official MCP servers repo for production patterns before writing your own.