How to Build a Custom MCP Server: Developer Tutorial

by Sandlabs Team, Founder, Sandlabs

The Model Context Protocol (MCP) has become the standard way AI models connect to external tools and data. With thousands of MCP server examples available in the open-source ecosystem, developers are building custom servers to connect Claude, ChatGPT, Gemini, and other AI applications to proprietary systems.

This tutorial walks you through building a complete MCP server in TypeScript — from project setup through deployment. By the end, you will have a working claude MCP server that exposes custom tools and resources, handles security properly, and is ready for production.

What You Will Build

We will build an MCP server for a project management system. It will expose:

  • Tools: Create tasks, update status, assign team members, query project metrics
  • Resources: Project summaries, team workload data, sprint dashboards

This is a realistic MCP server example that mirrors the kind of integrations we build for clients at Sandlabs — connecting AI to internal business systems that off-the-shelf connectors do not cover.

Prerequisites

  • Node.js 18+ and npm/pnpm
  • TypeScript basics
  • Familiarity with async/await patterns
  • A code editor (VS Code or Cursor recommended)

Step 1: Project Setup (TypeScript)

Start by scaffolding your MCP server project. The official @modelcontextprotocol/sdk package provides the TypeScript SDK.

mkdir project-manager-mcp && cd project-manager-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Create your tsconfig.json:

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

Update your package.json:

{
  "name": "project-manager-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "project-manager-mcp": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  }
}

Create the entry point at src/index.ts:

#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "project-manager",
  version: "1.0.0",
  capabilities: {
    tools: {},
    resources: {},
  },
});

// Tools and resources will be registered here

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Project Manager MCP server running on stdio");
}

main().catch(console.error);

Note that we log to stderr — MCP uses stdout for protocol communication, so any diagnostic logging must go to stderr.

Step 2: Define Tools

Tools are the core of any MCP server. They are executable functions that the AI model can invoke. Each tool needs a name, description, input schema, and handler.

The description is critical — it is what Claude reads to decide when to use a tool. Write descriptions that are clear about what the tool does, when to use it, and what it returns.

Add the following tools to src/index.ts, after the server instantiation:

import { z } from "zod";

// Tool: Create a new task
server.tool(
  "create_task",
  "Create a new task in a project. Use this when the user wants to add a new work item, ticket, or to-do. Returns the created task with its ID.",
  {
    project_id: z.string().describe("The project identifier (e.g., 'PROJ-001')"),
    title: z.string().describe("Short title describing the task"),
    description: z.string().optional().describe("Detailed description of what needs to be done"),
    priority: z.enum(["low", "medium", "high", "critical"]).default("medium").describe("Task priority level"),
    assignee: z.string().optional().describe("Email or username of the person to assign"),
  },
  async ({ project_id, title, description, priority, assignee }) => {
    const task = await createTask({
      projectId: project_id,
      title,
      description,
      priority,
      assignee,
    });

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

// Tool: Update task status
server.tool(
  "update_task_status",
  "Update the status of an existing task. Use this when a user wants to move a task through the workflow (e.g., mark as in progress, done, or blocked).",
  {
    task_id: z.string().describe("The unique task identifier"),
    status: z.enum(["todo", "in_progress", "in_review", "done", "blocked"]).describe("The new status"),
    comment: z.string().optional().describe("Optional comment explaining the status change"),
  },
  async ({ task_id, status, comment }) => {
    const updated = await updateTaskStatus(task_id, status, comment);

    return {
      content: [
        {
          type: "text",
          text: `Task ${task_id} updated to "${status}".${comment ? ` Comment: ${comment}` : ""}`,
        },
      ],
    };
  }
);

// Tool: Query project metrics
server.tool(
  "get_project_metrics",
  "Get current metrics for a project including task counts by status, velocity, and completion rate. Use this when the user asks about project progress or health.",
  {
    project_id: z.string().describe("The project identifier"),
    sprint: z.string().optional().describe("Specific sprint to get metrics for (defaults to current sprint)"),
  },
  async ({ project_id, sprint }) => {
    const metrics = await getProjectMetrics(project_id, sprint);

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

// Tool: Search tasks
server.tool(
  "search_tasks",
  "Search for tasks across projects using filters. Use this when the user wants to find specific tasks, see what someone is working on, or filter by status/priority.",
  {
    query: z.string().optional().describe("Free-text search query"),
    assignee: z.string().optional().describe("Filter by assignee email or username"),
    status: z.enum(["todo", "in_progress", "in_review", "done", "blocked"]).optional(),
    priority: z.enum(["low", "medium", "high", "critical"]).optional(),
    project_id: z.string().optional().describe("Limit search to a specific project"),
    limit: z.number().default(20).describe("Maximum results to return"),
  },
  async ({ query, assignee, status, priority, project_id, limit }) => {
    const results = await searchTasks({ query, assignee, status, priority, projectId: project_id, limit });

    return {
      content: [
        {
          type: "text",
          text: results.length > 0
            ? JSON.stringify(results, null, 2)
            : "No tasks found matching your criteria.",
        },
      ],
    };
  }
);

Tool Design Best Practices

  1. Use descriptive names — create_task not ct or newTask. Claude matches tool names to user intent.
  2. Write thorough descriptions — Include when to use the tool, not just what it does.
  3. Use Zod schemas — They generate JSON Schema automatically, giving Claude structured information about parameters.
  4. Return structured data — JSON is easier for Claude to parse and reason about than freeform text.
  5. Handle errors gracefully — Return error messages as content, not by throwing exceptions.

Step 3: Define Resources

Resources expose data that Claude can read without invoking a tool. They are useful for dashboards, summaries, and reference data that the AI might need as context.

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

// Static resource: team directory
server.resource(
  "team-directory",
  "team://directory",
  { description: "Full list of team members with roles and current workload" },
  async (uri) => {
    const team = await getTeamDirectory();

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(team, null, 2),
        },
      ],
    };
  }
);

// Dynamic resource template: project summary
server.resource(
  "project-summary",
  new ResourceTemplate("project://{project_id}/summary", { list: undefined }),
  { description: "Summary of a specific project including status, team, and recent activity" },
  async (uri, { project_id }) => {
    const summary = await getProjectSummary(project_id as string);

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(summary, null, 2),
        },
      ],
    };
  }
);

