Development

Bikin Custom MCP Server untuk Claude Code: Panduan Developer

Asep Alazhari

Pelajari cara bikin MCP server custom yang powerful dengan TypeScript. Contoh real-world: GitHub integration, business logic automation, dan API orchestration yang ngirit 15+ jam per minggu.

Bikin Custom MCP Server untuk Claude Code: Panduan Developer

Bulan lalu, gue nemuin diri sendiri jelasin API endpoint yang sama ke Claude untuk ke-47 kalinya. Copy-paste dokumentasi. Tunggu Claude nyerna. Benerin kesalahpahaman yang pasti ada. Ulang terus.

Terus gue nemuin kalau gue bisa ngajarin Claude sekali aja dengan bikin custom MCP server.

Hasilnya? Server pertama gue butuh 3 jam untuk bikin. Sekarang ngirit waktu gue 15+ jam tiap minggu. Claude langsung ngerti internal API kita, business rules, dan struktur data—tanpa perlu penjelasan lagi.

Kalau lo pernah berharap Claude bisa langsung akses database, API, atau internal tools lo, panduan ini buat lo. Gue bakal tunjukin cara bikin production-ready MCP server dengan contoh real-world yang beneran solve masalah.

Kenapa Harus Bikin Custom MCP Server?

Sebelum kita masuk ke code, mari kita bahas kapan bikin custom server itu masuk akal.

Kapan Lo Harus Bikin

Lo punya workflow repetitif dengan banyak context-switching. Kalau lo terus-terusan copy data antara Claude dan sistem lain—database, API, internal tools—custom server ngilangin friction itu.

Lo perlu integrasi sistem proprietary. Internal API lo, custom database schema, atau business logic engine lo nggak bakal punya pre-built MCP server. Bikin sendiri solusinya.

Lo pengen interaksi yang type-safe dan tervalidasi. Beda sama free-form prompt, MCP server pakai Zod schema untuk strict validation. Claude nggak bisa kirim request yang salah format, dan lo kontrol persis data apa yang masuk dan keluar.

Lo perlu agregasi banyak data source. Salah satu server gue kombinasiin PostgreSQL database kita, Stripe API, dan internal analytics service. Claude dapet unified view tanpa perlu tahu kompleksitas di baliknya.

Decision Matrix

Ini kapan harus bikin versus kapan pakai existing server:

SkenarioBikin CustomPakai Existing
Public API (GitHub, Stripe, dll)TidakYa - Cek MCP servers registry dulu
Sistem internal/proprietaryYaTidak - Nggak ada pilihan—harus custom
Business logic kompleksYaTidak - Rules lo, code lo
Operasi file sederhanaTidakYa - Pakai @modelcontextprotocol/server-filesystem
Query databaseMungkinMungkin - Mulai dari existing, customize kalau perlu

Value Proposition untuk Bisnis

Gue kasih angka spesifik dari pengalaman gue sendiri:

Sebelum custom MCP server:

  • 10 menit untuk generate customer quote (manual data gathering)
  • 25 menit untuk analisa pull request (GitHub UI + manual review)
  • 30+ context-switching interruptions per hari

Sesudah custom MCP server:

  • 30 detik untuk generate quote (automated pricing rules)
  • 2 menit untuk analisa PR (automated metrics + code review)
  • 3-5 context switches per hari (Claude handle sisanya)

ROI: 3 jam untuk bikin server pertama, 15+ jam hemat tiap minggu. Break-even dalam 12 hari.

Kalau lo masih baca sampai sini, lo pasti udah punya use case di pikiran. Yuk kita bikin.

Technical Foundation

Prerequisites

Sebelum kita mulai coding, pastikan lo punya:

  • Node.js v18+ (v20+ recommended)
  • TypeScript knowledge (intermediate level)
  • Claude Desktop installed dan configured
  • Basic understanding async/await dan JSON schemas

Kalau lo udah setup MCP dengan Claude Desktop, lo udah siap.

Project Setup

Bikin project baru dan install dependencies:

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

Konfigurasi krusial: Setup package.json lo sebagai ES Module. Ini mistake #1 yang gue liat developer sering bikin:

