Module 6: Building Custom MCP Servers
From Consumer to Creator
You've configured pre-built servers and used them effectively. Now it's time to build your own. Custom MCP servers let you expose any data source or capability to AI assistants. Whether it's your company's internal API, a custom database, or a specialized tool, you can make it accessible through MCP.
This module walks you through building MCP servers using the TypeScript SDK.
Why Build Custom Servers?
1. Internal Systems Integration Your company has internal tools and APIs that no public server supports.
2. Specialized Workflows You need specific logic that generic servers don't provide.
3. Security Requirements You need to add custom authentication or filtering.
4. Unique Data Sources Proprietary databases, file formats, or services.
5. Business Logic Tools that understand your domain-specific rules.
Prerequisites
To build MCP servers, you'll need:
- Node.js 18 or later
- npm or pnpm
- TypeScript knowledge (basic is fine)
- Understanding of async/await patterns
Project Setup
Create a new directory for your server:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
Install the MCP SDK and TypeScript:
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Update package.json:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Your First Server: Hello World
Create src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Create the server
const server = new Server(
{
name: "hello-world-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "say_hello",
description: "Says hello to the specified name",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "The name to greet",
},
},
required: ["name"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "say_hello") {
const name = request.params.arguments?.name as string;
return {
content: [
{
type: "text",
text: `Hello, ${name}! Welcome to MCP.`,
},
],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Hello World MCP server running on stdio");
}
main().catch(console.error);
Build and test:
npm run build
Adding the Server to Claude
Add to your MCP configuration:
{
"mcpServers": {
"hello-world": {
"command": "node",
"args": ["/path/to/my-mcp-server/dist/index.js"]
}
}
}
Restart Claude and ask: "Say hello to Claude!"
Claude will use your say_hello tool and display the greeting.
Understanding the Server Structure
Let's break down the key components:
Server Creation:
const server = new Server(
{
name: "server-name", // Identifier
version: "1.0.0", // Version string
},
{
capabilities: {
tools: {}, // We provide tools
// resources: {}, // We could provide resources
// prompts: {}, // We could provide prompts
},
}
);
Tool Definition:
{
name: "tool_name", // How Claude references it
description: "What it does", // Claude reads this to decide when to use it
inputSchema: { // JSON Schema for parameters
type: "object",
properties: { ... },
required: [ ... ],
},
}
Tool Implementation:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// request.params.name - which tool was called
// request.params.arguments - the parameters
return {
content: [
{ type: "text", text: "Result here" }
],
};
});
Building a Practical Server: Weather API
Let's build something more useful - a weather information server:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "weather-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Tool definitions
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_weather",
description: "Get current weather for a city",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "City name (e.g., 'London', 'New York')",
},
},
required: ["city"],
},
},
{
name: "get_forecast",
description: "Get 5-day weather forecast for a city",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "City name",
},
},
required: ["city"],
},
},
],
};
});
// Tool implementations
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "get_weather") {
const city = args?.city as string;
// In production, call actual weather API
const weather = await fetchWeather(city);
return {
content: [{ type: "text", text: JSON.stringify(weather, null, 2) }],
};
}
if (name === "get_forecast") {
const city = args?.city as string;
const forecast = await fetchForecast(city);
return {
content: [{ type: "text", text: JSON.stringify(forecast, null, 2) }],
};
}
throw new Error(`Unknown tool: ${name}`);
});
async function fetchWeather(city: string) {
// Replace with actual API call
return {
city,
temperature: 72,
conditions: "Sunny",
humidity: 45,
};
}
async function fetchForecast(city: string) {
// Replace with actual API call
return {
city,
days: [
{ day: "Monday", high: 75, low: 58, conditions: "Sunny" },
{ day: "Tuesday", high: 72, low: 55, conditions: "Partly Cloudy" },
// ...
],
};
}
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
Adding Resources
Resources let AI read data without modifying it. Here's how to add them:
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Update capabilities
const server = new Server(
{ name: "data-server", version: "1.0.0" },
{
capabilities: {
tools: {},
resources: {},
},
}
);
// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "config://app/settings",
name: "Application Settings",
description: "Current application configuration",
mimeType: "application/json",
},
{
uri: "data://users/active",
name: "Active Users",
description: "List of currently active users",
mimeType: "application/json",
},
],
};
});
// Read resource content
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
if (uri === "config://app/settings") {
const settings = getAppSettings(); // Your function
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(settings, null, 2),
},
],
};
}
if (uri === "data://users/active") {
const users = getActiveUsers(); // Your function
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(users, null, 2),
},
],
};
}
throw new Error(`Unknown resource: ${uri}`);
});
Adding Prompts
Prompts provide templates for common operations:
import {
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Update capabilities
const server = new Server(
{ name: "prompt-server", version: "1.0.0" },
{
capabilities: {
prompts: {},
},
}
);
// List available prompts
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: "code_review",
description: "Perform a thorough code review",
arguments: [
{
name: "language",
description: "Programming language",
required: true,
},
{
name: "focus",
description: "What to focus on (security, performance, style)",
required: false,
},
],
},
],
};
});
// Get prompt content
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
if (request.params.name === "code_review") {
const language = request.params.arguments?.language || "unknown";
const focus = request.params.arguments?.focus || "general";
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Please review the following ${language} code with a focus on ${focus}.
Look for bugs, improvements, and best practices.`,
},
},
],
};
}
throw new Error(`Unknown prompt: ${request.params.name}`);
});
Error Handling
Proper error handling makes servers robust:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
// Validate arguments
if (!args?.city) {
return {
content: [
{
type: "text",
text: "Error: City parameter is required",
},
],
isError: true,
};
}
const result = await riskyOperation(args.city);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
} catch (error) {
// Return error to Claude so it can inform the user
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
});
Environment Variables and Configuration
Make your server configurable:
// Read from environment
const API_KEY = process.env.WEATHER_API_KEY;
const BASE_URL = process.env.WEATHER_BASE_URL || "https://api.weather.com";
if (!API_KEY) {
console.error("WEATHER_API_KEY environment variable is required");
process.exit(1);
}
// Use in your functions
async function fetchWeather(city: string) {
const response = await fetch(`${BASE_URL}/weather?city=${city}`, {
headers: { "Authorization": `Bearer ${API_KEY}` },
});
return response.json();
}
Configure in MCP:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/path/to/server/dist/index.js"],
"env": {
"WEATHER_API_KEY": "${WEATHER_API_KEY}"
}
}
}
}
Complete Server Example: Todo List
Here's a complete example of a practical server:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: string;
}
// In-memory storage (use database in production)
let todos: Todo[] = [];
let nextId = 1;
const server = new Server(
{ name: "todo-server", version: "1.0.0" },
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Resources
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "todos://all",
name: "All Todos",
description: "Complete list of all todos",
mimeType: "application/json",
},
{
uri: "todos://pending",
name: "Pending Todos",
description: "List of incomplete todos",
mimeType: "application/json",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
let data: Todo[];
if (uri === "todos://all") {
data = todos;
} else if (uri === "todos://pending") {
data = todos.filter((t) => !t.completed);
} else {
throw new Error(`Unknown resource: ${uri}`);
}
return {
contents: [
{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2) },
],
};
});
// Tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "add_todo",
description: "Add a new todo item",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "The todo title" },
},
required: ["title"],
},
},
{
name: "complete_todo",
description: "Mark a todo as completed",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "The todo ID" },
},
required: ["id"],
},
},
{
name: "delete_todo",
description: "Delete a todo",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "The todo ID" },
},
required: ["id"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "add_todo": {
const todo: Todo = {
id: nextId++,
title: args?.title as string,
completed: false,
createdAt: new Date().toISOString(),
};
todos.push(todo);
return {
content: [
{ type: "text", text: `Created todo #${todo.id}: ${todo.title}` },
],
};
}
case "complete_todo": {
const id = args?.id as number;
const todo = todos.find((t) => t.id === id);
if (!todo) {
return {
content: [{ type: "text", text: `Todo #${id} not found` }],
isError: true,
};
}
todo.completed = true;
return {
content: [{ type: "text", text: `Completed todo #${id}` }],
};
}
case "delete_todo": {
const id = args?.id as number;
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
return {
content: [{ type: "text", text: `Todo #${id} not found` }],
isError: true,
};
}
todos.splice(index, 1);
return {
content: [{ type: "text", text: `Deleted todo #${id}` }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Todo MCP server running");
}
main().catch(console.error);
Key Takeaways
-
SDK structure - Server, Transport, Request Handlers
-
Three primitives - Tools (actions), Resources (data), Prompts (templates)
-
Input schemas - JSON Schema defines what parameters tools accept
-
Error handling - Return errors gracefully so Claude can respond appropriately
-
Configuration - Use environment variables for API keys and settings
-
Testing - Build, then add to MCP config and restart Claude
Looking Ahead
You can now build MCP servers that do almost anything. But with great power comes great responsibility. The next module covers security and permissions - how to ensure your MCP servers are safe to use.
Next up: Module 7 - MCP Security and Permissions

