Tools are the most commonly used MCP capability. Let's build a practical tool that searches a local notes directory.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readdir, readFile } from "fs/promises";
import { join } from "path";
const server = new McpServer({
name: "notes-server",
version: "1.0.0"
});
// Tool 1: Search notes by keyword
server.tool(
"search_notes",
"Search all markdown notes for a keyword. Returns matching filenames and snippets.",
{
query: z.string().describe("The keyword to search for"),
maxResults: z.number().optional().default(5).describe("Max results to return")
},
async ({ query, maxResults }) => {
const notesDir = process.env.NOTES_DIR || "./notes";
const files = await readdir(notesDir);
const matches: string[] = [];
for (const file of files) {
if (!file.endsWith(".md")) continue;
const content = await readFile(join(notesDir, file), "utf-8");
if (content.toLowerCase().includes(query.toLowerCase())) {
const lines = content.split("\n");
const matchLine = lines.find(l =>
l.toLowerCase().includes(query.toLowerCase())
);
matches.push(`**${file}**: ${matchLine?.trim() || "(match in body)"}`);
}
if (matches.length >= maxResults) break;
}
if (matches.length === 0) {
return {
content: [{ type: "text", text: `No notes found matching "${query}".` }]
};
}
return {
content: [{ type: "text", text: matches.join("\n") }]
};
}
);
// Tool 2: Create a new note
server.tool(
"create_note",
"Create a new markdown note file with the given title and content.",
{
title: z.string().describe("Note title (used as filename)"),
body: z.string().describe("Markdown content of the note")
},
async ({ title, body }) => {
const notesDir = process.env.NOTES_DIR || "./notes";
const filename = title.toLowerCase().replace(/\s+/g, "-") + ".md";
const fullPath = join(notesDir, filename);
try {
await writeFile(fullPath, `# ${title}\n\n${body}\n`);
return {
content: [{ type: "text", text: `✅ Note created: ${filename}` }]
};
} catch (e: any) {
return {
isError: true,
content: [{ type: "text", text: `Failed to create note: ${e.message}` }]
};
}
}
);
| Pattern | When to Use | Example |
|---|---|---|
| Simple Tool | Single action, no side effects | search_notes — reads data |
| Mutating Tool | Creates, updates, or deletes data | create_note — writes files |
| Async Tool | Calls external APIs with latency | fetch_weather — HTTP request |
| Streaming Tool | Returns progress updates | run_migration — long process |
.describe() on every Zod field. The LLM reads these descriptions to decide what values to pass. A missing description means the LLM guesses — and it will guess wrong.