How to Build a Custom MCP Server for Claude Code (2026 Tutorial)
ai-productivity

How to Build a Custom MCP Server for Claude Code (2026 Tutorial)

Ricardo Gil
August 26, 2026
10 min read
#claude-code #mcp #developer-tools #typescript #automation
πŸ›’

Products in This Post

Affiliate links

As an Amazon Associate I earn from qualifying purchases at no extra cost to you.

If you've been using Claude Code for a while, you've probably hit its built-in limits: it can read your files, run bash commands, and call a handful of built-in tools β€” but it can't query your internal database, hit your private API, or pull structured data from the systems you actually work with every day.

Model Context Protocol (MCP) fixes this. It's an open protocol from Anthropic that lets you build a local server exposing custom tools that Claude Code β€” and Claude Desktop β€” can call exactly like any built-in tool. Once wired up, Claude can SELECT from your Postgres database, fetch open tickets from your internal tracker, or pull metrics from Prometheus β€” all inside the same agentic loop, without leaving the terminal.

This tutorial walks through building a real MCP server from scratch in TypeScript, connecting it to Claude Code, and extending it with a live SQLite query tool. No filler β€” just the setup, the code, and the gotchas that burn people on first contact.

What MCP Servers Actually Do

An MCP server is a local process β€” or remote HTTPS endpoint β€” that speaks the Model Context Protocol over stdio or HTTP/SSE. It declares a list of tools, each with a name, a natural-language description, and a JSON Schema for its inputs. Claude reads those descriptions at startup, decides when a tool is relevant, sends structured inputs, and reasons about the response β€” the same way it uses built-in tools like Read or Bash.

From Claude Code's perspective, your custom tool is indistinguishable from a built-in one. You write the handler; Claude decides when and how to call it. The protocol supports three primitives:

  • Tools β€” callable functions with structured input/output (the focus of this tutorial)
  • Resources β€” read-only data sources Claude can browse, like a directory listing or API reference
  • Prompts β€” pre-built prompt templates the user can invoke by name

Prerequisites

You'll need Node.js 20+, npm, and Claude Code installed. If you haven't installed Claude Code yet:

bash
npm install -g @anthropic-ai/claude-code

Basic TypeScript familiarity helps, but the SDK is small enough that you can follow along even if you primarily write JavaScript. You can run the MCP server on your local dev box, or on a dedicated always-on machine. A Beelink EQ12 Mini PC (~$189) is a solid choice for a shared team MCP host β€” it draws under 10W at idle and handles multiple Node.js server processes without complaint.

Scaffolding the Project

bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx
npx tsc --init --target ES2022 --module Node16 --moduleResolution Node16 --outDir dist

Add a build script to package.json:

javascript
"scripts": {
  "build": "tsc",
  "dev": "tsx src/index.ts"
}

Building the Server

Create src/index.ts. This minimal server registers one tool β€” get_project_status β€” and handles the call:

javascript
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: "my-mcp-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_project_status",
      description:
        "Returns current status of a project by ID from our internal tracker.",
      inputSchema: {
        type: "object",
        properties: {
          project_id: {
            type: "string",
            description: "Project identifier, e.g. PROJ-123",
          },
        },
        required: ["project_id"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "get_project_status") {
    const { project_id } = request.params.arguments as { project_id: string };

    // Replace with your real data source
    const data: Record = {
      "PROJ-123": { status: "in-progress", owner: "alice", due: "2026-09-01" },
      "PROJ-456": { status: "blocked", owner: "bob", blocker: "PROJ-789" },
    };

    const result = data[project_id] ?? { error: "Project not found" };
    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  }

  throw new Error(`Unknown tool: ${request.params.name}`);
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main();

Connecting It to Claude Code

Build the project, then tell Claude Code where to find the server. Edit ~/.claude/claude_desktop_config.json (user-global) or create .claude/mcp_servers.json at your project root for project-scoped access:

javascript
{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
    }
  }
}

For development, skip the build step and use tsx directly:

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

Restart Claude Code and verify the server loaded by running /mcp or asking: "What MCP tools do you have available?" You should see get_project_status in the list. If it's missing, check the Claude Code logs β€” the server process stderr is captured there and will show any import or startup errors.

Real-World Extension: Querying a SQLite Database

The mock data example proves the wiring. Now connect it to a real local database. Install the driver:

bash
npm install better-sqlite3
npm install -D @types/better-sqlite3

Add a query_db tool. The key detail here is the safety guard: the tool only allows SELECT statements, so Claude can't accidentally mutate your data even if it generates a destructive query.

javascript
import Database from "better-sqlite3";

const db = new Database("/path/to/your/data.db", { readonly: true });

// Add to ListToolsRequestSchema handler:
{
  name: "query_db",
  description:
    "Run a read-only SQL SELECT against the local project database. Returns results as JSON rows.",
  inputSchema: {
    type: "object",
    properties: {
      sql: {
        type: "string",
        description: "A SELECT query. Mutations are rejected.",
      },
    },
    required: ["sql"],
  },
}

