Step-by-Step Guide: Building Your First Model Context Protocol (MCP) Server for Claude

Step-by-Step Guide: How to Build Your First Model Context Protocol (MCP) Server for Claude

📅 May 2025· ⏱ 22 min read· 🎯 Advanced· ✅ Production-ready code

The Model Context Protocol (MCP) is Anthropic’s open standard for connecting Claude to any external data source or tool. In this guide, you’ll build a fully functional MCP server from scratch — one that exposes a PlanetScale MySQL database and a local file system as context sources Claude can query in real time.

TypeScriptMCP SDKPlanetScaleClaude DesktopNode.js 18+stdio transport

What Is the Model Context Protocol (MCP)?

The Model Context Protocol (MCP) is an open-source, JSON-RPC 2.0-based protocol developed by Anthropic that standardises how large language models like Claude communicate with external data sources, APIs, and tools. Released in late 2024 and rapidly adopted across the AI ecosystem, MCP replaces ad-hoc prompt-stuffing and fragile function-calling glue code with a stable, inspectable, and composable integration layer.

Read Also: 7 Ways to Make Money With AI in Nigeria in 2026 (No Coding Required)

Why MCP over function calling?

Function calling requires encoding tool definitions directly into every API request, bloating your context window and coupling your application to a specific model’s schema. MCP externalises this entirely — Claude discovers capabilities at runtime through a standard handshake, making your servers reusable across models, hosts, and future protocol versions.

MCP servers can run as local processes (connected via stdio), or as remote HTTP servers with Server-Sent Events (SSE) for streaming. In this tutorial, we build a local stdio server — the simplest and most secure pattern for connecting Claude Desktop to private data.

Architecture Overview

Before writing a line of code, understand the three-tier topology of an MCP integration: MCP HOST Claude Desktop / API Client MCP Client stdio JSON-RPC YOUR MCP SERVER Resources handler Tools handler Prompts handler Transport (stdio) PlanetScale / MySQL tables · rows · schemas mysql2 driver Local File System files · dirs · metadata Node.js fs/promises

The MCP Host (Claude Desktop or any API client you build) spawns your server as a subprocess and communicates with it over stdin/stdout using JSON-RPC 2.0 messages. Your server registers its capabilities on startup, and Claude can then call those tools or read those resources mid-conversation, fetching live data instead of relying on stale training knowledge.

Prerequisites

Before you begin, ensure you have the following installed and configured:

RequirementVersion / NotesInstall
Node.js18.0+ (LTS recommended)nvm install --lts
TypeScript5.xnpm i -g typescript
Claude Desktopv0.7.0+claude.ai/download
PlanetScale accountFree tier sufficientplanetscale.com
MCP TypeScript SDK@modelcontextprotocol/sdk ^1.0npm install (covered below)

⚠️

Node.js version matters

The MCP SDK uses ES modules natively. Node.js 18 added stable fetch support and improved ESM interop. Versions below 18 will cause runtime errors with the SDK’s import syntax.

Read Also: How to Master Claude’s New “xhigh” Reasoning Effort Control for Complex Math and Logic

Step 1 — Bootstrap the Project

Create a new directory and initialise a TypeScript project with the MCP SDK and supporting libraries:

terminalbash

# Create the project
mkdir my-mcp-server && cd my-mcp-server
npm init -y

# MCP SDK + database driver + utilities
npm install @modelcontextprotocol/sdk mysql2 zod dotenv

# TypeScript toolchain
npm install -D typescript @types/node tsx

Now configure TypeScript. MCP servers must emit ES modules, so your tsconfig.json must target ESNext with moduleResolution: bundler or node16:

tsconfig.jsonjson

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Update package.json to declare the ES module format and add dev/build scripts:

package.json (excerpt)json

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Step 2 — Create the MCP Server Skeleton

Create src/index.ts. This file bootstraps the MCP server, registers all handlers, and connects the stdio transport. Every MCP server follows this same four-part pattern:

  1. Instantiate McpServer with a name and version
  2. Register resource, tool, and prompt handlers
  3. Create a StdioServerTransport
  4. Call server.connect(transport)

src/index.tstypescript

/**
 * MCP Server — PlanetScale + Local File System
 * Connects Claude to a MySQL database and the host file system.
 */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import 'dotenv/config';

import { createDbPool, closeDbPool } from './db.js';
import { registerDatabaseHandlers } from './handlers/database.js';
import { registerFileSystemHandlers } from './handlers/filesystem.js';

const server = new McpServer({
  name: 'my-mcp-server',
  version: '1.0.0',
});

// Register all handlers (see subsequent steps)
const pool = await createDbPool();
registerDatabaseHandlers(server, pool);
registerFileSystemHandlers(server);

// Stdio transport — Claude Desktop spawns this process
const transport = new StdioServerTransport();

