How to Build a Production-Ready MCP Server for Claude in Under an Hour
You've seen Claude do impressive things. Now you want to extend it — give it access to your APIs, your files, your internal tools. The path to that is MCP. And the first time you sit down to build an MCP server from scratch, you're going to hit a wall.
The official docs show you a "hello world" handler. That's about it. No types. No error handling patterns. No tests. And there's one protocol detail that catches almost everyone the first time, which I'll get to shortly.
This article walks through how to build a production-ready MCP server correctly — using real code from a starter kit I built specifically to solve this problem.
What Is MCP and Why Does It Matter
MCP — Model Context Protocol — is the open standard that lets AI assistants like Claude call external tools. Think of it as the bridge between what Claude can reason about and what actually exists in the world: APIs, files, databases, services.
Before MCP, giving Claude access to external data meant custom prompt injection, fragile workarounds, or proprietary plugin systems. MCP standardizes the whole thing. You build a server. Claude discovers your tools. It calls them like functions, passing typed arguments and reading structured responses back.
The protocol is built on JSON-RPC over stdio. Your server registers named tools with input schemas. The host (Claude Desktop, Claude Code, or any MCP-compatible client) discovers those tools and knows how to call them. Claude decides when to use them based on the conversation and the tool descriptions you write.
This is the layer that makes Claude genuinely useful for real work — not just answering questions, but taking actions.
The Three Problems Every First-Time MCP Developer Hits
1. The stdout problem
This is the one that kills most first implementations silently.
MCP uses stdout as the communication channel between your server and the host. That means every byte you write to stdout — every console.log, every debug print, every innocent status message — corrupts the JSON-RPC stream. Claude gets malformed data. Tools fail. Nothing tells you why.
The fix is to route all logging to stderr exclusively. But it's easy to forget, and it's easy to miss when a dependency writes to stdout. Here's what a correct logger looks like:
// MCP uses stdio for communication, so all logging MUST go to stderr
export const logger = {
debug(message: string, meta?: unknown): void {
if (shouldLog("debug")) {
process.stderr.write(format("debug", message, meta) + "\n");
}
},
info(message: string, meta?: unknown): void {
if (shouldLog("info")) {
process.stderr.write(format("info", message, meta) + "\n");
}
},
warn(message: string, meta?: unknown): void {
if (shouldLog("warn")) {
process.stderr.write(format("warn", message, meta) + "\n");
}
},
error(message: string, meta?: unknown): void {
if (shouldLog("error")) {
process.stderr.write(format("error", message, meta) + "\n");
}
},
};
Every log call goes to process.stderr.write directly. No console.log anywhere in the codebase. This is non-negotiable.
2. No type safety on tool inputs
The MCP SDK accepts tool arguments as unknown. Most tutorials cast straight to whatever type they expect and move on. This means validation errors surface as unreadable runtime crashes rather than clean error messages back to Claude.
The correct pattern is to define your schema with Zod and let it do double duty: runtime validation AND TypeScript type inference from a single source of truth.
import { z } from "zod";
export const FetchUrlSchema = z.object({
url: z.string().url("Must be a valid URL"),
headers: z.record(z.string()).optional().describe("Optional HTTP headers"),
timeout_ms: z
.number()
.int()
.min(100)
.max(30000)
.optional()
.describe("Request timeout in milliseconds (100–30000)"),
});
export type FetchUrlInput = z.infer<typeof FetchUrlSchema>;
You pass FetchUrlSchema.shape to server.tool(). The SDK uses the shape to generate the JSON Schema it advertises to Claude. Your tool handler receives validated, typed arguments. One schema, three jobs.
3. No consistent error handling
When a tool fails — bad URL, file not found, timeout, blocked domain — it needs to return a structured error back to Claude, not throw an exception and crash. Claude needs to be able to read the error and decide what to do next.
Most example code either throws and breaks the session, or returns a raw string with no structure. The correct pattern is a discriminated union that forces you to handle both cases:
export interface ToolSuccess<T = unknown> {
ok: true;
data: T;
}
export interface ToolError {
ok: false;
error: string;
code?: string;
}
export type ToolResult<T = unknown> = ToolSuccess<T> | ToolError;
Every tool function returns Promise<ToolResult<T>>. Your tool handler pattern-matches on result.ok before building the response:
server.tool(
"fetch_url",
"Fetch the content of a URL and return it as text...",
FetchUrlSchema.shape,
async (args) => {
const result = await fetchUrl(args);
if (!result.ok) {
return {
isError: true,
content: [{ type: "text", text: `Error [${result.code}]: ${result.error}` }],
};
}
// result.data is now fully typed as FetchUrlResult
const { data } = result;
return { content: [{ type: "text", text: buildSummary(data) }] };
}
);
The isError: true flag tells Claude the tool call failed without crashing the session. Claude can read the error message, reason about it, and try something else. This is the difference between a tool Claude can work with and a tool that randomly breaks mid-conversation.
The Three Included Tools
Rather than starting from hello-world, the starter kit ships three working tools that demonstrate these patterns in context.
fetch_url
Fetches web content and returns it as text. Sounds simple. The implementation handles a set of security concerns you'd have to figure out on your own:
- Blocks
file:,data:, andjavascript:schemes — only HTTP and HTTPS are allowed - Blocks requests to private IP ranges (RFC 1918) and loopback addresses to prevent SSRF
- Strips sensitive caller-supplied headers like
Authorization,Cookie, andX-Forwarded-Forbefore forwarding - Enforces a configurable max response size, streaming up to the limit and truncating cleanly rather than loading the whole response into memory
- Rejects binary content types — only returns text
Here's the SSRF guard, for example:
function isPrivateIp(hostname: string): boolean {
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [, a, b] = ipv4.map(Number);
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 RFC 1918
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
}
// ... IPv6 handling
}
This is the kind of thing you only think to add after you've either read a security brief or had a bad day. It's in here from day one.
read_file and list_directory
Safe filesystem access with a configured root directory. Path traversal (../) is blocked — attempts to escape the root return an error code, not an exception. Supports UTF-8 and base64 encoding for binary files. Configurable max bytes with clean truncation behavior.
The root directory is set via environment variable, so you control exactly what portion of your filesystem Claude can read.
transform_data
Converts data between JSON, CSV, TSV, Markdown table, and plain text summary. Useful when Claude fetches structured data from an API and you need it in a different shape before doing anything with it. Pass CSV in, get a Markdown table out. Pass JSON in, get a readable text summary. The format conversion logic is isolated and tested, so you can use it as a reference when adding your own data-handling tools.
Getting a Server Running
The full setup is covered in the kit's README, but the shape of it is:
1. Install and build:
npm install
npm run build
2. Configure via .env:
MCP_SERVER_NAME=my-mcp-server
FETCH_TIMEOUT_MS=10000
FETCH_MAX_BYTES=524288
FILE_READER_ROOT=/Users/yourname/documents
LOG_LEVEL=info
3. Connect to Claude Desktop. Add a block to claude_desktop_config.json:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"],
"env": {
"FILE_READER_ROOT": "/Users/yourname/documents"
}
}
}
}
Restart Claude Desktop. Your tools will appear in the tool picker.
4. Connect to Claude Code. Add via the MCP config command:
claude mcp add my-mcp-server node /absolute/path/to/dist/index.js
That's it. Claude Code will discover your tools in the next session.
Running the Tests
The kit ships with 19 tests covering all three tools — happy paths and failure cases. Run them with:
npm test
Tests use Vitest. They cover things like: URL validation, blocked domain enforcement, private IP rejection, path traversal attempts on the file reader, format conversion edge cases. When you add a tool, you have working tests as reference for what to write.
The Architecture Pattern You'll Use in Every MCP Server
The kit's structure is intentionally something you can copy as you add tools. The pattern is:
- Define your Zod schema in
types.ts— this is your contract - Write your tool function returning
Promise<ToolResult<YourResultType>>— isolated, testable, no MCP concerns - Register in
index.tswith the schema shape — pattern-match onresult.ok, build the MCP response
The tool implementation never touches the MCP SDK directly. The SDK boundary lives entirely in index.ts. This means your tool functions are just regular async functions you can test without spinning up a server. It's a clean separation that pays dividends immediately when you start adding tests.
Skip the Boilerplate, Ship the Tool
If you've been meaning to build an MCP server but haven't had time to absorb the SDK internals, figure out the stdout issue, and work through what a real error handling pattern looks like — this is the shortcut.
The kit is working code, not a skeleton. Three tools. Strict TypeScript. Zod validation. Structured logging that won't corrupt your stream. Path traversal protection. SSRF guards. 19 tests that pass. A README with working connection configs for both Claude Desktop and Claude Code.
You clone it, adjust the config, run npm run build, connect to Claude Desktop, and you have a working MCP server. Then you add your own tools using the patterns already in place.
Get the MCP Server Starter Kit for $24
Built on Node.js 18+, TypeScript 5.7, and @modelcontextprotocol/sdk 1.0. One-time purchase. No subscription.
Follow the ongoing build at r/idapixl.
Top comments (0)