Contents
In the previous part we connected existing MCP servers. But sometimes you need your own: internal API, niche database, corporate service with no public connector. Let's build a working server from scratch — in one sitting.
When to write your own vs use a ready-made one
| Situation | Solution |
|---|---|
| Need GitHub / Postgres / Slack / Jira | Existing one from the registry |
| Internal REST API without OpenAPI | Your own |
| Local utility (CLI, script) with a specific interface | Your own |
| DB with non-standard auth | Your own (or fork an existing one) |
| One or two simple calls — a script will do | Skill or Bash, not MCP |
A custom MCP is worth it when the tool will be reused: a team, multiple projects, recurring tasks. For one-off work just write a regular script and call it from Bash.
What we'll build
A docs-search server — searches local .md files in a folder. Two tools:
search_docs(query, limit)— full-text search, returns top-N matches with snippetsread_doc(path)— reads a specific file
Real case: internal teams keep wikis in /docs/ of a repo, and Claude needs to grep through them without loading everything into context.
Step 1. Project skeleton
mkdir mcp-docs-search && cd mcp-docs-search
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
In package.json add:
{
"type": "module",
"bin": {
"mcp-docs-search": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
Key tsconfig.json options:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
Step 2. Minimal server
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "docs-search", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "ping",
description: "Health check. Returns pong.",
inputSchema: { type: "object", properties: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === "ping") {
return { content: [{ type: "text", text: "pong" }] };
}
throw new Error(`Unknown tool: ${req.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Run npm run dev — the server waits for messages on stdin. There's a separate tool for testing (see step 5), continuing.
Step 3. Real tools
Replace ping with useful search_docs and read_doc. For search, a simple grep-style pass — enough for thousands of files, swap to an index later for larger volumes.
// src/index.ts (full version)
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 { readFile } from "node:fs/promises";
import { glob } from "node:fs/promises";
import { resolve, relative } from "node:path";
const DOCS_DIR = resolve(process.env.DOCS_DIR ?? "./docs");
const server = new Server(
{ name: "docs-search", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search_docs",
description:
"Searches the content of all .md files in DOCS_DIR. Returns matches with a context snippet.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search phrase" },
limit: { type: "number", default: 5, description: "Max results" },
},
required: ["query"],
},
},
{
name: "read_doc",
description: "Reads a file from DOCS_DIR by relative path.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Relative path to a .md file" },
},
required: ["path"],
},
},
],
}));
async function searchDocs(query: string, limit = 5) {
const q = query.toLowerCase();
const results: Array<{ path: string; snippet: string }> = [];
for await (const file of glob("**/*.md", { cwd: DOCS_DIR })) {
const full = resolve(DOCS_DIR, file);
const text = await readFile(full, "utf8");
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) continue;
const start = Math.max(0, idx - 80);
const end = Math.min(text.length, idx + q.length + 80);
results.push({
path: relative(DOCS_DIR, full),
snippet: text.slice(start, end).replace(/\s+/g, " ").trim(),
});
if (results.length >= limit) break;
}
return results;
}
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (name === "search_docs") {
const { query, limit = 5 } = args as { query: string; limit?: number };
const results = await searchDocs(query, limit);
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
}
if (name === "read_doc") {
const { path } = args as { path: string };
const full = resolve(DOCS_DIR, path);
if (!full.startsWith(DOCS_DIR)) throw new Error("Path traversal blocked");
const text = await readFile(full, "utf8");
return { content: [{ type: "text", text }] };
}
throw new Error(`Unknown tool: ${name}`);
});
await server.connect(new StdioServerTransport());
The full.startsWith(DOCS_DIR) check is critical. Without it read_doc with path: "../../etc/passwd" will read anything. An MCP server runs with your user's privileges.
Step 4. Hook into Claude Code
Compile: npm run build. You get dist/index.js. Register in the project's .mcp.json (like any server from the MCP overview, it becomes available to the whole team; basic Claude Code setup is here):
{
"mcpServers": {
"docs-search": {
"command": "node",
"args": ["./mcp-docs-search/dist/index.js"],
"env": {
"DOCS_DIR": "./docs"
}
}
}
}
Restart Claude Code — inside the session type /mcp and make sure docs-search shows 2 tools.
Now this works:
Find everything in our docs about staging deploys and assemble a checklist
Claude will call search_docs("staging deploy") → take the top-5 files → if needed read them fully via read_doc.
Step 5. Debugging with MCP Inspector
Launching Claude Code on every iteration is slow. There's Inspector — a UI showing server requests/responses in real time:
npx @modelcontextprotocol/inspector node ./dist/index.js
A browser opens. Left side — tool list, right side — form to call and raw JSON response. You see the full MCP protocol frame, errors, timings.
Inspector is the de facto standard for MCP development. Before commit, run all tools through it with edge cases: empty query, missing path, huge result.
Step 6. Logging
stdout is reserved for the protocol — your console.log will break the parser on Claude's side. Log to stderr or a file:
function log(msg: string, data?: unknown) {
process.stderr.write(`[docs-search] ${msg} ${data ? JSON.stringify(data) : ""}\n`);
}
log("server started", { docsDir: DOCS_DIR });
In Claude Code MCP server logs are visible via claude mcp logs docs-search.
Step 7. Publish for the team
If the server is useful beyond you, publish it on npm:
# package.json
{
"name": "@your-org/mcp-docs-search",
"version": "0.1.0",
"bin": { "mcp-docs-search": "./dist/index.js" }
}
npm publish --access=public
Now in your team's .mcp.json:
{
"mcpServers": {
"docs-search": {
"command": "npx",
"args": ["-y", "@your-org/mcp-docs-search"],
"env": { "DOCS_DIR": "./docs" }
}
}
}
Nobody clones anything — npx pulls the package on first run.
What else MCP does
We only did tools, but the protocol is broader:
| Capability | When to use |
|---|---|
| Tools | Side-effect actions (search, write, API call) |
| Resources | Read-only data the model can "read" (files, DB rows) |
| Prompts | Predefined prompt templates with parameters |
| Sampling | The server asks the model to perform a sub-task |
Resources map well to "open tickets list" or "migrations list" — you hand out URIs, Claude decides when to load. Prompts are handy for templates like "code review by our rules" — but Skills usually cover that.
Summary
- A custom MCP is worth it when the tool will be reused
- TypeScript skeleton:
@modelcontextprotocol/sdk+StdioServerTransport+ two handlers - Security: always validate paths and never trust input
- Logs go to
stderronly, otherwise you break the protocol - Debug via
@modelcontextprotocol/inspector - Share with the team via npm publish +
.mcp.jsonwithnpx - Tools cover 80% of cases; resources/prompts — when you need read-only access or templates
Discussion
No comments yet. Be the first.