// Add to CallToolRequestSchema handler:
if (request.params.name === "query_db") {
  const { sql } = request.params.arguments as { sql: string };

  if (!sql.trim().toUpperCase().startsWith("SELECT")) {
    return {
      content: [{ type: "text", text: "Error: only SELECT queries are allowed." }],
      isError: true,
    };
  }

  try {
    const rows = db.prepare(sql).all();
    return {
      content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
    };
  } catch (err) {
    return {
      content: [
        { type: "text", text: `SQL error: ${(err as Error).message}` },
      ],
      isError: true,
    };
  }
}

Once registered, you can prompt Claude Code: "Query the database for all tasks assigned to me that are past their due date and summarize them." Claude writes the SQL, calls your tool, reads the rows, and gives you a summary β€” no browser switching, no copy-pasting results.

If your MCP server loads large indexes or caches query result sets on disk, storage throughput starts to matter. A WD Black 2TB NVMe SSD (~$129) keeps read latency low and handles the write throughput from index updates without bottlenecking the rest of your dev loop.

Passing Secrets with Environment Variables

Real servers need credentials β€” API keys, connection strings, OAuth tokens. Pass them through the MCP config rather than hardcoding:

javascript
{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/path/to/dist/index.js"],
      "env": {
        "DATABASE_URL": "postgres://user:pass@localhost:5432/mydb",
        "INTERNAL_API_KEY": "sk-..."
      }
    }
  }
}

Read them in your server with process.env as usual. The config file lives in your home directory, outside any git repo, so secrets stay off version control. If you're running the server as a systemd service on a shared host, use a .env file with EnvironmentFile= in the unit config instead.

Handling Errors and Partial Failures

Return structured error responses rather than letting uncaught exceptions crash the process. Claude gets better signal from an isError: true response than from a silent stdio disconnect:

javascript
try {
  const result = await callExternalApi(params);
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
  return {
    content: [
      {
        type: "text",
        text: `Tool failed: ${(err as Error).message}. The upstream service may be down.`,
      },
    ],
    isError: true,
  };
}

With isError: true set, Claude knows the call failed and can decide whether to retry, ask you for clarification, or try an alternative approach. Without it, Claude may treat an error message as valid data and reason incorrectly about it.

Project-Scoped Servers for Team Sharing

User-level config makes the server available everywhere. For a server that's specific to one project β€” say, a tool that knows about your monorepo's service registry β€” use project-level config instead:

bash
mkdir -p .claude
cat > .claude/mcp_servers.json << 'EOF'
{
  "mcpServers": {
    "monorepo-tools": {
      "command": "node",
      "args": ["./mcp/dist/index.js"]
    }
  }
}
EOF

Claude Code detects and loads this when it finds the project root. Every developer who clones the repo gets the same MCP tooling automatically β€” no manual configuration step in the onboarding docs.

What to Build Next

Once the pattern is in muscle memory, the useful servers are easy to spot. A few that have high ROI for most developer teams:

  • Internal docs search β€” embed your Confluence or Notion pages and expose a search_docs tool so Claude can retrieve relevant architecture docs before writing code that touches those systems
  • GitHub PR context β€” fetch PR descriptions, review comments, and CI status by PR number so Claude can reason about feedback without you switching to the browser
  • Deployment and health status β€” wrap your Kubernetes API or deployment pipeline so Claude can verify a service is healthy before suggesting you restart it
  • Metric lookups β€” a thin wrapper around your Prometheus or Datadog API gives Claude the last 24 hours of error rates when you're debugging an incident at 2am

The pattern is always the same: identify the context Claude is missing, wrap the data source in a tool with a clear description, wire it up in 50–100 lines of TypeScript. The description is where most of the work actually lives β€” Claude uses it to decide when to call the tool, so a vague description produces vague behavior.

If you plan to run several MCP servers concurrently, RAM starts to matter. A Crucial 32GB DDR4 upgrade (~$59) keeps your Node processes from swapping when you have three or four servers active alongside a local Ollama instance. Combined with a low-power always-on host, you end up with a dedicated MCP infrastructure layer that any machine on your LAN can use.

Wrapping Up

MCP servers are the cleanest way to extend Claude Code beyond its default toolset. The SDK is minimal, the stdio transport is easy to debug with basic logging, and the integration with Claude Code's agentic loop is seamless once the server is registered. Start with one small tool β€” a lookup you perform five times a day that requires switching context β€” get it working end to end, then expand from there. The first one is the hardest; after that, adding a new tool is a 20-minute job.

The source code for this tutorial is straightforward enough to adapt directly. Clone the structure, swap the handlers for your real data sources, drop the config file in your project root, and you're live.

πŸ“¬Weekly Newsletter

Get the best home lab & AI content

No spam. One email per week. Unsubscribe anytime.

Share this article