Library · Connections: APIs, MCP and running 24/7

MCP Builder: building your own MCP server

Engineer85 minUpdated: October 2026
25 of 105 in the library

Time: about 25 min of theory + 60 min of practice

The commands and packages in this lesson were checked against the official documentation as of October 2026. The SDKs and Claude Code update often: if a command doesn't work, check the MCP docs for Claude Code and modelcontextprotocol.io. Current versions: What's current.


The gist

MCP is like a USB port for Claude. USB is a standard connector: plug in a mouse, a flash drive, a microphone, a printer, and the computer sees them. MCP is a standard protocol: plug in your CRM, your database, your company's API, your file system, and Claude sees them as tools. Today you'll write your own MCP server: 30-50 lines of code, and Claude gains abilities it didn't have before.

🎨 Picture this: MCP = a USB connector for AI. Anthropic created the standard, you build the device. The user just plugs it in. The whole ecosystem works because the connector is always the same.


Key terms

  • MCP (Model Context Protocol): Anthropic's open standard for connecting AI to external tools (modelcontextprotocol.io)
  • MCP server: a program that provides tools, resources and prompts to Claude
  • Transports: stdio (local), HTTP (remote, recommended), SSE (remote, deprecated), WebSocket (only through a JSON config)
  • Three types of objects: tools (actions), resources (data), prompts (templates)
  • Three scopes: local (the default, private), project (through .mcp.json, for a team), user (all your projects)
  • Installation: claude mcp add (CLI), .mcp.json (a file), or through a plugin
  • Token economics: a warning at >10,000 tokens, a default limit of 25,000 tokens per call
  • TypeScript SDK: @modelcontextprotocol/server / Python SDK: pip install "mcp[cli]" (the older TypeScript package @modelcontextprotocol/sdk still shows up in examples)

Theory

How MCP is built

Code
Claude Code (Client)
       │
       │  Standard MCP protocol (JSON-RPC 2.0)
       │  over stdio or HTTP (SSE is deprecated)
       ▼
MCP Server (your code)
       │
       ├── tools    → functions Claude can call
       ├── resources → data Claude can read
       └── prompts  → templates for recurring tasks
       │
       ▼
External system (CRM, database, API, files...)

🎨 Picture this: an MCP server is like the foreman on a construction site. He's always on site while work is going on. Claude calls the foreman ("find this contact"), the foreman goes to the CRM and comes back with the answer. Claude doesn't know how the CRM works inside, only that the foreman knows how to handle it.

The key point: an MCP server is an ordinary program. It starts when Claude Code starts in the project and stays running while the session is open. Claude calls tools through JSON-RPC requests, and the server answers with results.

What you can do with connected MCP servers (from the official documentation):

  • Build a feature from an issue tracker: "Build the feature from JIRA ENG-4521 and open a PR on GitHub"
  • Analyze monitoring: "Check Sentry and show me the errors from the last 24 hours"
  • Query databases: "Find the users who used feature X"
  • Bring in designs: "Update the template based on the new Figma mockups"
  • Automate: "Draft emails to these 10 users"

Three ways to install MCP servers

Option 1: Remote HTTP server (recommended for cloud services)

bash
claude mcp add --transport http notion https://mcp.notion.com/mcp

Option 2: Remote SSE server (deprecated, use HTTP)

bash
claude mcp add --transport sse asana https://mcp.asana.com/sse

Option 3: Local stdio server

bash
claude mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable \
  -- npx -y airtable-mcp-server

Important: all options (--transport, --env, --scope) go before the server name. The -- separates the name from the launch command.

Three scopes

Scope Where it's stored Who can use it When to use it
local (default) ~/.claude.json Only you, only in this project Personal servers, experiments
project .mcp.json in the project root The whole team (through git) Tools shared across the project
user ~/.claude.json You, in every project Personal utilities for all projects
bash
# Add with project scope (for the team)
claude mcp add --transport http --scope project sentry https://mcp.sentry.dev/mcp

If names conflict, the priority is: local > project > user > plugin > claude.ai connectors. Servers set by your organization's admin take priority over all of them.

Managing servers

bash
claude mcp list              # List all servers
claude mcp get github        # Details for a specific server
claude mcp remove github     # Remove a server
/mcp                         # Inside Claude Code: server status and reconnecting

MCP token economics (important for business)

Every MCP server uses up tokens from the context window. This matters a lot for cost:

  • A warning when a single call outputs >10,000 tokens
  • The default limit: 25,000 tokens per MCP tool response
  • Changing the limit: MAX_MCP_OUTPUT_TOKENS=50000 claude
  • Startup timeout: MCP_TIMEOUT=10000 claude (10 seconds)

🎨 Picture this: every MCP server = a passenger on the context bus. It used to be that the descriptions of all its tools took up seats right away. Now tool search is on by default: at startup only the names are loaded, and the details get pulled in when Claude needs them. But call results still take up context. Pick the passengers you really need.

Auto-reconnect: if an HTTP/SSE server disconnects, Claude Code reconnects automatically with exponential backoff (up to 5 attempts). Local stdio servers don't reconnect on their own: restart them through /mcp.


Three types of MCP objects

🎨 Picture this: Tools are a screwdriver (actions). Resources are a blueprint (data to read). Prompts are assembly instructions (a template). Most projects only need the screwdriver.

Tools: actions Claude can perform:

  • get_contact: get a contact from the CRM
  • create_task: create a task
  • send_message: send a message
  • query_database: run a database query

Resources: data Claude can read:

  • crm://contacts/list: the contact list
  • db://reports/monthly: the monthly report
  • file://config/settings: the app config

Prompts (templates): ready-made instructions for common tasks:

  • analyze_deal: a deal analysis template
  • write_followup: a follow-up email template

For most projects, tools alone are enough.


A minimal MCP server: Hello World

Install the SDK:

bash
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src

Add "type": "module" to package.json. Put a tsconfig.json next to it:

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

Create src/server.ts:

typescript
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

// Create the server
const server = new McpServer({
  name: "my-first-mcp",
  version: "1.0.0",
});

// Add a tool: a simple function
server.registerTool(
  "get_weather",                              // Tool name
  {
    description: "Get the weather for a city",           // Description for Claude
    inputSchema: z.object({                              // Parameters (Zod schema)
      city: z.string().describe("City name"),
    }),
  },
  async ({ city }) => {
    // Real logic goes here: an API call, a database query, etc.
    // For the example, a stub
    return {
      content: [{
        type: "text",
        text: `Weather in ${city}: 72°F, cloudy`
      }]
    };
  }
);

// Connect the stdio transport and start
const transport = new StdioServerTransport();
await server.connect(transport);

Important: a stdio server talks to Claude through standard output, so you can't print logs with console.log in it. Use console.error for logs.

Compile and run:

bash
npx tsc
node build/server.js

A real example: an MCP server for a CRM

A complete server that Claude Code uses to work with a fictional CRM through a REST API:

typescript
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const CRM_API_URL = process.env.CRM_API_URL || "https://api.mycrm.com";
const CRM_API_KEY = process.env.CRM_API_KEY || "";

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

// Tool 1: Get a contact by name or email
server.registerTool(
  "get_contact",
  {
    description: "Find a contact in the CRM by name or email address",
    inputSchema: z.object({
      query: z.string().describe("Name or email to search for"),
    }),
  },
  async ({ query }) => {
    const response = await fetch(
      `${CRM_API_URL}/contacts/search?q=${encodeURIComponent(query)}`,
      { headers: { "X-API-Key": CRM_API_KEY } }
    );
    const data = await response.json();

    if (!data.contacts?.length) {
      return { content: [{ type: "text", text: `Contact "${query}" not found` }] };
    }

    const contact = data.contacts[0];
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          id: contact.id,
          name: contact.full_name,
          email: contact.email,
          company: contact.company,
          deal_stage: contact.deal_stage,
          last_contact: contact.last_contact_date,
        }, null, 2)
      }]
    };
  }
);

// Tool 2: Create a task
server.registerTool(
  "create_task",
  {
    description: "Create a task in the CRM linked to a contact",
    inputSchema: z.object({
      contact_id: z.string().describe("Contact ID"),
      title: z.string().describe("Task title"),
      due_date: z.string().describe("Due date in YYYY-MM-DD format"),
      priority: z.enum(["low", "medium", "high"]).describe("Task priority"),
    }),
  },
  async ({ contact_id, title, due_date, priority }) => {
    const response = await fetch(`${CRM_API_URL}/tasks`, {
      method: "POST",
      headers: {
        "X-API-Key": CRM_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ contact_id, title, due_date, priority }),
    });
    const task = await response.json();

    return {
      content: [{
        type: "text",
        text: `Task created. ID: ${task.id}. Due: ${due_date}. Priority: ${priority}.`
      }]
    };
  }
);

// Tool 3: Update the deal stage
server.registerTool(
  "update_deal_stage",
  {
    description: "Update the deal stage for a contact",
    inputSchema: z.object({
      contact_id: z.string().describe("Contact ID"),
      stage: z.enum(["lead", "qualified", "proposal", "negotiation", "closed_won", "closed_lost"])
             .describe("New deal stage"),
      note: z.string().optional().describe("Note about the stage change"),
    }),
  },
  async ({ contact_id, stage, note }) => {
    await fetch(`${CRM_API_URL}/contacts/${contact_id}`, {
      method: "PATCH",
      headers: {
        "X-API-Key": CRM_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ deal_stage: stage, stage_note: note }),
    });

    return {
      content: [{
        type: "text",
        text: `Deal stage updated: ${stage}${note ? `. Note: ${note}` : ""}`
      }]
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Registering it in the project: .mcp.json

To have Claude Code start your MCP server automatically when you open the project, create .mcp.json in the project root:

json
{
  "mcpServers": {
    "crm": {
      "command": "node",
      "args": ["./mcp-servers/crm/build/server.js"],
      "env": {
        "CRM_API_URL": "https://api.mycrm.com",
        "CRM_API_KEY": "${CRM_API_KEY}"
      }
    }
  }
}

After that, the server starts automatically whenever you open Claude Code in this folder. Claude sees the tools get_contact, create_task and update_deal_stage as built-in abilities.

Environment variables in .mcp.json (an official feature):

The ${VAR} and ${VAR:-default} syntax is supported in the command, args, env, url and headers fields:

json
{
  "mcpServers": {
    "api-server": {
      "type": "http",
      "url": "${API_BASE_URL:-https://api.example.com}/mcp",
      "headers": {
        "Authorization": "Bearer ${API_KEY}"
      }
    }
  }
}

This lets you commit .mcp.json to git without secrets: each developer sets their own environment variables.

Registering through the CLI (an alternative):

bash
# As user scope (available in all projects)
claude mcp add --transport stdio --scope user crm -- node ./server.js

# Add from JSON
claude mcp add-json crm '{"command":"node","args":["./server.js"],"env":{"CRM_API_KEY":"${CRM_API_KEY}"}}'

Importing from Claude Desktop (if you've already set it up there; works on macOS and WSL):

bash
claude mcp add-from-claude-desktop

Testing: MCP Inspector

🎨 Picture this: MCP Inspector is like test-driving a car in an empty parking lot. Before you get on the highway (Claude Code), you check that the steering works, the brakes work and the engine isn't knocking. It saves hours of debugging in live use.

The MCP project provides an interactive inspector for testing servers without Claude (it needs Node 22.19 or newer):

bash
npx @modelcontextprotocol/inspector node build/server.js

It opens a web interface where you can:

  • See all registered tools
  • Call each tool by hand with parameters
  • See what the server returns
  • Debug errors

There's also a command-line mode: npx @modelcontextprotocol/inspector --cli node build/server.js --method tools/list shows the list of tools and exits. This is much faster than testing through Claude Code.


Claude Code as an MCP server

Claude Code itself can act as an MCP server for other apps:

bash
claude mcp serve

Connecting from Claude Desktop:

json
{
  "mcpServers": {
    "claude-code": {
      "type": "stdio",
      "command": "claude",
      "args": ["mcp", "serve"],
      "env": {}
    }
  }
}

This gives other AI apps access to Claude Code's tools (Read, Edit, Bash and others).


Attaching MCP servers to subagents

🎨 Picture this: attaching an MCP server to a subagent is like handing tools only to the crew that needs them. The plumber doesn't carry the electrician's tools, just his own. The main conversation doesn't get cluttered with tools that are only needed while one subagent is working.

You can attach MCP servers to a specific subagent with the mcpServers field in its frontmatter:

Type this into the chat
---
name: browser-tester
description: Tests features in a real browser using Playwright
mcpServers:
  - playwright:
      type: stdio
      command: npx
      args: ["-y", "@playwright/mcp@latest"]
---

Inline servers connect when the subagent starts and disconnect when it finishes. The main conversation doesn't see these tools, which saves context.


The Python option: FastMCP

If you prefer Python, there's a more declarative way: the SDK builds the tool description from type annotations and the docstring. In the current documentation the class is called MCPServer (older examples use FastMCP):

python
# pip install "mcp[cli]"   or   uv add "mcp[cli]"
from mcp.server import MCPServer

mcp = MCPServer("crm-server")

@mcp.tool()
def get_contact(query: str) -> str:
    """Find a contact in the CRM by name or email"""
    # Search logic
    return f"Contact found: {query}"

@mcp.tool()
def create_task(contact_id: str, title: str, due_date: str) -> str:
    """Create a task in the CRM"""
    # Task creation logic
    return f"Task created: {title} for {contact_id}"

if __name__ == "__main__":
    mcp.run(transport="stdio")

Registering it in .mcp.json:

json
{
  "mcpServers": {
    "crm-python": {
      "command": "python",
      "args": ["./mcp-servers/crm_server.py"]
    }
  }
}

Publishing to npm

If you want to share your MCP server or use it in several projects:

bash
# package.json
{
  "name": "@yourname/mcp-crm",
  "version": "1.0.0",
  "type": "module",
  "bin": { "mcp-crm": "./server.js" },
  "main": "./server.js"
}

# Publish
npm publish --access public

Once it's published, anyone can use it:

json
{
  "mcpServers": {
    "crm": {
      "command": "npx",
      "args": ["-y", "@yourname/mcp-crm"]
    }
  }
}

MCP resources: @-mentions

MCP servers can provide resources that you reference with @:

Type this into the chat
Analyze @github:issue://123 and suggest a fix
Look at the docs @docs:file://api/authentication

Resources show up in autocomplete next to files when you type @.

Updating tools on the fly

Claude Code supports list_changed notifications from MCP servers. If a server adds or removes tools, Claude Code updates the list automatically without reconnecting.


Practice

Assignment: an MCP server for working with local note files

  1. Create a my-notes-mcp/ folder and set up the project following the Hello World steps above: npm init -y, npm install @modelcontextprotocol/server zod, npm install -D @types/node typescript, "type": "module" and tsconfig.json
  2. Create src/server.ts with three tools:
    • list_notes: lists the files in the ~/Notes/ folder (or any folder of yours)
    • read_note: reads a specific file by name
    • create_note: creates a new file with a note
  3. Compile: npx tsc
  4. Test it with MCP Inspector: npx @modelcontextprotocol/inspector node build/server.js
  5. Register it in the project's .mcp.json
  6. Restart Claude Code and check that the tools showed up
  7. Ask Claude: "Create a note about today's meeting". It should use your tool
  8. Bonus: add a search_notes tool that searches the contents of your notes with grep

Goal: write a working MCP server from scratch, register it and test it through Claude Code.


Tools and resources

  • @modelcontextprotocol/server: npm install @modelcontextprotocol/server, the official TypeScript SDK
  • mcp: pip install "mcp[cli]", the Python SDK (class MCPServer, formerly FastMCP)
  • zod: npm install zod, typing for tool parameters (required for the TypeScript SDK)
  • MCP Inspector: npx @modelcontextprotocol/inspector, testing without Claude
  • Documentation: modelcontextprotocol.io, the protocol specification
  • The official MCP page for Claude Code: https://code.claude.com/docs/en/mcp
  • GitHub: github.com/modelcontextprotocol/servers, hundreds of ready-made servers
  • CLI commands:
    • claude mcp add: add a server
    • claude mcp list: list servers
    • claude mcp get <name>: server details
    • claude mcp remove <name>: remove a server
    • claude mcp add-from-claude-desktop: import from Claude Desktop
    • claude mcp add-json <name> '<json>': add from JSON
    • claude mcp serve: run Claude Code as an MCP server
    • /mcp: server status inside Claude Code (including OAuth authentication)

Key takeaways

An MCP server is an ordinary program in TypeScript or Python. 30-50 lines of code give Claude new tools. The complexity grows only with the complexity of your own logic, not with the MCP protocol.

Transports: stdio (local), HTTP (remote, recommended), SSE (deprecated), WebSocket (through a JSON config). Three scopes: local (the default), project (.mcp.json for the team), user (all projects).

Token economics: MCP call results use up context (tool descriptions are loaded as needed by default). A warning at >10,000 tokens of output. The default limit is 25,000 tokens. You can change it with MAX_MCP_OUTPUT_TOKENS.

Test with MCP Inspector before connecting to Claude: it saves debugging time.

You can attach MCP servers to subagents with the mcpServers field in the frontmatter: the server connects only while the subagent is working and doesn't clutter the main conversation's context.

.mcp.json supports environment variables (${VAR}, ${VAR:-default}), so you can commit it to git without secrets.


Next lesson

→ Portfolio and case studies: how to show your value

The mark stays in this browser only and is never sent anywhere. My progress