Skip to content

Building Custom MCP Servers for Claude Code: A Practical Guide

12 min read2238 words

MCP (Model Context Protocol) servers extend Claude Code with custom tools, resources, and prompts. Instead of Claude only having access to your filesystem and terminal, you can give it access to databases, APIs, internal documentation, Slack, GitHub, Jira - anything you can write code to interact with.

I built my first MCP server last month to give Claude access to our internal knowledge base. Now Claude can search our documentation, find relevant code examples, and understand our architecture without me copying and pasting context into every conversation.

This guide walks through building MCP servers from scratch, with practical examples you can adapt for your own needs.


What MCP Servers Actually Are

An MCP server is a program that exposes capabilities to Claude through a standardized protocol. The server runs locally (or remotely) and communicates with Claude Code via JSON-RPC over stdio or HTTP.

How the architecture works:

ComponentRoleCommunication
Claude CodeMCP Host - manages conversations and file accessSends JSON-RPC requests
MCP ServerYour custom code - exposes tools and resourcesResponds via stdio or HTTP
External ServicesDatabases, APIs, Slack, GitHub, etc.Called by your MCP server

MCP servers can expose three types of capabilities:

CapabilityDescriptionExample
ToolsFunctions Claude can callSearch database, send Slack message
ResourcesData Claude can readDocumentation, config files
PromptsPre-defined prompt templatesCode review checklist

Most servers focus on tools since they provide the most flexibility.


Project Setup

Create a new directory and initialize the project:

mkdir my-mcp-server
cd my-mcp-server
npm init -y

Install the MCP SDK and dependencies:

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Update package.json:

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-mcp-server": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod 755 build/index.js",
    "dev": "npx tsx src/index.ts"
  },
  "files": ["build"]
}

Your First MCP Server

Create src/index.ts with a simple calculator tool:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
const server = new Server(
  {
    name: "my-mcp-server",
    version: "1.0.0"
  },
  {
    capabilities: {
      tools: {}
    }
  }
);
 
server.setRequestHandler("tools/list", async () => {
  return {
    tools: [
      {
        name: "calculate",
        description: "Perform basic arithmetic operations",
        inputSchema: {
          type: "object",
          properties: {
            operation: {
              type: "string",
              enum: ["add", "subtract", "multiply", "divide"],
              description: "The operation to perform"
            },
            a: { type: "number", description: "First operand" },
            b: { type: "number", description: "Second operand" }
          },
          required: ["operation", "a", "b"]
        }
      }
    ]
  };
});
 
server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;
 
  if (name === "calculate") {
    const { operation, a, b } = args as {
      operation: string;
      a: number;
      b: number;
    };
 
    let result: number;
    switch (operation) {
      case "add":
        result = a + b;
        break;
      case "subtract":
        result = a - b;
        break;
      case "multiply":
        result = a * b;
        break;
      case "divide":
        if (b === 0) {
          return {
            content: [{ type: "text", text: "Error: Division by zero" }],
            isError: true
          };
        }
        result = a / b;
        break;
      default:
        return {
          content: [{ type: "text", text: "Unknown operation" }],
          isError: true
        };
    }
 
    return {
      content: [{ type: "text", text: `${a} ${operation} ${b} = ${result}` }]
    };
  }
 
  return {
    content: [{ type: "text", text: "Unknown tool" }],
    isError: true
  };
});
 
const transport = new StdioServerTransport();
await server.connect(transport);

Build and test:

npm run build

Registering With Claude Code

Add your server to Claude Code's configuration. Edit ~/.claude/settings.json or .claude/settings.json in your project:

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

Restart Claude Code. You should see the MCP server icon indicating connection.


Real-World Use Case: Slack Notification Server

Here is a practical MCP server I actually use - it lets Claude send Slack messages to notify me about task completion, errors, or anything that needs my attention.

Why this is useful: When Claude is running long tasks in the background (tests, builds, deployments), I want to be notified when something needs my attention without watching the terminal.

Install dependencies:

npm install @slack/web-api

Create src/index.ts:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { WebClient } from "@slack/web-api";
 
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const DEFAULT_CHANNEL = process.env.SLACK_DEFAULT_CHANNEL || "#claude-alerts";
 
if (!SLACK_TOKEN) {
  console.error("SLACK_BOT_TOKEN environment variable required");
  process.exit(1);
}
 
const slack = new WebClient(SLACK_TOKEN);
 
const server = new Server(
  { name: "slack-notifier", version: "1.0.0" },
  { capabilities: { tools: {} } }
);
 