// Dynamic resource template: sprint dashboard
server.resource(
  "sprint-dashboard",
  new ResourceTemplate("sprint://{project_id}/{sprint_id}", { list: undefined }),
  { description: "Sprint dashboard with burndown data, task distribution, and blockers" },
  async (uri, { project_id, sprint_id }) => {
    const dashboard = await getSprintDashboard(
      project_id as string,
      sprint_id as string
    );

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(dashboard, null, 2),
        },
      ],
    };
  }
);

Resources vs Tools: When to Use Each

Use CasePrimitiveWhy
Fetch current project statusResourceRead-only data, no side effects
Create or update a taskToolPerforms an action, has side effects
Get team member listResourceReference data Claude may need as context
Search with complex filtersToolRequires dynamic input parameters
Sprint burndown chart dataResourceStatic data for a given sprint

Resources are listed via resources/list and read via resources/read. Claude can proactively read resources it thinks are relevant to a conversation, making them a good way to give the AI background context.

Step 4: Implement Handlers

Now wire up the actual business logic. In a real MCP server, these handlers connect to your database, API, or internal systems. Here is a complete implementation using an in-memory store for demonstration:

// src/data.ts — Data layer (replace with your actual database/API)

interface Task {
  id: string;
  projectId: string;
  title: string;
  description?: string;
  status: "todo" | "in_progress" | "in_review" | "done" | "blocked";
  priority: "low" | "medium" | "high" | "critical";
  assignee?: string;
  createdAt: string;
  updatedAt: string;
}

// In-memory store — replace with database calls in production
const tasks: Map<string, Task> = new Map();
let taskCounter = 0;

