Advanced MCP Server Development
Pre-built MCP servers cover common use cases like file access and database queries, but real-world projects often need custom integrations that do not exist yet. Maybe you need to connect an AI to your company's internal API, orchestrate a multi-step deployment workflow, or expose a proprietary data source. Building a custom MCP server lets you create exactly the tools your AI needs.
This guide goes beyond the basics. We will build a complete, production-ready MCP server from scratch, add comprehensive error handling, write tests with Vitest, implement Streamable HTTP transport for remote access, containerize it with Docker, and set up health checks and monitoring. Every code example is a complete, working snippet - not a fragment.
If you are brand new to MCP, start with our getting started guide first. This guide assumes you understand the basics of MCP tools, resources, and the client-server architecture.
Complete Working Server: Jira Integration
Instead of building toy examples, let us create something genuinely useful - an MCP server that connects to Jira's REST API. This server will let an AI list projects, search issues, create issues, and add comments. The full source code is production-ready.
Project Setup
mkdir mcp-jira-server
cd mcp-jira-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node vitest
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist --rootDir src --strict true
The Complete Server (src/index.ts)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
// Configuration from environment variables
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
const JIRA_EMAIL = process.env.JIRA_EMAIL || '';
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
if (!JIRA_BASE_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) {
console.error('Missing required environment variables: JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN');
process.exit(1);
}
const AUTH_HEADER = 'Basic ' + Buffer.from(`${JIRA_EMAIL}:${JIRA_API_TOKEN}`).toString('base64');
// Reusable fetch wrapper with error handling
async function jiraFetch(path: string, options: RequestInit = {}): Promise {
const url = `${JIRA_BASE_URL}/rest/api/3${path}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': AUTH_HEADER,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorBody = await response.text();
throw new McpError(
ErrorCode.InternalError,
`Jira API error (${response.status}): ${errorBody}`
);
}
return response.json();
}
// Create the server
const server = new Server(
{ name: 'jira-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// Register tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_projects',
description: 'List all Jira projects the authenticated user has access to',
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'search_issues',
description: 'Search Jira issues using JQL (Jira Query Language)',
inputSchema: {
type: 'object',
properties: {
jql: { type: 'string', description: 'JQL query string, e.g. "project = MYPROJ AND status = Open"' },
maxResults: { type: 'number', description: 'Maximum results to return (default 20, max 50)', default: 20 }
},
required: ['jql']
}
},
{
name: 'create_issue',
description: 'Create a new Jira issue',
inputSchema: {
type: 'object',
properties: {
projectKey: { type: 'string', description: 'Project key, e.g. "MYPROJ"' },
summary: { type: 'string', description: 'Issue title/summary' },
description: { type: 'string', description: 'Issue description in plain text' },
issueType: { type: 'string', description: 'Issue type: "Bug", "Task", "Story", etc.', default: 'Task' }
},
required: ['projectKey', 'summary']
}
},
{
name: 'add_comment',
description: 'Add a comment to an existing Jira issue',
inputSchema: {
type: 'object',
properties: {
issueKey: { type: 'string', description: 'Issue key, e.g. "MYPROJ-123"' },
comment: { type: 'string', description: 'Comment text' }
},
required: ['issueKey', 'comment']
}
}
]
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_projects': {
const data = await jiraFetch('/project');
const projects = data.map((p: any) => `${p.key}: ${p.name} (${p.projectTypeKey})`);
return {
content: [{ type: 'text', text: `Found ${projects.length} projects:\n${projects.join('\n')}` }]
};
}
case 'search_issues': {
const { jql, maxResults = 20 } = args as { jql: string; maxResults?: number };
const clampedMax = Math.min(maxResults, 50);
const data = await jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${clampedMax}`);
const issues = data.issues.map((issue: any) =>
`${issue.key}: ${issue.fields.summary} [Status: ${issue.fields.status.name}] [Assignee: ${issue.fields.assignee?.displayName || 'Unassigned'}]`
);
return {
content: [{ type: 'text', text: `Found ${data.total} issues (showing ${issues.length}):\n${issues.join('\n')}` }]
};
}
case 'create_issue': {
const { projectKey, summary, description = '', issueType = 'Task' } = args as {
projectKey: string; summary: string; description?: string; issueType?: string;
};
const data = await jiraFetch('/issue', {
method: 'POST',
body: JSON.stringify({
fields: {
project: { key: projectKey },
summary,
description: {
type: 'doc',
version: 1,
content: [{ type: 'paragraph', content: [{ type: 'text', text: description || summary }] }]
},
issuetype: { name: issueType }
}
})
});
return {
content: [{ type: 'text', text: `Created issue ${data.key}: ${JIRA_BASE_URL}/browse/${data.key}` }]
};
}
case 'add_comment': {
const { issueKey, comment } = args as { issueKey: string; comment: string };
await jiraFetch(`/issue/${issueKey}/comment`, {
method: 'POST',
body: JSON.stringify({
body: {
type: 'doc',
version: 1,
content: [{ type: 'paragraph', content: [{ type: 'text', text: comment }] }]
}
})
});
return {
content: [{ type: 'text', text: `Comment added to ${issueKey}` }]
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
);
}
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Jira MCP server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
Add It to Claude Desktop
{
"mcpServers": {
"jira": {
"command": "node",
"args": ["/path/to/mcp-jira-server/dist/index.js"],
"env": {
"JIRA_BASE_URL": "https://yourcompany.atlassian.net",
"JIRA_EMAIL": "you@company.com",
"JIRA_API_TOKEN": "your_api_token_here"
}
}
}
}
Testing with Vitest
MCP servers need tests just like any other software. Vitest is the fastest test runner for TypeScript projects. Here is how to set up comprehensive testing for your custom server.
Test Setup (vitest.config.ts)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
testTimeout: 10000,
setupFiles: ['./src/test-setup.ts'],
},
});
Mock Setup (src/test-setup.ts)
import { vi } from 'vitest';
// Mock global fetch for Jira API calls
global.fetch = vi.fn();
Unit Tests (src/index.test.ts)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
// Set required env vars before importing server
process.env.JIRA_BASE_URL = 'https://test.atlassian.net';
process.env.JIRA_EMAIL = 'test@example.com';
process.env.JIRA_API_TOKEN = 'test-token';
describe('Jira MCP Server', () => {
let client: Client;
beforeEach(async () => {
vi.resetAllMocks();
// Create in-memory transport pair for testing
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// Import and connect server
const { server } = await import('./index.js');
await server.connect(serverTransport);
// Connect test client
client = new Client({ name: 'test-client', version: '1.0.0' }, {});
await client.connect(clientTransport);
});
it('should list available tools', async () => {
const result = await client.listTools();
expect(result.tools).toHaveLength(4);
const toolNames = result.tools.map(t => t.name);
expect(toolNames).toContain('list_projects');
expect(toolNames).toContain('search_issues');
expect(toolNames).toContain('create_issue');
expect(toolNames).toContain('add_comment');
});
it('should search issues with JQL', async () => {
const mockResponse = {
total: 2,
issues: [
{
key: 'TEST-1',
fields: {
summary: 'Fix login bug',
status: { name: 'Open' },
assignee: { displayName: 'Alice' }
}
},
{
key: 'TEST-2',
fields: {
summary: 'Update docs',
status: { name: 'In Progress' },
assignee: null
}
}
]
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await client.callTool({
name: 'search_issues',
arguments: { jql: 'project = TEST', maxResults: 10 }
});
expect(result.content[0].text).toContain('Found 2 issues');
expect(result.content[0].text).toContain('TEST-1');
expect(result.content[0].text).toContain('Unassigned');
});
it('should handle API errors gracefully', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => 'Unauthorized',
});
await expect(
client.callTool({ name: 'list_projects', arguments: {} })
).rejects.toThrow();
});
it('should reject unknown tools', async () => {
await expect(
client.callTool({ name: 'nonexistent_tool', arguments: {} })
).rejects.toThrow();
});
it('should clamp maxResults to 50', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ total: 0, issues: [] }),
});
await client.callTool({
name: 'search_issues',
arguments: { jql: 'project = TEST', maxResults: 200 }
});
const fetchCall = (global.fetch as any).mock.calls[0][0];
expect(fetchCall).toContain('maxResults=50');
});
});
describe('Input Validation', () => {
it('should require JQL for search_issues', async () => {
// Tool schema requires 'jql' - client should validate
// This tests the schema definition is correct
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const { server } = await import('./index.js');
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '1.0.0' }, {});
await client.connect(clientTransport);
const tools = await client.listTools();
const searchTool = tools.tools.find(t => t.name === 'search_issues');
expect(searchTool?.inputSchema.required).toContain('jql');
});
});
Running Tests
# Run all tests
npx vitest run
# Run tests in watch mode during development
npx vitest
# Run with coverage
npx vitest run --coverage
Error Handling Patterns
Production MCP servers must handle errors gracefully. The MCP SDK provides structured error types that clients understand. Here are the key patterns:
Error Classification
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
// Use the right error code for each situation
function handleError(error: unknown): never {
if (error instanceof McpError) {
throw error; // Already an MCP error, re-throw
}
if (error instanceof TypeError) {
throw new McpError(ErrorCode.InvalidParams, `Invalid input: ${error.message}`);
}
if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
throw new McpError(ErrorCode.InternalError, 'Cannot connect to external service. Is it running?');
}
if (error instanceof Error && error.message.includes('401')) {
throw new McpError(ErrorCode.InvalidRequest, 'Authentication failed. Check your API credentials.');
}
if (error instanceof Error && error.message.includes('429')) {
throw new McpError(ErrorCode.InternalError, 'Rate limit exceeded. Try again in a few seconds.');
}
// Catch-all for unexpected errors
throw new McpError(
ErrorCode.InternalError,
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
);
}
Retry Logic for Transient Failures
async function withRetry(
fn: () => Promise,
maxRetries: number = 3,
baseDelayMs: number = 1000
): Promise {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Only retry on transient errors
const isTransient = lastError.message.includes('429') ||
lastError.message.includes('503') ||
lastError.message.includes('ECONNRESET');
if (!isTransient || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff
const delay = baseDelayMs * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Usage in a tool handler
case 'search_issues': {
const data = await withRetry(() => jiraFetch(`/search?jql=${encodeURIComponent(jql)}`));
// ... process data
}
Graceful Shutdown
// Handle process signals for clean shutdown
process.on('SIGINT', async () => {
console.error('Shutting down gracefully...');
await server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.error('Received SIGTERM, shutting down...');
await server.close();
process.exit(0);
});
// Handle uncaught errors without crashing
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
// Log but do not exit - let the MCP client decide whether to restart
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
});
Streamable HTTP Transport
Stdio transport works great for local servers, but if you want to host your MCP server remotely - shared across a team, running on a cloud server, or accessible from multiple machines - you need Streamable HTTP transport. This is the recommended transport for remote servers in 2026, replacing the older SSE approach.
Adding Streamable HTTP to Your Server
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
const app = express();
app.use(express.json());
const server = new Server(
{ name: 'jira-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// ... register your tool handlers (same as before) ...
// Set up the Streamable HTTP transport
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
});
app.post('/mcp', async (req, res) => {
try {
await transport.handleRequest(req, res, req.body);
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/mcp', async (req, res) => {
res.writeHead(405).end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed. Use POST.' },
id: null,
}));
});
app.delete('/mcp', async (req, res) => {
res.writeHead(405).end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Session termination not supported in stateless mode.' },
id: null,
}));
});
// Connect server to transport
await server.connect(transport);
// Start HTTP server
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.error(`Jira MCP server running on http://localhost:${PORT}/mcp`);
});
Connecting from Claude Desktop
To connect to a remote Streamable HTTP server from Claude Desktop:
{
"mcpServers": {
"jira-remote": {
"url": "https://mcp.yourcompany.com/mcp",
"headers": {
"Authorization": "Bearer your-auth-token"
}
}
}
}
Deployment with Docker
Docker is the standard way to deploy MCP servers to production. Here is a complete setup for both stdio and HTTP transports.
Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
RUN addgroup -g 1001 -S mcpuser && adduser -S mcpuser -u 1001
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
# Health check for HTTP transport
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
USER mcpuser
EXPOSE 3001
CMD ["node", "dist/index.js"]
Docker Compose (docker-compose.yml)
version: '3.8'
services:
jira-mcp:
build: .
ports:
- "3001:3001"
environment:
- JIRA_BASE_URL=https://yourcompany.atlassian.net
- JIRA_EMAIL=${JIRA_EMAIL}
- JIRA_API_TOKEN=${JIRA_API_TOKEN}
- PORT=3001
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Build and Run
# Build the image
docker build -t mcp-jira-server .
# Run with environment variables
docker run -d \
--name jira-mcp \
-p 3001:3001 \
-e JIRA_BASE_URL=https://yourcompany.atlassian.net \
-e JIRA_EMAIL=you@company.com \
-e JIRA_API_TOKEN=your_token \
mcp-jira-server
# Check logs
docker logs jira-mcp
# Or use Docker Compose
docker compose up -d
Health Checks and Monitoring
Production MCP servers need observability. Here is how to add health checks, metrics, and structured logging.
Health Check Endpoint
// Add to your Express app (for HTTP transport)
app.get('/health', async (req, res) => {
const health = {
status: 'healthy',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
memoryUsage: process.memoryUsage().rss / 1024 / 1024, // MB
checks: {} as Record,
};
// Check Jira connectivity
try {
await jiraFetch('/myself');
health.checks.jira = 'connected';
} catch {
health.status = 'degraded';
health.checks.jira = 'unreachable';
}
const statusCode = health.status === 'healthy' ? 200 : 503;
res.status(statusCode).json(health);
});
Structured Logging
// Simple structured logger for MCP servers
function log(level: 'info' | 'warn' | 'error', message: string, meta?: Record) {
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...meta,
};
// Write to stderr (stdout is reserved for MCP protocol in stdio transport)
console.error(JSON.stringify(entry));
}
// Usage in tool handlers
case 'search_issues': {
log('info', 'Searching issues', { jql, maxResults: clampedMax });
const startTime = Date.now();
const data = await jiraFetch(`/search?jql=${encodeURIComponent(jql)}&maxResults=${clampedMax}`);
log('info', 'Search completed', {
jql,
totalResults: data.total,
returnedResults: data.issues.length,
durationMs: Date.now() - startTime,
});
// ... process results
}
Metrics Collection
// Simple metrics tracker
class MetricsCollector {
private toolCalls = new Map();
recordCall(toolName: string, durationMs: number, error: boolean) {
const existing = this.toolCalls.get(toolName) || { count: 0, totalMs: 0, errors: 0 };
existing.count++;
existing.totalMs += durationMs;
if (error) existing.errors++;
this.toolCalls.set(toolName, existing);
}
getMetrics() {
const metrics: Record = {};
for (const [tool, data] of this.toolCalls) {
metrics[tool] = {
totalCalls: data.count,
avgDurationMs: Math.round(data.totalMs / data.count),
errorRate: (data.errors / data.count * 100).toFixed(1) + '%',
};
}
return metrics;
}
}
const metrics = new MetricsCollector();
// Add metrics endpoint
app.get('/metrics', (req, res) => {
res.json({
uptime: process.uptime(),
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
tools: metrics.getMetrics(),
});
});
Advanced Patterns
Caching for External APIs
External API calls are slow and rate-limited. Add a cache layer to avoid redundant requests:
class TTLCache {
private cache = new Map();
get(key: string): T | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
set(key: string, value: T, ttlMs: number): void {
this.cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
clear(): void {
this.cache.clear();
}
}
const projectCache = new TTLCache();
// Usage: cache project list for 5 minutes
case 'list_projects': {
const cacheKey = 'projects';
let data = projectCache.get(cacheKey);
if (!data) {
data = await jiraFetch('/project');
projectCache.set(cacheKey, data, 5 * 60 * 1000);
}
// ... format and return
}
Input Validation with Zod
The MCP SDK validates input against your JSON schema, but adding Zod gives you type-safe parsing with better error messages:
import { z } from 'zod';
const SearchIssuesInput = z.object({
jql: z.string().min(1, 'JQL query cannot be empty'),
maxResults: z.number().int().min(1).max(50).default(20),
});
const CreateIssueInput = z.object({
projectKey: z.string().regex(/^[A-Z][A-Z0-9]+$/, 'Project key must be uppercase letters/numbers'),
summary: z.string().min(1).max(255),
description: z.string().max(10000).optional(),
issueType: z.enum(['Bug', 'Task', 'Story', 'Epic']).default('Task'),
});
// Usage in handler
case 'search_issues': {
const parsed = SearchIssuesInput.safeParse(args);
if (!parsed.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid input: ${parsed.error.message}`);
}
const { jql, maxResults } = parsed.data;
// ... proceed with validated, typed data
}
Resource Lifecycle Management
abstract class ManagedResource {
abstract initialize(): Promise;
abstract cleanup(): Promise;
abstract healthCheck(): Promise;
}
class ResourceManager {
private resources: Map = new Map();
async register(name: string, resource: ManagedResource) {
await resource.initialize();
this.resources.set(name, resource);
}
async startHealthChecks(intervalMs: number = 30000) {
setInterval(async () => {
for (const [name, resource] of this.resources) {
try {
const healthy = await resource.healthCheck();
if (!healthy) {
log('warn', `Resource ${name} is unhealthy, reinitializing`);
await resource.cleanup();
await resource.initialize();
}
} catch (error) {
log('error', `Health check failed for ${name}`, {
error: error instanceof Error ? error.message : String(error)
});
}
}
}, intervalMs);
}
async shutdownAll() {
for (const [name, resource] of this.resources) {
try {
await resource.cleanup();
log('info', `Resource ${name} shut down cleanly`);
} catch (error) {
log('error', `Failed to clean up ${name}`, {
error: error instanceof Error ? error.message : String(error)
});
}
}
}
}
Publishing Your Server
Once your server is working, you can publish it for others to use. MCP servers are distributed as regular npm packages.
Preparing for Publication
# Update package.json with the right fields
{
"name": "@yourorg/mcp-server-jira",
"version": "1.0.0",
"description": "MCP server for Jira integration",
"main": "dist/index.js",
"type": "module",
"bin": {
"mcp-server-jira": "dist/index.js"
},
"files": ["dist"],
"keywords": ["mcp", "mcp-server", "jira", "model-context-protocol"],
"engines": { "node": ">=20" }
}
Adding a Shebang for npx
To make your server work with npx -y @yourorg/mcp-server-jira, add a shebang to the top of your entry file:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
// ... rest of your server code
Publishing to npm
# Build and publish
npm run build
npm publish --access public
# Users can then add it to Claude Desktop:
# "command": "npx", "args": ["-y", "@yourorg/mcp-server-jira"]
After publishing, submit your server to the MCPgee directory so other developers can discover it.
Multi-Tool Server Patterns
Real-world servers typically expose multiple related tools. Here are patterns for organizing them effectively:
Domain-Grouped Tools
Group tools by the domain they operate on. Our Jira server example does this naturally - all tools relate to Jira operations. For a more complex server, you might have:
// Project management server with domain-grouped tools
const tools = [
// Issue tools
{ name: 'create_issue', description: 'Create a new issue' },
{ name: 'update_issue', description: 'Update an existing issue' },
{ name: 'search_issues', description: 'Search issues with filters' },
// Sprint tools
{ name: 'get_active_sprint', description: 'Get the current active sprint' },
{ name: 'add_to_sprint', description: 'Add an issue to a sprint' },
// Reporting tools
{ name: 'sprint_burndown', description: 'Get sprint burndown data' },
{ name: 'velocity_report', description: 'Get team velocity over time' },
];
Keeping Tool Count Manageable
Each tool you register consumes tokens for its description in every AI interaction. A server with 20 tools might use 3,000+ tokens just for tool descriptions. Keep your server focused - if you find yourself with more than 10-12 tools, consider splitting into multiple servers.
Security Best Practices
Custom MCP servers often handle sensitive data and credentials. Follow these practices:
- Never log secrets: Mask API tokens and passwords in all log output. A log entry like
Connecting with token: ghp_abc...is a security incident waiting to happen - Validate all input: Use Zod or similar libraries to validate every tool input. The AI can generate unexpected input shapes
- Use read-only credentials: If your server only needs to read data, use credentials that cannot write. This limits damage from prompt injection attacks
- Rate limit tool calls: Add per-tool rate limiting to prevent runaway AI loops from hammering external APIs
- Audit tool usage: Log every tool call with its arguments (minus secrets) for security review
- Pin dependency versions: Use exact versions in package.json to prevent supply chain attacks through compromised npm packages
Debugging Your Server During Development
Debugging MCP servers can be tricky because stdout is reserved for protocol messages. Here are practical debugging techniques:
- Use stderr for logging: Always use
console.error()instead ofconsole.log(). In stdio transport, stdout carries MCP protocol messages, so writing to it corrupts the communication. Stderr is safe and shows up in client logs. - Use the MCP Inspector: The MCP SDK includes an inspector tool that lets you test your server interactively without needing a full AI client. Run
npx @modelcontextprotocol/inspector node dist/index.jsto open a web-based UI where you can call tools and inspect responses. - Test with curl for HTTP transport: If your server uses Streamable HTTP, you can test it directly with curl:
curl -X POST http://localhost:3001/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' - Add verbose mode: Include a
VERBOSEenvironment variable that enables detailed logging. This helps when a user reports an issue - you can ask them to enable verbose mode and share the logs.
Conclusion
You now have a complete toolkit for building production-grade MCP servers: a working server with real API integration, comprehensive tests, error handling, HTTP transport for remote access, Docker deployment, and monitoring. The patterns in this guide scale from simple single-tool servers to complex multi-tool integrations.
For more resources, explore our tutorials, browse the server directory for inspiration, or read the getting started guide if you need to review the fundamentals. To understand how MCP compares to other integration approaches, check our MCP vs REST APIs guide.