// Graceful shutdown
process.on('SIGTERM', async () => {
  await closeDbPool(pool);
  process.exit(0);
});

await server.connect(transport);

💡

Top-level await requires ES modules

The await server.connect() at the top level works because "type": "module" in package.json makes every .js file an ES module. If you use CommonJS, wrap everything in an async main() function and call it with main().catch(console.error).

Step 3 — Connect to PlanetScale

PlanetScale provides serverless MySQL — connection strings look identical to standard MySQL, so the standard mysql2 driver works out of the box. Create your environment file first:

.envbash

PLANETSCALE_HOST=aws.connect.psdb.cloud
PLANETSCALE_DB=your_database_name
PLANETSCALE_USER=your_username
PLANETSCALE_PASSWORD=pscale_pw_xxxxxxxxxxxx
FS_ROOT=/Users/yourname/Documents/project

Now implement the database connection pool in src/db.ts. We use a pool rather than a single connection so the server handles concurrent tool calls gracefully:

src/db.tstypescript

import mysql from 'mysql2/promise';

export async function createDbPool(): Promise<mysql.Pool> {
  const pool = mysql.createPool({
    host:     process.env.PLANETSCALE_HOST,
    database: process.env.PLANETSCALE_DB,
    user:     process.env.PLANETSCALE_USER,
    password: process.env.PLANETSCALE_PASSWORD,
    ssl:      { rejectUnauthorized: true }, // Required for PlanetScale
    waitForConnections: true,
    connectionLimit:   10,
    queueLimit:        0,
  });

  // Verify connectivity at startup — fail fast
  const conn = await pool.getConnection();
  await conn.ping();
  conn.release();
  return pool;
}

export async function closeDbPool(pool: mysql.Pool): Promise<void> {
  await pool.end();
}

Step 4 — Register Database Resources and Tools

Create src/handlers/database.ts. This module registers three capabilities: a schema resource (Claude can read the DB layout), a table resource (Claude can inspect any table’s data), and a query tool (Claude can run read-only SQL).

src/handlers/database.tstypescript

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import mysql from 'mysql2/promise';
import { z } from 'zod';

export function registerDatabaseHandlers(
  server: McpServer,
  pool: mysql.Pool
) {

  // ── RESOURCE: List all tables (schema overview) ──
  server.resource(
    'db-schema',
    'db://schema',
    {
      name: 'Database Schema',
      description: 'Lists all tables and their columns in the database.',
      mimeType: 'application/json',
    },
    async () => {
      const [rows] = await pool.query<mysql.RowDataPacket[]>(`
        SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE
        FROM INFORMATION_SCHEMA.COLUMNS
        WHERE TABLE_SCHEMA = DATABASE()
        ORDER BY TABLE_NAME, ORDINAL_POSITION
      `);
      return {
        contents: [{
          uri: 'db://schema',
          mimeType: 'application/json',
          text: JSON.stringify(rows, null, 2),
        }],
      };
    }
  );

  // ── RESOURCE TEMPLATE: Fetch a specific table's rows ──
  server.resource(
    'db-table',
    new ResourceTemplate('db://table/{tableName}', { list: undefined }),
    {
      name: 'Database Table',
      description: 'Fetches the first 100 rows from a named table.',
      mimeType: 'application/json',
    },
    async (uri, { tableName }) => {
      // Whitelist table name to prevent SQL injection
      const safe = /^[a-zA-Z0-9_]+$/.test(String(tableName));
      if (!safe) throw new Error('Invalid table name');
      const [rows] = await pool.query(
        `SELECT * FROM \`${tableName}\` LIMIT 100`
      );
      return {
        contents: [{
          uri: uri.toString(),
          mimeType: 'application/json',
          text: JSON.stringify(rows, null, 2),
        }],
      };
    }
  );

  // ── TOOL: Run a parameterised read-only SQL query ──
  server.tool(
    'query-database',
    'Execute a read-only SQL SELECT query against the PlanetScale database.',
    {
      sql:    z.string().describe('A valid MySQL SELECT statement'),
      params: z.array(z.unknown()).optional().describe('Bind parameters'),
    },
    async ({ sql, params = [] }) => {
      // Hard-block mutation queries
      const trimmed = sql.trim().toUpperCase();
      if (!trimmed.startsWith('SELECT'))
        throw new Error('Only SELECT statements are permitted.');

      const [rows] = await pool.query(sql, params);
      return {
        content: [{
          type: 'text',
          text: JSON.stringify(rows, null, 2),
        }],
      };
    }
  );
}

🔒

SQL injection — never interpolate user input

Always use parameterised queries (pool.query(sql, params)) for any user-supplied values. The table-name check (/^[a-zA-Z0-9_]+$/) is not optional — even in a developer-only tool. Treat every value Claude generates as untrusted input.