{
    "name": "my-custom-mcp-server",
    "version": "1.0.0",
    "type": "module",
    "main": "build/index.js",
    "scripts": {
        "build": "tsc",
        "dev": "tsx src/index.ts"
    },
    "dependencies": {
        "@modelcontextprotocol/sdk": "^1.0.0",
        "zod": "^3.25.0"
    },
    "devDependencies": {
        "@types/node": "^20.0.0",
        "typescript": "^5.7.0",
        "tsx": "^4.19.2"
    }
}

Bikin tsconfig.json:

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

Kenapa ES Modules penting: MCP SDK pakai ES Module syntax (import .js). Kalau lo lupa "type": "module" di package.json, lo bakal dapet error import yang cryptic yang buang-buang waktu berjam-jam. Percaya deh.

Server Pertama Lo: Calculator

Mari kita bikin simple calculator server untuk ngerti core concepts. Server ini bakal expose basic math operations ke Claude.

Bikin src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as z from "zod";

// Initialize the server
const server = new McpServer({
    name: "calculator-server",
    version: "1.0.0",
});

// Register the 'add' tool
server.registerTool(
    "add",
    {
        title: "Add Numbers",
        description: "Add two numbers together",
        inputSchema: {
            a: z.number().describe("First number"),
            b: z.number().describe("Second number"),
        },
        outputSchema: {
            result: z.number(),
            operation: z.string(),
        },
    },
    async ({ a, b }) => {
        const output = {
            result: a + b,
            operation: "addition",
        };

        return {
            content: [
                {
                    type: "text",
                    text: `${a} + ${b} = ${output.result}`,
                },
            ],
            structuredContent: output,
        };
    }
);

// Register the 'multiply' tool
server.registerTool(
    "multiply",
    {
        title: "Multiply Numbers",
        description: "Multiply two numbers together",
        inputSchema: {
            a: z.number().describe("First number"),
            b: z.number().describe("Second number"),
        },
        outputSchema: {
            result: z.number(),
            operation: z.string(),
        },
    },
    async ({ a, b }) => {
        const output = {
            result: a * b,
            operation: "multiplication",
        };

        return {
            content: [
                {
                    type: "text",
                    text: `${a} × ${b} = ${output.result}`,
                },
            ],
            structuredContent: output,
        };
    }
);

// Connect via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

console.error("Calculator MCP Server running on stdio");

Konsep kunci dijelasin:

  1. Server initialization: McpServer nerima name dan version. Ini muncul di MCP server list Claude.

  2. Tool registration: Setiap tool punya:

    • Unique name ('add', 'multiply')
    • Metadata (title, description)
    • Input schema (Zod validators)
    • Output schema (apa yang Claude terima balik)
    • Handler function (business logic lo)
  3. Stdio transport: Untuk local server, kita pakai StdioServerTransport. Claude Desktop spawn server lo sebagai child process dan komunikasi via standard input/output.

  4. Response format: Tools return:

    • content: Human-readable text untuk Claude
    • structuredContent: Typed data matching output schema lo

Build dan test:

npm run build
node build/index.js

Lo harus liat “Calculator MCP Server running on stdio” di console. Tekan Ctrl+C untuk stop.

Contoh Real-World 1: GitHub PR Analyzer

Sekarang mari kita bikin sesuatu yang praktis. Server ini analisa GitHub pull request dengan agregasi data dari multiple API endpoint.

Use case: Sebelum review PR, gue pengen Claude kasih summary: changed files, test coverage, CI status, dan review comments. Manual gathering ini butuh 10+ menit. Dengan server ini, instant.

Install GitHub API client:

npm install @octokit/rest

Bikin src/github-server.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Octokit } from "@octokit/rest";
import * as z from "zod";

// Initialize GitHub client
const octokit = new Octokit({
    auth: process.env.GITHUB_TOKEN,
});

const server = new McpServer({
    name: "github-pr-analyzer",
    version: "1.0.0",
});

