All posts
model-context-protocoltypescriptnode.jsclaude-api

How I Built and Deployed My Own MCP Server

How I built a custom MCP server that exposes internal tools and data sources to LLMs via the Model Context Protocol standard — architecture decisions, key challenges, and what I'd do differently.

SR

Suhail Roushan

May 16, 2026

·
5 min read

I built a custom MCP server to securely expose internal APIs and databases to Claude, turning natural language into actionable workflows. This project bridges our internal tools with AI assistants using the Model Context Protocol standard. You can find more about my development work at suhailroushan.com.

The Model Context Protocol (MCP) creates a standardized way for LLMs to interact with external data and tools. My server implements three core MCP concepts: resources (data sources), tools (functions Claude can call), and prompts (reusable conversation starters). The architecture sits between Claude Desktop and our internal systems, acting as a controlled gateway.

Architecture Overview

The server follows a clean separation between MCP protocol handling and business logic. At its core, a Node.js TypeScript application implements the MCP JSON-RPC server specification, while adapter layers connect to our actual data sources. This separation proved crucial for testing and maintenance.

graph TD
    A[Claude Desktop] -->|JSON-RPC over stdio| B[MCP Server]
    B --> C[Protocol Handler]
    C --> D[Resource Manager]
    C --> E[Tool Executor]
    D --> F[Database Adapters]
    E --> G[API Clients]
    F --> H[(PostgreSQL)]
    G --> I[Internal REST APIs]

The diagram shows how Claude communicates via standard input/output with our MCP server, which then routes requests to appropriate handlers. Resources handle data retrieval, while tools execute actions like creating tickets or fetching user data.

Key Technical Decisions

I chose TypeScript over plain JavaScript for type safety across the MCP interface. The protocol has specific shapes for requests and responses, and TypeScript catches mismatches at compile time rather than runtime.

// Defining a proper MCP tool interface
interface MCPTool {
  name: string;
  description: string;
  inputSchema: {
    type: "object";
    properties: Record<string, {
      type: string;
      description: string;
    }>;
    required: string[];
  };
}

// Concrete implementation with type safety
const createTicketTool: MCPTool = {
  name: "create_support_ticket",
  description: "Create a new customer support ticket",
  inputSchema: {
    type: "object",
    properties: {
      title: {
        type: "string",
        description: "Brief summary of the issue"
      },
      priority: {
        type: "string",
        description: "high, medium, or low",
        enum: ["high", "medium", "low"]
      }
    },
    required: ["title", "priority"]
  }
};

Another key decision was implementing proper error boundaries. When internal APIs fail, the MCP server needs to return structured errors that Claude can understand, not crash or return raw stack traces.

async function executeTool(toolName: string, args: any) {
  try {
    const result = await internalToolHandlers[toolName](args);
    return {
      content: [{ type: "text", text: JSON.stringify(result) }]
    };
  } catch (error) {
    // Convert internal errors to MCP-friendly format
    return {
      content: [{
        type: "text", 
        text: `Failed to execute ${toolName}: ${error.message}. Please try again with different parameters.`
      }],
      isError: true
    };
  }
}

What Broke and How I Fixed It

The first major issue was stdin/stdout deadlocks. MCP uses JSON-RPC over standard streams, and if both sides try to write simultaneously, everything hangs. The fix was implementing proper request/response correlation with unique IDs and a request queue.

// Before: Concurrent writes causing deadlocks
async function handleMessage(message: string) {
  const response = await processRequest(message);
  process.stdout.write(JSON.stringify(response)); // Could clash with other writes
}

// After: Single-writer pattern with queue
class MCPTransport {
  private writeQueue: string[] = [];
  private isWriting = false;
  
  async send(message: any) {
    this.writeQueue.push(JSON.stringify(message));
    await this.flushQueue();
  }
  
  private async flushQueue() {
    if (this.isWriting || this.writeQueue.length === 0) return;
    
    this.isWriting = true;
    while (this.writeQueue.length > 0) {
      const message = this.writeQueue.shift();
      await new Promise(resolve => {
        process.stdout.write(message + '\n', resolve);
      });
    }
    this.isWriting = false;
  }
}

The second issue was schema validation. Claude would sometimes send parameters that didn't match our expected types. Instead of failing silently, I added runtime validation using Zod that provides clear feedback to Claude about what went wrong.

How to Build Something Similar

Start with the official MCP TypeScript SDK—it handles the protocol basics so you can focus on your tools. Create a simple "hello world" tool first to verify the connection works before adding complex logic.

# Quick start with the MCP SDK
npm install @modelcontextprotocol/sdk
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

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

// Add your first tool
server.setRequestHandler("tools/call", async (request) => {
  if (request.params.name === "get_time") {
    return {
      content: [{
        type: "text",
        text: `Current time: ${new Date().toISOString()}`
      }]
    };
  }
});

// Connect to Claude Desktop
const transport = new StdioServerTransport();
await server.connect(transport);

Test incrementally using the MCP Inspector tool before connecting to Claude Desktop. Build one tool at a time, starting with read-only operations before moving to write operations.

When should you build a custom MCP server?

Build an MCP server when you have internal data or tools that would benefit from natural language access, but security prevents direct LLM access. Don't build one if you only need public APIs—use existing MCP servers instead. The sweet spot is company-specific workflows that involve multiple systems.

Would I Build It the Same Way Again?

I would keep the TypeScript foundation and protocol separation, but I'd add more instrumentation from day one. Logging MCP tool usage patterns revealed which tools were actually valuable versus which sounded good in theory. Next time, I'd implement usage tracking before deploying to the team.

The one thing you should know before starting: MCP is still evolving, so pin your SDK version and expect some protocol changes. Focus on the clean separation between protocol handling and your business logic—that abstraction will save you during updates.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch