Skip to main content
Back to Research
Field ReportDeep Readmcp-engineering

Building an MCP Server From Scratch — A Practical Guide

The Model Context Protocol defines how AI agents connect to tools and data. Building your own MCP server is simpler than it looks, and the payoff — giving…

Abstract

A step-by-step guide to building production-grade MCP servers in Node.js: from protocol basics through tool registration, error handling, and integration with Claude Code. Includes concrete examples for file operations, API clients, and database access.

May 5, 2026
7 min read

The Model Context Protocol is Anthropic's open standard for connecting AI models to external tools and data sources. If you have used Claude Code with GitHub, Supabase, or any of the growing ecosystem of MCP integrations, you have already consumed MCP servers. Building one is the next step — and it is more accessible than the specification documents suggest.

This guide builds a real MCP server, from an empty directory to a working integration with Claude Code, with production-quality error handling and structured outputs along the way.

The Protocol in 90 Seconds

MCP uses JSON-RPC 2.0 over stdio (local) or HTTP+SSE (remote). A server exposes three primitive types:

  • Tools — callable functions that the AI can invoke with structured arguments. Think: query a database, write a file, call an API.
  • Resources — readable data sources that the AI can include as context. Think: file contents, API documentation, database schema.
  • Prompts — reusable prompt templates that the user can invoke. Think: "summarize this codebase", "review this PR".

For most practical integrations, you only need Tools. Resources and Prompts are powerful but optional.

Project Setup

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "outDir": "dist"
  }
}

A 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'
import { z } from 'zod'

const server = new Server(
  { name: 'my-mcp-server', version: '1.0.0' },
  { capabilities: { tools: {} } },
)

// Declare available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'get_weather',
        description: 'Get current weather for a city',
        inputSchema: {
          type: 'object',
          properties: {
            city: { type: 'string', description: 'City name' },
          },
          required: ['city'],
        },
      },
    ],
  }
})

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params

  if (name === 'get_weather') {
    const { city } = z.object({ city: z.string() }).parse(args)
    // Replace with real weather API call
    const weather = await fetchWeather(city)
    return {
      content: [{ type: 'text', text: JSON.stringify(weather) }],
    }
  }

  throw new Error(`Unknown tool: ${name}`)
})

async function main() {
  const transport = new StdioServerTransport()
  await server.connect(transport)
  console.error('[mcp] Server running on stdio')
}

main().catch(console.error)

Structured Tool Output — The Right Way

Returning raw JSON as a text string works, but structured outputs are more useful to the AI. The content array supports mixed types:

return {
  content: [
    {
      type: 'text',
      text: `Weather in ${city}:`,
    },
    {
      type: 'text',
      text: JSON.stringify({
        temperature: weather.temp,
        condition: weather.description,
        humidity: weather.humidity,
        windSpeed: weather.wind,
      }, null, 2),
    },
  ],
}

For binary data (images, files), use the blob content type with base64 encoding. The AI will include the image in its context window and can reason about its contents.

Error Handling That Does Not Confuse Claude

MCP has two levels of errors: protocol errors (the tool call itself failed) and logical errors (the tool ran but produced an error result). The distinction matters because protocol errors terminate the tool interaction, while logical errors give the AI information it can act on.

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const result = await executeToolCall(request.params)
    return { content: [{ type: 'text', text: result }] }
  } catch (err) {
    // Logical error — let the AI see and reason about it
    const message = err instanceof Error ? err.message : String(err)
    return {
      content: [{ type: 'text', text: `Error: ${message}` }],
      isError: true,
    }
  }
})

The isError: true flag tells the AI that this is an error response. Claude will explain the error to the user rather than trying to parse the error message as the tool's output.

A Real-World Example — File System Server

Here is a practical MCP server that provides file operation tools — useful for giving AI agents controlled access to a specific directory:

import { readFile, writeFile, readdir, stat } from 'fs/promises'
import { join, resolve, relative } from 'path'

const ALLOWED_ROOT = process.env.MCP_ALLOWED_ROOT ?? process.cwd()

function validatePath(inputPath: string): string {
  const resolved = resolve(ALLOWED_ROOT, inputPath)
  if (!resolved.startsWith(ALLOWED_ROOT)) {
    throw new Error(`Path traversal blocked: ${inputPath}`)
  }
  return resolved
}

// Tool: read_file
if (name === 'read_file') {
  const { path } = z.object({ path: z.string() }).parse(args)
  const safePath = validatePath(path)
  const content  = await readFile(safePath, 'utf-8')
  const info     = await stat(safePath)
  return {
    content: [
      { type: 'text', text: `File: ${relative(ALLOWED_ROOT, safePath)} (${info.size} bytes)\n\n${content}` },
    ],
  }
}

// Tool: list_directory
if (name === 'list_directory') {
  const { path } = z.object({ path: z.string().default('.') }).parse(args)
  const safePath = validatePath(path)
  const entries  = await readdir(safePath, { withFileTypes: true })
  const lines    = entries.map(e => `${e.isDirectory() ? 'DIR' : 'FILE'}  ${e.name}`)
  return {
    content: [{ type: 'text', text: lines.join('\n') }],
  }
}

Note the path validation — this is not optional. Without it, a tool like read_file('../../../etc/passwd') would work. Path traversal attacks on MCP servers are realistic because the inputs come from AI-generated arguments, not from trusted users.

Registering With Claude Code

Add your server to ~/.claude/settings.json:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "MCP_ALLOWED_ROOT": "/home/user/projects"
      }
    }
  }
}

For development, use tsx instead of compiling:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
    }
  }
}

Restart Claude Code after editing the config. You can verify the server is detected by asking Claude: "What MCP tools do you have access to?"

Performance Considerations

MCP servers run as child processes. Each Claude Code session spawns your server fresh. For servers that need to maintain state — database connection pools, authenticated API clients, in-memory caches — you need to initialize them at startup and handle reconnection gracefully.

For expensive initializations (loading large datasets, establishing multiple API connections), implement lazy initialization: return immediately from the tool call if the resource is not ready, and initialize in the background. A get_status tool that reports initialization state is a useful addition for complex servers.

The Protocol Is Simple Enough to Debug With curl

Because MCP uses JSON-RPC over stdio, you can test your server manually:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js

The server will respond with its tool list. This is invaluable for debugging — no special tooling required.

MCP is the infrastructure layer for AI-native development. Building your own server gives Claude reliable, type-safe access to your specific systems — not a generic approximation, but exactly the interface your infrastructure exposes. Once you have one server running, the pattern generalizes instantly to any other system you want Claude to operate.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP