Problem: You Want Claude to Do Things It Can't Do Yet
Claude is smart, but it can't read your local files, query your database, or call your internal API — unless you give it tools.
Model Context Protocol (MCP) is how you do that. It's a standard that lets you write a server exposing tools, and Claude calls them during a conversation.
You'll learn:
- How MCP servers work and what the protocol actually sends
- How to build a working MCP server in TypeScript from scratch
- How to test it locally with Claude Desktop, then publish it to npm
Time: 30 min | Difficulty: Intermediate
Why MCP Instead of Just a System Prompt
You could paste file contents into a system prompt. You could write a wrapper script. MCP is worth doing instead because:
- Tools are called on demand — Claude only fetches what it needs
- Claude sees tool schemas, not raw data — it knows what to ask for
- Servers are reusable across Claude Desktop, Claude Code, and any MCP-compatible client
- Published servers work for anyone, not just you
MCP is a JSON-RPC protocol over stdio (for local servers) or HTTP+SSE (for remote ones). Claude sends a tools/call request; your server executes logic and returns a result. That's the whole loop.
What You'll Build
A file-search MCP server that exposes two tools:
search_files— find files by name pattern in a directoryread_file— return the contents of a specific file path
Simple enough to follow completely, useful enough to actually keep.
Solution
Step 1: Scaffold the Project
mkdir mcp-file-tools && cd mcp-file-tools
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
The @modelcontextprotocol/sdk package handles all protocol boilerplate — you write the logic, SDK handles the JSON-RPC framing.
Initialize TypeScript:
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext \
--outDir dist --rootDir src --strict
Add a build script and bin entry to package.json:
{
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
},
"bin": {
"mcp-file-tools": "./dist/index.js"
},
"type": "module"
}
The bin field is what makes your server installable and runnable as a CLI command after publishing to npm.
Step 2: Create the Server Entry Point
// src/index.ts
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { readdir, readFile } from "fs/promises";
import { join, resolve } from "path";
const server = new Server(
{ name: "mcp-file-tools", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
The Server constructor takes a name/version (shown in Claude's UI) and a capabilities object. tools: {} tells Claude this server exposes callable tools.
Step 3: Register Your Tools
MCP requires two handlers: one that lists available tools (their schemas), and one that executes them.
List handler — declare what tools exist and what they accept:
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_files",
description:
"Find files by name pattern in a directory. Returns matching absolute file paths.",
inputSchema: {
type: "object",
properties: {
directory: {
type: "string",
description: "Absolute path to the directory to search",
},
pattern: {
type: "string",
description: "Glob-style pattern, e.g. '*.ts' or 'README*'",
},
},
required: ["directory", "pattern"],
},
},
{
name: "read_file",
description: "Read the full text contents of a file by absolute path.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Absolute path to the file to read",
},
},
required: ["path"],
},
},
],
};
});
Write description fields as instructions for Claude — because that's exactly what they are. Be specific about what the tool returns and what each argument means. Claude decides when to call a tool based on this text.
Call handler — execute the tool when Claude calls it:
const SearchFilesArgs = z.object({
directory: z.string(),
pattern: z.string(),
});
const ReadFileArgs = z.object({
path: z.string(),
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "search_files") {
const { directory, pattern } = SearchFilesArgs.parse(args);
const safeDir = resolve(directory);
const entries = await readdir(safeDir, { recursive: true });
const regex = new RegExp(
"^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
);
const matches = (entries as string[])
.filter((e) => regex.test(e.split("/").pop() ?? ""))
.map((e) => join(safeDir, e))
.slice(0, 50); // cap at 50 — Claude's context window is finite
return {
content: [
{
type: "text",
text: matches.length > 0 ? matches.join("\n") : "No files matched.",
},
],
};
}
if (name === "read_file") {
const { path: filePath } = ReadFileArgs.parse(args);
const safeFile = resolve(filePath);
const content = await readFile(safeFile, "utf-8");
// Truncate large files — sending 10MB to Claude wastes tokens and may fail
const truncated = content.length > 50_000;
const text = truncated
? content.slice(0, 50_000) + "\n\n[truncated — file exceeds 50KB]"
: content;
return { content: [{ type: "text", text }] };
}
throw new Error(`Unknown tool: ${name}`);
} catch (err) {
return {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true, // tells Claude the tool failed — it can adapt rather than treating the error as a result
};
}
});
Step 4: Start the Transport
const transport = new StdioServerTransport();
await server.connect(transport);
Build it:
npm run build
chmod +x dist/index.js
Important: Log to stderr, not stdout. The MCP transport uses stdout for JSON-RPC messages. Any console.log will corrupt the stream. Use console.error for debug output.
Step 5: Connect to Claude Desktop
macOS:
# Edit this file:
~/Library/Application\ Support/Claude/claude_desktop_config.json
Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"file-tools": {
"command": "node",
"args": ["/absolute/path/to/mcp-file-tools/dist/index.js"]
}
}
}
Use the full absolute path. Claude Desktop spawns the process from its own working directory — relative paths will fail silently.
Restart Claude Desktop. A tools icon (🔧) appears in the input bar — click it to confirm both tools are listed.
Step 6: Test It
In a new conversation:
"Use the search_files tool to find all TypeScript files in /Users/yourname/projects/my-app"
Claude calls search_files, returns paths, then you can ask it to read specific files.
If tools don't appear:
- Config syntax error → Validate JSON at jsonlint.com
- Wrong path → Run
node /your/path/dist/index.jsin a terminal to confirm it starts - Crashes silently → Check Console.app (macOS) for stderr from the process
If Claude doesn't call the tool:
- Be explicit: "Use the search_files tool to..."
- Tighten your
descriptiontext — Claude routes based on it
Step 7: Publish to npm
{
"name": "mcp-file-tools",
"version": "1.0.0",
"description": "MCP server: search and read local files from Claude Desktop",
"main": "dist/index.js",
"bin": { "mcp-file-tools": "./dist/index.js" },
"files": ["dist"],
"keywords": ["mcp", "claude", "model-context-protocol"],
"license": "MIT",
"engines": { "node": ">=20" }
}
npm run build
npm login
npm publish --access public
Once published, anyone can add it to their config:
{
"mcpServers": {
"file-tools": {
"command": "npx",
"args": ["-y", "mcp-file-tools"]
}
}
}
-y skips the install prompt. npx caches on first run and picks up version bumps automatically.
Step 8: Submit to the MCP Registry (Optional)
Anthropic maintains a community registry at modelcontextprotocol.io. Getting listed puts your server in front of Claude users searching for tools.
Requirements: a published npm package, a README.md with tool descriptions and install instructions, and a mcp.json manifest in the repo root. Submit as a PR to github.com/modelcontextprotocol/servers.
Verification
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js
You should see a JSON response listing both tools with their input schemas. A blank response means the transport isn't reading stdin — confirm server.connect(transport) is awaited at the end of the file.
Production Considerations
Sandbox file access. An unrestricted file tool can read anything on the filesystem. Accept a --root CLI flag and reject paths outside it:
const ROOT = process.argv[2] ? resolve(process.argv[2]) : null;
// Inside the call handler, before any fs operation:
if (ROOT && !safePath.startsWith(ROOT)) {
throw new Error(`Access denied: path is outside allowed root ${ROOT}`);
}
Users pass the root in their config:
{ "args": ["-y", "mcp-file-tools", "/Users/yourname/projects"] }
Version tool schemas carefully. Renaming a tool or changing a required argument after users adopt your server will break their Claude Desktop silently until they restart. Treat tool names and input schemas like a public API — bump major versions for breaking changes.
What You Learned
- MCP servers are processes speaking JSON-RPC over stdio — Claude Desktop manages their lifecycle
- Two handlers do all the work:
ListToolsRequestSchemadeclares schemas,CallToolRequestSchemaruns the logic - Tool
descriptiontext is what Claude reads when deciding which tool to call — write it precisely - Publish with a
binentry and users run vianpxwith no local build step
Limitation: Stdio MCP servers only work with local clients (Claude Desktop, Claude Code). For a server accessible from Claude.ai or remote agent pipelines, you need the HTTP+SSE transport — a separate deployment model with its own auth requirements.
Tested on @modelcontextprotocol/sdk 1.8.0, Node.js 22.x, Claude Desktop 0.9.x, macOS Sequoia and Ubuntu 24.04