Skip to content

Instantly share code, notes, and snippets.

@ikbelkirasan
Created November 26, 2025 11:40
Show Gist options
  • Select an option

  • Save ikbelkirasan/146e6a7d523ec8e73dfabb3fa6dbb8d9 to your computer and use it in GitHub Desktop.

Select an option

Save ikbelkirasan/146e6a7d523ec8e73dfabb3fa6dbb8d9 to your computer and use it in GitHub Desktop.
Building a Custom MCP Integration: Complete Developer Tutorial

Building a Custom MCP Integration: Complete Developer Tutorial

A comprehensive guide to building, securing, and deploying a Model Context Protocol (MCP) server with Node.js and TypeScript.


Table of Contents

  1. Overview & Architecture
  2. Project Setup
  3. Building the MCP Server
  4. Implementing Read-Only Tools
  5. Secure Authentication
  6. Rate Limiting & Caching
  7. Cloud Deployment
  8. Testing & Documentation
  9. Demo & Verification

1. Overview & Architecture

What is MCP?

The Model Context Protocol (MCP) is an open protocol developed by Anthropic that standardizes how applications provide context to Large Language Models (LLMs). Think of MCP as a universal adapter for AI—just as USB-C provides a standardized way to connect devices, MCP provides a standardized way to connect AI models to data sources and tools.

Architecture Overview

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   MCP Client    │────▶│   MCP Server     │────▶│ Internal System │
│  (Claude, etc)  │◀────│  (Your Service)  │◀────│   (Database)    │
└─────────────────┘     └──────────────────┘     └─────────────────┘
         │                       │
         │                       ├── Authentication Layer
         │                       ├── Rate Limiting
         │                       └── Caching Layer
         │
    OAuth 2.1 / Bearer Token

Key Components

Component Description
MCP Server Your microservice exposing tools to LLMs
Tools Functions that perform actions or retrieve data
Transport Communication layer (Streamable HTTP recommended)
Authentication OAuth 2.1 / Bearer token security

2. Project Setup

Prerequisites

  • Node.js 18+
  • npm or yarn
  • TypeScript knowledge
  • Basic understanding of Express.js

Initialize Project

# Create project directory
mkdir mcp-integration-server
cd mcp-integration-server

# Initialize npm project
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk express zod dotenv jose
npm install -D typescript @types/node @types/express ts-node nodemon

# Create TypeScript config
npx tsc --init

Project Structure

mcp-integration-server/
├── src/
│   ├── index.ts              # Main entry point
│   ├── server.ts             # MCP server setup
│   ├── tools/
│   │   ├── index.ts          # Tool exports
│   │   └── dataTools.ts      # Read-only data tools
│   ├── middleware/
│   │   ├── auth.ts           # Authentication middleware
│   │   ├── rateLimit.ts      # Rate limiting
│   │   └── cache.ts          # Caching layer
│   ├── services/
│   │   └── internalApi.ts    # Internal system connector
│   └── types/
│       └── index.ts          # TypeScript types
├── package.json
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── .env.example

Configure TypeScript

Update tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Configure package.json

{
  "name": "mcp-integration-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon --exec ts-node src/index.ts",
    "test": "jest"
  }
}

3. Building the MCP Server

Core Server Implementation

Create src/server.ts:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';

// Initialize Express app
const app = express();
app.use(express.json());

// Store transports by session ID for stateful connections
const transports: Map<string, StreamableHTTPServerTransport> = new Map();

// Create the MCP server instance
export function createMcpServer(): McpServer {
  const server = new McpServer({
    name: 'internal-data-server',
    version: '1.0.0',
  });

  return server;
}