Step 5 — Register File System Resources and Tools

Create src/handlers/filesystem.ts. The file system integration exposes directory listing, file reading, and file writing — all scoped to a configurable root directory to prevent path traversal attacks:

src/handlers/filesystem.tstypescript

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';

const FS_ROOT = process.env.FS_ROOT ?? process.cwd();

/** Resolves a relative path within FS_ROOT, refusing traversal. */
function safePath(rel: string): string {
  const resolved = path.resolve(FS_ROOT, rel);
  if (!resolved.startsWith(FS_ROOT))
    throw new Error(`Path traversal detected: ${rel}`);
  return resolved;
}

export function registerFileSystemHandlers(server: McpServer) {

  // ── RESOURCE: Directory listing ──
  server.resource(
    'fs-root',
    'file:///',
    { name: 'Project Root', description: 'Lists all files in the project root directory.', mimeType: 'application/json' },
    async () => {
      const entries = await fs.readdir(FS_ROOT, { withFileTypes: true });
      const listing = entries.map((e) => ({
        name:      e.name,
        type:      e.isDirectory() ? 'directory' : 'file',
        uri:       `file:///${e.name}`,
      }));
      return {
        contents: [{ uri: 'file:///', mimeType: 'application/json', text: JSON.stringify(listing, null, 2) }],
      };
    }
  );

  // ── RESOURCE TEMPLATE: Read a file ──
  server.resource(
    'fs-file',
    new ResourceTemplate('file:///{filePath}', { list: undefined }),
    { name: 'File Reader', description: 'Reads a file from the project directory.' },
    async (uri, { filePath }) => {
      const abs = safePath(String(filePath));
      const text = await fs.readFile(abs, 'utf-8');
      return {
        contents: [{ uri: uri.toString(), mimeType: 'text/plain', text }],
      };
    }
  );

  // ── TOOL: Write a file (with confirmation hint) ──
  server.tool(
    'write-file',
    'Write or overwrite a file within the project root. Use with care.',
    {
      filePath: z.string().describe('Relative path from project root'),
      content:  z.string().describe('UTF-8 file content to write'),
    },
    async ({ filePath, content }) => {
      const abs = safePath(filePath);
      await fs.mkdir(path.dirname(abs), { recursive: true });
      await fs.writeFile(abs, content, 'utf-8');
      return {
        content: [{ type: 'text', text: `Written ${content.length} bytes to ${filePath}` }],
      };
    }
  );
}

Step 6 — Register the Server with Claude Desktop

Claude Desktop discovers MCP servers through a JSON configuration file. Its location depends on your OS:

OSConfig file path
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json
Linux~/.config/claude/claude_desktop_config.json

Add your server entry. Claude Desktop will spawn this process when it starts, and terminate it when you quit the app:

claude_desktop_config.jsonjson

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "PLANETSCALE_HOST":     "aws.connect.psdb.cloud",
        "PLANETSCALE_DB":       "your_database_name",
        "PLANETSCALE_USER":     "your_username",
        "PLANETSCALE_PASSWORD":  "pscale_pw_xxxxxxxxxxxx",
        "FS_ROOT":              "/Users/yourname/Documents/project"
      }
    }
  }
}

⚠️

Use compiled output, not tsx in production

Always point Claude Desktop to dist/index.js (compiled TypeScript), not src/index.ts via tsx. The tsx watch runner is for development — it’s slower to start and not suitable for the Claude Desktop lifecycle. Run npm run build before registering.

After saving the config, completely quit and restart Claude Desktop (not just close the window). A hammer icon (🔨) should appear in the input bar, indicating that MCP tools are available.

Read Also: The Ultimate Claude AI Masterclass: From Beginner to Advanced Agentic Workflows (2026 Edition)

Step 7 — Test Your MCP Server

The MCP Inspector is the official debugging tool. It connects to your server independently of Claude and lets you call tools and read resources manually:

terminalbash

# Run the inspector (no install needed)
npx @modelcontextprotocol/inspector node dist/index.js

# Inspector opens at http://localhost:5173
# Connect → Tools → Invoke → Results

Once in Claude Desktop, test your server with these prompts — they exercise all three capability types:

  • 1 “What tables are in my database?”Claude reads the db://schema resource and describes your database structure without any SQL.
  • 2 “How many users signed up last week?”Claude generates a SELECT COUNT(*) query and invokes the query-database tool with the correct parameters.
  • 3 “List all files in my project root”Claude reads the file:/// resource and returns a directory listing scoped to FS_ROOT.
  • 4 “Read the contents of README.md”Claude resolves file:///README.md, calls the file resource handler, and returns the file content.

Security Best Practices and Production Hardening