server.registerTool(
    "analyze-pr",
    {
        title: "Analyze GitHub Pull Request",
        description: "Get comprehensive analysis of a GitHub PR including files, reviews, and CI status",
        inputSchema: {
            owner: z.string().describe("Repository owner (username or org)"),
            repo: z.string().describe("Repository name"),
            pr_number: z.number().describe("Pull request number"),
        },
        outputSchema: {
            pr: z.object({
                title: z.string(),
                state: z.string(),
                author: z.string(),
                created_at: z.string(),
                mergeable: z.boolean().nullable(),
            }),
            files: z.array(
                z.object({
                    filename: z.string(),
                    additions: z.number(),
                    deletions: z.number(),
                    status: z.string(),
                })
            ),
            reviews: z.array(
                z.object({
                    user: z.string(),
                    state: z.string(),
                    submitted_at: z.string(),
                })
            ),
            checks: z.object({
                total: z.number(),
                passed: z.number(),
                failed: z.number(),
                pending: z.number(),
            }),
        },
    },
    async ({ owner, repo, pr_number }) => {
        try {
            // Fetch PR details
            const { data: pr } = await octokit.pulls.get({
                owner,
                repo,
                pull_number: pr_number,
            });

            // Fetch changed files
            const { data: files } = await octokit.pulls.listFiles({
                owner,
                repo,
                pull_number: pr_number,
            });

            // Fetch reviews
            const { data: reviews } = await octokit.pulls.listReviews({
                owner,
                repo,
                pull_number: pr_number,
            });

            // Fetch CI checks
            const { data: checks } = await octokit.checks.listForRef({
                owner,
                repo,
                ref: pr.head.sha,
            });

            // Aggregate check statuses
            const checkSummary = checks.check_runs.reduce(
                (acc, check) => {
                    acc.total++;
                    if (check.conclusion === "success") acc.passed++;
                    else if (check.conclusion === "failure") acc.failed++;
                    else acc.pending++;
                    return acc;
                },
                { total: 0, passed: 0, failed: 0, pending: 0 }
            );

            const output = {
                pr: {
                    title: pr.title,
                    state: pr.state,
                    author: pr.user?.login || "unknown",
                    created_at: pr.created_at,
                    mergeable: pr.mergeable,
                },
                files: files.map((f) => ({
                    filename: f.filename,
                    additions: f.additions,
                    deletions: f.deletions,
                    status: f.status,
                })),
                reviews: reviews.map((r) => ({
                    user: r.user?.login || "unknown",
                    state: r.state,
                    submitted_at: r.submitted_at || "",
                })),
                checks: checkSummary,
            };

            return {
                content: [
                    {
                        type: "text",
                        text:
                            `**${pr.title}** by @${pr.user?.login}\n\n` +
                            `State: ${pr.state} | Mergeable: ${pr.mergeable}\n` +
                            `Files changed: ${files.length}\n` +
                            `Reviews: ${reviews.length}\n` +
                            `CI: ${checkSummary.passed}/${checkSummary.total} passed`,
                    },
                ],
                structuredContent: output,
            };
        } catch (error) {
            return {
                content: [
                    {
                        type: "text",
                        text: `Error analyzing PR: ${error instanceof Error ? error.message : "Unknown error"}`,
                    },
                ],
                isError: true,
            };
        }
    }
);

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

console.error("GitHub PR Analyzer running on stdio");

Yang bikin ini powerful:

  1. Multi-endpoint aggregation: Kita panggil 4 GitHub API endpoint berbeda dan kombinasiin hasilnya. Claude dapet semua dalam satu request.

  2. Error handling: Try-catch block pastiin graceful failure. Kalau API down, Claude dapet clear error message instead of crash.

  3. Type safety: Zod schema enforce bahwa Claude nerima exactly data structure yang kita define. No surprises.

Business value: Single tool ini ngurangin waktu persiapan PR review gue dari 10 menit jadi 30 detik. Itu ~50 jam hemat per tahun cuma untuk satu workflow ini.

Contoh Real-World 2: Business Logic Server

Mari kita tackle masalah berbeda: complex business rules. Server ini implement e-commerce pricing logic dengan tier-based discount.

Use case: Sales team kita butuh quote yang akurat, tapi pricing rules kita kompleks (volume discount, customer tier, seasonal promotion). Sebelum server ini, generate quote butuh buka 3 spreadsheet berbeda.

Bikin src/pricing-server.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as z from "zod";