export async function createTask(params: {
  projectId: string;
  title: string;
  description?: string;
  priority: string;
  assignee?: string;
}): Promise<Task> {
  const id = `TASK-${++taskCounter}`;
  const task: Task = {
    id,
    projectId: params.projectId,
    title: params.title,
    description: params.description,
    status: "todo",
    priority: params.priority as Task["priority"],
    assignee: params.assignee,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
  tasks.set(id, task);
  return task;
}

export async function updateTaskStatus(
  taskId: string,
  status: Task["status"],
  comment?: string
): Promise<Task> {
  const task = tasks.get(taskId);
  if (!task) throw new Error(`Task ${taskId} not found`);

  task.status = status;
  task.updatedAt = new Date().toISOString();
  return task;
}

export async function getProjectMetrics(
  projectId: string,
  sprint?: string
): Promise<Record<string, unknown>> {
  const projectTasks = [...tasks.values()].filter(
    (t) => t.projectId === projectId
  );

  const byStatus = projectTasks.reduce(
    (acc, t) => {
      acc[t.status] = (acc[t.status] || 0) + 1;
      return acc;
    },
    {} as Record<string, number>
  );

  return {
    projectId,
    totalTasks: projectTasks.length,
    byStatus,
    completionRate:
      projectTasks.length > 0
        ? ((byStatus["done"] || 0) / projectTasks.length) * 100
        : 0,
  };
}

export async function searchTasks(filters: {
  query?: string;
  assignee?: string;
  status?: string;
  priority?: string;
  projectId?: string;
  limit: number;
}): Promise<Task[]> {
  let results = [...tasks.values()];

  if (filters.projectId) {
    results = results.filter((t) => t.projectId === filters.projectId);
  }
  if (filters.assignee) {
    results = results.filter((t) => t.assignee === filters.assignee);
  }
  if (filters.status) {
    results = results.filter((t) => t.status === filters.status);
  }
  if (filters.priority) {
    results = results.filter((t) => t.priority === filters.priority);
  }
  if (filters.query) {
    const q = filters.query.toLowerCase();
    results = results.filter(
      (t) =>
        t.title.toLowerCase().includes(q) ||
        t.description?.toLowerCase().includes(q)
    );
  }

  return results.slice(0, filters.limit);
}

Import these functions at the top of your src/index.ts:

import {
  createTask,
  updateTaskStatus,
  getProjectMetrics,
  searchTasks,
  getTeamDirectory,
  getProjectSummary,
  getSprintDashboard,
} from "./data.js";

Error Handling Pattern

Wrap your tool handlers with consistent error handling:

server.tool(
  "create_task",
  "Create a new task in a project.",
  { /* schema */ },
  async (params) => {
    try {
      const task = await createTask(params);
      return {
        content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error creating task: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    }
  }
);

The isError: true flag tells Claude that the tool call failed, so it can report the error to the user or retry with different parameters.

Step 5: Add Security

Security is non-negotiable for production MCP servers. Here are the layers you need to implement.

Input Validation

Zod handles schema validation, but add business-logic validation too:

async function validateProjectAccess(
  projectId: string,
  userId: string
): Promise<boolean> {
  const project = await db.projects.findOne({ id: projectId });
  if (!project) throw new Error(`Project ${projectId} not found`);

  const isMember = project.members.includes(userId);
  if (!isMember) throw new Error(`Access denied to project ${projectId}`);

  return true;
}

Rate Limiting

Prevent abuse by limiting how frequently tools can be called:

const rateLimiter = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(toolName: string, maxPerMinute: number = 30): void {
  const now = Date.now();
  const key = toolName;
  const entry = rateLimiter.get(key);

  if (!entry || now > entry.resetAt) {
    rateLimiter.set(key, { count: 1, resetAt: now + 60_000 });
    return;
  }

  if (entry.count >= maxPerMinute) {
    throw new Error(`Rate limit exceeded for ${toolName}. Try again in ${Math.ceil((entry.resetAt - now) / 1000)}s.`);
  }

  entry.count++;
}

OAuth 2.1 for Remote Servers

If you are deploying your MCP server as a remote HTTP endpoint, use OAuth 2.1 for authentication. The MCP specification requires this for remote servers:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();

// OAuth 2.1 middleware — validate Bearer tokens
async function authenticateRequest(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    res.status(401).json({ error: "Missing or invalid authorization header" });
    return;
  }

  const token = authHeader.slice(7);
  const session = await validateOAuthToken(token);

  if (!session) {
    res.status(403).json({ error: "Invalid or expired token" });
    return;
  }

  req.userSession = session;
  next();
}

app.use("/mcp", authenticateRequest);

Audit Logging

Log every tool invocation for compliance and debugging:

function auditLog(event: {
  tool: string;
  params: Record<string, unknown>;
  userId: string;
  result: "success" | "error";
  duration: number;
}) {
  // Redact sensitive fields before logging
  const sanitizedParams = { ...event.params };
  delete sanitizedParams.password;
  delete sanitizedParams.token;

  console.error(JSON.stringify({
    timestamp: new Date().toISOString(),
    type: "tool_invocation",
    ...event,
    params: sanitizedParams,
  }));
}

Security Checklist

  • Validate all inputs beyond schema validation (business rules, access control)
  • Use OAuth 2.1 for remote/HTTP transport (never API keys in production)
  • Implement rate limiting per tool and per user
  • Sanitise all outputs — never leak internal errors, stack traces, or credentials
  • Log all tool invocations with redacted parameters
  • Apply principle of least privilege — only expose the operations Claude actually needs
  • Block SSRF by validating URLs and blocking private IP ranges

For a deeper dive, see our guide on MCP server security best practices.

Step 6: Test Locally

Testing MCP servers requires validating both the protocol layer and your business logic.

Unit Testing With Vitest

Install the testing dependencies:

npm install -D vitest @vitest/coverage-v8

Write unit tests for your handlers:

// src/__tests__/data.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createTask, searchTasks, updateTaskStatus, getProjectMetrics } from "../data.js";

describe("Task operations", () => {
  beforeEach(() => {
    // Reset in-memory store between tests
    clearAllTasks();
  });

  it("creates a task with correct defaults", async () => {
    const task = await createTask({
      projectId: "PROJ-001",
      title: "Implement login page",
      priority: "high",
    });

    expect(task.id).toMatch(/^TASK-\d+$/);
    expect(task.status).toBe("todo");
    expect(task.priority).toBe("high");
    expect(task.projectId).toBe("PROJ-001");
  });

  it("updates task status", async () => {
    const task = await createTask({
      projectId: "PROJ-001",
      title: "Test task",
      priority: "medium",
    });

    const updated = await updateTaskStatus(task.id, "in_progress", "Starting work");
    expect(updated.status).toBe("in_progress");
  });

  it("searches tasks by assignee", async () => {
    await createTask({ projectId: "PROJ-001", title: "Task A", priority: "low", assignee: "[email protected]" });
    await createTask({ projectId: "PROJ-001", title: "Task B", priority: "low", assignee: "[email protected]" });

    const results = await searchTasks({ assignee: "[email protected]", limit: 10 });
    expect(results).toHaveLength(1);
    expect(results[0].assignee).toBe("[email protected]");
  });

  it("calculates project metrics", async () => {
    await createTask({ projectId: "PROJ-001", title: "Done task", priority: "medium" });
    await createTask({ projectId: "PROJ-001", title: "Todo task", priority: "medium" });

    const first = (await searchTasks({ projectId: "PROJ-001", limit: 1 }))[0];
    await updateTaskStatus(first.id, "done");

    const metrics = await getProjectMetrics("PROJ-001");
    expect(metrics.totalTasks).toBe(2);
    expect(metrics.completionRate).toBe(50);
  });
});

Integration Testing With the MCP Inspector

The MCP Inspector is a browser-based tool for testing MCP servers interactively:

npx @modelcontextprotocol/inspector tsx src/index.ts

This opens a web UI where you can:

  1. See all registered tools and resources
  2. Call tools with custom parameters and inspect responses
  3. Read resources and verify their output
  4. Check the JSON-RPC messages exchanged between client and server

Testing With Claude Desktop

Add your server to claude_desktop_config.json for end-to-end testing:

{
  "mcpServers": {
    "project-manager": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/src/index.ts"]
    }
  }
}

