This tutorial gets you from zero to a working MCP server connected to Claude Desktop in roughly 30 minutes. We’ll build the server in either TypeScript or Python (both included), expose two tools, and walk through running it end to end.
If you don’t yet know what MCP is, start with What is MCP? first.
What we’re building
A small notes server that exposes:
add_note— append a note to an in-memory listsearch_notes— find notes whose body contains a substring
It’s the simplest thing that’s actually useful and exercises both a write and a read tool.
Prerequisites
- Node 18+ or Python 3.10+
- Claude Desktop installed (for testing)
- A terminal you’re comfortable in
Path A — TypeScript
Set up
mkdir notes-mcp && cd notes-mcp
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript tsx @types/node
npx tsc --initThe server
Create src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const notes: { id: number; body: string; ts: number }[] = [];
let nextId = 1;
const server = new Server(
{ name: "notes", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.tool(
"add_note",
"Append a note. Returns the created note's id.",
{ body: z.string().min(1).describe("Note body") },
async ({ body }) => {
const note = { id: nextId++, body, ts: Date.now() };
notes.push(note);
return { content: [{ type: "text", text: JSON.stringify(note) }] };
}
);
server.tool(
"search_notes",
"Find notes whose body contains the query (case-insensitive).",
{ query: z.string().describe("Substring to match") },
async ({ query }) => {
const q = query.toLowerCase();
const hits = notes.filter(n => n.body.toLowerCase().includes(q));
return { content: [{ type: "text", text: JSON.stringify(hits) }] };
}
);
const transport = new StdioServerTransport();
server.connect(transport);Run it
npx tsx src/index.tsThe server starts, listens on stdio, and waits for an MCP client to connect.
Path B — Python
Set up
mkdir notes-mcp && cd notes-mcp
python -m venv .venv && source .venv/bin/activate
pip install mcpThe server
Create server.py:
from mcp.server.fastmcp import FastMCP
import time
app = FastMCP("notes")
notes: list[dict] = []
next_id = {"v": 1}
@app.tool()
def add_note(body: str) -> dict:
"""Append a note. Returns the created note."""
note = {"id": next_id["v"], "body": body, "ts": int(time.time())}
next_id["v"] += 1
notes.append(note)
return note
@app.tool()
def search_notes(query: str) -> list[dict]:
"""Find notes whose body contains the query (case-insensitive)."""
q = query.lower()
return [n for n in notes if q in n["body"].lower()]
if __name__ == "__main__":
app.run()Run it
python server.pyConnect it to Claude Desktop
Open Claude Desktop’s config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add an entry under mcpServers:
{
"mcpServers": {
"notes": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/notes-mcp/src/index.ts"]
}
}
}Or for the Python version:
{
"mcpServers": {
"notes": {
"command": "/absolute/path/to/notes-mcp/.venv/bin/python",
"args": ["/absolute/path/to/notes-mcp/server.py"]
}
}
}Restart Claude Desktop. You should see a small tools indicator showing 2 tools available from notes.
Try it
In Claude, ask: “Add a note that says ‘meet with Mira on Thursday’, then search my notes for ‘mira’.” The model picks the tools, calls them in order, and returns the matches.
Debug tip. If tools don’t show up, check the JSON config for typos and restart the host fully. The error logs are in Claude Desktop’s log directory; tailing them while you test catches almost every issue.
Where to go next
- Persist
notesto disk (or a real DB) so they survive restarts - Add a
delete_notetool - Expose notes as MCP resources so the model can read them passively, not only by tool call
- Read the protocol overview for deeper concepts (resources, prompts, sampling)