← Back to tutorials

How to Build Your Own MCP Server (Complete Tutorial)

From scratch, implement a production-ready custom MCP Server in 30 minutes

How to Build Your Own MCP Server (Complete Tutorial)

Prerequisites: Node.js 18+, basic TypeScript/JavaScript knowledge


Why Do You Need a Custom MCP Server?

The existing 500+ MCP Servers cover most common scenarios, but you might need to:

  • Query internal company APIs (e.g., ERP, CRM systems)
  • Encapsulate access logic for private databases
  • Integrate SaaS tools not in the public list
  • Customize dedicated tools for specific business processes
  • These scenarios require you to write your own MCP Server. The good news is: the MCP SDK is designed to be very concise—writing a basic server takes only ~50 lines of code.


    Core Concepts of MCP Server

    An MCP Server can expose three types of capabilities:

    TypeDescriptionExample

    ToolsFunctions that AI can callSearch, query, execute operations ResourcesData that AI can readFiles, database records, API responses PromptsPredefined prompt templatesCode review template, report format

    This tutorial focuses on the most commonly used Tools.


    Step 1: Initialize the Project

    bash
    mkdir my-mcp-server && cd my-mcp-server
    npm init -y
    npm install @modelcontextprotocol/sdk zod
    npm install -D typescript @types/node tsx
    

    Create tsconfig.json:

    json
    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "Node16",
        "moduleResolution": "Node16",
        "outDir": "./dist",
        "strict": true
      }
    }
    

    Update package.json:

    json
    {
      "type": "module",
      "bin": { "my-mcp-server": "./dist/index.js" },
      "scripts": {
        "build": "tsc",
        "dev": "tsx src/index.ts"
      }
    }
    


    Step 2: Implement a Minimal MCP Server

    Create src/index.ts:

    typescript
    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
    import { z } from 'zod';

    // 1. Create Server instance const server = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });

    // 2. Register a tool server.tool( 'get_weather', // Tool name (AI will use this to call) 'Get real-time weather for a specified city', // Tool description (AI uses to decide when to call) { city: z.string().describe('City name, e.g., "Beijing", "Shanghai"') }, async ({ city }) => { // Call your actual API here const res = await fetch(https://api.weather.example.com/v1/current?city=${city}); const data = await res.json(); return { content: [{ type: 'text', text: Current weather in ${city}: ${data.condition}, temperature ${data.temp}°C, humidity ${data.humidity}% }] }; } );

    // 3. Start the Server const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Server started'); // Note: logs should go to stderr, stdout is for protocol communication


    Step 3: Add Multiple Tools (Practical Example)

    Below is a practical example of querying internal company data:

    typescript
    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
    import { z } from 'zod';

    const server = new McpServer({ name: 'company-internal-api', version: '1.0.0', description: 'Company internal API integration MCP Server' });

    const API_BASE = process.env.INTERNAL_API_URL ?? 'https://api.company.internal'; const API_TOKEN = process.env.INTERNAL_API_TOKEN ?? '';

    // Helper function: fetch with authentication async function apiFetch(path: string, options?: RequestInit) { const res = await fetch(${API_BASE}${path}, { ...options, headers: { 'Authorization': Bearer ${API_TOKEN}, 'Content-Type': 'application/json', ...options?.headers } }); if (!res.ok) throw new Error(API error ${res.status}: ${await res.text()}); return res.json(); }

    // Tool 1: Query customer information server.tool( 'get_customer', 'Query detailed customer information by customer ID or name', { query: z.string().describe('Customer ID (e.g., C001) or customer name'), include_orders: z.boolean().optional().default(false).describe('Whether to include recent orders') }, async ({ query, include_orders }) => { const customer = await apiFetch(/customers/search?q=${encodeURIComponent(query)}); let result = `Customer Info:

  • Name: ${customer.name}
  • Company: ${customer.company}
  • Contact: ${customer.email} / ${customer.phone}
  • Tier: ${customer.tier}
  • Registration Date: ${customer.created_at}`;
  • if (include_orders && customer.id) { const orders = await apiFetch(/customers/${customer.id}/orders?limit=5); result += `

    Recent 5 Orders: ${orders.map((o: any) => - #${o.id} ${o.created_at}: ${o.amount} yuan (${o.status}) ).join('\n')}`; }

    return { content: [{ type: 'text', text: result }] }; } );

    // Tool 2: Check inventory server.tool( 'check_inventory', 'Check stock quantity and estimated arrival time for a specified product', { sku: z.string().describe('Product SKU'), warehouse: z.enum(['beijing', 'shanghai', 'guangzhou', 'all']).default('all').describe('Warehouse to query') }, async ({ sku, warehouse }) => { const inventory = await apiFetch(/inventory/${sku}?warehouse=${warehouse}); const lines = warehouse === 'all' ? inventory.warehouses.map((w: any) => - ${w.name}: ${w.stock} units) : [- Current stock: ${inventory.stock} units]; return { content: [{ type: 'text', text: `Inventory for product ${sku}: ${lines.join('\n')} Total stock: ${inventory.total} units Estimated restock date: ${inventory.restock_date ?? 'No plan yet'}` }] }; } );

    // Tool 3: Create a ticket server.tool( 'create_ticket', 'Create a customer service ticket, suitable for issues requiring human follow-up', { customer_id: z.string().describe('Customer ID'), category: z.enum(['refund', 'delivery', 'product', 'other']).describe('Ticket category'), description: z.string().min(10).describe('Issue description (at least 10 characters)'), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal') }, async ({ customer_id, category, description, priority }) => { const ticket = await apiFetch('/tickets', { method: 'POST', body: JSON.stringify({ customer_id, category, description, priority }) }); return { content: [{ type: 'text', text: `✅ Ticket created successfully!

  • Ticket ID: ${ticket.id}
  • Priority: ${priority}
  • Expected response time: ${ticket.sla_deadline}
  • Assigned to: ${ticket.assigned_to ?? 'Pending assignment'}`
  • }] }; } );

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


    Step 4: Error Handling Best Practices

    typescript
    // ✅ Good error handling: provide AI-friendly error messages
    server.tool('get_customer', '...', { query: z.string() }, async ({ query }) => {
      try {
        const data = await apiFetch(/customers/search?q=${query});
        if (!data || data.length === 0) {
          return {
            content: [{ type: 'text', text: No customer found matching "${query}". Please check the spelling or try other keywords. }],
            isError: true
          };
        }
        return { content: [{ type: 'text', text: formatCustomer(data[0]) }] };
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return {
          content: [{ type: 'text', text: Query failed: ${message}. Please try again later. }],
          isError: true  // Mark as error, AI will know this call failed
        };
      }
    });
    


    Step 5: Test in Claude Desktop

  • Build the project: npm run build
  • Add to Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
  • json
    {
      "mcpServers": {
        "company-api": {
          "command": "node",
          "args": ["/absolute/path/my-mcp-server/dist/index.js"],
          "env": {
            "INTERNAL_API_URL": "https://api.company.internal",
            "INTERNAL_API_TOKEN": "your-token-here"
          }
        }
      }
    }
    

  • Restart Claude Desktop
  • Test:
  • - "Look up customer Zhang San's information" - "Check inventory of SKU-12345 in Shanghai warehouse" - "Create a high-priority refund ticket for customer C001"


    Step 6: Publish as an npm Package (Optional)

    If you want your team members to use it too:

    bash
    

    Modify package.json

    { "name": "@your-org/mcp-server-company", "version": "1.0.0", "bin": { "mcp-server-company": "./dist/index.js" } }

    npm publish --access restricted # Publish to private npm Registry

    Team member configuration:

    json
    {
      "mcpServers": {
        "company-api": {
          "command": "npx",
          "args": ["@your-org/mcp-server-company"],
          "env": { "INTERNAL_API_TOKEN": "..." }
        }
      }
    }
    


    Production Deployment Considerations

    ItemRecommendation

    Secret managementUse environment variables, never hardcode in code Least privilegeOnly expose the interfaces AI truly needs LoggingUse console.error to log to stderr for debugging Timeout controlSet a 30s timeout for each tool call Input validationStrictly validate all parameters with Zod Schema Read-only firstQuery tools should have no side effects Operation confirmationFor write operations (create, delete), mention in the tool description that data will be modified


    FAQ

    Q: Does MCP Server have to be written in TypeScript? A: No, the official Python SDK (mcp package) is available, and a Go SDK is under development.

    Q: Can MCP Server handle concurrent requests? A: stdio mode is serial (one request at a time). For high concurrency, you can use SSE (Server-Sent Events) mode.

    Q: How to debug an MCP Server? A: We recommend MCP Inspector, the official visual debugging tool that allows you to manually test tool calls.

    bash
    npx @modelcontextprotocol/inspector dist/index.js
    

    Open browser at http://localhost:5173

    Also available in 中文.

    How to Build Your Own MCP Server (Complete Tutorial) | AI Skill Navigation | AI Skill Navigation