Restart Claude Desktop, then test with natural language:

  • "Create a new high-priority task in PROJ-001 called 'Fix authentication bug'"
  • "What is the completion rate for PROJ-001?"
  • "Show me all tasks assigned to [email protected]"
  • "Move TASK-3 to in review"

Testing With Claude Code

# Add your server
claude mcp add project-manager -- npx tsx /absolute/path/to/src/index.ts

# Verify it is recognised
claude mcp list

# Start a conversation and test
claude

Testing Checklist

  • All tools return valid MCP response format (content array with typed objects)
  • Error cases return isError: true instead of throwing
  • Input validation rejects malformed data
  • Rate limiting triggers correctly under load
  • Resources return correct MIME types
  • Tool descriptions are clear enough for Claude to use tools correctly

Step 7: Deploy

Build for Production

npm run build

This compiles TypeScript to JavaScript in the dist/ directory.

Docker Deployment (Remote HTTP Server)

For production remote servers, package as a Docker container:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist/ ./dist/
EXPOSE 3000
CMD ["node", "dist/index.js"]

Deploy to Cloud Providers

MCP servers are standard HTTP services when using the Streamable HTTP transport. Deploy to any cloud platform:

  • AWS: ECS/Fargate with ALB and API Gateway for OAuth
  • Google Cloud: Cloud Run (auto-scaling, pay-per-request)
  • Azure: Container Apps with Easy Auth
  • Railway/Render: Simplest for small teams — push and deploy

Environment Variables

Never hardcode credentials. Use environment variables for all secrets:

# Production environment
DATABASE_URL=postgresql://...
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
ALLOWED_ORIGINS=https://claude.ai,https://your-app.com