server.setRequestHandler("tools/list", async () => ({
  tools: [
    {
      name: "send_slack_message",
      description: "Send a message to a Slack channel",
      inputSchema: {
        type: "object",
        properties: {
          message: {
            type: "string",
            description: "The message to send"
          },
          channel: {
            type: "string",
            description: "Slack channel (defaults to #claude-alerts)"
          },
          urgency: {
            type: "string",
            enum: ["info", "warning", "error"],
            description: "Message urgency level"
          }
        },
        required: ["message"]
      }
    },
    {
      name: "send_slack_code_block",
      description: "Send a code block to Slack with syntax highlighting",
      inputSchema: {
        type: "object",
        properties: {
          code: { type: "string", description: "The code to share" },
          language: { type: "string", description: "Programming language" },
          title: { type: "string", description: "Title for the code block" },
          channel: { type: "string", description: "Slack channel" }
        },
        required: ["code"]
      }
    }
  ]
}));
 
server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;
 
  if (name === "send_slack_message") {
    const { message, channel, urgency } = args as {
      message: string;
      channel?: string;
      urgency?: string;
    };
 
    const emoji = {
      info: ":information_source:",
      warning: ":warning:",
      error: ":rotating_light:"
    }[urgency || "info"];
 
    try {
      await slack.chat.postMessage({
        channel: channel || DEFAULT_CHANNEL,
        text: `${emoji} *Claude Code Alert*\n${message}`,
        mrkdwn: true
      });
 
      return {
        content: [{ type: "text", text: `Message sent to ${channel || DEFAULT_CHANNEL}` }]
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Slack error: ${(error as Error).message}` }],
        isError: true
      };
    }
  }
 
  if (name === "send_slack_code_block") {
    const { code, language, title, channel } = args as {
      code: string;
      language?: string;
      title?: string;
      channel?: string;
    };
 
    try {
      const header = title ? `*${title}*\n` : "";
      const codeBlock = "```" + (language || "") + "\n" + code + "\n```";
 
      await slack.chat.postMessage({
        channel: channel || DEFAULT_CHANNEL,
        text: `${header}${codeBlock}`,
        mrkdwn: true
      });
 
      return {
        content: [{ type: "text", text: "Code block sent to Slack" }]
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Slack error: ${(error as Error).message}` }],
        isError: true
      };
    }
  }
 
  return {
    content: [{ type: "text", text: "Unknown tool" }],
    isError: true
  };
});
 
const transport = new StdioServerTransport();
await server.connect(transport);

Configuration in Claude Code:

{
  "mcpServers": {
    "slack-notifier": {
      "command": "node",
      "args": ["/path/to/slack-notifier/build/index.js"],
      "env": {
        "SLACK_BOT_TOKEN": "xoxb-your-token-here",
        "SLACK_DEFAULT_CHANNEL": "#dev-alerts"
      }
    }
  }
}

How I use it:

  • "Run the full test suite and send me a Slack message when it's done"
  • "If any tests fail, send the failure details to Slack with error urgency"
  • "Send the updated API response format to #backend-team as a code block"

Real-World Use Case: GitHub Integration

Another server I use daily - it gives Claude access to GitHub issues, PRs, and repository information without me needing to copy-paste URLs.

Install dependencies:

npm install @octokit/rest

Create src/index.ts:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Octokit } from "@octokit/rest";
 
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
  console.error("GITHUB_TOKEN environment variable required");
  process.exit(1);
}
 
const octokit = new Octokit({ auth: GITHUB_TOKEN });
 
const server = new Server(
  { name: "github-tools", version: "1.0.0" },
  { capabilities: { tools: {} } }
);
 
server.setRequestHandler("tools/list", async () => ({
  tools: [
    {
      name: "get_issue",
      description: "Get details of a GitHub issue",
      inputSchema: {
        type: "object",
        properties: {
          owner: { type: "string", description: "Repository owner" },
          repo: { type: "string", description: "Repository name" },
          issue_number: { type: "number", description: "Issue number" }
        },
        required: ["owner", "repo", "issue_number"]
      }
    },
    {
      name: "list_open_issues",
      description: "List open issues in a repository",
      inputSchema: {
        type: "object",
        properties: {
          owner: { type: "string", description: "Repository owner" },
          repo: { type: "string", description: "Repository name" },
          labels: { type: "string", description: "Filter by labels (comma-separated)" },
          limit: { type: "number", description: "Max issues to return (default 10)" }
        },
        required: ["owner", "repo"]
      }
    },
    {
      name: "get_pr_diff",
      description: "Get the diff of a pull request",
      inputSchema: {
        type: "object",
        properties: {
          owner: { type: "string", description: "Repository owner" },
          repo: { type: "string", description: "Repository name" },
          pull_number: { type: "number", description: "PR number" }
        },
        required: ["owner", "repo", "pull_number"]
      }
    },
    {
      name: "create_issue",
      description: "Create a new GitHub issue",
      inputSchema: {
        type: "object",
        properties: {
          owner: { type: "string", description: "Repository owner" },
          repo: { type: "string", description: "Repository name" },
          title: { type: "string", description: "Issue title" },
          body: { type: "string", description: "Issue body" },
          labels: {
            type: "array",
            items: { type: "string" },
            description: "Labels to add"
          }
        },
        required: ["owner", "repo", "title"]
      }
    }
  ]
}));
 
server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;
 
  try {
    if (name === "get_issue") {
      const { owner, repo, issue_number } = args as {
        owner: string;
        repo: string;
        issue_number: number;
      };
 
      const { data } = await octokit.issues.get({
        owner,
        repo,
        issue_number
      });
 
      return {
        content: [{
          type: "text",
          text: `**#${data.number}: ${data.title}**\n\nState: ${data.state}\nAuthor: ${data.user?.login}\nLabels: ${data.labels.map((l: any) => l.name).join(", ") || "none"}\n\n${data.body || "No description"}`
        }]
      };
    }
 
    if (name === "list_open_issues") {
      const { owner, repo, labels, limit } = args as {
        owner: string;
        repo: string;
        labels?: string;
        limit?: number;
      };
 
      const { data } = await octokit.issues.listForRepo({
        owner,
        repo,
        state: "open",
        labels,
        per_page: limit || 10
      });
 
      const issueList = data
        .map((i) => `- #${i.number}: ${i.title} (${i.labels.map((l: any) => l.name).join(", ")})`)
        .join("\n");
 
      return {
        content: [{
          type: "text",
          text: `**Open Issues in ${owner}/${repo}:**\n\n${issueList || "No open issues"}`
        }]
      };
    }
 
    if (name === "get_pr_diff") {
      const { owner, repo, pull_number } = args as {
        owner: string;
        repo: string;
        pull_number: number;
      };
 
      const { data } = await octokit.pulls.get({
        owner,
        repo,
        pull_number,
        mediaType: { format: "diff" }
      });
 
      return {
        content: [{ type: "text", text: data as unknown as string }]
      };
    }
 
    if (name === "create_issue") {
      const { owner, repo, title, body, labels } = args as {
        owner: string;
        repo: string;
        title: string;
        body?: string;
        labels?: string[];
      };
 
      const { data } = await octokit.issues.create({
        owner,
        repo,
        title,
        body,
        labels
      });
 
      return {
        content: [{
          type: "text",
          text: `Created issue #${data.number}: ${data.html_url}`
        }]
      };
    }
  } catch (error) {
    return {
      content: [{ type: "text", text: `GitHub error: ${(error as Error).message}` }],
      isError: true
    };
  }
 
  return {
    content: [{ type: "text", text: "Unknown tool" }],
    isError: true
  };
});
 
const transport = new StdioServerTransport();
await server.connect(transport);

How I use it:

  • "What are the open bugs in our main repo?"
  • "Show me the diff for PR #142 and summarize the changes"
  • "Create a bug report for the login timeout issue we just discussed"

Testing With the MCP Inspector

Before connecting to Claude Code, test your server with the official inspector:

npx @modelcontextprotocol/inspector node ./build/index.js

This opens a web interface where you can list tools, call them with custom inputs, and see raw request/response payloads.


Dynamic Tools with list_changed

Claude Code 2.1 introduced dynamic tool updates. Your server can add or remove tools at runtime:

let availableTools = ["basic_tool"];
 
server.setRequestHandler("tools/list", async () => ({
  tools: availableTools.map((name) => ({
    name,
    description: `Tool: ${name}`,
    inputSchema: { type: "object", properties: {} }
  }))
}));
 
async function addTool(name: string) {
  availableTools.push(name);
  await server.notification({
    method: "notifications/tools/list_changed"
  });
}

When Claude Code receives the notification, it re-fetches the tool list without reconnection.


Error Handling

Return errors gracefully so Claude can recover:

server.setRequestHandler("tools/call", async (request) => {
  try {
    const result = await doSomethingRisky();
    return {
      content: [{ type: "text", text: result }]
    };
  } catch (error) {
    return {
      content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
      isError: true
    };
  }
});

The isError: true flag tells Claude the tool failed, allowing it to try alternative approaches.


Security Considerations

Treat MCP servers like API keys. Anyone with access to your server can run any tool you expose.

Validate inputs. Use Zod or similar validation. Never trust that Claude sent valid data.

Limit capabilities. A database tool should only allow SELECT queries. A file tool should restrict paths.

Use environment variables for secrets. Never hardcode tokens or credentials.

const API_KEY = process.env.MY_API_KEY;
if (!API_KEY) {
  console.error("MY_API_KEY environment variable required");
  process.exit(1);
}

Publishing to the MCP Registry

Once your server is stable, consider publishing to the MCP Registry (now containing nearly 2,000 servers):

  1. Create a GitHub repository for your server
  2. Add a mcp.json manifest with name, version, description, and tool list
  3. Submit via the registry contribution process

Resources