Building Custom MCP Servers with TypeScript
The Model Context Protocol (MCP) TypeScript SDK provides a robust foundation for building custom servers that can integrate with any MCP-compatible AI application. This comprehensive guide will walk you through creating production-ready MCP servers using TypeScript.
Why TypeScript for MCP?
TypeScript offers several advantages for MCP server development:
- Type Safety: Catch errors at compile time
- IntelliSense: Better IDE support and autocomplete
- Ecosystem: Rich npm ecosystem and tooling
- Performance: Node.js runtime performance
- Maintainability: Easier to maintain and refactor
Setting Up Your Development Environment
Prerequisites
Before starting, ensure you have:
- Node.js 18+ installed
- TypeScript knowledge
- Basic understanding of MCP concepts
Project Initialization
Create a new TypeScript project:
# Create project directory
mkdir my-mcp-server
cd my-mcp-server
# Initialize npm project
npm init -y
# Install MCP TypeScript SDK
npm install @modelcontextprotocol/sdk
# Install development dependencies
npm install -D typescript @types/node tsx
TypeScript Configuration
Create a tsconfig.json file:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
MCP Server Architecture
An MCP server consists of three main components:
1. Resources
Static or dynamic data that the server provides:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
class MyServer {
private server: McpServer;
constructor() {
this.server = new McpServer(
{ name: "my-server", version: "1.0.0" },
{ capabilities: { resources: {} } }
);
this.setupResourceHandlers();
}
private setupResourceHandlers() {
// List available resources
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "file://config.json",
name: "Application Configuration",
mimeType: "application/json"
},
{
uri: "file://logs/latest.log",
name: "Latest Log File",
mimeType: "text/plain"
}
]
};
});
// Read specific resource
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
switch (uri) {
case "file://config.json":
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify({
environment: "production",
debug: false,
apiUrl: "https://api.example.com"
}, null, 2)
}
]
};
case "file://logs/latest.log":
return {
contents: [
{
uri,
mimeType: "text/plain",
text: "2025-01-08 10:00:00 INFO Application started\n2025-01-08 10:01:00 INFO Processing request"
}
]
};
default:
throw new Error(`Resource not found: ${uri}`);
}
});
}
}
2. Tools
Functions that clients can invoke:
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
class MyServer {
private setupToolHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "calculate",
description: "Perform mathematical calculations",
inputSchema: {
type: "object",
properties: {
expression: {
type: "string",
description: "Mathematical expression to evaluate"
}
},
required: ["expression"]
}
},
{
name: "fetch_data",
description: "Fetch data from external API",
inputSchema: {
type: "object",
properties: {
endpoint: {
type: "string",
description: "API endpoint to fetch data from"
},
method: {
type: "string",
enum: ["GET", "POST"],
default: "GET"
}
},
required: ["endpoint"]
}
}
]
};
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "calculate":
return await this.handleCalculate(args);
case "fetch_data":
return await this.handleFetchData(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
}
private async handleCalculate(args: any) {
try {
const { expression } = args;
// Simple expression evaluation (use a proper parser in production)
const result = eval(expression);
return {
content: [
{
type: "text",
text: `Result: ${result}`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error.message}`
}
],
isError: true
};
}
}
private async handleFetchData(args: any) {
try {
const { endpoint, method = "GET" } = args;
const response = await fetch(endpoint, { method });
const data = await response.text();
return {
content: [
{
type: "text",
text: `Data from ${endpoint}:\n${data}`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching data: ${error.message}`
}
],
isError: true
};
}
}
}
3. Prompts
Predefined templates for common interactions:
import { GetPromptRequestSchema, ListPromptsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
class MyServer {
private setupPromptHandlers() {
// List available prompts
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: "code_review",
description: "Generate a comprehensive code review",
arguments: [
{
name: "code",
description: "Code to review",
required: true
},
{
name: "language",
description: "Programming language",
required: false
}
]
},
{
name: "documentation",
description: "Generate API documentation",
arguments: [
{
name: "endpoint",
description: "API endpoint to document",
required: true
},
{
name: "method",
description: "HTTP method",
required: true
}
]
}
]
};
});
// Handle prompt requests
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "code_review":
return this.generateCodeReviewPrompt(args);
case "documentation":
return this.generateDocumentationPrompt(args);
default:
throw new Error(`Unknown prompt: ${name}`);
}
});
}
private generateCodeReviewPrompt(args: any) {
const { code, language = "javascript" } = args;
return {
description: `Code review for ${language} code`,
messages: [
{
role: "user" as const,
content: {
type: "text",
text: `Please review the following ${language} code and provide feedback on:
1. Code quality and best practices
2. Potential bugs or issues
3. Performance considerations
4. Security concerns
5. Suggestions for improvement
Code to review:
\`\`\`${language}
${code}
\`\`\`
Please provide a comprehensive review with specific recommendations.`
}
}
]
};
}
private generateDocumentationPrompt(args: any) {
const { endpoint, method } = args;
return {
description: `API documentation for ${method} ${endpoint}`,
messages: [
{
role: "user" as const,
content: {
type: "text",
text: `Generate comprehensive API documentation for the following endpoint:
**Endpoint:** ${method} ${endpoint}
Please include:
1. Description of what this endpoint does
2. Request parameters (path, query, body)
3. Response format and status codes
4. Example request and response
5. Error handling
6. Authentication requirements
7. Rate limiting information
Format the documentation in a clear, professional manner suitable for developers.`
}
}
]
};
}
}
Advanced Server Features
Error Handling and Logging
Implement comprehensive error handling:
import { Logger } from './logger.js';
class MyServer {
private logger: Logger;
constructor() {
this.logger = new Logger("MyServer");
this.server = new McpServer(
{ name: "my-server", version: "1.0.0" },
{ capabilities: { resources: {}, tools: {}, prompts: {} } }
);
this.setupErrorHandling();
}
private setupErrorHandling() {
this.server.onerror = (error) => {
this.logger.error("Server error:", error);
};
// Wrap handlers with error handling
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
return await this.handleToolCall(request);
} catch (error) {
this.logger.error(`Tool call error: ${error.message}`, { request });
throw error;
}
});
}
}
Configuration Management
Create a configuration system:
interface ServerConfig {
port: number;
debug: boolean;
apiKeys: {
[service: string]: string;
};
rateLimits: {
requestsPerMinute: number;
};
}
class ConfigManager {
private config: ServerConfig;
constructor() {
this.config = this.loadConfig();
}
private loadConfig(): ServerConfig {
return {
port: parseInt(process.env.PORT || "3000"),
debug: process.env.DEBUG === "true",
apiKeys: {
openai: process.env.OPENAI_API_KEY || "",
weather: process.env.WEATHER_API_KEY || ""
},
rateLimits: {
requestsPerMinute: parseInt(process.env.RATE_LIMIT || "60")
}
};
}
public get<T extends keyof ServerConfig>(key: T): ServerConfig[T] {
return this.config[key];
}
}
Rate Limiting
Implement rate limiting for production use:
class RateLimiter {
private requests: Map<string, number[]> = new Map();
public checkLimit(clientId: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const requests = this.requests.get(clientId) || [];
// Remove old requests outside the window
const validRequests = requests.filter(time => now - time < windowMs);
if (validRequests.length >= limit) {
return false;
}
validRequests.push(now);
this.requests.set(clientId, validRequests);
return true;
}
}
class MyServer {
private rateLimiter = new RateLimiter();
private async handleToolCall(request: any) {
const clientId = request.meta?.clientId || "unknown";
if (!this.rateLimiter.checkLimit(clientId, 60, 60000)) {
throw new Error("Rate limit exceeded");
}
// Process request...
}
}
Testing Your MCP Server
Unit Testing Setup
Create comprehensive tests:
// tests/server.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { MyServer } from '../src/server.js';
describe('MyServer', () => {
let server: MyServer;
beforeEach(() => {
server = new MyServer();
});
describe('Tools', () => {
it('should handle calculate tool correctly', async () => {
const result = await server.handleToolCall({
params: {
name: "calculate",
arguments: { expression: "2 + 2" }
}
});
expect(result.content[0].text).toBe("Result: 4");
});
it('should handle invalid expressions', async () => {
const result = await server.handleToolCall({
params: {
name: "calculate",
arguments: { expression: "invalid" }
}
});
expect(result.isError).toBe(true);
});
});
describe('Resources', () => {
it('should list available resources', async () => {
const result = await server.listResources();
expect(result.resources).toHaveLength(2);
expect(result.resources[0].uri).toBe("file://config.json");
});
});
});
Integration Testing
Test with real MCP clients:
// tests/integration.test.ts
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { MyServer } from '../src/server.js';
describe('Integration Tests', () => {
it('should work with stdio transport', async () => {
const server = new MyServer();
const transport = new StdioServerTransport();
await server.connect(transport);
// Test server connection and basic functionality
expect(server.isConnected()).toBe(true);
});
});
Deployment and Production
Docker Deployment
Create a Dockerfile:
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY dist/ ./dist/
# Set environment
ENV NODE_ENV=production
EXPOSE 3000
# Run server
CMD ["node", "dist/index.js"]
Environment Configuration
Set up environment variables:
# .env.production
NODE_ENV=production
PORT=3000
DEBUG=false
OPENAI_API_KEY=your_openai_key
WEATHER_API_KEY=your_weather_key
RATE_LIMIT=100
Monitoring and Observability
Add monitoring capabilities:
import { createPrometheusMetrics } from './metrics.js';
class MyServer {
private metrics = createPrometheusMetrics();
private async handleToolCall(request: any) {
const startTime = Date.now();
try {
const result = await this.processToolCall(request);
this.metrics.toolCallDuration.observe(Date.now() - startTime);
this.metrics.toolCallsTotal.inc({ status: 'success' });
return result;
} catch (error) {
this.metrics.toolCallsTotal.inc({ status: 'error' });
throw error;
}
}
}
Best Practices
Security Considerations
- Input Validation: Always validate input parameters
- API Key Management: Use environment variables, never hardcode
- Rate Limiting: Implement proper rate limiting
- Error Messages: Don't expose sensitive information in errors
Performance Optimization
- Caching: Cache frequently accessed data
- Connection Pooling: Reuse database connections
- Async Operations: Use async/await for I/O operations
- Memory Management: Monitor memory usage and implement cleanup
Code Organization
- Modular Design: Separate concerns into different modules
- Type Safety: Use TypeScript types and interfaces
- Error Handling: Implement comprehensive error handling
- Testing: Write thorough unit and integration tests
Conclusion
Building MCP servers with TypeScript provides a robust, type-safe foundation for creating powerful AI integrations. By following these patterns and best practices, you can create production-ready servers that integrate seamlessly with any MCP-compatible AI application.
The TypeScript SDK's rich feature set, combined with proper architecture and testing, enables you to build scalable, maintainable MCP servers that enhance AI applications with external data and tools.
Ready to deploy your MCP server? Check out our deployment guides and production best practices for scaling your MCP infrastructure.