An MCP server that Claude can write to or query freely is a significant attack surface. Before deploying or sharing your server:

1. Scope permissions by default

Give your MCP server a dedicated read-only database user with SELECT privileges only. Never use a root or admin credential. For file system access, set FS_ROOT to the narrowest directory Claude legitimately needs — never / or ~.

2. Validate and sanitise all inputs

Use Zod schemas for every tool input — the MCP SDK validates them before your handler runs, but adding extra business-logic checks (like the SELECT-only assertion and the regex table-name check above) adds defence in depth.

3. Rate-limit tool invocations

Claude can invoke tools many times per conversation. Implement a per-session token bucket or simple counter to prevent runaway queries from exhausting your database connection pool or PlanetScale’s row-read limits.

4. Log every tool call

Write structured logs (tool name, arguments, duration, result status) to a file or monitoring system. This lets you audit what Claude actually queried during a conversation — essential for debugging and for compliance in production environments.

Human-in-the-loop for write operations

The MCP specification includes a annotations.destructive flag for tools that modify state. Set this on write-file and any INSERT/UPDATE/DELETE tools. Claude Desktop will prompt the user for confirmation before invoking such tools, giving you a natural safety gate for irreversible actions.

5. Use environment variables, never hardcode secrets

The env block in claude_desktop_config.json is the correct place for credentials when running locally. For remote deployments, use a secrets manager (AWS Secrets Manager, 1Password Secrets Automation, or Doppler) and inject values at runtime — never commit credentials to source control.

Read Also: Getting Started with Claude Code: The Complete Guide to Anthropic’s Command-Line Agent (2026)

Common Errors and How to Fix Them

ErrorCauseFix
Server not appearing in Claude DesktopConfig file syntax error or wrong pathValidate JSON at jsonlint.com; use absolute paths
ERR_UNKNOWN_FILE_EXTENSIONRunning .ts files with Node directlyRun npm run build first; point config to dist/
ECONNREFUSED on PlanetScaleSSL not enabled or wrong hostEnsure ssl: { rejectUnauthorized: true } in pool config
Path traversal error on file readsClaude generating paths with ../Expected — safePath() is working correctly
Tool not listed in Claude’s tool menuServer crashed on startupCheck stderr: tail -f ~/Library/Logs/Claude/mcp*.log

Claude Desktop writes MCP server logs to ~/Library/Logs/Claude/ on macOS. The files are named mcp-server-<name>.log and contain both stdout and stderr from your server process — always check these first when debugging connection issues.

What to Build Next

Your MCP server is now a fully independent capability layer that any MCP-compatible client can connect to. Here’s where to take it:

HTTP + SSE Transport

Swap stdio for StreamableHTTPServerTransport to make your server network-accessible — enabling Claude.ai web integrations and multi-user scenarios.

Prompt Templates

Register server.prompt() handlers with Zod-typed parameters so users can trigger pre-built analysis workflows directly from Claude’s prompt menu.

Sampling Callbacks

Use MCP’s sampling API to let your server make its own Claude API calls — enabling autonomous agents that plan, query, and reflect without user interaction.

Multi-server Composition

Register multiple servers in claude_desktop_config.json. Claude transparently composes tools across servers — combine your DB server with a GitHub or Slack server.

Conclusion

You’ve built a production-ready MCP server that gives Claude real-time access to a PlanetScale MySQL database and a scoped local file system — without modifying a single API call or stuffing context into your prompts. The key architectural decisions to remember:

MCP server checklist: stdio transport for local · ES modules with Node 16 resolution · parameterised SQL queries only · path-traversal guard on all file operations · read-only DB user · Zod schemas on every tool input · build before registering with Claude Desktop.

The Model Context Protocol is still young, but its adoption trajectory — already embraced by dozens of developer tools, databases, and API providers — makes it the safest bet for building composable AI integrations that survive model updates and platform changes. Your server works today with Claude, and will work tomorrow with any MCP-compatible model.

The MCP TypeScript SDK on GitHub includes comprehensive examples covering OAuth flows, sampling, and multi-transport setups. The official MCP documentation is the canonical reference for protocol versioning and capability negotiation.

On this page

Article Info

DifficultyAdvanced

Read time~22 min

SDK version^1.0.0

Node.js18 LTS+

Status✓ Production tested

Written for developers building on the Model Context Protocol open standard. Last updated May 2025. Code examples MIT licensed.

Back to top ↑

Olasunkanmi Adeniyi O : Olasunkanmi is a  Product Manager, AI Prompt Engineer, and Technical Writer specializing in advanced automation and digital strategy. As the founder of AI Discoveries, he creates high-performance frameworks and digital operating systems designed to help professionals leverage artificial intelligence, optimize workflows, and build scalable global brands. 

Leave a Reply

Your email address will not be published. Required fields are marked *