mcp-server-writing
MCP Server Writing
Purpose
Guide creation of production-ready Model Context Protocol (MCP) servers following official SDK patterns, security best practices, and real-world implementation patterns.
When to Use This Skill
- Building a new MCP server from scratch
- Adding tools, resources, or prompts to an existing MCP server
- Choosing between TypeScript and Python implementations
- Implementing proper error handling and validation for MCP tools
- Setting up structured logging for MCP servers
When NOT to Use This Skill
- Reviewing existing MCP code (use mcp-server-reviewing)
- Building simple CLI tools that don't need LLM integration
- Creating HTTP REST APIs (MCP uses JSON-RPC, not REST)
- General TypeScript/Python development unrelated to MCP
Quick Decision: TypeScript vs Python
| Factor | TypeScript | Python FastMCP |
|---|---|---|
| Type Safety | Excellent (Zod/Ajv) | Good (type hints) |
| Schema Validation | Built-in with Zod | Decorator-based |
| Prototyping Speed | Medium | Fast |
| Production Readiness | High | High |
| Ecosystem | Node.js, npm | pip, uv |
Recommendation: Use TypeScript for production servers with complex schemas. Use Python FastMCP for rapid prototyping or when integrating with Python libraries.
Core Workflow
Step 1: Initialize Server
TypeScript:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
Python FastMCP:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-mcp-server", json_response=True)
Step 2: Define Tools with Input Schemas
Tools are functions the LLM can call. Always include:
- Clear
descriptionexplaining what the tool does inputSchemawith property descriptions- Validation before processing
TypeScript (Low-Level API):
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
const TOOLS: Tool[] = [
{
name: "process_data",
description:
"Validates and transforms input data. Returns structured result with any validation errors.",
inputSchema: {
type: "object",
properties: {
data: {
type: "string",
description: "Raw data string to process",
},
format: {
type: "string",
enum: ["json", "csv", "xml"],
description: "Expected input format",
},
},
required: ["data", "format"],
},
},
];
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: TOOLS }));
Python FastMCP:
@mcp.tool()
def process_data(data: str, format: str = "json") -> dict:
"""Validates and transforms input data.
Args:
data: Raw data string to process
format: Expected input format (json, csv, xml)
Returns:
Structured result with validation errors if any
"""
# Implementation here
return {"success": True, "processed": data}
Step 3: Implement Tool Handler with Validation
Critical: Always validate inputs before processing. Use Ajv for TypeScript.
import Ajv from "ajv";
const ajv = new Ajv();
const validateProcessDataArgs = ajv.compile({
type: "object",
properties: {
data: { type: "string" },
format: { type: "string", enum: ["json", "csv", "xml"] },
},
required: ["data", "format"],
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "process_data") {
// Validate inputs
if (!validateProcessDataArgs(args)) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "Validation failed",
details: validateProcessDataArgs.errors,
suggestion: "Check input parameters match the schema",
}),
},
],
isError: true,
};
}
// Process valid inputs
const result = processData(args.data, args.format);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
});
Step 4: Add Resources (Optional)
Resources expose data the LLM can read. Use for configuration, status, or reference data.
TypeScript:
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [
{
uri: "config://settings",
name: "Application Settings",
description: "Current server configuration",
mimeType: "application/json",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === "config://settings") {
return {
contents: [
{
uri: "config://settings",
mimeType: "application/json",
text: JSON.stringify({ theme: "dark", version: "1.0.0" }),
},
],
};
}
throw new Error(`Unknown resource: ${request.params.uri}`);
});
Python FastMCP:
@mcp.resource("config://settings")
def get_settings() -> str:
"""Current server configuration."""
return json.dumps({"theme": "dark", "version": "1.0.0"})
Step 5: Add Prompts (Optional)
Prompts are reusable templates the LLM can request.
Python FastMCP:
@mcp.prompt()
def review_code(code: str, language: str = "python") -> str:
"""Generate a code review prompt."""
return f"Please review this {language} code for best practices:\n\n{code}"
Step 6: Implement Structured Logging
Critical for MCP: Log to stderr, NEVER stdout. stdout is reserved for JSON-RPC.
// utils/logger.ts
class Logger {
private log(level: string, message: string, context?: object): void {
const entry = {
timestamp: new Date().toISOString(),
level,
message,
service: "my-mcp-server",
...context,
};
// CRITICAL: Use stderr, not stdout
console.error(JSON.stringify(entry));
}
info(message: string, context?: object): void {
this.log("INFO", message, context);
}
error(message: string, context?: object, error?: Error): void {
this.log("ERROR", message, {
...context,
error: error
? { name: error.name, message: error.message, stack: error.stack }
: undefined,
});
}
}
export const logger = new Logger();
Step 7: Start the Server
TypeScript:
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("MCP Server started", { transport: "stdio" });
}
main().catch((error) => {
logger.error("Fatal error", {}, error);
process.exit(1);
});
Python FastMCP:
if __name__ == "__main__":
mcp.run(transport="stdio")
Examples
Example 1: Tool with Validation Error Response
// Always return actionable suggestions with errors
if (!validateArgs(args)) {
return {
content: [
{
type: "text",
text: JSON.stringify({
success: false,
errors: [
{
field: "partner_id",
message: "Invalid UUID format",
suggestion: "Use format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
],
}),
},
],
isError: true,
};
}
Example 2: ReDoS Prevention
// Limit input length before regex processing
const MAX_INPUT_LENGTH = 50_000;
function safeInput(text: string): string {
return text.length > MAX_INPUT_LENGTH
? text.slice(0, MAX_INPUT_LENGTH)
: text;
}
// Use in tool handlers
const safeText = safeInput(args.text);
const matches = safeText.match(/pattern/);
Example 3: Dynamic Resource Template
@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
"""Get user profile by ID."""
user = fetch_user(user_id)
return json.dumps({"id": user_id, "name": user.name, "role": user.role})
Validation Checklist
Before deploying your MCP server:
- All tools have clear
descriptionfields - All input schema properties have
description - Input validation runs before any processing
- Error responses include
isError: trueflag - Error messages include actionable
suggestion - Logging goes to stderr (not stdout)
- Input length limited before regex processing (ReDoS prevention)
- No hardcoded secrets in code
- Environment variables used for configuration
Common Patterns
Pattern: Structured Error Response
interface ToolError {
field: string;
message: string;
suggestion: string;
}
function createErrorResponse(errors: ToolError[]) {
return {
content: [
{
type: "text",
text: JSON.stringify({ success: false, errors }),
},
],
isError: true,
};
}
Pattern: Tool Invocation Logging
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const startTime = Date.now();
logger.info("Tool invocation started", {
tool: name,
args_keys: Object.keys(args || {}),
});
try {
const result = await handleTool(name, args);
logger.info("Tool invocation completed", {
tool: name,
duration_ms: Date.now() - startTime,
});
return result;
} catch (error) {
logger.error(
"Tool invocation failed",
{
tool: name,
duration_ms: Date.now() - startTime,
},
error,
);
throw error;
}
});
Troubleshooting
Issue: Server hangs or produces garbled output
Cause: Logging to stdout instead of stderr
Solution: Change all console.log() to console.error() for logging
Issue: Validation errors not shown to LLM
Cause: Missing isError: true in response
Solution: Add isError: true to all error responses
Issue: Complex regex causes timeouts
Cause: ReDoS vulnerability with unbounded input
Solution: Limit input length with safeInput() function
Related Skills
- mcp-server-reviewing - Audit MCP servers for best practices
- security-scan - Check for secrets before commits
- quality-check - Run linting and formatting
Resources
- MCP TypeScript SDK
- MCP Python SDK
- MCP Best Practices
- See REFERENCE.md for complete templates and advanced patterns
More from meriley/claude-code-skills
obs-cpp-qt-patterns
C++ and Qt integration patterns for OBS Studio plugins. Covers Qt6 Widgets for settings dialogs, CMAKE_AUTOMOC, OBS frontend API, optional Qt builds with C fallbacks, and modal dialog patterns. Use when adding UI components or C++ features to OBS plugins.
54vendure-developing
Develop Vendure e-commerce plugins, extend GraphQL APIs, create Admin UI components, and define database entities. Use vendure-expert agent for comprehensive guidance across all Vendure development domains.
36vendure-admin-ui-writing
Create Vendure Admin UI extensions with React components, route registration, navigation menus, and GraphQL integration. Handles useQuery, useMutation, useInjector patterns. Use when building Admin UI features for Vendure plugins.
33vendure-entity-writing
Define Vendure database entities extending VendureEntity, with TypeORM decorators, relations, custom fields, and channel-awareness. Use when creating database models in Vendure.
31vendure-graphql-writing
Extend Vendure GraphQL schema with custom types, queries, mutations, and resolvers. Handles RequestContext threading, permissions, and dual Shop/Admin API separation. Use when adding GraphQL endpoints to Vendure.
31vendure-plugin-writing
Create production-ready Vendure plugins with @VendurePlugin decorator, NestJS dependency injection, lifecycle hooks, and configuration patterns. Use when developing new Vendure plugins or extending existing ones.
29