MCP Claude Code TypeScript developer tools extensions API build

Writing Your Own MCP Server for Claude Code: A Practical Guide

The Prompt Shelf ·

The Model Context Protocol (MCP) lets you add custom tools to Claude Code that it can call the same way it calls built-in tools. Your tool does anything a Node.js process can do — query a database, call an internal API, read a proprietary file format, run a domain-specific calculation — and exposes it to Claude Code as a structured tool call.

This guide walks through building a working MCP server from scratch, connecting it to Claude Code, and the patterns that make custom tools useful rather than annoying.

What MCP Servers Actually Are

An MCP server is a process that communicates with Claude Code over stdio (or HTTP/SSE for remote servers). When Claude Code starts, it launches the MCP servers you have configured, sends a tools/list request to each, and learns what tools are available. From that point, whenever Claude Code wants to use one of your tools, it sends a structured JSON-RPC call, your server executes it, and returns the result.

The interaction is:

Claude Code → JSON-RPC request → MCP Server process
Claude Code ← JSON-RPC response ← MCP Server process

Your server process stays running for the duration of the Claude Code session. It is not a one-shot script.

Project Setup

Install the MCP TypeScript SDK:

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 NodeNext --moduleResolution NodeNext

Update tsconfig.json to add "outDir": "dist".

Basic Server Structure

// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
  ErrorCode,
  McpError,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "my-project-tools", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

// Register available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_database_schema",
      description: "Returns the current database schema including table names, columns, and relationships.",
      inputSchema: {
        type: "object",
        properties: {},
        required: [],
      },
    },
    {
      name: "query_feature_flags",
      description: "Returns the current state of all feature flags. Useful before implementing features that might be behind a flag.",
      inputSchema: {
        type: "object",
        properties: {
          environment: {
            type: "string",
            enum: ["development", "staging", "production"],
            description: "Which environment's flags to query",
          },
        },
        required: ["environment"],
      },
    },
  ],
}));

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  switch (request.params.name) {
    case "get_database_schema":
      return await getDatabaseSchema();

    case "query_feature_flags": {
      const { environment } = request.params.arguments as { environment: string };
      return await queryFeatureFlags(environment);
    }

    default:
      throw new McpError(
        ErrorCode.MethodNotFound,
        `Unknown tool: ${request.params.name}`
      );
  }
});

async function getDatabaseSchema() {
  // Your implementation here
  const schema = {
    tables: [
      { name: "users", columns: ["id", "email", "created_at"], relationships: ["orders(user_id)"] },
      { name: "orders", columns: ["id", "user_id", "total", "status", "created_at"], relationships: ["order_items(order_id)"] },
    ]
  };

  return {
    content: [
      {
        type: "text",
        text: JSON.stringify(schema, null, 2),
      },
    ],
  };
}

async function queryFeatureFlags(environment: string) {
  // Fetch from your internal feature flag service
  const flags = await fetchFlags(environment); // your implementation

  return {
    content: [
      {
        type: "text",
        text: `Feature flags for ${environment}:\n${flags.map(f => `- ${f.name}: ${f.enabled}`).join("\n")}`,
      },
    ],
  };
}

// Start the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio"); // use stderr, not stdout
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

Two important details:

  • Log to stderr, not stdout. Stdout is the JSON-RPC communication channel. Anything you write to stdout that is not valid JSON-RPC will break the protocol.
  • Use McpError with appropriate ErrorCode for errors. Do not throw generic errors — they don’t map cleanly to MCP responses.

Tool Schema Design

The inputSchema is a JSON Schema object that Claude Code uses to understand what your tool accepts. Write it carefully — it affects both how Claude Code calls your tool and how it describes the tool to the user.

{
  name: "search_codebase",
  description: "Searches the codebase for a pattern. Use this before writing new code to check if similar functionality already exists.",
  inputSchema: {
    type: "object",
    properties: {
      pattern: {
        type: "string",
        description: "The search pattern. Supports ripgrep regex syntax.",
      },
      path: {
        type: "string",
        description: "Directory to search in, relative to project root. Defaults to entire project.",
      },
      fileTypes: {
        type: "array",
        items: { type: "string" },
        description: "Limit search to these file extensions (e.g., [\"ts\", \"tsx\"]). Omit to search all files.",
      },
    },
    required: ["pattern"],
  },
}

Guidelines for good tool schemas:

  • description on the tool itself: explain when to use it, not just what it does. “Use this before writing new code to check…” gives Claude Code the context to call the right tool at the right time.
  • description on each parameter: describe the format and constraints, not just the name.
  • Mark only truly required parameters as required. Optional parameters with sensible defaults produce more natural tool calls.
  • Use enum for parameters with a fixed set of valid values.

Error Handling

Two error categories: errors from your tool logic, and errors in the request itself.

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    switch (name) {
      case "my_tool": {
        // Validate arguments
        if (!args || typeof args.requiredParam !== "string") {
          throw new McpError(
            ErrorCode.InvalidParams,
            "requiredParam must be a string"
          );
        }

        const result = await doWork(args.requiredParam);
        return { content: [{ type: "text", text: result }] };
      }

      default:
        throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
    }
  } catch (error) {
    if (error instanceof McpError) throw error;

    // Convert unexpected errors to MCP errors
    throw new McpError(
      ErrorCode.InternalError,
      `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
    );
  }
});

Return tool errors as structured content when the error is meaningful to the user (not a bug):

// When the tool ran but got a "not found" result — return it as content, not an error
return {
  content: [
    {
      type: "text",
      text: `No results found for "${query}". Try a broader search term or check the spelling.`,
    },
  ],
};

Reserve McpError for cases where the tool could not execute at all.

Connecting to Claude Code

Add the server to .claude/settings.json:

{
  "mcpServers": {
    "my-project-tools": {
      "command": "npx",
      "args": ["tsx", "/path/to/my-mcp-server/src/index.ts"],
      "env": {
        "DATABASE_URL": "${DATABASE_URL}",
        "FEATURE_FLAGS_API_KEY": "${FEATURE_FLAGS_API_KEY}"
      }
    }
  }
}

For a compiled server:

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

The env field passes environment variables from the Claude Code process to your server. Use it for credentials — never hardcode them.

Making Tools Actually Useful

The difference between a useful MCP tool and one that gets ignored:

Be specific about when to use the tool. A description that says “fetches user data” gives Claude Code no signal about when to call it. “Fetches a user’s subscription status and feature entitlements. Call this before implementing any feature that might vary by subscription tier” tells Claude Code exactly when to reach for it.

Return structured, relevant information. If your tool returns 500 lines of raw JSON, Claude Code will have to parse it to extract what it needs. Pre-format the output for the expected use case:

// Less useful: dump raw data
return { content: [{ type: "text", text: JSON.stringify(rawApiResponse) }] };

// More useful: curated summary
const summary = formatForContext(rawApiResponse);
return {
  content: [{
    type: "text",
    text: `Subscription: ${summary.tier}\nFeatures enabled: ${summary.features.join(", ")}\nRenewal: ${summary.renewalDate}`,
  }],
};

Keep tools focused. A tool that does three things is harder for Claude Code to invoke correctly than three tools that do one thing each. The overhead of an extra MCP call is negligible. The benefit of a clear tool interface is significant.

Resources (Read-Only Context)

In addition to tools, MCP servers can expose resources — static or dynamic content that Claude Code can read as context. Resources are not interactive (no input parameters), but they are useful for documentation, configuration, and reference data:

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "docs://architecture/decisions",
      name: "Architecture Decision Records",
      description: "Current ADRs for this project",
      mimeType: "text/markdown",
    },
  ],
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  if (request.params.uri === "docs://architecture/decisions") {
    const adrs = await loadADRs(); // read from your docs folder
    return { contents: [{ uri: request.params.uri, text: adrs, mimeType: "text/markdown" }] };
  }
  throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${request.params.uri}`);
});

Resources are good for project documentation that Claude Code should be able to read but that is too long to put in CLAUDE.md.

Debugging MCP Servers

When a tool is not working as expected:

# Run the server directly and send a test request
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | npx tsx src/index.ts

# Check what Claude Code is actually sending
# Add debug logging to stderr in your handlers
console.error("Received request:", JSON.stringify(request.params, null, 2));

Claude Code also logs MCP communication if you run it with --debug. Check the output for malformed requests or unexpected responses.

Summary

A custom MCP server lets you extend Claude Code with tools that know your specific codebase. The investment is a few hours of TypeScript to write the server, and the return is Claude Code that can query your database schema, check feature flags, search internal documentation, or run any domain-specific operation — the same way it uses its built-in tools.

The key practices:

  • Log to stderr, never stdout
  • Write specific tool descriptions that include when to use the tool
  • Return curated output, not raw dumps
  • Use McpError for error responses
  • Commit .claude/settings.json so the whole team gets the tools

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules