MCP Masterclass — Part 3 of 5

Connecting Live Data — APIs, Scraping & Databases

Your MCP server works — now let's connect it to the real world. REST APIs, web scraping, SQL databases, file systems. Plus: caching, rate limiting, error handling, and retry strategies that make your tools production-ready.

REST APIs Web Scraping SQL Databases Caching
MCP from A to Z3/5
REST APIsFinancial DataWeb ScrapingDatabasesFile SystemCaching & Errors
Data Source #1

Fetching from REST APIs

Most MCP tools connect to REST APIs. The pattern is always the same: construct URL, add auth headers, make the request, parse the response, handle errors. Let's build a robust, reusable API client.

The Universal API Helper

lib/api-client.js
// Reusable API client with timeout, retries, and error handling

export async function fetchAPI(url, options = {}) {
  const {
    headers = {},
    timeout = 10000,       // 10s default timeout
    retries = 2,            // Retry twice on failure
    retryDelay = 1000,      // 1s between retries
  } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeout);

      const res = await fetch(url, {
        headers: { "Accept": "application/json", ...headers },
        signal: controller.signal
      });

      clearTimeout(timer);

      if (res.status === 429) {
        // Rate limited — wait and retry
        const wait = parseInt(res.headers.get("Retry-After") || "5") * 1000;
        await new Promise(r => setTimeout(r, wait));
        continue;
      }

      if (!res.ok) {
        throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      }

      return await res.json();
    } catch (err) {
      if (attempt === retries) throw err;
      await new Promise(r => setTimeout(r, retryDelay * (attempt + 1)));
    }
  }
}

This helper handles the three most common API issues: timeouts (AbortController), rate limiting (429 + Retry-After), and transient failures (exponential backoff retries).

Adding Authentication

Authentication Patterns
// Pattern 1: API Key in query string
const url = `https://api.example.com/data?apikey=${process.env.API_KEY}`;

// Pattern 2: API Key in header
await fetchAPI(url, {
  headers: { "X-API-Key": process.env.API_KEY }
});

// Pattern 3: Bearer token (OAuth)
await fetchAPI(url, {
  headers: { "Authorization": `Bearer ${process.env.TOKEN}` }
});

// Pattern 4: Basic auth
const creds = Buffer.from(`${user}:${pass}`).toString("base64");
await fetchAPI(url, {
  headers: { "Authorization": `Basic ${creds}` }
});

Environment Variables are Sacred

Never hardcode API keys in your MCP server code. Always use process.env.VARIABLE_NAME and pass keys through the "env" field in .mcp.json. This keeps secrets out of version control and allows different users to use their own keys.

Data Source #2

Financial Data APIs

Financial data is one of the most valuable use cases for MCP. Let's build tools that fetch stock quotes, crypto prices, and economic indicators from popular free APIs.

Tool #1: Stock Quote (Yahoo Finance via RapidAPI)

tools/stock-quote.js
import { z } from "zod";
import { fetchAPI } from "../lib/api-client.js";

export function registerStockTools(server) {
  const RAPID_KEY = process.env.RAPIDAPI_KEY;

  server.tool(
    "get_stock_quote",
    "Get real-time stock quote with price, change, volume, and key metrics",
    {
      symbol: z.string()
        .toUpperCase()
        .describe("Stock ticker (e.g., AAPL, MSFT, TSLA)"),
      include_extended: z.boolean()
        .optional().default(false)
        .describe("Include pre/post market data")
    },
    async ({ symbol, include_extended }) => {
      const data = await fetchAPI(
        `https://yahoo-finance15.p.rapidapi.com/api/v1/markets/quote?ticker=${symbol}`,
        { headers: { "x-rapidapi-key": RAPID_KEY } }
      );

      const q = data.body;
      const result = {
        symbol: q.symbol,
        name: q.shortName,
        price: q.regularMarketPrice,
        change: q.regularMarketChange,
        changePercent: `${q.regularMarketChangePercent.toFixed(2)}%`,
        volume: q.regularMarketVolume,
        marketCap: q.marketCap,
        peRatio: q.trailingPE,
        fiftyTwoWeekHigh: q.fiftyTwoWeekHigh,
        fiftyTwoWeekLow: q.fiftyTwoWeekLow
      };

      if (include_extended && q.preMarketPrice) {
        result.preMarket = q.preMarketPrice;
        result.afterHours = q.postMarketPrice;
      }

      return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
    }
  );
}

