DEV Community

Cover image for How to Build a Production-Ready MCP Server for Claude in Under an Hour
Idapixl
Idapixl

Posted on

How to Build a Production-Ready MCP Server for Claude in Under an Hour

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");
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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) }] };
  }
);
Enter fullscreen mode Exit fullscreen mode

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:, and javascript: 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, and X-Forwarded-For before 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Define your Zod schema in types.ts — this is your contract
  2. Write your tool function returning Promise<ToolResult<YourResultType>> — isolated, testable, no MCP concerns
  3. Register in index.ts with the schema shape — pattern-match on result.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)