// Set up the HTTP transport endpoint
export function setupHttpEndpoint(server: McpServer) {
  
  // Main MCP endpoint
  app.post('/mcp', async (req: Request, res: Response) => {
    const sessionId = req.headers['mcp-session-id'] as string | undefined;
    let transport: StreamableHTTPServerTransport;

    if (sessionId && transports.has(sessionId)) {
      // Reuse existing transport
      transport = transports.get(sessionId)!;
    } else if (!sessionId && isInitializeRequest(req.body)) {
      // New initialization request
      const newSessionId = randomUUID();
      transport = new StreamableHTTPServerTransport({
        sessionId: newSessionId,
      });
      
      transports.set(newSessionId, transport);
      
      // Connect server to transport
      await server.connect(transport);
      
      // Set session ID in response header
      res.setHeader('mcp-session-id', newSessionId);
    } else {
      res.status(400).json({ error: 'Invalid request or missing session' });
      return;
    }

    // Handle the request
    await transport.handleRequest(req, res);
  });

  // Health check endpoint
  app.get('/health', (req: Request, res: Response) => {
    res.json({ status: 'healthy', timestamp: new Date().toISOString() });
  });

  return app;
}

Main Entry Point

Create src/index.ts:

import dotenv from 'dotenv';
import { createMcpServer, setupHttpEndpoint } from './server.js';
import { registerTools } from './tools/index.js';
import { authMiddleware } from './middleware/auth.js';
import { rateLimitMiddleware } from './middleware/rateLimit.js';

dotenv.config();

const PORT = process.env.PORT || 3000;

async function main() {
  // Create MCP server
  const server = createMcpServer();
  
  // Register all tools
  registerTools(server);
  
  // Set up HTTP endpoint with Express
  const app = setupHttpEndpoint(server);
  
  // Apply middleware (order matters!)
  app.use('/mcp', rateLimitMiddleware);
  app.use('/mcp', authMiddleware);
  
  // Start the server
  app.listen(PORT, () => {
    console.log(`MCP Server running on http://localhost:${PORT}`);
    console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
  });
}

main().catch(console.error);

4. Implementing Read-Only Tools

Tool Registration

Create src/tools/index.ts:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { queryInternalData, getUserProfile, getMetrics } from './dataTools.js';

export function registerTools(server: McpServer) {
  
  // Tool 1: Query internal data
  server.tool(
    'query_data',
    'Query internal data with filters. Returns matching records.',
    {
      collection: z.string().describe('The data collection to query'),
      filters: z.object({
        field: z.string().optional(),
        value: z.string().optional(),
        limit: z.number().max(100).default(10),
      }).optional(),
    },
    async ({ collection, filters }) => {
      const result = await queryInternalData(collection, filters);
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(result, null, 2),
          },
        ],
      };
    }
  );

  // Tool 2: Get user profile
  server.tool(
    'get_user_profile',
    'Retrieve a user profile by ID. Read-only access.',
    {
      userId: z.string().describe('The unique user identifier'),
    },
    async ({ userId }) => {
      const profile = await getUserProfile(userId);
      if (!profile) {
        return {
          content: [{ type: 'text', text: 'User not found' }],
          isError: true,
        };
      }
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(profile, null, 2),
          },
        ],
      };
    }
  );

  // Tool 3: Get system metrics
  server.tool(
    'get_metrics',
    'Retrieve system metrics and statistics.',
    {
      metricType: z.enum(['usage', 'performance', 'health']).describe('Type of metrics'),
      timeRange: z.enum(['1h', '24h', '7d', '30d']).default('24h'),
    },
    async ({ metricType, timeRange }) => {
      const metrics = await getMetrics(metricType, timeRange);
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(metrics, null, 2),
          },
        ],
      };
    }
  );

  console.log('Tools registered: query_data, get_user_profile, get_metrics');
}

Data Tools Implementation

Create src/tools/dataTools.ts:

import { InternalApiClient } from '../services/internalApi.js';

const apiClient = new InternalApiClient();

interface QueryFilters {
  field?: string;
  value?: string;
  limit?: number;
}

interface UserProfile {
  id: string;
  name: string;
  email: string;
  department: string;
  createdAt: string;
}

interface MetricData {
  metricType: string;
  timeRange: string;
  values: Array<{ timestamp: string; value: number }>;
  summary: { min: number; max: number; avg: number };
}

export async function queryInternalData(
  collection: string, 
  filters?: QueryFilters
): Promise<object[]> {
  try {
    const data = await apiClient.query(collection, {
      field: filters?.field,
      value: filters?.value,
      limit: filters?.limit || 10,
    });
    return data;
  } catch (error) {
    console.error('Query error:', error);
    throw new Error(`Failed to query ${collection}`);
  }
}

export async function getUserProfile(userId: string): Promise<UserProfile | null> {
  try {
    const profile = await apiClient.getUser(userId);
    return profile;
  } catch (error) {
    console.error('User lookup error:', error);
    return null;
  }
}

export async function getMetrics(
  metricType: string, 
  timeRange: string
): Promise<MetricData> {
  try {
    const metrics = await apiClient.getMetrics(metricType, timeRange);
    return metrics;
  } catch (error) {
    console.error('Metrics error:', error);
    throw new Error(`Failed to retrieve ${metricType} metrics`);
  }
}

Internal API Service

Create src/services/internalApi.ts:

import dotenv from 'dotenv';

dotenv.config();

const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://localhost:4000';
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || '';

export class InternalApiClient {
  private baseUrl: string;
  private apiKey: string;

  constructor() {
    this.baseUrl = INTERNAL_API_URL;
    this.apiKey = INTERNAL_API_KEY;
  }