Tool #2: Crypto Prices (CoinGecko — Free, No Key)

tools/crypto.js
export function registerCryptoTools(server) {
  server.tool(
    "get_crypto_price",
    "Get cryptocurrency price, market cap, and 24h stats",
    {
      coin: z.string().toLowerCase()
        .describe("Coin ID (e.g., bitcoin, ethereum, solana)"),
      currency: z.enum(["usd", "eur", "gbp"])
        .optional().default("usd")
        .describe("Fiat currency for prices")
    },
    async ({ coin, currency }) => {
      const data = await fetchAPI(
        `https://api.coingecko.com/api/v3/coins/${coin}`
        + `?localization=false&tickers=false&community_data=false`
      );

      return { content: [{ type: "text", text: JSON.stringify({
        name: data.name,
        symbol: data.symbol.toUpperCase(),
        price: data.market_data.current_price[currency],
        change_24h: `${data.market_data.price_change_percentage_24h.toFixed(2)}%`,
        market_cap: data.market_data.market_cap[currency],
        volume_24h: data.market_data.total_volume[currency],
        ath: data.market_data.ath[currency],
        ath_change: `${data.market_data.ath_change_percentage[currency].toFixed(1)}%`
      }, null, 2) }] };
    }
  );
}
APIFree TierAuthRate LimitBest For
CoinGeckoUnlimitedNone30/minCrypto prices
Alpha Vantage25 req/dayAPI key5/minStock fundamentals
Yahoo Finance500/monthRapidAPI key5/secReal-time quotes
Finnhub60 req/minAPI key60/minNews, earnings
FRED (Fed)UnlimitedAPI key120/minEconomic indicators
Data Source #3

Web Scraping with Cheerio

When there's no API, scraping is the answer. Cheerio is a lightweight HTML parser that uses jQuery syntax. It's perfect for MCP servers because it's fast, has no browser overhead, and works server-side.

Terminal
npm install cheerio

Tool: Scrape Headlines from a News Site

tools/scraper.js
import * as cheerio from "cheerio";

export function registerScraperTools(server) {
  server.tool(
    "scrape_headlines",
    "Scrape top headlines from a financial news site",
    {
      source: z.enum(["reuters", "bloomberg", "cnbc"])
        .describe("News source to scrape"),
      limit: z.number().min(1).max(20).optional().default(10)
        .describe("Number of headlines")
    },
    async ({ source, limit }) => {
      const urls = {
        reuters: "https://www.reuters.com/markets/",
        bloomberg: "https://www.bloomberg.com/markets",
        cnbc: "https://www.cnbc.com/world/?region=world"
      };

      const res = await fetch(urls[source], {
        headers: {
          "User-Agent": "Mozilla/5.0 (compatible; MCPBot/1.0)"
        }
      });
      const html = await res.text();
      const $ = cheerio.load(html);

      const headlines = [];
      $("h3, [data-testid='Heading'] a, .Card-title").each((i, el) => {
        if (headlines.length >= limit) return false;
        const text = $(el).text().trim();
        const link = $(el).closest("a").attr("href") || $(el).find("a").attr("href");
        if (text.length > 10) {
          headlines.push({ title: text, url: link || "" });
        }
      });

      return { content: [{ type: "text",
        text: JSON.stringify({ source, count: headlines.length, headlines }, null, 2)
      }] };
    }
  );
}

Web Scraping Ethics & Pitfalls

Rate limit yourself. Add delays between requests. A good rule: max 1 request per second per domain.

Respect robots.txt. Check site.com/robots.txt before scraping. Many financial sites disallow bot access.

Selectors break. Websites change their HTML structure frequently. Use data attributes ([data-testid]) when available — they're more stable than CSS classes.

Prefer APIs. Scraping should be a last resort. If an API exists (even paid), it's more reliable and ethical.

Data Source #4

Database Connections

Connecting MCP to a database is incredibly powerful — the AI can query data directly, analyze schemas, and even generate reports. Let's implement tools for SQLite (local) and PostgreSQL (remote).

SQLite — Zero-Config Local Database

tools/sqlite.js
import Database from "better-sqlite3";

export function registerSQLiteTools(server) {
  const dbPath = process.env.SQLITE_DB || "./data.db";
  const db = new Database(dbPath, { readonly: true });

  server.tool(
    "query_database",
    "Execute a read-only SQL query against the SQLite database",
    {
      sql: z.string().describe("SQL SELECT query (read-only)"),
      limit: z.number().optional().default(50)
        .describe("Max rows to return (default 50)")
    },
    async ({ sql, limit }) => {
      // Safety: only allow SELECT statements
      const normalized = sql.trim().toUpperCase();
      if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
        return { content: [{ type: "text",
          text: "Error: Only SELECT and WITH (CTE) queries are allowed."
        }], isError: true };
      }

      try {
        const safeSql = sql.replace(/;\s*$/, "") + ` LIMIT ${limit}`;
        const rows = db.prepare(safeSql).all();
        return { content: [{ type: "text",
          text: JSON.stringify({ rowCount: rows.length, rows }, null, 2)
        }] };
      } catch (err) {
        return { content: [{ type: "text",
          text: `SQL Error: ${err.message}`
        }], isError: true };
      }
    }
  );

  // Resource: Database schema
  server.resource("schema", "db://schema",
    { description: "Database table schemas", mimeType: "application/json" },
    async () => {
      const tables = db.prepare(
        "SELECT name, sql FROM sqlite_master WHERE type='table'"
      ).all();
      return { contents: [{ uri: "db://schema",
        mimeType: "application/json",
        text: JSON.stringify(tables, null, 2)
      }] };
    }
  );
}

DuckDB — Analytics on Files

tools/duckdb.js
import duckdb from "duckdb";

export function registerDuckDBTools(server) {
  const db = new duckdb.Database(":memory:");

  server.tool(
    "analyze_csv",
    "Run SQL analytics on a CSV/Parquet file using DuckDB",
    {
      file: z.string().describe("Path to CSV or Parquet file"),
      query: z.string().describe("SQL query (use 'data' as table name)")
    },
    async ({ file, query }) => {
      return new Promise((resolve, reject) => {
        const conn = db.connect();
        // Auto-detect file format
        const viewSQL = file.endsWith(".parquet")
          ? `CREATE OR REPLACE VIEW data AS SELECT * FROM '${file}'`
          : `CREATE OR REPLACE VIEW data AS SELECT * FROM read_csv_auto('${file}')`;

        conn.run(viewSQL, () => {
          conn.all(query, (err, rows) => {
            if (err) resolve({ content: [{ type:"text", text:`Error: ${err.message}` }], isError:true });
            else resolve({ content: [{ type:"text", text: JSON.stringify(rows, null, 2) }] });
          });
        });
      });
    }
  );
}
Data Source #5

File System Access

Reading local files is one of the simplest yet most useful MCP capabilities. JSON configs, CSV exports, log files, Excel reports — all can be exposed as tools or resources.

tools/filesystem.js
import { readFile, readdir, stat } from "node:fs/promises";
import { join, extname } from "node:path";

export function registerFileTools(server) {
  const ROOT = process.env.DATA_DIR || "./data";

  server.tool(
    "read_file",
    "Read a JSON, CSV, or text file from the data directory",
    {
      path: z.string().describe("Relative path within data directory")
    },
    async ({ path }) => {
      // Security: prevent directory traversal
      if (path.includes("..")) {
        return { content: [{ type:"text", text:"Error: Path traversal not allowed" }], isError:true };
      }

      const fullPath = join(ROOT, path);
      const content = await readFile(fullPath, "utf-8");

      // Parse JSON automatically
      if (extname(path) === ".json") {
        const parsed = JSON.parse(content);
        return { content: [{ type:"text",
          text: JSON.stringify(parsed, null, 2)
        }] };
      }

      return { content: [{ type:"text", text: content }] };
    }
  );

  // Resource: List available files
  server.resource("files", "files://list",
    { description: "List available data files" },
    async () => {
      const entries = await readdir(ROOT, { recursive: true });
      return { contents: [{ uri:"files://list", mimeType:"application/json",
        text: JSON.stringify(entries)
      }] };
    }
  );
}

Security: Path Traversal

Always validate file paths to prevent directory traversal attacks. A malicious AI (or prompt injection) could try ../../etc/passwd. Block .. in paths, use path.resolve() and verify the resolved path starts with your allowed root directory. The MCP SDK doesn't enforce this — it's your responsibility.

Production Patterns

Caching, Rate Limiting & Error Handling

Production MCP servers need three things that toy examples skip: caching (don't re-fetch identical data), rate limiting (don't get banned), and graceful error handling (don't crash the agent).

In-Memory TTL Cache

lib/cache.js
export class TTLCache {
  constructor(defaultTTL = 60000) { // 60s default
    this.cache = new Map();
    this.defaultTTL = defaultTTL;
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() > entry.expires) {
      this.cache.delete(key);
      return null;
    }
    return entry.value;
  }

  set(key, value, ttl = this.defaultTTL) {
    this.cache.set(key, {
      value,
      expires: Date.now() + ttl
    });
  }

  wrap(key, fn, ttl) {
    return async (...args) => {
      const cached = this.get(key);
      if (cached) return cached;
      const result = await fn(...args);
      this.set(key, result, ttl);
      return result;
    };
  }
}

Using the Cache in Tools

Usage
const cache = new TTLCache(300000); // 5 min default

server.tool("get_stock_quote", "...", { symbol: z.string() },
  async ({ symbol }) => {
    const cacheKey = `quote:${symbol}`;
    let data = cache.get(cacheKey);

    if (!data) {
      data = await fetchAPI(`.../${symbol}`);
      cache.set(cacheKey, data, 60000); // Cache 60s for real-time data
    }

    return { content: [{ type:"text", text: JSON.stringify(data) }] };
  }
);

Caching Strategy by Data Type

Data TypeTTLRationale
Real-time quotes30-60sChanges every second during market hours
Daily bars/history1 hourOnly updates once per day after close
Fundamentals6 hoursChanges quarterly (earnings)
News headlines5 minFrequent updates, but repetition is fine
Static reference data24 hoursTicker lists, exchange info rarely change
Database schemas1 hourOnly changes on migrations

Graceful Error Handling Pattern

lib/safe-tool.js
// Wrapper that catches all errors and returns MCP-formatted error responses
export function safeTool(handler) {
  return async (params) => {
    try {
      return await handler(params);
    } catch (error) {
      const message = error.name === "AbortError"
        ? "Request timed out. The API may be slow or unreachable."
        : error.message.includes("429")
        ? "Rate limited. Please wait a moment and try again."
        : error.message.includes("ENOTFOUND")
        ? "Cannot reach the API. Check your network connection."
        : `Error: ${error.message}`;

      return {
        content: [{ type: "text", text: message }],
        isError: true
      };
    }
  };
}

// Usage
server.tool("get_quote", "...", { symbol: z.string() },
  safeTool(async ({ symbol }) => {
    // Your logic here — any thrown error is caught and formatted
  })
);
Knowledge Check

Quick Quiz

Why should you use AbortController with fetch in MCP tools?
MCP tool calls have an implicit timeout (the AI host won't wait forever). If your API call hangs, the entire tool call times out with an unhelpful error. AbortController lets you set an explicit timeout (e.g., 10s) and throw a meaningful error message that tells the AI what happened.
What's the security risk of a database query tool, and how do you mitigate it?
The main risk is SQL injection / destructive queries. An AI (or prompt injection) could attempt DROP TABLE or DELETE FROM. Mitigations: (1) Open the database as read-only, (2) Only allow SELECT/WITH statements, (3) Add a LIMIT clause to prevent memory exhaustion, (4) Use a separate read-only database user with restricted permissions.
When should you cache for 30 seconds vs 6 hours?
30s for real-time data (stock quotes, crypto prices) — stale-but-not-stale enough for conversational use. 6h for slowly-changing data (company fundamentals, analyst ratings) that only updates quarterly. The rule: cache TTL should match the data's natural update frequency.
Why use cheerio instead of a headless browser (Puppeteer) for scraping?
Performance. Cheerio parses HTML in memory (~5ms) vs Puppeteer launching a full Chrome instance (~2-5 seconds). For MCP tools that need to respond quickly, Cheerio is ideal for static HTML. Use Puppeteer only when JavaScript rendering is required (SPAs, dynamic content).

Key Takeaways — Part 3

Part 4 of 5
Multi-Tool Servers — Building a Real Platform

MCP from A to Z — Supercharging AI with Live Data

Complete series in 5 parts — From protocol theory to production deployment

Back to Home
MCP from A to Z3/5