Skip to content

index.ts

Source file: 03-mcp/templates/typescript-server/src/index.ts. Copy it from the repository, or use the copy button on the code block.

index.ts
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
// stdout carries the MCP protocol. Log only to stderr.
const log = (...args: unknown[]) => console.error("[notes]", ...args);
const NOTES_FILE = process.env.NOTES_FILE ?? join(homedir(), ".notes-mcp.json");
interface Note {
title: string;
body: string;
created: string;
}
async function loadNotes(): Promise<Note[]> {
try {
return JSON.parse(await readFile(NOTES_FILE, "utf8")) as Note[];
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
}
async function saveNotes(notes: Note[]): Promise<void> {
await writeFile(NOTES_FILE, JSON.stringify(notes, null, 2), "utf8");
}
export function searchNotes(notes: Note[], query: string, limit: number): Note[] {
const q = query.toLowerCase();
return notes
.filter((n) => `${n.title} ${n.body}`.toLowerCase().includes(q))
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, limit);
}
const server = new McpServer({ name: "notes", version: "0.1.0" });
server.registerTool(
"search_notes",
{
title: "Search notes",
description:
"Search saved notes by keyword in title or body. Returns up to `limit` matches, newest first. Use before add_note to avoid duplicates.",
inputSchema: z.object({
query: z.string().min(1).describe("Keyword or phrase, case-insensitive"),
limit: z.number().int().min(1).max(20).default(5).describe("Maximum results (1-20)"),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query, limit }) => {
const hits = searchNotes(await loadNotes(), query, limit);
const text = hits.length
? hits.map((n) => `- ${n.title} (${n.created}): ${n.body.slice(0, 200)}`).join("\n")
: `No notes match "${query}". Try a broader keyword.`;
return { content: [{ type: "text", text }] };
},
);
server.registerTool(
"add_note",
{
title: "Add note",
description: "Save a new note. Fails if a note with the same title already exists.",
inputSchema: z.object({
title: z.string().min(1).max(120).describe("Unique note title"),
body: z.string().min(1).max(10_000).describe("Note content (Markdown allowed)"),
}),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
async ({ title, body }) => {
const notes = await loadNotes();
if (notes.some((n) => n.title.toLowerCase() === title.toLowerCase())) {
return {
isError: true,
content: [{ type: "text", text: `A note titled "${title}" already exists. Choose a different title or use search_notes to find it.` }],
};
}
notes.push({ title, body, created: new Date().toISOString() });
await saveNotes(notes);
log(`saved note "${title}"`);
return { content: [{ type: "text", text: `Saved note "${title}".` }] };
},
);
server.registerResource(
"all-notes",
"notes://all",
{ title: "All notes", description: "Every saved note as JSON", mimeType: "application/json" },
async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await loadNotes(), null, 2) }] }),
);
server.registerPrompt(
"weekly-review",
{
title: "Weekly review",
description: "Summarise this week's notes",
argsSchema: z.object({ focus: z.string().optional().describe("Optional topic to focus on") }),
},
({ focus }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Search my notes from this week and write a weekly review${focus ? ` focused on ${focus}` : ""}.`,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
log(`running on stdio (NOTES_FILE=${NOTES_FILE})`);