  private async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': this.apiKey,
        ...options.headers,
      },
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.status} ${response.statusText}`);
    }

    return response.json();
  }

  async query(
    collection: string, 
    params: { field?: string; value?: string; limit: number }
  ): Promise<object[]> {
    const queryParams = new URLSearchParams();
    if (params.field) queryParams.set('field', params.field);
    if (params.value) queryParams.set('value', params.value);
    queryParams.set('limit', params.limit.toString());

    return this.fetch<object[]>(
      `/api/v1/${collection}?${queryParams.toString()}`
    );
  }

  async getUser(userId: string): Promise<any> {
    return this.fetch(`/api/v1/users/${userId}`);
  }

  async getMetrics(metricType: string, timeRange: string): Promise<any> {
    return this.fetch(`/api/v1/metrics/${metricType}?range=${timeRange}`);
  }
}

5. Secure Authentication

OAuth 2.1 / Bearer Token Authentication

MCP uses OAuth 2.1 for authentication. The server acts as a Resource Server that validates access tokens.

Create src/middleware/auth.ts:

import { Request, Response, NextFunction } from 'express';
import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose';

// Configuration from environment
const JWKS_URL = process.env.JWKS_URL || 'https://auth.example.com/.well-known/jwks.json';
const EXPECTED_AUDIENCE = process.env.AUTH_AUDIENCE || 'https://mcp-server.example.com';
const EXPECTED_ISSUER = process.env.AUTH_ISSUER || 'https://auth.example.com';

// Create JWKS client for token verification
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

// WWW-Authenticate header for 401 responses
const WWW_AUTHENTICATE_HEADER = [
  'Bearer error="unauthorized"',
  'error_description="Authorization required"',
  `resource_metadata="${EXPECTED_AUDIENCE}/.well-known/oauth-protected-resource"`,
].join(', ');

// Extend Express Request to include auth info
declare global {
  namespace Express {
    interface Request {
      auth?: {
        sub: string;
        scopes: string[];
        payload: JWTPayload;
      };
    }
  }
}

export async function authMiddleware(
  req: Request, 
  res: Response, 
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;
  
  // Extract Bearer token
  const token = authHeader?.match(/^Bearer (.+)$/)?.[1];
  
  if (!token) {
    res.status(401)
      .set('WWW-Authenticate', WWW_AUTHENTICATE_HEADER)
      .json({
        error: 'unauthorized',
        error_description: 'Bearer token required',
      });
    return;
  }

  try {
    // Verify the JWT
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: EXPECTED_ISSUER,
      audience: EXPECTED_AUDIENCE,
    });

    // Attach auth info to request
    req.auth = {
      sub: payload.sub || '',
      scopes: (payload.scope as string)?.split(' ') || [],
      payload,
    };

    next();
  } catch (error) {
    console.error('Token verification failed:', error);
    
    res.status(401)
      .set('WWW-Authenticate', WWW_AUTHENTICATE_HEADER)
      .json({
        error: 'invalid_token',
        error_description: 'Token verification failed',
      });
  }
}

// Scope verification middleware factory
export function requireScopes(...requiredScopes: string[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.auth) {
      res.status(401).json({ error: 'Not authenticated' });
      return;
    }

    const hasAllScopes = requiredScopes.every(
      scope => req.auth!.scopes.includes(scope)
    );

    if (!hasAllScopes) {
      res.status(403).json({
        error: 'insufficient_scope',
        error_description: `Required scopes: ${requiredScopes.join(', ')}`,
      });
      return;
    }

    next();
  };
}

OAuth Protected Resource Metadata

Add the discovery endpoint to src/server.ts:

// OAuth Protected Resource Metadata endpoint
app.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => {
  res.json({
    resource: process.env.AUTH_AUDIENCE,
    authorization_servers: [process.env.AUTH_ISSUER],
    bearer_methods_supported: ['header'],
    resource_documentation: 'https://docs.example.com/mcp-api',
    scopes_supported: [
      'mcp:read',
      'mcp:query',
      'mcp:metrics',
    ],
  });
});

6. Rate Limiting & Caching

Rate Limiting Middleware

Create src/middleware/rateLimit.ts:

import { Request, Response, NextFunction } from 'express';
import { rateLimit } from 'express-rate-limit';

// In-memory store for simple deployments
// For production, use Redis store
interface RateLimitStore {
  [key: string]: {
    count: number;
    resetTime: number;
  };
}

const store: RateLimitStore = {};

// Configuration
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const MAX_REQUESTS = 100; // per window

// Using express-rate-limit
export const rateLimitMiddleware = rateLimit({
  windowMs: WINDOW_MS,
  max: MAX_REQUESTS,
  standardHeaders: 'draft-8', // Return rate limit info in RateLimit header
  legacyHeaders: false,
  message: {
    error: 'rate_limit_exceeded',
    error_description: 'Too many requests, please try again later',
    retry_after: Math.ceil(WINDOW_MS / 1000),
  },
  keyGenerator: (req: Request): string => {
    // Use authenticated user ID if available, otherwise IP
    return req.auth?.sub || req.ip || 'unknown';
  },
  skip: (req: Request): boolean => {
    // Skip rate limiting for health checks
    return req.path === '/health';
  },
});

// Custom rate limiter for more control (alternative approach)
export function customRateLimiter(
  req: Request, 
  res: Response, 
  next: NextFunction
): void {
  const key = req.auth?.sub || req.ip || 'unknown';
  const now = Date.now();

  if (!store[key] || now > store[key].resetTime) {
    store[key] = {
      count: 1,
      resetTime: now + WINDOW_MS,
    };
  } else {
    store[key].count++;
  }

  const remaining = Math.max(0, MAX_REQUESTS - store[key].count);
  const resetTime = Math.ceil((store[key].resetTime - now) / 1000);

  // Set rate limit headers
  res.setHeader('RateLimit-Limit', MAX_REQUESTS);
  res.setHeader('RateLimit-Remaining', remaining);
  res.setHeader('RateLimit-Reset', resetTime);

  if (store[key].count > MAX_REQUESTS) {
    res.status(429).json({
      error: 'rate_limit_exceeded',
      error_description: 'Too many requests',
      retry_after: resetTime,
    });
    return;
  }

  next();
}

// Cleanup old entries periodically
setInterval(() => {
  const now = Date.now();
  for (const key in store) {
    if (store[key].resetTime < now) {
      delete store[key];
    }
  }
}, 60000); // Clean up every minute

Caching Layer

Create src/middleware/cache.ts:

import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

interface CacheEntry {
  data: any;
  expiresAt: number;
}

class SimpleCache {
  private cache: Map<string, CacheEntry> = new Map();
  private defaultTTL: number;

  constructor(defaultTTL = 300000) { // 5 minutes default
    this.defaultTTL = defaultTTL;
    
    // Periodic cleanup
    setInterval(() => this.cleanup(), 60000);
  }

  private generateKey(req: Request): string {
    const body = JSON.stringify(req.body);
    const userId = req.auth?.sub || 'anonymous';
    return crypto
      .createHash('sha256')
      .update(`${req.method}:${req.path}:${body}:${userId}`)
      .digest('hex');
  }

  get(key: string): any | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.data;
  }

  set(key: string, data: any, ttl?: number): void {
    this.cache.set(key, {
      data,
      expiresAt: Date.now() + (ttl || this.defaultTTL),
    });
  }

  private cleanup(): void {
    const now = Date.now();
    for (const [key, entry] of this.cache.entries()) {
      if (entry.expiresAt < now) {
        this.cache.delete(key);
      }
    }
  }

  // Middleware factory
  middleware(ttl?: number) {
    return (req: Request, res: Response, next: NextFunction): void => {
      // Only cache GET-like read operations in MCP
      if (req.method !== 'POST') {
        next();
        return;
      }

      const key = this.generateKey(req);
      const cached = this.get(key);

      if (cached) {
        res.setHeader('X-Cache', 'HIT');
        res.json(cached);
        return;
      }

      // Store original json method
      const originalJson = res.json.bind(res);
      
      // Override to cache response
      res.json = (body: any) => {
        // Only cache successful responses
        if (res.statusCode >= 200 && res.statusCode < 300) {
          this.set(key, body, ttl);
        }
        res.setHeader('X-Cache', 'MISS');
        return originalJson(body);
      };

      next();
    };
  }
}

export const cache = new SimpleCache();

// Pre-configured middleware for different TTLs
export const shortCache = cache.middleware(60000);   // 1 minute
export const mediumCache = cache.middleware(300000); // 5 minutes
export const longCache = cache.middleware(900000);   // 15 minutes

7. Cloud Deployment

Docker Configuration

Create Dockerfile:

# Build stage
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files
COPY package*.json ./
COPY tsconfig.json ./

# Install dependencies
RUN npm ci

# Copy source code
COPY src/ ./src/

# Build TypeScript
RUN npm run build

# Production stage
FROM node:20-alpine AS production

WORKDIR /app

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy package files and install production dependencies only
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy built application
COPY --from=builder /app/dist ./dist

# Set ownership
RUN chown -R nodejs:nodejs /app

# Switch to non-root user
USER nodejs

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))"

# Start application
CMD ["node", "dist/index.js"]

Create docker-compose.yml:

version: '3.8'

services:
  mcp-server:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - INTERNAL_API_URL=${INTERNAL_API_URL}
      - INTERNAL_API_KEY=${INTERNAL_API_KEY}
      - JWKS_URL=${JWKS_URL}
      - AUTH_AUDIENCE=${AUTH_AUDIENCE}
      - AUTH_ISSUER=${AUTH_ISSUER}
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: unless-stopped
    networks:
      - mcp-network

  # Optional: Redis for distributed rate limiting
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    networks:
      - mcp-network

networks:
  mcp-network:
    driver: bridge

volumes:
  redis-data:

AWS Deployment with CDK

Create infra/lib/mcp-stack.ts:

import * as cdk from 'aws-cdk-lib';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

export class McpServerStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new ec2.Vpc(this, 'McpVpc', {
      maxAzs: 2,
      natGateways: 1,
    });

    // ECS Cluster
    const cluster = new ecs.Cluster(this, 'McpCluster', {
      vpc,
      containerInsights: true,
    });

    // ECR Repository
    const repository = new ecr.Repository(this, 'McpRepository', {
      repositoryName: 'mcp-server',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Task Definition
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'McpTaskDef', {
      memoryLimitMiB: 512,
      cpu: 256,
    });

    // Container
    const container = taskDefinition.addContainer('mcp-server', {
      image: ecs.ContainerImage.fromEcrRepository(repository),
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'mcp-server',
        logRetention: logs.RetentionDays.ONE_WEEK,
      }),
      environment: {
        NODE_ENV: 'production',
        PORT: '3000',
      },
      secrets: {
        // Add secrets from AWS Secrets Manager
      },
      healthCheck: {
        command: ['CMD-SHELL', 'wget -q --spider http://localhost:3000/health || exit 1'],
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
        retries: 3,
      },
    });

    container.addPortMappings({
      containerPort: 3000,
      protocol: ecs.Protocol.TCP,
    });

    // Fargate Service
    const service = new ecs.FargateService(this, 'McpService', {
      cluster,
      taskDefinition,
      desiredCount: 2,
      assignPublicIp: false,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
    });

    // Application Load Balancer
    const alb = new elbv2.ApplicationLoadBalancer(this, 'McpAlb', {
      vpc,
      internetFacing: true,
    });

    // Listener
    const listener = alb.addListener('HttpsListener', {
      port: 443,
      protocol: elbv2.ApplicationProtocol.HTTPS,
      // Add certificate ARN
    });

    // Target Group
    listener.addTargets('McpTargets', {
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [service],
      healthCheck: {
        path: '/health',
        interval: cdk.Duration.seconds(30),
      },
    });

    // Auto Scaling
    const scaling = service.autoScaleTaskCount({
      minCapacity: 2,
      maxCapacity: 10,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
    });

    // Outputs
    new cdk.CfnOutput(this, 'AlbDnsName', {
      value: alb.loadBalancerDnsName,
    });

    new cdk.CfnOutput(this, 'McpEndpoint', {
      value: `https://${alb.loadBalancerDnsName}/mcp`,
    });
  }
}

Environment Configuration

Create .env.example:

# Server Configuration
NODE_ENV=development
PORT=3000

# Internal API Connection
INTERNAL_API_URL=http://localhost:4000
INTERNAL_API_KEY=your-internal-api-key

# OAuth 2.1 Configuration
JWKS_URL=https://your-auth-provider.com/.well-known/jwks.json
AUTH_AUDIENCE=https://your-mcp-server.com
AUTH_ISSUER=https://your-auth-provider.com

# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

# Redis (optional, for distributed caching/rate limiting)
REDIS_URL=redis://localhost:6379

8. Testing & Documentation

Unit Tests

Create src/__tests__/tools.test.ts:

import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { queryInternalData, getUserProfile } from '../tools/dataTools';

// Mock the internal API client
jest.mock('../services/internalApi');

describe('Data Tools', () => {
  describe('queryInternalData', () => {
    it('should return data for valid collection', async () => {
      const result = await queryInternalData('users', { limit: 5 });
      expect(Array.isArray(result)).toBe(true);
      expect(result.length).toBeLessThanOrEqual(5);
    });

    it('should apply filters correctly', async () => {
      const result = await queryInternalData('users', {
        field: 'department',
        value: 'engineering',
        limit: 10,
      });
      expect(result).toBeDefined();
    });
  });

  describe('getUserProfile', () => {
    it('should return user profile for valid ID', async () => {
      const profile = await getUserProfile('user-123');
      expect(profile).toHaveProperty('id');
      expect(profile).toHaveProperty('name');
    });

    it('should return null for invalid ID', async () => {
      const profile = await getUserProfile('invalid-id');
      expect(profile).toBeNull();
    });
  });
});

Integration Tests

Create src/__tests__/integration.test.ts:

import request from 'supertest';
import { createMcpServer, setupHttpEndpoint } from '../server';
import { registerTools } from '../tools';

describe('MCP Server Integration', () => {
  let app: Express.Application;

  beforeAll(() => {
    const server = createMcpServer();
    registerTools(server);
    app = setupHttpEndpoint(server);
  });

  it('should respond to health check', async () => {
    const response = await request(app).get('/health');
    expect(response.status).toBe(200);
    expect(response.body.status).toBe('healthy');
  });

  it('should require authentication for /mcp', async () => {
    const response = await request(app)
      .post('/mcp')
      .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 });
    expect(response.status).toBe(401);
  });

  it('should serve OAuth metadata', async () => {
    const response = await request(app)
      .get('/.well-known/oauth-protected-resource');
    expect(response.status).toBe(200);
    expect(response.body.scopes_supported).toBeDefined();
  });
});

API Documentation

Create docs/API.md:

# MCP Server API Documentation

## Base URL
- Production: `https://mcp.example.com`
- Development: `http://localhost:3000`

## Authentication

All `/mcp` endpoints require OAuth 2.1 Bearer token authentication.

### Headers

Authorization: Bearer <access_token>


### Token Requirements
- Valid JWT signed by configured issuer
- Audience claim matching server's resource identifier
- Required scopes for the requested operation

