How I Built My First MCP Server for Claude Code (4 Lessons) A developer built their first Model Context Protocol (MCP) server to give Claude Code read access to a local project knowledge base, sharing four lessons about tool design. The server uses the official @modelcontextprotocol/sdk and communicates over stdio, exposing search_notes and get_note tools with carefully crafted descriptions that act as instructions to the agent. The developer emphasizes that tool schemas must tell the model exactly when and how to use them, and that errors should be handled gracefully rather than thrown. I built my first Model Context Protocol MCP server to give Claude Code read access to a local project knowledge base — and the first version was bad in ways I didn't expect. Here's the minimal TypeScript skeleton that actually works, plus 4 lessons about tool design that I wish someone had told me on day one. I use Claude Code v2.x as my daily driver, and most of the time the built-in file and shell tools are enough. But I had one recurring annoyance: a pile of internal docs — design notes, decision logs, runbooks — scattered across a directory that the agent kept re-reading from scratch every session. It would grep , open three files, lose the thread, and grep again. I wanted the agent to ask one focused question — "what did we decide about retries?" — and get back the relevant note, not a directory listing. That's exactly what MCP is for. MCP is an open protocol that lets you expose tools callable functions , resources , and prompts to an LLM client over a standard interface. Claude Code speaks it natively. So instead of teaching the model to navigate my files, I could hand it a search notes tool and a get note tool and let it do the obvious thing. The interesting constraint: the model only uses a tool well if the schema tells it exactly when and how. A tool the model calls with garbage arguments is worse than no tool at all. That's where the real work was. The whole thing is a small Node.js server Node 22.x using the official @modelcontextprotocol/sdk . Here's the architecture — it's deliberately boring: php flowchart LR A Claude Code -- stdio / JSON-RPC -- B MCP Server B -- C search notes B -- D get note C -- E Local notes dir D -- E The server talks to Claude Code over stdio standard in/out , which is the simplest transport — no ports, no auth, no network. The client launches your server as a subprocess and pipes JSON-RPC over the pipe. For a local, single-user tool this is exactly what you want. js import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const server = new Server { name: "notes-server", version: "0.3.0" }, { capabilities: { tools: {} } }, ; server.setRequestHandler ListToolsRequestSchema, async = { tools: { name: "search notes", description: "Search the project knowledge base by keyword. Returns up to 5 " + "matching note titles with a one-line snippet. Use this FIRST to " + "find a note before fetching its full text.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Keyword or phrase to search for" }, }, required: "query" , }, }, { name: "get note", description: "Fetch the full Markdown of one note by its exact id, as returned " + "by search notes. Do not guess ids.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The note id from search notes" }, }, required: "id" , }, }, , } ; Notice the descriptions read like instructions to a teammate, not API docs. That's the single highest-leverage thing in this file. More on that below. The handler is where I learned to be careful about failure. The first version threw exceptions on a missing file. Bad idea — a thrown error reads to the agent like the tool is broken, so it gives up instead of correcting course. js server.setRequestHandler CallToolRequestSchema, async req = { const { name, arguments: args } = req.params; try { if name === "search notes" { const hits = await searchNotes String args?.query ?? "" ; if hits.length === 0 { return { content: { type: "text", text: "No notes matched. Try a broader single keyword.", } , }; } return { content: { type: "text", text: formatHits hits } }; } if name === "get note" { const note = await loadNote String args?.id ?? "" ; if note { return { content: { type: "text", text: No note with id "${args?.id}". Call search notes to get a valid id. , } , isError: true, }; } return { content: { type: "text", text: note.body } }; } return { content: { type: "text", text: Unknown tool: ${name} } , isError: true, }; } catch err { return { content: { type: "text", text: Tool failed: ${err instanceof Error ? err.message : String err } , } , isError: true, }; } } ; const transport = new StdioServerTransport ; await server.connect transport ; The key move: errors come back as content with isError: true , with a sentence telling the model what to do next "call search notes to get a valid id" . The agent reads that and self-corrects, usually on the very next turn. One line in the MCP config registers it: { "mcpServers": { "notes": { "command": "node", "args": "/abs/path/to/dist/index.js" } } } Restart Claude Code, and search notes / get note show up as callable tools. That's the whole loop. My first search notes description was literally "Searches notes." The model called it with full sentences, paragraph queries, sometimes the entire user message as the query. Garbage in, garbage out. When I rewrote the description to say what it returns , when to use it , and what to do next "Use this FIRST… then get note" , the call quality jumped immediately. Treat every description field as a mini system prompt. The model has no other signal about your intent. If you find yourself debugging "why won't the agent use my tool right?", the answer is almost always in the description, not the code. A raw exception bubbling up makes the agent think the tool is dead. A short, actionable error message makes it a recoverable hiccup. isError: true + "here's the valid next step" turned my flakiest tool into the most reliable one. Think of error messages as instructions for recovery , because to the model, that's exactly what they are. I was tempted to build a single notes action, ... mega-tool with an action enum. Don't. The model reasons about distinct tools far better than about a polymorphic one with conditional arguments. Two tools with clear names and required fields gave me dramatically more predictable behavior than one tool with five optional params. Narrow tools are also easier to grant or withhold — a real win when you care about what the agent can touch. I bumped version: "0.3.0" and added a stable id contract to get note "ids come from search notes, don't guess" . When I later changed the search output format, the explicit contract meant I knew exactly what the model depended on. Schemas rot the same way APIs do — the model has memorized your old shape from earlier in the session. A version field and an explicit contract make breaking changes visible instead of mysterious. A few smaller things that bit me: console.log to stdout corrupts the JSON-RPC stream. Log to console.error or a file. This cost me an hour of "why is the connection dropping?" required fields actually required. query and id as required in the JSON Schema stopped the model from calling tools with empty arguments "just to see what happens." The schema is a contract the client enforces before your code ever runs — lean on it. tools/list request straight into the process over stdin and eyeball the response. Doing that once saved me from chasing a config problem that was actually a serialization bug in my own formatHits .Three directions I'm exploring: list recent notes resource so the agent can browse without searching first.The two-tool interface stays stable through all of that, which is the whole point of designing the schema carefully up front. Building an MCP server turned out to be 20% protocol and 80% interface design for a reader who happens to be a language model . The SDK gets you a working server in 40 lines; the lessons are all about making tools the model uses correctly . If you're already using Claude Code, try writing one tiny MCP server this week — expose one thing you keep re-explaining to the agent and watch how much friction disappears. 💡 If this was useful: