Build an MCP Server
Create a working MCP server step by step.
You know the architecture; now you'll build the real thing. In this lesson you write a working MCP server in TypeScript — a small "notes" server exposing tools to create and search notes, a resource for reading them, and a prompt template — then connect it to a real host like Claude Desktop. Total: about sixty lines of code.
Why does this exist?
Reading about protocols builds vague familiarity; implementing one builds actual understanding. And practically: the moment you can write an MCP server, every internal system you own — ticketing, analytics, deploy scripts — is one small file away from being usable by any MCP-capable AI app your team runs. This is the highest-leverage sixty lines in the course.
Step 0: project setup
You need Node.js 18+. Create a project and install the official SDK (plus zod, which the SDK uses for input schemas):
mkdir notes-mcp && cd notes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist
Set "type": "module" in package.json. All code below goes in src/index.ts.
Step 1: a server that exists
Start with the smallest possible server: a name, a version, and a stdio transport.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP server running on stdio");
Two things deserve attention. First, StdioServerTransport: the host will launch this file as a subprocess and speak JSON-RPC over stdin/stdout — the initialize handshake from last lesson is handled for you by connect(). Second, that log goes to console.error, not console.log. stdout belongs to the protocol — write anything else there and you corrupt the JSON-RPC stream. This is the single most common MCP server bug.
Step 2: state and a first tool
Our "database" is an in-memory array (a real server would use files or a DB — the protocol doesn't care). Tools are registered with a name, metadata, a zod input schema, and a handler:
import { z } from "zod";
type Note = { id: number; title: string; content: string; createdAt: string };
const notes: Note[] = [];
let nextId = 1;
server.registerTool(
"create_note",
{
title: "Create Note",
description:
"Create a new note with a title and content. Returns the new note's id.",
inputSchema: {
title: z.string().describe("Short title for the note"),
content: z.string().describe("The note body text"),
},
},
async ({ title, content }) => {
const note: Note = {
id: nextId++,
title,
content,
createdAt: new Date().toISOString(),
};
notes.push(note);
return {
content: [{ type: "text", text: `Created note #${note.id}: "${title}"` }],
};
}
);
Trace what each part becomes on the wire: the name, description, and schema are what tools/list returns to clients — and what the LLM reads when deciding whether to call your tool. The handler runs when a tools/call request arrives; its return value becomes the result.content the model sees. Descriptions are your API docs for the model — write them like you're explaining to a new teammate.
Step 3: a search tool (and honest errors)
server.registerTool(
"search_notes",
{
title: "Search Notes",
description: "Search notes by keyword in title or content.",
inputSchema: {
query: z.string().describe("Keyword to search for"),
},
},
async ({ query }) => {
const q = query.toLowerCase();
const hits = notes.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
if (hits.length === 0) {
return {
content: [{ type: "text", text: `No notes matched "${query}".` }],
};
}
return {
content: [
{
type: "text",
text: hits
.map((n) => `#${n.id} ${n.title}: ${n.content.slice(0, 100)}`)
.join("\n"),
},
],
};
}
);
Notice the empty-result branch returns a clear message instead of an empty string or a thrown error. Remember agent error spirals: the model will read this text and decide what to do next, so make failure states as informative as success states. For genuine failures, return { isError: true, content: [...] } so the model knows the call itself failed.
Step 4: a resource and a prompt
Tools are model-controlled; let's add the other two capability types. A resource lets the host attach all notes as context, and a prompt ships a reusable command to the user's UI:
server.registerResource(
"all-notes",
"notes://all",
{
title: "All Notes",
description: "Every note as a plain-text document",
mimeType: "text/plain",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: notes.map((n) => `[#${n.id}] ${n.title}\n${n.content}`).join("\n\n"),
},
],
})
);
server.registerPrompt(
"summarize_notes",
{
title: "Summarize Notes",
description: "Ask the model to summarize all current notes",
argsSchema: { style: z.string().optional() },
},
({ style }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize my notes${style ? ` in a ${style} style` : ""}. Group related notes together.`,
},
},
],
})
);
Step 5: run and connect it
Compile and smoke-test with the MCP Inspector — an official debugging UI that plays the client role:
npx tsc
npx @modelcontextprotocol/inspector node dist/index.js
The Inspector opens in your browser: you can see tools/list output, invoke create_note by hand, and inspect every raw JSON-RPC message — the exact hops from the previous lesson's simulation. Once it works, register the server with a real host. For Claude Desktop, add this to claude_desktop_config.json:
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["/absolute/path/to/notes-mcp/dist/index.js"]
}
}
}
Restart the app, and "create a note reminding me to review the RAG lesson" will trigger a real tools/call into your sixty lines.
Debugging checklist
Server not showing up? In order: (1) something printed to stdout — move all logging to console.error; (2) relative path in the config — hosts launch servers from an arbitrary working directory, so use absolute paths; (3) forgot to rebuild after editing — the host runs dist/, not src/.
Build it yourself
- Add a
delete_note(id)tool. Return anisErrorresult when the id doesn't exist. - Persist notes to a JSON file so they survive restarts.
- Add per-note resources (
notes://{id}) usingResourceTemplatefrom the SDK — check the SDK README for the pattern. - Stretch: wrap something you actually use — a TODO file, a small SQLite database — and use it from your AI app for a day. Notice how quickly "my tools" and "my assistant" stop being separate things.
Summary
- A working MCP server is:
McpServer+ registered capabilities + a transport, connected. The SDK handles the handshake and JSON-RPC plumbing. registerTooltakes name, metadata, a zod input schema, and an async handler; the metadata is the model's only documentation, so write it carefully.- stdout is sacred on stdio transport — log to stderr, always.
- Return informative text for empty results and
isErrorresults for failures; models read and act on these. - Debug with the MCP Inspector first, then register the compiled server (absolute path!) in your host's config.