## Endpoints

### POST /mcp
Main MCP protocol endpoint.

### GET /health
Health check endpoint (no auth required).

### GET /.well-known/oauth-protected-resource
OAuth 2.0 Protected Resource Metadata.

## Available Tools

### query_data
Query internal data collections.

**Parameters:**
- `collection` (string, required): Collection name
- `filters` (object, optional): Query filters
  - `field`: Field to filter on
  - `value`: Filter value
  - `limit`: Max results (1-100, default 10)

### get_user_profile
Retrieve a user profile.

**Parameters:**
- `userId` (string, required): User identifier

### get_metrics
Retrieve system metrics.

**Parameters:**
- `metricType` (enum): 'usage' | 'performance' | 'health'
- `timeRange` (enum): '1h' | '24h' | '7d' | '30d'

## Rate Limits
- 100 requests per 15-minute window per user
- Returns 429 status when exceeded
- RateLimit headers included in responses

## Error Responses

```json
{
  "error": "error_code",
  "error_description": "Human readable message"
}

Common errors:

  • unauthorized: Missing or invalid token
  • insufficient_scope: Token lacks required scope
  • rate_limit_exceeded: Too many requests

---

## 9. Demo & Verification

### Testing with MCP Inspector

```bash
# Install MCP Inspector
npx @modelcontextprotocol/inspector

# Connect to your server
# URL: http://localhost:3000/mcp
# Transport: Streamable HTTP

Testing with cURL

# Get OAuth token (example with client credentials)
TOKEN=$(curl -s -X POST https://auth.example.com/oauth2/token \
  -d "grant_type=client_credentials" \
  -d "client_id=your-client-id" \
  -d "client_secret=your-client-secret" \
  -d "scope=mcp:read mcp:query" | jq -r '.access_token')

# Initialize MCP session
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2024-11-05",
      "capabilities": {},
      "clientInfo": { "name": "test-client", "version": "1.0.0" }
    }
  }'

# List available tools
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "mcp-session-id: <session-id-from-init>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list"
  }'

# Call a tool
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "mcp-session-id: <session-id>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "query_data",
      "arguments": {
        "collection": "users",
        "filters": { "limit": 5 }
      }
    }
  }'

Connecting with Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "internal-data": {
      "url": "https://mcp.example.com/mcp",
      "transport": "streamable-http",
      "auth": {
        "type": "bearer",
        "token": "<your-access-token>"
      }
    }
  }
}

Summary Checklist

  • MCP Server Setup: Node.js + TypeScript with @modelcontextprotocol/sdk
  • Streamable HTTP Transport: Modern, session-based communication
  • Read-Only Tools: query_data, get_user_profile, get_metrics
  • OAuth 2.1 Authentication: JWT verification with jose library
  • Rate Limiting: express-rate-limit with configurable windows
  • Caching: In-memory cache with TTL support
  • Docker Deployment: Multi-stage build with health checks
  • AWS CDK Infrastructure: ECS Fargate with ALB
  • Testing: Unit and integration test examples
  • Documentation: API docs and usage examples

Additional Resources


Tutorial created for MCP integration development. Last updated: November 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment