09 — Build your own MCP server (TypeScript)
In short: you'll build a small notes server, watch its raw messages, and plug it into Claude Code. All you need is Node.js, and it takes about 10 minutes.
You'll learn:
- how a few lines of TypeScript become MCP tools, a resource and a prompt
- how to connect a local server to Claude Code
- what changes, and what doesn't, when the same server moves to HTTP
| Part | Transport | You need |
|---|---|---|
| Part 1 | stdio (a local process) | Node.js |
| Part 2 | Streamable HTTP on 127.0.0.1 |
Node.js |
Code: labs/notes-mcp. It uses the official MCP TypeScript SDK v2 (@modelcontextprotocol/server).
Before you start
node --version
claude --version
You should see: v22 or higher for Node.js, then a Claude Code version number. If a command isn't found, install the LTS version from nodejs.org, or Claude Code from its overview page.
Part 1: stdio
Step 1: build and test
Same commands on macOS, Linux and Windows:
cd labs/notes-mcp
npm install
npm run build
npm test
You should see, near the end: ℹ pass 4 and ℹ fail 0.
Step 2: watch the raw protocol
npm run demo
You should see about 300 lines that start like this:
=== 1. CONNECT (the 2025-era handshake, which this server still accepts) ===
➡ HOST → SERVER
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
The demo plays the host's role. It starts the server as a child process and writes JSON-RPC to its stdin. Here's the whole run:
HOST (wire demo) SERVER (notes)
│── initialize ────────────────────────────▶│
│◀── result {capabilities: tools, resources, prompts}
│── notifications/initialized ─────────────▶│ no id, no reply
│── tools/list, resources/list, prompts/list ▶│ discovery, once
│── tools/call add_note ───────────────────▶│ every use
│── resources/read notes://all ────────────▶│
│── prompts/get summarize_notes ───────────▶│
│── tools/call delete_note (twice) ────────▶│ 2nd reply: isError: true
The demo uses the legacy initialize handshake because it's the easiest to read. The server also speaks spec 2026-07-28, and serveStdio (the SDK's stdio runner) picks the right one for each client.
Step 3: connect it to Claude Code
Run this from inside labs/notes-mcp.
macOS / Linux:
claude mcp add --transport stdio notes -- node "$PWD/dist/stdio.js"
Windows (PowerShell):
claude mcp add --transport stdio notes -- node "$PWD\dist\stdio.js"
Windows (cmd):
claude mcp add --transport stdio notes -- node "%CD%\dist\stdio.js"
Everything after -- is the command Claude Code runs to start your server, and $PWD makes the path absolute. The default scope is local: the server is yours, for this folder only.
claude mcp get notes
You should see: the notes entry with its command. Now start claude in the same folder and run /mcp.
You should see: notes listed as connected.
Step 4: use it
Try these, one at a time:
| You type | What happens |
|---|---|
| "Add a note titled groceries: milk, eggs, coffee." | Claude calls mcp__notes__add_note. In Manual mode, you get a permission prompt the first time. |
| "Search my notes for coffee." | Claude calls mcp__notes__search_notes. |
@notes:notes://all |
Attaches the resource: every note, as Markdown. |
/mcp__notes__summarize_notes |
Runs the prompt. It also shows in the / menu as /notes:summarize_notes (MCP). |
Open notes.json in the folder. You should see your groceries note in it.
In Manual mode, tired of the prompt for read-only tools? Add this to .claude/settings.json:
{ "permissions": { "allow": ["mcp__notes__list_notes", "mcp__notes__search_notes"] } }
How the code maps to MCP
Here's one tool from src/notes.ts:
server.registerTool(
"search_notes",
{
description: "Find notes whose title or text contains the query (not case-sensitive).",
inputSchema: z.object({ query: z.string().min(1).describe("Word or phrase to look for") }),
},
async ({ query }) => {
const q = query.toLowerCase();
const hits = loadNotes().filter((n) => `${n.title}\n${n.text}`.toLowerCase().includes(q));
return reply(hits.length ? asMarkdown(hits) : `No notes match "${query}".`);
},
);
| Code | Protocol |
|---|---|
name, description, inputSchema |
What tools/list returns. The zod schema becomes JSON Schema. |
the async handler |
What runs on tools/call |
registerResource("all-notes", "notes://all", ...) |
Shows in resources/list, served by resources/read |
registerPrompt("summarize_notes", ...) |
Shows in prompts/list, served by prompts/get |
zod is a small library that checks inputs: here, that query is a non-empty string. The SDK runs that check before your handler.
What the demo teaches
| You saw | Lesson |
|---|---|
tools/list once, tools/call per use |
Discovery happens once per connection. Each use is one call. |
notes.json changed |
The server process does the work. The host only passes the call on. |
[notes] ready on stdio printed separately |
Logs go to stderr. stdout is reserved for JSON-RPC. |
Every reply carries the request's id |
That's how the host matches replies to requests. |
The 2nd delete_note returned isError: true |
A tool execution error. The model reads it and can recover. |
Two more error cases behave as the spec says: an unknown tool name gets a JSON-RPC error (code -32602), and bad input, like an empty title, gets isError: true with the validation message.
Security
- The server only ever touches
notes.jsonin its own folder. The path comes from the server's own location, never from input. - The Bash sandbox doesn't cover MCP servers. Your server runs with your user's rights.
- Note text is data that the model reads, and a note could contain hostile instructions.
Part 2: Streamable HTTP on localhost
Same server, new transport. src/http.ts reuses the same createServer() function and serves it over HTTP on 127.0.0.1, behind a bearer token (a secret sent in the Authorization header).
1. Make a throwaway token and start the server.
macOS / Linux:
export NOTES_TOKEN=$(node -e "console.log(require('crypto').randomBytes(24).toString('hex'))")
echo $NOTES_TOKEN
npm run start:http
Windows (PowerShell):
$env:NOTES_TOKEN = node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
$env:NOTES_TOKEN
npm run start:http
You should see: [notes] listening on http://127.0.0.1:3333/mcp.
2. Connect Claude Code. In a second terminal, with your token in place of <token>:
claude mcp add --transport http notes-http http://127.0.0.1:3333/mcp --header "Authorization: Bearer <token>"
Start claude, run /mcp, and try the same prompts as in Part 1.
3. See the guard work (optional). Send a request with no token:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:3333/mcp
You should see: 401. (On Windows, use curl.exe and -o NUL.)
The checks in front of the MCP handler:
const rejected =
hostHeaderValidationResponse(request, localhostAllowedHostnames()) ??
originValidationResponse(request, localhostAllowedOrigins());
if (rejected) return rejected;
if (!hasValidToken(request.headers.get("authorization"), TOKEN)) {
return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": "Bearer" } });
}
return mcp.fetch(request);
The Host and Origin checks stop a web page in your browser from reaching the server, an attack called DNS rebinding. The token check stops other programs that don't know the secret.
What changed: the transport (a URL instead of a child process) and auth (the token). What stayed: the tools, the resource, the prompt and every message. A static token is fine on your own machine, but for a shared server the MCP spec's answer is OAuth 2.1.
Clean up
claude mcp remove notes
claude mcp remove notes-http
Stop the HTTP server with Ctrl+C. Delete notes.json if you want your notes gone.
Common mistakes
| Mistake | Reality |
|---|---|
Debug output with console.log in a stdio server |
It goes to stdout and breaks JSON-RPC. Use console.error. |
A relative path in claude mcp add |
Use the absolute path ($PWD/...), so the command works from anywhere. |
Skipping npm run build |
dist/stdio.js doesn't exist, so the server can't start. |
| Throwing on a failed operation | Return isError: true with a message, so the model can recover. |
| Leaving the token in your config | --header stores it in your Claude Code config. Use a throwaway token and remove the server after. |
Remember:
- A tool is a name, a description, an input schema and a handler.
- The server does the work. The host routes and checks.
- stdout belongs to the protocol. Log to stderr.
- Moving to HTTP changes the transport and auth, not the tools.
Test yourself with the quiz: 5 questions.
Sources
- MCP quickstart, build a server: https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server
- MCP TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk
- MCP stdio transport: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio
- MCP Streamable HTTP transport: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- Claude Code MCP: https://code.claude.com/docs/en/mcp
- Permission modes: https://code.claude.com/docs/en/permission-modes#which-mode-a-session-starts-in