Large Language ModelsGenerate imagesGenerate videos
How to Build an MCP Server Locally and Connect It to Claude (With Working Code)
Build a local MCP server from an empty folder: install the TypeScript SDK, register two working tools, test them in the MCP Inspector, then connect the server to Claude Desktop and Claude Code. Includes config files, Windows path fixes, and a checklist for the failures that break most setups.
Most MCP tutorials stop at "hello world" and leave you staring at a red Disconnected badge. This one ends with a server running on your own machine, Claude calling its tools, and a short checklist for the failures that bite most people. You will write about 70 lines of TypeScript, test them in a browser-based inspector, and wire the result into both Claude Desktop and Claude Code.
The server is a small notes tool: Claude can save a note to a JSON file on your disk and search it later. It is deliberately plain, because the plumbing is identical whether your tools read a notes file, query a database, or call an image model. I compiled and ran the server file below against version 1.32 of the TypeScript SDK, so the code builds exactly as shown.
What You Are Actually Building
MCP in Two Paragraphs
The Model Context Protocol (MCP) is an open standard, introduced by Anthropic in November 2024, that lets an AI app talk to outside tools in one consistent way. Instead of every app inventing its own plugin format, an MCP server exposes capabilities, and an MCP client such as Claude Desktop or Claude Code finds and calls them. Messages are plain JSON-RPC 2.0, so a server can be written in any language.
A server can offer three kinds of things:
Tools: functions the model can call, like "save a note" or "run a query".
Resources: read-only data the app can load as context, like a file or a database record.
Prompts: reusable prompt templates that the user triggers on purpose.
This tutorial sticks to tools, because they are the simplest to test and the most useful on day one.
Why Run It Locally
A local server runs as a child process of the client, on your machine, with your files and your permissions. Nothing is exposed to the internet, there is no hosting bill, and iteration is fast: edit a file, rebuild, restart. The transport it uses is stdio: the client launches your program and talks to it through standard input and output.
stdio (local)
Streamable HTTP (remote)
Where it runs
Child process on your computer
A web server you or someone else hosts
Who can reach it
Only the app that launched it
Anyone with the URL and credentials
Authentication
None, it inherits your user account
Required (OAuth or tokens)
Best for
Personal tools, file access, development
Shared team tools, SaaS integrations
💡 Good to know: Streamable HTTP replaced the older HTTP+SSE transport in the 2025-03-26 spec revision. And because a browser cannot launch a process on your computer, a stdio server cannot be added to claude.ai in the browser. Only remote servers work there.
Set Up the Project
What You Need Installed
Tool
Version
Check with
Node.js
20 LTS or newer
node --version
npm
Ships with Node
npm --version
Claude Desktop
Latest, macOS or Windows
Settings, then Developer
Claude Code (optional)
Latest
claude --version
Claude Desktop ships for macOS and Windows. On Linux, use the Claude Code route in the connection section below; the server itself is identical.
⚠️ Watch out: TypeScript 7, the version npm installs today, no longer loads @types packages on its own. Without the "types": ["node"] line you get Cannot find name 'process' and similar errors on every Node import.
Write Your First Two Tools
The Full Server File
Save this as src/index.ts. It exposes save_note and search_notes, and stores everything in one JSON file.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
const NOTES_DIR = process.env.NOTES_DIR ?? path.join(os.homedir(), "mcp-notes");
const NOTES_FILE = path.join(NOTES_DIR, "notes.json");
type Note = { id: number; title: string; body: string; createdAt: string };
async function readNotes(): Promise<Note[]> {
try {
return JSON.parse(await fs.readFile(NOTES_FILE, "utf8"));
} catch {
return [];
}
}
const server = new McpServer({ name: "local-notes", version: "1.0.0" });
server.registerTool(
"save_note",
{
title: "Save note",
description: "Save a short note with a title and a body to the local notes file.",
inputSchema: {
title: z.string().min(1).max(120).describe("Short title for the note"),
body: z.string().min(1).describe("The text of the note"),
},
},
async ({ title, body }) => {
const notes = await readNotes();
const note: Note = {
id: notes.length + 1,
title,
body,
createdAt: new Date().toISOString(),
};
await fs.mkdir(NOTES_DIR, { recursive: true });
await fs.writeFile(NOTES_FILE, JSON.stringify([...notes, note], null, 2));
return { content: [{ type: "text", text: `Saved note #${note.id}: ${title}` }] };
}
);
server.registerTool(
"search_notes",
{
title: "Search notes",
description: "Find saved notes whose title or body contains a word or phrase.",
inputSchema: {
query: z.string().min(1).describe("Word or phrase to look for"),
},
},
async ({ query }) => {
const q = query.toLowerCase();
const hits = (await readNotes()).filter((n) =>
`${n.title} ${n.body}`.toLowerCase().includes(q)
);
if (hits.length === 0) {
return { content: [{ type: "text", text: `No notes match "${query}".` }] };
}
const text = hits.map((n) => `#${n.id} ${n.title}\n${n.body}`).join("\n\n");
return { content: [{ type: "text", text }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("local-notes MCP server running on stdio");
What Each Part Does
McpServer is the high-level class. The name and version you pass show up in the client's server list and logs.
registerTool takes a tool name, a config object (title, description, inputSchema), and an async handler.
The Zod schema is validated before your handler runs, then converted to JSON Schema so Claude can read it. Every .describe() string reaches the model.
The return value is always { content: [...] }. Plain text is the simplest content type; images and resource links are also supported.
StdioServerTransport reads requests from stdin and writes responses to stdout.
💡 Tip: Write descriptions for the model, not for humans. "Find saved notes whose title or body contains a word or phrase" tells Claude when to call the tool. "Search" does not. Claude picks tools mostly from their names and descriptions.
Because search_notes never changes anything, mark it that way in its config: annotations: { readOnlyHint: true }. Hints are advisory, and clients decide for themselves how much to trust them, but they let well-behaved clients treat read-only tools more gently.
Never Print to Stdout
With stdio, stdout is the protocol channel. One stray console.log("started") pushes a non-JSON line into the stream, and most clients will drop the connection or show the server as failed. Remember this rule and you will skip the most common first-day failure:
Do:console.error("message"), which writes to stderr, where clients collect logs.
Don't:console.log(...) or process.stdout.write(...) anywhere in your server, including inside libraries you import.
Test It Before Claude Does
Run the MCP Inspector
The MCP Inspector is the official debugging tool. It launches your server the same way a client would and gives you buttons instead of prompts.
npm run build
npx @modelcontextprotocol/inspector node build/index.js
A page opens in your browser. Click Connect, open the Tools tab, and press List Tools. You should see save_note and search_notes with their schemas. Run save_note with a title and a body, then run search_notes with a word from that body. The first call returns Saved note #1: Standup, and the notes file appears in a mcp-notes folder inside your home directory (or in NOTES_DIR if you set it).
Send Raw JSON-RPC Messages
If you want to see the protocol itself, stdio uses one JSON message per line. Put these three lines in requests.jsonl:
The second reply lists both tools with their JSON Schemas. That exchange is everything Claude does when it connects: handshake, list tools, call tools.
Connect It to Claude
Edit the Claude Desktop Config
In Claude Desktop, open Settings, then Developer, then Edit Config. That reveals claude_desktop_config.json:
Add your server under mcpServers. Use absolute paths, because Claude Desktop launches your process from its own working directory, not from your project folder.
On macOS the path looks like /Users/you/projects/local-notes-mcp/build/index.js. Save the file, then fully quit Claude Desktop (on Windows, from the system tray, not just the window's close button) and reopen it. Your tools appear in the chat input's tools menu, and Claude asks for permission before running one.
Add It to Claude Code
Claude Code needs no file editing. One command registers the server:
Everything after the double dash is the command that launches your server. Check it with claude mcp list, or type /mcp inside a session to see its status. A scope flag decides who gets the server:
Scope
Stored in
Who sees it
local (default)
Your private settings for this project
Only you, in this project
project (--scope project)
.mcp.json in the repo
Everyone who clones it, after they approve it
user (--scope user)
Your user config
Only you, in every project
Try a Real Prompt
Ask Claude something that forces a tool call:
Save a note titled "Standup" that says "Ship the MCP post on Friday". Then search my notes for "Friday".
Claude calls save_note, then search_notes, and quotes the result back. Open notes.json to confirm the data landed on your disk. If it did, you have a working local MCP server.
Fix Failures and Lock It Down
Fix the Common Failures
Symptom
Likely cause
Fix
Server shows as failed or disconnected
Output on stdout, or a crash at startup
Run node build/index.js by hand and read stderr; remove every console.log
No tools after editing the config
Client still running, or invalid JSON
Quit fully; check for trailing commas
spawn node ENOENT
The app cannot find node on its PATH
Use the absolute path to the node binary as command
Works in Inspector, fails in Claude
Relative paths or missing environment variables
Absolute paths, and put variables in env
Code changes have no effect
You did not rebuild or restart
Run npm run build, then restart the client
Two Windows details cause half of the remaining trouble. Backslashes inside JSON strings must be doubled (C:\\Users\\you\\...), or you can simply use forward slashes as in the example above. And on native Windows, servers launched through npx usually need a cmd /c wrapper in command; a plain node command does not.
When something still fails, read the logs. Claude Desktop writes one log per server, in ~/Library/Logs/Claude on macOS and in %APPDATA%\Claude\logs on Windows. Your own console.error lines end up there.
Safe Defaults Worth Keeping
A stdio server inherits your permissions, so treat every tool as code that can act as you.
Limit the blast radius. Keep file access inside one folder. If a tool accepts a path, resolve it and reject anything outside the allowed directory.
Validate every input. Zod's min, max and enum rules cost nothing and block malformed calls before your handler runs.
Keep secrets out of code. Put tokens in the env block of your config, and keep that file out of version control.
Read before you install. Only add third-party servers whose source you have looked at. They run with your account's access.
Treat tool output as untrusted. Text your tool fetches from web pages or emails can contain instructions aimed at the model. Return it as data and keep write actions behind confirmation.
Moving From stdio to HTTP
When teammates need the same tools, swap the transport. The SDK ships StreamableHTTPServerTransport, which serves the same McpServer over HTTP behind your own authentication. Your registerTool calls do not change. Only the transport and the client registration do, for example claude mcp add --transport http notes https://your-host/mcp.
You can already see this pattern in the wild. The PicassoIA connector in claude.ai is a remote MCP server that lists image generation, image editing and video generation as tools, and Claude calls them with no local process at all.
Draft Tool Specs on PicassoIA
Good tools start with good names and descriptions, and a language model is a fast way to draft them before you write code. PicassoIA hosts 75 text models in its Large Language Models category, including Claude Sonnet 5, which is built for coding tasks. Here is how to use it for tool design.
Describe the tool in plain language: what it does, what it takes in, what it returns, and whether it changes anything.
Ask for a fixed output format: a snake_case tool name, a description of at most two sentences written for a model, a Zod schema with .describe() on every field, and three edge cases that should fail validation.
Paste the result into a registerTool call, rebuild, and test it in the Inspector.
Iterate on the description, not the schema, when Claude picks the wrong tool. Wording is usually the problem.
A prompt that works well:
I am building an MCP tool called list_overdue_tasks. It reads tasks.json, returns tasks whose dueDate is before today, and changes nothing. Write the tool name, a two-sentence description for an AI model, a Zod input schema with a describe() on each field, and three invalid inputs it should reject.
For larger refactors, such as splitting a 600-line server into modules, try Claude Fable 5 or Claude Opus 4.7 with your whole file pasted in.
Make Your First Image on PicassoIA
Your notes server is a template. Swap the JSON file for a call to an image model, and Claude can render pictures on request. You do not have to build that first to see the result. Every photo in this article was generated with P-Image, one of the text-to-image models on Picasso IA.
Open Picasso IA, type one sentence describing a scene, and generate. Then try three experiments:
Change the lens. Rewrite the same prompt with "35mm" and then "85mm" and compare the framing.
Change the light. Swap "morning window light" for "warm desk lamp" and watch the mood shift.
Change the angle. Ask for an overhead shot, then a low-angle shot of the same subject.
Build one tool you wish Claude had, connect it with the steps above, and then spend ten minutes on Picasso IA making the visuals for your project. The server takes an afternoon. The images take seconds.