Here is the entire server. Read it top to bottom — every section is annotated. After the code, we break down each part in detail.
// ── Imports ──────────────────────────────────────────────
// McpServer: the main class that handles protocol messages
// StdioServerTransport: connects via stdin/stdout (local)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
import { join } from "path";
// ── Storage Setup ────────────────────────────────────────
// Notes are stored as individual .txt files in a "notes" folder
// next to the server. Simple, inspectable, no database needed.
const NOTES_DIR = join(process.cwd(), "notes");
if (!existsSync(NOTES_DIR)) mkdirSync(NOTES_DIR);
// ── Create the Server ────────────────────────────────────
// name: shows up in Claude's tool list as the server identity
// version: used during capability negotiation with the client
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
// ── Tool 1: create_note ──────────────────────────────────
// server.tool() takes three arguments:
// 1. name — what Claude calls to invoke this tool
// 2. schema — Zod schema defining required inputs
// 3. handler — async function that does the actual work
server.tool(
"create_note",
{
title: z.string().describe("The note title (used as filename)"),
content: z.string().describe("The note body text"),
},
async ({ title, content }) => {
// Sanitize the title to prevent path traversal attacks
const safe = title.replace(/[^a-zA-Z0-9_-]/g, "_");
const path = join(NOTES_DIR, `${safe}.txt`);
writeFileSync(path, content, "utf-8");
return {
content: [{ type: "text", text: `Note "${title}" saved.` }],
};
}
);
// ── Tool 2: read_note ────────────────────────────────────
server.tool(
"read_note",
{
title: z.string().describe("The title of the note to read"),
},
async ({ title }) => {
const safe = title.replace(/[^a-zA-Z0-9_-]/g, "_");
const path = join(NOTES_DIR, `${safe}.txt`);
if (!existsSync(path)) {
return {
content: [{ type: "text", text: `Note "${title}" not found.` }],
isError: true,
};
}
const text = readFileSync(path, "utf-8");
return {
content: [{ type: "text", text }],
};
}
);
// ── Tool 3: search_notes ─────────────────────────────────
server.tool(
"search_notes",
{
query: z.string().describe("Keyword to search for in note contents"),
},
async ({ query }) => {
const files = readdirSync(NOTES_DIR).filter(f => f.endsWith(".txt"));
const matches = [];
for (const file of files) {
const text = readFileSync(join(NOTES_DIR, file), "utf-8");
if (text.toLowerCase().includes(query.toLowerCase())) {
matches.push({ title: file.replace(".txt", ""), preview: text.slice(0, 100) });
}
}
if (matches.length === 0) {
return { content: [{ type: "text", text: `No notes found matching "${query}".` }] };
}
const result = matches.map(m => `• ${m.title}: ${m.preview}...`).join("\n");
return {
content: [{ type: "text", text: `Found ${matches.length} match(es):\n${result}` }],
};
}
);
// ── Start the Server ─────────────────────────────────────
// StdioServerTransport reads from stdin and writes to stdout.
// Claude Desktop launches this process and communicates
// through these streams — no network, no ports, no config.
const transport = new StdioServerTransport();
await server.connect(transport);