// Pricing tiers and rules
const PRICING_TIERS = {
    bronze: { multiplier: 1.0, min_quantity: 1 },
    silver: { multiplier: 0.9, min_quantity: 10 },
    gold: { multiplier: 0.8, min_quantity: 50 },
    platinum: { multiplier: 0.7, min_quantity: 100 },
} as const;

const PRODUCTS = {
    widget_a: { base_price: 29.99, name: "Widget A" },
    widget_b: { base_price: 49.99, name: "Widget B" },
    widget_c: { base_price: 99.99, name: "Widget C" },
} as const;

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

server.registerTool(
    "calculate-quote",
    {
        title: "Calculate Customer Quote",
        description: "Calculate pricing quote with volume discounts and customer tier benefits",
        inputSchema: {
            customer_tier: z.enum(["bronze", "silver", "gold", "platinum"]).describe("Customer tier level"),
            items: z
                .array(
                    z.object({
                        product_id: z.enum(["widget_a", "widget_b", "widget_c"]).describe("Product identifier"),
                        quantity: z.number().min(1).describe("Quantity ordered"),
                    })
                )
                .min(1)
                .describe("List of items in the quote"),
        },
        outputSchema: {
            quote: z.object({
                customer_tier: z.string(),
                tier_discount: z.number(),
                items: z.array(
                    z.object({
                        product: z.string(),
                        quantity: z.number(),
                        unit_price: z.number(),
                        volume_discount: z.number(),
                        line_total: z.number(),
                    })
                ),
                subtotal: z.number(),
                total_discount: z.number(),
                final_total: z.number(),
            }),
        },
    },
    async ({ customer_tier, items }) => {
        const tierConfig = PRICING_TIERS[customer_tier];
        const processedItems = items.map((item) => {
            const product = PRODUCTS[item.product_id];

            // Calculate volume discount
            let volumeDiscount = 0;
            if (item.quantity >= 100) volumeDiscount = 0.15;
            else if (item.quantity >= 50) volumeDiscount = 0.1;
            else if (item.quantity >= 10) volumeDiscount = 0.05;

            // Apply tier multiplier and volume discount
            const basePrice = product.base_price;
            const tierAdjustedPrice = basePrice * tierConfig.multiplier;
            const finalUnitPrice = tierAdjustedPrice * (1 - volumeDiscount);
            const lineTotal = finalUnitPrice * item.quantity;

            return {
                product: product.name,
                quantity: item.quantity,
                unit_price: parseFloat(finalUnitPrice.toFixed(2)),
                volume_discount: parseFloat((volumeDiscount * 100).toFixed(1)),
                line_total: parseFloat(lineTotal.toFixed(2)),
            };
        });

        const subtotal = processedItems.reduce((sum, item) => sum + item.line_total, 0);
        const originalTotal = items.reduce((sum, item) => {
            return sum + PRODUCTS[item.product_id].base_price * item.quantity;
        }, 0);
        const totalDiscount = originalTotal - subtotal;

        const output = {
            quote: {
                customer_tier,
                tier_discount: parseFloat(((1 - tierConfig.multiplier) * 100).toFixed(1)),
                items: processedItems,
                subtotal: parseFloat(subtotal.toFixed(2)),
                total_discount: parseFloat(totalDiscount.toFixed(2)),
                final_total: parseFloat(subtotal.toFixed(2)),
            },
        };

        return {
            content: [
                {
                    type: "text",
                    text:
                        `Quote untuk customer tier ${customer_tier.toUpperCase()}:\n\n` +
                        processedItems
                            .map((i) => `${i.product} x${i.quantity} @ $${i.unit_price} = $${i.line_total}`)
                            .join("\n") +
                        `\n\nSubtotal: $${output.quote.subtotal}\n` +
                        `Total Discount: $${output.quote.total_discount}\n` +
                        `**Final Total: $${output.quote.final_total}**`,
                },
            ],
            structuredContent: output,
        };
    }
);

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

console.error("Pricing Engine running on stdio");

Detail implementasi kunci:

  1. Complex business rules: Code ini implement tier-based discount (customer loyalty) dan volume discount (quantity-based). Real-world pricing jarang yang simple.

  2. Type safety untuk business logic: enum validator pastiin Claude cuma bisa request valid product dan tier. Invalid request fail di validation, bukan di runtime.

  3. Calculation transparency: Response show both calculation dan final number. Sales team bisa jelasin quote ke customer.

Business value: Quote generation turun dari 10 menit (manual spreadsheet work) jadi 30 detik. Sales team kita sekarang bisa generate quote during customer call.

Contoh Real-World 3: Database + API Hybrid

MCP server paling powerful kombinasiin multiple data source. Server ini enrich customer data dengan joining local database dan external API call.

Use case: Waktu analisa customer behavior, gue butuh both internal data (PostgreSQL) dan external enrichment (Clearbit). Manual cross-reference ini butuh 15+ menit per customer.

Install dependencies:

npm install pg dotenv

Bikin .env:

DATABASE_URL=postgresql://user:password@localhost:5432/mydb
CLEARBIT_API_KEY=your_clearbit_key_here

Bikin src/customer-server.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import pkg from "pg";
const { Pool } = pkg;
import * as z from "zod";
import "dotenv/config";

// Database connection
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
});

// Simple in-memory cache
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

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

server.registerTool(
    "get-customer-profile",
    {
        title: "Get Enriched Customer Profile",
        description: "Retrieve customer data from database and enrich with external API data",
        inputSchema: {
            email: z.string().email().describe("Customer email address"),
        },
        outputSchema: {
            customer: z.object({
                id: z.number(),
                email: z.string(),
                name: z.string(),
                created_at: z.string(),
                lifetime_value: z.number(),
                enrichment: z
                    .object({
                        company: z.string().optional(),
                        title: z.string().optional(),
                        location: z.string().optional(),
                        linkedin: z.string().optional(),
                    })
                    .optional(),
            }),
        },
    },
    async ({ email }) => {
        try {
            // Check cache first
            const cacheKey = `customer:${email}`;
            const cached = cache.get(cacheKey);
            if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
                return {
                    content: [
                        {
                            type: "text",
                            text: `[CACHED] Customer: ${cached.data.name} (${cached.data.email})`,
                        },
                    ],
                    structuredContent: { customer: cached.data },
                };
            }

            // Fetch from database
            const dbResult = await pool.query(
                `SELECT id, email, name, created_at, lifetime_value
         FROM customers
         WHERE email = $1`,
                [email]
            );

            if (dbResult.rows.length === 0) {
                return {
                    content: [
                        {
                            type: "text",
                            text: `Customer tidak ditemukan: ${email}`,
                        },
                    ],
                    isError: true,
                };
            }

            const customer = dbResult.rows[0];

            // Enrich with Clearbit API (with graceful fallback)
            let enrichment = undefined;
            try {
                const clearbitResponse = await fetch(`https://person.clearbit.com/v2/people/find?email=${email}`, {
                    headers: {
                        Authorization: `Bearer ${process.env.CLEARBIT_API_KEY}`,
                    },
                });

                if (clearbitResponse.ok) {
                    const clearbitData = await clearbitResponse.json();
                    enrichment = {
                        company: clearbitData.employment?.name,
                        title: clearbitData.employment?.title,
                        location: clearbitData.location,
                        linkedin: clearbitData.linkedin?.handle,
                    };
                }
            } catch (error) {
                console.error("Clearbit enrichment failed:", error);
                // Continue without enrichment
            }

            const output = {
                id: customer.id,
                email: customer.email,
                name: customer.name,
                created_at: customer.created_at.toISOString(),
                lifetime_value: parseFloat(customer.lifetime_value),
                enrichment,
            };

            // Cache the result
            cache.set(cacheKey, { data: output, timestamp: Date.now() });

            return {
                content: [
                    {
                        type: "text",
                        text:
                            `**${output.name}** (${output.email})\n` +
                            `LTV: $${output.lifetime_value}\n` +
                            `Customer sejak: ${new Date(output.created_at).toLocaleDateString("id-ID")}\n` +
                            (enrichment
                                ? `\n${enrichment.title} di ${enrichment.company}\n${enrichment.location}`
                                : ""),
                    },
                ],
                structuredContent: { customer: output },
            };
        } catch (error) {
            return {
                content: [
                    {
                        type: "text",
                        text: `Error fetching customer: ${error instanceof Error ? error.message : "Unknown error"}`,
                    },
                ],
                isError: true,
            };
        }
    }
);

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

