A comprehensive guide to building, securing, and deploying a Model Context Protocol (MCP) server with Node.js and TypeScript.
- Overview & Architecture
- Project Setup
- Building the MCP Server
- Implementing Read-Only Tools
- Secure Authentication
- Rate Limiting & Caching
- Cloud Deployment
- Testing & Documentation
- Demo & Verification
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.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ MCP Client │────▶│ MCP Server │────▶│ Internal System │
│ (Claude, etc) │◀────│ (Your Service) │◀────│ (Database) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
│ ├── Authentication Layer
│ ├── Rate Limiting
│ └── Caching Layer
│
OAuth 2.1 / Bearer Token
| 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 |
- Node.js 18+
- npm or yarn
- TypeScript knowledge
- Basic understanding of Express.js
# 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 --initmcp-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
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"]
}{
"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"
}
}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;
}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);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');
}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`);
}
}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}`);
}
}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();
};
}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',
],
});
});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 minuteCreate 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 minutesCreate 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: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`,
});
}
}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:6379Create 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();
});
});
});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();
});
});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.
### HeadersAuthorization: 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 tokeninsufficient_scope: Token lacks required scoperate_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
# 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 }
}
}
}'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>"
}
}
}
}- 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
Tutorial created for MCP integration development. Last updated: November 2025