← All Posts

Building MCP Servers: A Practical Tutorial

2026-01-09 8 min read

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:

  1. Tools - Functions the AI can invoke (e.g., lookup_bias)
  2. Resources - Data the AI can read (e.g., files, databases)
  3. 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:

  1. Define types for your domain
  2. Implement caching for external APIs
  3. Create an API client with error handling
  4. Define tools with clear descriptions and schemas
  5. Implement handlers that return structured responses
  6. 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.