Building MCP Servers: A Practical Tutorial
Building MCP Servers: A Practical Tutorial
January 9, 2026
How to create custom Model Context Protocol servers for Claude and other AI assistants.
Introduction
The Model Context Protocol (MCP) enables AI assistants like Claude to interact with external services through a standardized interface. Instead of hardcoding integrations, MCP servers expose tools that the assistant can discover and invoke dynamically.
This tutorial walks through building a real MCP server from scratch: a Media Bias/Fact Check lookup tool that provides bias and factuality ratings for news sources.
What you'll learn: - MCP server architecture and concepts - TypeScript implementation patterns - Caching strategies for external APIs - Tool design best practices
Prerequisites
- Node.js 18+ and npm
- TypeScript basics
- An API key (we'll use RapidAPI for this example)
Project Setup
Create a new project:
mkdir mbfc-mcp && cd mbfc-mcp
npm init -y
Install dependencies:
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node
Configure TypeScript (tsconfig.json):
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
Update package.json:
{
"name": "mbfc-mcp",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
}
}
Core Concepts
MCP Architecture
An MCP server provides three types of capabilities:
- Tools - Functions the AI can invoke (e.g.,
lookup_bias) - Resources - Data the AI can read (e.g., files, databases)
- Prompts - Pre-defined prompt templates
For this tutorial, we focus on tools.
Communication
MCP servers communicate over stdio (standard input/output). The SDK handles protocol details - you just define your tools and handlers.
Step 1: Define Types
Create src/types.ts with your data structures:
export interface MBFCSource {
name: string;
url: string;
bias_rating: BiasRating;
factual_reporting: FactualRating;
credibility_rating: CredibilityRating;
detailed_report?: string;
country?: string;
media_type?: string;
}
export type BiasRating =
| "Left" | "Left-Center" | "Center"
| "Right-Center" | "Right"
| "Far Left" | "Far Right"
| "Questionable" | "Satire";
export type FactualRating =
| "Very High" | "High" | "Mostly Factual"
| "Mixed" | "Low" | "Very Low";
export type CredibilityRating =
| "High Credibility" | "Medium Credibility" | "Low Credibility";
export interface MBFCLookupResult {
found: boolean;
source?: MBFCSource;
error?: string;
}
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
Tip: Strong typing catches errors at compile time and provides better documentation for your tools.
Step 2: Implement Caching
External APIs have rate limits and costs. Caching is essential. Create src/cache.ts:
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { CacheEntry } from "./types.js";
export class FileCache {
private cacheDir: string;
private ttl: number;
constructor(ttl: number = 604800) { // 7 days default
this.ttl = ttl;
this.cacheDir = process.env.MBFC_CACHE_DIR
|| join(homedir(), ".mbfc-mcp", "cache");
if (!existsSync(this.cacheDir)) {
mkdirSync(this.cacheDir, { recursive: true });
}
}
private getFilePath(key: string): string {
const sanitized = Buffer.from(key).toString("base64url");
return join(this.cacheDir, `${sanitized}.json`);
}
get<T>(key: string): T | null {
const filePath = this.getFilePath(key);
if (!existsSync(filePath)) return null;
try {
const content = readFileSync(filePath, "utf-8");
const entry: CacheEntry<T> = JSON.parse(content);
if (Date.now() - entry.timestamp > entry.ttl * 1000) {
return null; // Expired
}
return entry.data;
} catch {
return null;
}
}
set<T>(key: string, data: T): void {
const filePath = this.getFilePath(key);
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
ttl: this.ttl
};
writeFileSync(filePath, JSON.stringify(entry, null, 2));
}
}
Design choices: - File-based: Persists across server restarts - Base64url keys: Safe for filesystem - Configurable TTL: Different data has different freshness needs
Step 3: Create the API Client
Create src/api.ts to wrap the external API:
import { MBFCSource, MBFCLookupResult } from "./types.js";
import { FileCache } from "./cache.js";
const RAPIDAPI_HOST = "media-bias-fact-check-ratings-api2.p.rapidapi.com";
export class MBFCClient {
private apiKey: string;
private cache: FileCache;
constructor(apiKey: string) {
if (!apiKey) throw new Error("API key required");
this.apiKey = apiKey;
this.cache = new FileCache();
}
private async request<T>(
endpoint: string,
params: Record<string, string> = {}
): Promise<T> {
const url = new URL(`https://${RAPIDAPI_HOST}${endpoint}`);
Object.entries(params).forEach(([k, v]) =>
url.searchParams.set(k, v)
);
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"X-RapidAPI-Key": this.apiKey,
"X-RapidAPI-Host": RAPIDAPI_HOST
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
return response.json();
}
async lookupByDomain(domain: string): Promise<MBFCLookupResult> {
// Normalize domain
const normalized = domain
.toLowerCase()
.replace(/^(https?:\/\/)?(www\.)?/, "")
.replace(/\/.*$/, "");
// Check cache first
const cacheKey = `domain:${normalized}`;
const cached = this.cache.get<MBFCLookupResult>(cacheKey);
if (cached) return cached;
try {
const data = await this.request<MBFCSource | { error: string }>(
"/api/source",
{ domain: normalized }
);
if ("error" in data) {
const result: MBFCLookupResult = { found: false, error: data.error };
this.cache.set(cacheKey, result);
return result;
}
const result: MBFCLookupResult = { found: true, source: data };
this.cache.set(cacheKey, result);
return result;
} catch (error) {
return {
found: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
}
}
Key patterns:
- Domain normalization: Handle variations like https://www.example.com/path
- Cache-first: Always check cache before API calls
- Graceful errors: Don't throw - return structured error responses
Step 4: Define Tools
Now the main event - src/index.ts:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { MBFCClient } from "./api.js";
const RAPIDAPI_KEY = process.env.RAPIDAPI_KEY;
if (!RAPIDAPI_KEY) {
console.error("Error: RAPIDAPI_KEY environment variable required");
process.exit(1);
}
const client = new MBFCClient(RAPIDAPI_KEY);
// Define available tools
const tools: Tool[] = [
{
name: "mbfc_lookup",
description: `Look up media bias and factuality rating for a news source.
Returns bias rating (Left/Center/Right spectrum), factuality (High/Mixed/Low),
and credibility assessment. Use for ANY news source you cite.`,
inputSchema: {
type: "object",
properties: {
domain: {
type: "string",
description: "Domain of the news source (e.g., 'nytimes.com')"
}
},
required: ["domain"]
}
},
{
name: "mbfc_bias_spectrum",
description: `Categorize multiple sources by political bias.
Returns sources grouped by lean, identifies perspective gaps.`,
inputSchema: {
type: "object",
properties: {
domains: {
type: "array",
items: { type: "string" },
description: "Array of domains to categorize"
}
},
required: ["domains"]
}
}
];
Tool design tips:
- Descriptive names: mbfc_lookup clearly indicates namespace and action
- Detailed descriptions: The AI uses these to decide when to call your tool
- JSON Schema inputs: Enables validation and autocomplete
Step 5: Implement Handlers
Continue in src/index.ts:
// Create server
const server = new Server(
{ name: "mbfc-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "mbfc_lookup": {
const domain = args?.domain as string;
if (!domain) {
return {
content: [{ type: "text", text: "Error: domain required" }],
isError: true
};
}
const result = await client.lookupByDomain(domain);
if (!result.found) {
return {
content: [{
type: "text",
text: JSON.stringify({
found: false,
domain,
message: result.error || "Source not found in MBFC database",
suggestion: "Check source's About page and track record manually."
}, null, 2)
}]
};
}
const source = result.source!;
return {
content: [{
type: "text",
text: JSON.stringify({
found: true,
source: source.name,
bias: {
rating: source.bias_rating,
interpretation: getBiasInterpretation(source.bias_rating)
},
factuality: {
rating: source.factual_reporting,
interpretation: getFactualityInterpretation(source.factual_reporting)
},
credibility: source.credibility_rating
}, null, 2)
}]
};
}
// ... other tool handlers
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true
};
}
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown"}`
}],
isError: true
};
}
});
// Helper functions for human-readable interpretations
function getBiasInterpretation(bias: string): string {
const interpretations: Record<string, string> = {
"Left": "Slight to moderate liberal bias.",
"Left-Center": "Slight liberal bias. Generally factual.",
"Center": "Minimal bias. Balanced reporting.",
"Right-Center": "Slight conservative bias. Generally factual.",
"Right": "Slight to moderate conservative bias.",
"Far Left": "Strong liberal bias. May publish misleading reports.",
"Far Right": "Strong conservative bias. May publish misleading reports.",
"Questionable": "Extreme bias, lack of transparency, or poor factual record."
};
return interpretations[bias] || "Unknown bias category";
}
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MBFC MCP Server running on stdio");
}
main().catch(console.error);
Step 6: Configuration
Add to Claude's MCP config (~/.claude.json):
{
"mcpServers": {
"mbfc-mcp": {
"command": "node",
"args": ["/path/to/mbfc-mcp/dist/index.js"],
"env": {
"RAPIDAPI_KEY": "your-key-here"
}
}
}
}
Build and test:
npm run build
RAPIDAPI_KEY=test node dist/index.js # Should start without errors
Restart Claude Code to load the new MCP server.
Advanced Patterns
Batch Operations
For efficiency, support batch lookups:
{
name: "mbfc_batch_lookup",
description: "Look up bias ratings for multiple domains at once.",
inputSchema: {
type: "object",
properties: {
domains: {
type: "array",
items: { type: "string" },
description: "Array of domains (max 20)"
}
},
required: ["domains"]
}
}
Process with controlled concurrency:
async getBiasSummary(sources: string[]): Promise<Record<string, BiasInfo | null>> {
const results: Record<string, BiasInfo | null> = {};
const batchSize = 5; // Limit concurrent requests
for (let i = 0; i < sources.length; i += batchSize) {
const batch = sources.slice(i, i + batchSize);
const lookups = await Promise.all(
batch.map(domain => this.lookupByDomain(domain))
);
// ... process results
}
return results;
}
Rich Responses
Add interpretation and guidance:
{
spectrum: {
"Left": ["nytimes.com"],
"Center": ["apnews.com", "reuters.com"],
"Right": ["foxnews.com"]
},
perspective_gaps: ["No far-left or far-right sources"],
recommendation: "Good coverage across mainstream spectrum."
}
Environment Configuration
Support customization:
const config = {
cacheTtl: parseInt(process.env.MBFC_CACHE_TTL || "604800"),
cacheDir: process.env.MBFC_CACHE_DIR || "~/.mbfc-mcp/cache",
maxBatchSize: parseInt(process.env.MBFC_MAX_BATCH || "20")
};
Testing
Test your MCP server manually:
# Start server and send JSON-RPC requests
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
Or use the MCP Inspector:
npx @modelcontextprotocol/inspector node dist/index.js
Summary
Building MCP servers follows a consistent pattern:
- Define types for your domain
- Implement caching for external APIs
- Create an API client with error handling
- Define tools with clear descriptions and schemas
- Implement handlers that return structured responses
- Configure in the MCP client
The key to good MCP tools is clarity - clear names, detailed descriptions, and helpful responses that guide the AI toward correct usage.
Resources
This post documents the development of the bias-mcp server, part of Project Aegis. Full source code available in the repository.