Publishing to the MCP Registry

Once your server is stable, consider publishing it to the MCP server registry so other developers can discover and use it.

To publish to npm (which the registry indexes):

# Ensure your package.json has the correct bin entry
npm login
npm publish

Add an mcp.json manifest to your repository root for registry discoverability:

{
  "name": "project-manager-mcp",
  "description": "MCP server for project management — create tasks, track progress, query metrics",
  "version": "1.0.0",
  "tools": [
    { "name": "create_task", "description": "Create a new task in a project" },
    { "name": "update_task_status", "description": "Update task status" },
    { "name": "get_project_metrics", "description": "Get project metrics and velocity" },
    { "name": "search_tasks", "description": "Search and filter tasks" }
  ],
  "resources": [
    { "uri": "team://directory", "description": "Team member directory" },
    { "uri": "project://{id}/summary", "description": "Project summary" }
  ],
  "transport": ["stdio", "streamable-http"],
  "runtime": "node",
  "license": "MIT"
}

Submit your server to the official MCP registry at mcp.so or the Anthropic connector directory. Open source MCP server examples that solve common integration problems tend to get strong community adoption.

Monitoring in Production

Track these metrics for your deployed MCP server:

  • Tool invocation count — which tools are used most, which are never used
  • Latency (p50/p95/p99) — slow tools degrade the user experience
  • Error rate — per tool, per user
  • Token consumption — MCP tool calls and responses consume tokens from Claude's context window

Building Custom MCP Connectors for Complex Systems

This tutorial covers a single MCP server. For more complex scenarios — connecting multiple internal systems, orchestrating between APIs, or building connectors for enterprise platforms — see our in-depth guide on building custom MCP connectors.

That guide covers the architecture for multi-system integrations, OAuth flows for third-party platforms, and patterns for handling eventual consistency across distributed systems.

Frequently Asked Questions

What is the best language for building an MCP server?

TypeScript and Python are the two best-supported options. The official MCP SDK supports both with first-party libraries. TypeScript is a strong choice for teams already working in the Node.js ecosystem, while Python's FastMCP framework offers a more concise decorator-based syntax for rapid prototyping.

How long does it take to build a custom MCP server?

A basic MCP server with three to five tools can be built in a day by a developer familiar with the SDK. Production-ready servers with OAuth, rate limiting, monitoring, and comprehensive error handling typically take one to two weeks. Complex multi-system integrations with orchestration logic can take three to six weeks.

Can one MCP server expose tools for multiple systems?

Yes. A single MCP server can expose tools that connect to multiple backends — your database, third-party APIs, and internal services. However, for maintainability, we recommend one MCP server per logical domain. If your tools span unrelated systems, split them into separate servers.

How do I handle authentication between Claude and my MCP server?

For local stdio servers, authentication is handled by the host environment — whoever runs the process has access. For remote HTTP servers, the MCP specification requires OAuth 2.1 with PKCE. Claude and other MCP clients handle the OAuth flow automatically when configured with your authorization server endpoints.

What is the difference between MCP tools and MCP resources?

Tools are functions that perform actions and may have side effects — creating records, sending messages, or triggering workflows. Resources are read-only data sources that provide context — dashboards, configuration files, or reference data. Claude can read resources proactively for background context, but only invokes tools when the user requests a specific action.

Next Steps

Building custom MCP servers unlocks a powerful integration layer between AI and your internal systems. Whether you are connecting Claude to a proprietary CRM, automating project workflows, or building custom connectors for enterprise platforms, the patterns in this tutorial apply.

If you need help building production-grade MCP servers for your business, get in touch with our team. At Sandlabs, we build custom AI integrations for companies across Australia and the US — typically delivered in 2-6 weeks with fixed pricing.

Explore our AI & Claude consulting services

More articles

Claude Certification 2026: All Four Anthropic Exams, What They Cost, and Whether Your Team Needs One

Anthropic now runs four proctored Claude certifications through Pearson VUE. What CCAO-F, CCDV-F, CCAR-F and CCAR-P actually test, what they cost in AUD, how to study for free, and the honest answer on whether a certificate changes anything for your business.

Read more

Does Anthropic Charge GST in Australia? Claude Invoices, ABNs and Your BAS

Anthropic adds 10% GST to Australian Claude subscriptions by default - and if you do not add your ABN, you probably cannot claim it back. How the imported-services rules work, the two-minute fix, and what to do about invoices you have already paid.

Read more

Let's build something great together.

Melbourne, Australia — serving founders worldwide. [email protected]