console.error("Customer Enrichment Server running on stdio");

Advanced pattern yang didemonstrasiikan:

  1. Multi-source aggregation: Kombinasiin PostgreSQL (internal data) dengan Clearbit API (external enrichment). Claude dapet unified view.

  2. Graceful failure handling: Kalau Clearbit down atau rate-limit kita, kita tetap return database data. Partial data lebih baik daripada no data.

  3. Performance optimization: Simple in-memory cache dengan TTL prevent repeated database dan API call untuk customer yang sama.

  4. Environment-based configuration: Sensitive credential (database URL, API key) dari environment variable, never hardcoded.

Business value: Customer analysis time turun dari 15 menit jadi 2 detik. Support team kita sekarang enrich ticket in real-time during customer conversation.

Untuk lebih banyak database integration pattern, liat panduan gue tentang MCP MySQL integration.

Konfigurasi & Integrasi dengan Claude Desktop

Sekarang setelah kita bikin server, mari kita connect ke Claude Desktop.

Konfigurasi Claude Desktop

Edit config file Claude Desktop lo:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

Windows: %APPDATA%\Claude\claude_desktop_config.json

Tambahin server lo ke section mcpServers:

{
    "mcpServers": {
        "calculator": {
            "command": "node",
            "args": ["/absolute/path/to/my-custom-mcp-server/build/index.js"]
        },
        "github-pr-analyzer": {
            "command": "node",
            "args": ["/absolute/path/to/my-custom-mcp-server/build/github-server.js"],
            "env": {
                "GITHUB_TOKEN": "ghp_your_github_token_here"
            }
        },
        "pricing-engine": {
            "command": "node",
            "args": ["/absolute/path/to/my-custom-mcp-server/build/pricing-server.js"]
        },
        "customer-enrichment": {
            "command": "node",
            "args": ["/absolute/path/to/my-custom-mcp-server/build/customer-server.js"],
            "env": {
                "DATABASE_URL": "postgresql://user:password@localhost:5432/mydb",
                "CLEARBIT_API_KEY": "sk_your_clearbit_key"
            }
        }
    }
}

Detail krusial:

  1. Absolute path: Selalu pakai full path ke built JavaScript file lo, bukan relative path.

  2. Environment variable: Pass sensitive data via object env, bukan di code.

  3. Server name: Key ("calculator", "github-pr-analyzer") muncul di tool list Claude.

Development Workflow

Ini workflow yang gue rekomendasiin:

# 1. Bikin perubahan code
vim src/index.ts

# 2. Build TypeScript
npm run build

# 3. Test manual
node build/index.js

# 4. Restart Claude Desktop untuk reload server
# (macOS: Cmd+Q untuk quit, terus buka lagi)
# (Windows: Tutup penuh dan buka lagi)

# 5. Test di Claude
# Tanya Claude: "Pakai calculator untuk tambah 5 dan 3"

Pro tip: Gue pakai watch script waktu development:

{
    "scripts": {
        "dev": "tsx src/index.ts",
        "build": "tsc",
        "watch": "tsc --watch"
    }
}

Jalanin npm run watch di satu terminal, edit code, dan Claude Desktop otomatis pick up perubahan on restart.

Advanced Topics

Error Handling Pattern

Production server butuh robust error handling. Ini pattern gue:

server.registerTool(
    "risky-operation",
    {
        title: "Risky Operation",
        description: "Operation that might fail",
        inputSchema: {
            data: z.string(),
        },
        outputSchema: {
            success: z.boolean(),
            result: z.string().optional(),
            error: z.string().optional(),
        },
    },
    async ({ data }) => {
        try {
            // Validate input beyond Zod schema
            if (data.length > 10000) {
                throw new Error("Input terlalu besar (max 10KB)");
            }

            // Perform operation
            const result = await performRiskyOperation(data);

            return {
                content: [
                    {
                        type: "text",
                        text: `Sukses: ${result}`,
                    },
                ],
                structuredContent: {
                    success: true,
                    result,
                },
            };
        } catch (error) {
            // Log error server-side
            console.error("Operation failed:", error);

            // Return user-friendly error to Claude
            return {
                content: [
                    {
                        type: "text",
                        text: `Operation gagal: ${error instanceof Error ? error.message : "Unknown error"}`,
                    },
                ],
                structuredContent: {
                    success: false,
                    error: error instanceof Error ? error.message : "Unknown error",
                },
                isError: true,
            };
        }
    }
);

Prinsip kunci:

  1. Validate beyond schema: Zod handle type; lo handle business rule.
  2. Log error server-side: Pakai console.error untuk debugging (muncul di Claude Desktop log).
  3. Return user-friendly error: Jangan leak stack trace atau sensitive info ke Claude.
  4. Pakai flag isError: Kasih tahu Claude operation gagal.

Performance Optimization

Untuk server yang handle expensive operation:

import { LRUCache } from "lru-cache";

// Cache expensive API calls
const cache = new LRUCache({
    max: 100,
    ttl: 1000 * 60 * 5, // 5 menit
});

server.registerTool(
    "expensive-operation",
    {
        /* ... */
    },
    async ({ input }) => {
        const cacheKey = `operation:${input}`;

        // Check cache
        const cached = cache.get(cacheKey);
        if (cached) {
            return cached;
        }

        // Perform operation
        const result = await expensiveApiCall(input);

        // Cache result
        cache.set(cacheKey, result);

        return result;
    }
);

Kapan harus cache:

  • External API call (GitHub, Stripe, dll)
  • Database query dengan stable data
  • Expensive computation

Kapan TIDAK harus cache:

  • Real-time data (harga saham, live metric)
  • User-specific sensitive data
  • Operation dengan side effect (write, update)

Security Consideration

Input validation:

inputSchema: {
  user_id: z.number()
    .int()
    .positive()
    .max(999999999)
    .describe('User ID'),
  query: z.string()
    .min(1)
    .max(1000)
    .regex(/^[a-zA-Z0-9\s]+$/)
    .describe('Search query (alphanumeric only)')
}

API key management:

// ❌ JANGAN pernah gini
const API_KEY = "sk_live_abc123";

// ✅ Selalu pakai environment variable
const API_KEY = process.env.API_KEY;
if (!API_KEY) {
    throw new Error("API_KEY environment variable diperlukan");
}

Rate limiting:

import { RateLimiter } from "limiter";

const limiter = new RateLimiter({
    tokensPerInterval: 10,
    interval: "minute",
});

server.registerTool(
    "rate-limited-api",
    {
        /* ... */
    },
    async (params) => {
        const allowed = await limiter.removeTokens(1);
        if (!allowed) {
            throw new Error("Rate limit exceeded. Coba lagi nanti.");
        }

        return await apiCall(params);
    }
);

Testing Server Lo

Gue pakai Vitest untuk testing MCP server:

npm install -D vitest

Bikin src/index.test.ts:

import { describe, it, expect } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as z from "zod";

describe("Calculator Server", () => {
    it("should add two numbers correctly", async () => {
        const server = new McpServer({
            name: "test-server",
            version: "1.0.0",
        });

        let capturedResult: any;

        server.registerTool(
            "add",
            {
                title: "Add",
                description: "Add numbers",
                inputSchema: {
                    a: z.number(),
                    b: z.number(),
                },
                outputSchema: {
                    result: z.number(),
                },
            },
            async ({ a, b }) => {
                const output = { result: a + b };
                capturedResult = output;
                return {
                    content: [{ type: "text", text: `${output.result}` }],
                    structuredContent: output,
                };
            }
        );

        // Simulate tool call
        const tools = server.server.getCapabilities().tools;
        expect(tools).toBeDefined();

        // Call the handler directly
        await server.server.callTool({ name: "add", arguments: { a: 5, b: 3 } }, {});

        expect(capturedResult).toEqual({ result: 8 });
    });
});

Untuk workflow testing yang lebih advanced, liat Claude Tools Monitor untuk real-time debugging.

Troubleshooting Common Issues

Server Nggak Muncul di Claude

Masalah: Server udah dikonfigurasi tapi nggak muncul di tool list Claude.

Solusi:

  1. Cek absolute path: Verify claude_desktop_config.json pakai full path:

    # macOS/Linux
    pwd  # Get current directory
    # Pakai full path kayak /Users/yourname/projects/server/build/index.js
  2. Verify build output: Pastiin TypeScript compiled successfully:

    npm run build
    ls build/  # Harus ada index.js
  3. Cek Claude Desktop log:

    • macOS: ~/Library/Logs/Claude/
    • Windows: %APPDATA%\Claude\logs\

    Cari error message tentang server lo.

  4. Restart Claude Desktop completely: Quit fully (bukan cuma tutup window) dan buka lagi.

Error “Module not found”

Masalah: Error: Cannot find module '@modelcontextprotocol/sdk'

Solusi:

  1. Verify package.json punya "type": "module":

    {
        "type": "module"
    }
  2. Pakai extension .js di import:

    // ✅ Benar
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
    
    // ❌ Salah
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
  3. Cek setting module tsconfig.json:

    {
        "compilerOptions": {
            "module": "ES2022",
            "moduleResolution": "bundler"
        }
    }

Tool Nggak Executing

Masalah: Server muncul di Claude tapi tool nggak jalan.

Solusi:

  1. Cek tool registration:

    // Pastiin lo manggil registerTool, bukan cuma define
    server.registerTool(
        "tool-name",
        {
            /* config */
        },
        async () => {
            /* handler */
        }
    );
  2. Verify Zod schema:

    // ❌ Salah - missing .describe()
    inputSchema: {
        name: z.string();
    }
    
    // ✅ Benar
    inputSchema: {
        name: z.string().describe("User name");
    }
  3. Cek handler error:

    // Tambahin logging ke handler lo
    async ({ param }) => {
        console.error("Tool dipanggil dengan:", param);
        try {
            const result = await operation();
            console.error("Tool sukses:", result);
            return result;
        } catch (error) {
            console.error("Tool gagal:", error);
            throw error;
        }
    };

Environment Variable Nggak Loading

Masalah: process.env.VARIABLE undefined.

Solusi:

  1. Cek section env di claude_desktop_config.json:

    {
        "mcpServers": {
            "your-server": {
                "command": "node",
                "args": ["path/to/server.js"],
                "env": {
                    "API_KEY": "your_key_here"
                }
            }
        }
    }
  2. Untuk local testing, pakai dotenv:

    npm install dotenv
    import "dotenv/config";
    
    const apiKey = process.env.API_KEY;
    if (!apiKey) {
        throw new Error("API_KEY diperlukan");
    }
  3. Verify variable name match exactly (case-sensitive).

Kesimpulan: Perjalanan MCP Server Lo

Gue kasih lo angka spesifik dari pengalaman gue sendiri:

Waktu invested:

  • Server pertama (calculator): 3 jam
  • GitHub PR analyzer: 5 jam
  • Pricing engine: 4 jam
  • Customer enrichment: 6 jam
  • Total: ~18 jam

Waktu hemat per minggu:

  • PR review: 5 jam
  • Customer quote: 4 jam
  • Customer analysis: 3 jam
  • Workflow lain-lain: 3 jam
  • Total: ~15 jam/minggu

ROI: Break even dalam kurang dari 2 minggu. Sekarang hemat 60+ jam per bulan.

Next Step Lo

Mulai simple: Bikin calculator server dari panduan ini. Get comfortable dengan workflow.

Identifikasi pain point lo: Repetitive task apa yang paling buang-buang waktu lo? Itu server kedua lo.

Iterate: Server pertama gue kasar. Gue refine berdasarkan real usage. Jangan aim for perfection—aim for useful.

Gambaran Lebih Besar

MCP server bukan cuma tentang hemat waktu. Ini tentang fundamentally changing cara lo kerja dengan AI.

Sebelum custom server, gue spend berjam-jam jelasin context ke Claude. Sekarang Claude tahu sistem gue, data gue, business rule gue. Conversation lebih cepat, lebih akurat, dan lebih valuable.

Lo udah liat codenya. Lo udah ngerti patternnya. Pertanyaannya adalah: apa yang bakal lo bikin?

Mulai hari ini. Bikin server pertama lo. Hemat jam pertama lo. Terus balik lagi dan ceritain ke gue apa yang lo automate.

Back to Blog

Related Posts

View All Posts »