Day 12 · Level 2 · Spark Phase

Real API
Integrations

Go beyond toy examples — build production MCP servers that talk to GitHub, Slack, and PostgreSQL with real live data, proper rate-limit handling, secrets management, and integration testing.

📅 Day 12 of 120 🐙 GitHub 💬 Slack 🐘 PostgreSQL
Three Integrations, One Architecture
Today you build three standalone MCP servers — each demonstrating a fundamentally different integration pattern. By the end you'll have a reusable template for integrating any API into MCP.
GitHub MCP
REST API + webhook events
search_repositories list_issues create_issue get_pull_request add_pr_review
Slack MCP
Web API + real-time messaging
send_message list_channels search_messages get_user_info upload_file
PostgreSQL MCP
Connection pool + parameterized queries
query_database list_tables describe_table execute_statement get_query_plan
graph TD A["🤖 Claude / AI Host"] -- "tools/call" --> B["MCP Client Layer"] B --> C["GitHub MCP Server\nsrc/github-mcp/"] B --> D["Slack MCP Server\nsrc/slack-mcp/"] B --> E["PostgreSQL MCP Server\nsrc/pg-mcp/"] C -- "REST + JWT" --> F["api.github.com"] D -- "OAuth Bot Token" --> G["slack.com/api"] E -- "pg pool" --> H["PostgreSQL\nyour-db:5432"] style A fill:#1e1b4b,stroke:#8b5cf6,color:#c4b5fd style B fill:#1a1a24,stroke:#555,color:#888 style C fill:#1a1a10,stroke:#ea580c,color:#fed7aa style D fill:#1a1015,stroke:#ea580c,color:#fed7aa style E fill:#0d1420,stroke:#ea580c,color:#fed7aa style F fill:#111118,stroke:#444,color:#666 style G fill:#111118,stroke:#444,color:#666 style H fill:#111118,stroke:#444,color:#666
Dependencies & Shared Infrastructure
All three servers share the same base scaffold from Day 7 plus integration-specific packages. Install once, configure per-server with environment variables.
npm install
@modelcontextprotocol/sdkMCP server + stdio/SSE transports
zodTool input schema validation
@octokit/restGitHub REST API client with type safety
@slack/web-apiOfficial Slack Web API client
pgPostgreSQL client + connection pool
@types/pgTypeScript types for pg (dev)
bottleneckRate-limit scheduler for all three APIs
// src/shared/base-server.ts — shared bootstrap every integration server uses
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

export function createServer(name: string, version = '1.0.0'): McpServer {
  return new McpServer(
    { name, version },
    {
      capabilities: {
        tools:     { listChanged: false },
        resources: { listChanged: false },
        logging:   {},
      },
    }
  );
}

export async function startStdio(server: McpServer): Promise<void> {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error(`[${server.name}] started on stdio`);
}

// src/shared/rate-limiter.ts — bottleneck limiter factory
import Bottleneck from 'bottleneck';

export function createLimiter(options: {
  requestsPerSecond: number;
  maxConcurrent: number;
}): Bottleneck {
  return new Bottleneck({
    minTime:      Math.ceil(1000 / options.requestsPerSecond),
    maxConcurrent: options.maxConcurrent,
    reservoir:    options.requestsPerSecond * 10, // burst capacity
    reservoirRefreshAmount: options.requestsPerSecond * 10,
    reservoirRefreshInterval: 10_000,
  });
}src/shared/
GitHub MCP Server
GitHub's REST API is one of the most feature-rich — and rate-limited — APIs you'll integrate with. The Octokit client handles auth and retries; your MCP layer adds tool schemas, resource templates, and structured error handling.
// src/github-mcp/index.ts
import { Octokit } from '@octokit/rest';
import { z } from 'zod';
import { createServer, startStdio } from '../shared/base-server.js';
import { createLimiter } from '../shared/rate-limiter.js';

const gh = new Octokit({
  auth: process.env.GITHUB_TOKEN,
  throttle: {  // Octokit built-in retry on 429/5xx
    onRateLimit:    (retryAfter: number, options: any) => options.request.retryCount < 2,
    onSecondaryRateLimit: (_: number, options: any) => options.request.retryCount < 1,
  },
});

// 5 req/sec — GitHub REST allows 5000/hr authenticated
const limiter = createLimiter({ requestsPerSecond: 5, maxConcurrent: 3 });
const ghCall = <T>(fn: () => Promise<T>) => limiter.schedule(fn);

const server = createServer('github-mcp');

// ── Tool: search_repositories ────────────────────────────────────────────
server.tool(
  'search_repositories',
  'Search GitHub repositories with filters. Returns name, description, stars, language, URL.',
  {
    query:    z.string().min(1).describe('GitHub search query (e.g. "mcp typescript stars:>100")'),
    sort:     z.enum(['stars','forks','updated']).default('stars').describe('Sort order'),
    per_page: z.number().int().min(1).max(30).default(10).describe('Results per page (max 30)'),
    page:     z.number().int().min(1).default(1).describe('Page number for pagination'),
  },
  async ({ query, sort, per_page, page }) => {
    try {
      const { data } = await ghCall(() =>
        gh.rest.search.repos({ q: query, sort, per_page, page })
      );
      const lines = [
        `Found ${data.total_count.toLocaleString()} repositories (page ${page}):`,
        '',
        ...data.items.map(r =>
          `**${r.full_name}** ⭐ ${r.stargazers_count.toLocaleString()}\n` +
          `  ${r.description ?? 'No description'}\n` +
          `  Language: ${r.language ?? 'unknown'} | Forks: ${r.forks_count}\n` +
          `  ${r.html_url}`
        ),
        data.items.length === per_page ? `\nNext page: ${page + 1}` : '\nNo more results.',
      ];
      return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `GitHub search failed: ${err.message}` }] };
    }
  }
);

// ── Tool: list_issues ─────────────────────────────────────────────────────
server.tool(
  'list_issues',
  'List issues for a GitHub repository with optional filters.',
  {
    owner:  z.string().describe('Repository owner (user or org)'),
    repo:   z.string().describe('Repository name'),
    state:  z.enum(['open','closed','all']).default('open').describe('Issue state filter'),
    labels: z.string().optional().describe('Comma-separated label names to filter by'),
    limit:  z.number().int().min(1).max(50).default(20).describe('Max issues to return'),
  },
  async ({ owner, repo, state, labels, limit }) => {
    try {
      const { data } = await ghCall(() =>
        gh.rest.issues.listForRepo({
          owner, repo, state,
          labels: labels ?? undefined,
          per_page: limit,
        })
      );
      // Filter out pull requests (GitHub returns them in issues endpoint)
      const issues = data.filter(i => !i.pull_request);
      const lines = [
        `${issues.length} ${state} issue(s) in ${owner}/${repo}:`,
        '',
        ...issues.map(i =>
          `#${i.number} [${i.state}] ${i.title}\n` +
          `  Labels: ${i.labels.map((l: any) => l.name).join(', ') || 'none'}\n` +
          `  Opened: ${new Date(i.created_at).toDateString()} by ${i.user?.login}\n` +
          `  ${i.html_url}`
        ),
      ];
      return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `list_issues failed: ${err.message}` }] };
    }
  }
);

// ── Tool: create_issue ────────────────────────────────────────────────────
server.tool(
  'create_issue',
  'Create a new issue in a GitHub repository.',
  {
    owner:  z.string().describe('Repository owner'),
    repo:   z.string().describe('Repository name'),
    title:  z.string().min(1).max(256).describe('Issue title'),
    body:   z.string().optional().describe('Issue body in Markdown'),
    labels: z.array(z.string()).optional().describe('Label names to apply'),
  },
  { readOnlyHint: false, destructiveHint: false },
  async ({ owner, repo, title, body, labels }) => {
    try {
      const { data } = await ghCall(() =>
        gh.rest.issues.create({ owner, repo, title, body, labels })
      );
      return {
        content: [{
          type: 'text' as const,
          text: `Issue created: #${data.number} — ${data.title}\n${data.html_url}`,
        }],
      };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `create_issue failed: ${err.message}` }] };
    }
  }
);

// ── Tool: get_pull_request ────────────────────────────────────────────────
server.tool(
  'get_pull_request',
  'Fetch full details of a pull request including diff stats and review status.',
  {
    owner:      z.string().describe('Repository owner'),
    repo:       z.string().describe('Repository name'),
    pr_number:  z.number().int().min(1).describe('Pull request number'),
  },
  async ({ owner, repo, pr_number }) => {
    try {
      const [{ data: pr }, { data: reviews }] = await Promise.all([
        ghCall(() => gh.rest.pulls.get({ owner, repo, pull_number: pr_number })),
        ghCall(() => gh.rest.pulls.listReviews({ owner, repo, pull_number: pr_number })),
      ]);
      const summary = [
        `PR #${pr.number}: ${pr.title}`,
        `State: ${pr.state} | Draft: ${pr.draft ? 'yes' : 'no'}`,
        `Author: ${pr.user?.login} | Base: ${pr.base.ref} ← ${pr.head.ref}`,
        `+${pr.additions} / -${pr.deletions} in ${pr.changed_files} file(s)`,
        `Reviews: ${reviews.length} (${reviews.filter(r => r.state === 'APPROVED').length} approved)`,
        `Mergeable: ${pr.mergeable ?? 'unknown'}`,
        '',
        pr.body ? `## Description\n${pr.body.slice(0, 500)}${pr.body.length > 500 ? '…' : ''}` : 'No description.',
        '',
        pr.html_url,
      ];
      return { content: [{ type: 'text' as const, text: summary.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `get_pull_request failed: ${err.message}` }] };
    }
  }
);

await startStdio(server);src/github-mcp/index.ts
Octokit's built-in retry plugin handles GitHub's 403 secondary rate limits automatically. Combined with Bottleneck's limiter, you get defense in depth — Bottleneck prevents hitting the limit, Octokit retries when you do. The two layers complement each other.
Slack MCP Server
Slack's Web API uses OAuth Bot tokens for authentication. Unlike GitHub, Slack's rate limits are per-method — different endpoints have different tiers. The @slack/web-api client handles retries automatically; you focus on building useful tools.
⚠️
Bot token scopes matter. Your Slack app must request the right OAuth scopes before the bot token will work. The tools below require: channels:read, channels:history, chat:write, files:write, search:read, users:read. Add them in your Slack App manifest.
// src/slack-mcp/index.ts
import { WebClient } from '@slack/web-api';
import { z } from 'zod';
import { createServer, startStdio } from '../shared/base-server.js';
import { createLimiter } from '../shared/rate-limiter.js';

const slack = new WebClient(process.env.SLACK_BOT_TOKEN, {
  retryConfig: { retries: 3 },  // auto-retry on 429 and 5xx
});

// Tier 3 methods: 50 req/min → ~0.8 req/sec with headroom
const limiter = createLimiter({ requestsPerSecond: 0.8, maxConcurrent: 2 });
const slackCall = <T>(fn: () => Promise<T>) => limiter.schedule(fn);

const server = createServer('slack-mcp');

// ── Tool: list_channels ───────────────────────────────────────────────────
server.tool(
  'list_channels',
  'List public Slack channels with member counts and topics.',
  {
    limit:   z.number().int().min(1).max(100).default(20).describe('Max channels to return'),
    cursor:  z.string().optional().describe('Pagination cursor from previous call'),
    exclude_archived: z.boolean().default(true).describe('Skip archived channels'),
  },
  async ({ limit, cursor, exclude_archived }) => {
    try {
      const result = await slackCall(() =>
        slack.conversations.list({
          limit,
          cursor,
          exclude_archived,
          types: 'public_channel',
        })
      );
      if (!result.ok) throw new Error(result.error);
      const channels = result.channels ?? [];
      const lines = [
        `${channels.length} channel(s):`,
        '',
        ...channels.map((c: any) =>
          `#${c.name} (${c.num_members} members)\n  Topic: ${c.topic?.value || 'none'}\n  ID: ${c.id}`
        ),
        result.response_metadata?.next_cursor
          ? `\nNext cursor: ${result.response_metadata.next_cursor}`
          : '\nNo more channels.',
      ];
      return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `list_channels failed: ${err.message}` }] };
    }
  }
);

// ── Tool: send_message ────────────────────────────────────────────────────
server.tool(
  'send_message',
  'Send a message to a Slack channel or DM. Supports Markdown via Slack mrkdwn.',
  {
    channel:     z.string().describe('Channel ID (C01234) or user ID (U01234) for DM'),
    text:        z.string().min(1).max(4000).describe('Message text (Slack mrkdwn supported)'),
    thread_ts:   z.string().optional().describe('Thread timestamp to reply in a thread'),
    unfurl_links: z.boolean().default(false).describe('Whether to unfurl link previews'),
  },
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
  async ({ channel, text, thread_ts, unfurl_links }) => {
    try {
      const result = await slackCall(() =>
        slack.chat.postMessage({ channel, text, thread_ts, unfurl_links })
      );
      if (!result.ok) throw new Error(result.error);
      return {
        content: [{
          type: 'text' as const,
          text: `Message sent ✓\nChannel: ${channel}\nTimestamp: ${result.ts}\nPermalink: https://slack.com/archives/${channel}/p${result.ts?.replace('.', '')}`,
        }],
      };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `send_message failed: ${err.message}` }] };
    }
  }
);

// ── Tool: search_messages ─────────────────────────────────────────────────
server.tool(
  'search_messages',
  'Full-text search across Slack messages the bot can access.',
  {
    query:    z.string().min(1).describe('Search query (supports Slack search modifiers: in:channel, from:user, before:date)'),
    count:    z.number().int().min(1).max(20).default(10).describe('Number of results'),
    sort:     z.enum(['score','timestamp']).default('score').describe('Result ordering'),
  },
  async ({ query, count, sort }) => {
    try {
      const result = await slackCall(() =>
        slack.search.messages({ query, count, sort })
      );
      if (!result.ok) throw new Error(result.error);
      const messages = result.messages?.matches ?? [];
      const lines = [
        `Found ${result.messages?.total ?? 0} message(s), showing ${messages.length}:`,
        '',
        ...messages.map((m: any) =>
          `[${new Date(Number(m.ts) * 1000).toISOString().slice(0,10)}] #${m.channel?.name ?? m.channel?.id}\n` +
          `  ${m.username ?? m.user}: ${m.text?.slice(0, 200)}\n` +
          `  ${m.permalink}`
        ),
      ];
      return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `search_messages failed: ${err.message}` }] };
    }
  }
);

// ── Tool: get_user_info ───────────────────────────────────────────────────
server.tool(
  'get_user_info',
  'Look up a Slack user by email address or user ID.',
  {
    identifier: z.string().describe('Slack user ID (U01234…) or email address'),
  },
  async ({ identifier }) => {
    try {
      const result = await slackCall(() =>
        identifier.includes('@')
          ? slack.users.lookupByEmail({ email: identifier })
          : slack.users.info({ user: identifier })
      );
      if (!result.ok) throw new Error((result as any).error);
      const user = (result as any).user;
      return {
        content: [{
          type: 'text' as const,
          text: [
            `User: ${user.real_name} (@${user.name})`,
            `ID: ${user.id}`,
            `Email: ${user.profile?.email ?? 'hidden'}`,
            `Title: ${user.profile?.title || 'none'}`,
            `Timezone: ${user.tz}`,
            `Status: ${user.profile?.status_emoji} ${user.profile?.status_text}`,
            `Deleted: ${user.deleted} | Bot: ${user.is_bot}`,
          ].join('\n'),
        }],
      };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `get_user_info failed: ${err.message}` }] };
    }
  }
);

await startStdio(server);src/slack-mcp/index.ts
PostgreSQL MCP Server
Database integrations require extra care: parameterized queries to prevent SQL injection, connection pooling to avoid exhausting database connections, and a read/write permission split so AI never accidentally runs a DELETE without explicit intent.
🚨
SQL injection is a real threat in AI contexts. An LLM might construct SQL based on user input. Always use parameterized queries ($1, $2, …) — never string-concatenate user input into SQL. The query_database tool only accepts read statements; write operations require an explicit separate tool with destructive annotation.
// src/pg-mcp/index.ts
import pg from 'pg';
import { z } from 'zod';
import { createServer, startStdio } from '../shared/base-server.js';

const { Pool } = pg;

// Connection pool — max 5 connections (be conservative with DB resources)
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max:              5,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false,
});

// Verify connection on startup
pool.query('SELECT 1').then(() => {
  console.error('[pg-mcp] Database connected ✓');
}).catch(err => {
  console.error('[pg-mcp] Database connection failed:', err.message);
  process.exit(1);
});

const server = createServer('pg-mcp');

// ── Tool: list_tables ─────────────────────────────────────────────────────
server.tool(
  'list_tables',
  'List all user-defined tables and views in the connected database with row estimates.',
  {
    schema: z.string().default('public').describe('PostgreSQL schema name'),
  },
  { readOnlyHint: true },
  async ({ schema }) => {
    try {
      const { rows } = await pool.query(
        `SELECT
           table_name,
           table_type,
           pg_size_pretty(pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name))) AS size,
           (SELECT reltuples::bigint FROM pg_class WHERE relname = table_name) AS row_estimate
         FROM information_schema.tables
         WHERE table_schema = $1
           AND table_type IN ('BASE TABLE', 'VIEW')
         ORDER BY table_name`,
        [schema]
      );
      const lines = [
        `${rows.length} table(s)/view(s) in schema "${schema}":`,
        '',
        ...rows.map((r: any) =>
          `${r.table_type === 'VIEW' ? '👁' : '📋'} ${r.table_name}  ~${Number(r.row_estimate).toLocaleString()} rows  ${r.size}`
        ),
      ];
      return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `list_tables failed: ${err.message}` }] };
    }
  }
);

// ── Tool: describe_table ──────────────────────────────────────────────────
server.tool(
  'describe_table',
  'Show columns, types, constraints, and indexes for a table.',
  {
    table:  z.string().describe('Table name'),
    schema: z.string().default('public').describe('Schema name'),
  },
  { readOnlyHint: true },
  async ({ table, schema }) => {
    try {
      const { rows: cols } = await pool.query(
        `SELECT column_name, data_type, character_maximum_length,
                is_nullable, column_default
         FROM information_schema.columns
         WHERE table_schema = $1 AND table_name = $2
         ORDER BY ordinal_position`,
        [schema, table]
      );
      const { rows: idxs } = await pool.query(
        `SELECT indexname, indexdef
         FROM pg_indexes
         WHERE schemaname = $1 AND tablename = $2`,
        [schema, table]
      );
      const colLines = cols.map((c: any) =>
        `  ${c.column_name.padEnd(28)} ${c.data_type}${c.character_maximum_length ? `(${c.character_maximum_length})` : ''}${c.is_nullable === 'NO' ? ' NOT NULL' : ''}${c.column_default ? ` DEFAULT ${c.column_default}` : ''}`
      );
      const idxLines = idxs.map((i: any) => `  ${i.indexname}: ${i.indexdef.replace(/.*USING/, 'USING')}`);
      return {
        content: [{
          type: 'text' as const,
          text: [
            `Table: ${schema}.${table}`,
            `\nColumns (${cols.length}):`,
            ...colLines,
            idxLines.length ? `\nIndexes (${idxLines.length}):` : '',
            ...idxLines,
          ].filter(Boolean).join('\n'),
        }],
      };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `describe_table failed: ${err.message}` }] };
    }
  }
);

// ── Tool: query_database ──────────────────────────────────────────────────
server.tool(
  'query_database',
  'Execute a SELECT query against the database. READ-ONLY — no INSERT/UPDATE/DELETE.',
  {
    sql:    z.string().describe('SQL SELECT statement (parameterized $1,$2 placeholders allowed)'),
    params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
              .default([])
              .describe('Parameter values for $1, $2, … placeholders'),
    limit:  z.number().int().min(1).max(500).default(50).describe('Hard cap on rows returned'),
  },
  { readOnlyHint: true },
  async ({ sql, params, limit }) => {
    // Reject non-SELECT statements at the tool layer
    const normalized = sql.trim().toLowerCase();
    if (!/^(select|with|explain)\b/.test(normalized)) {
      return {
        isError: true,
        content: [{ type: 'text' as const, text: 'Only SELECT, WITH, and EXPLAIN statements are allowed in query_database. Use execute_statement for writes.' }],
      };
    }
    try {
      // Wrap in a read-only transaction for extra safety
      const client = await pool.connect();
      try {
        await client.query('BEGIN READ ONLY');
        // Enforce row limit by wrapping in a subquery
        const limitedSql = `SELECT * FROM (${sql}) _q LIMIT $${params.length + 1}`;
        const { rows, fields } = await client.query(limitedSql, [...params, limit]);
        await client.query('COMMIT');
        const header = fields.map((f: any) => f.name).join(' | ');
        const separator = '-'.repeat(header.length);
        const dataRows = rows.map((r: any) =>
          fields.map((f: any) => String(r[f.name] ?? 'NULL')).join(' | ')
        );
        return {
          content: [{
            type: 'text' as const,
            text: [
              `${rows.length} row(s) returned:`,
              header, separator, ...dataRows,
              rows.length === limit ? `\n⚠ Row limit (${limit}) reached — results may be truncated.` : '',
            ].filter(Boolean).join('\n'),
          }],
        };
      } finally {
        client.release();
      }
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `query_database failed: ${err.message}` }] };
    }
  }
);

// ── Tool: execute_statement ───────────────────────────────────────────────
server.tool(
  'execute_statement',
  'Execute a write SQL statement (INSERT/UPDATE/DELETE). DESTRUCTIVE — use with caution.',
  {
    sql:    z.string().describe('SQL statement with $1,$2 placeholders'),
    params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).default([]),
  },
  { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
  async ({ sql, params }) => {
    const normalized = sql.trim().toLowerCase();
    // Block DDL — no DROP, ALTER, TRUNCATE from AI
    if (/\b(drop|alter|truncate|create|grant|revoke)\b/.test(normalized)) {
      return {
        isError: true,
        content: [{ type: 'text' as const, text: 'DDL statements (DROP/ALTER/TRUNCATE/CREATE) are not permitted.' }],
      };
    }
    try {
      const { rowCount, command } = await pool.query(sql, params);
      return {
        content: [{
          type: 'text' as const,
          text: `${command} completed. Rows affected: ${rowCount}`,
        }],
      };
    } catch (err: any) {
      return { isError: true, content: [{ type: 'text' as const, text: `execute_statement failed: ${err.message}` }] };
    }
  }
);

// Graceful shutdown — release pool connections
process.on('SIGTERM', async () => { await pool.end(); process.exit(0); });

await startStdio(server);src/pg-mcp/index.ts
Rate Limits — Know Your APIs
Every external API has different rate-limiting rules, error codes, and retry guidance. Encode this knowledge into your Bottleneck configuration so you stay safely inside limits even under heavy AI-driven load.
APIDefault limitAuthenticated limitRate-limit errorRetry afterRisk
GitHub REST 60 req/hr unauthenticated 5,000 req/hr (PAT) HTTP 429 + X-RateLimit-Reset X-RateLimit-Reset header (Unix ts) LOW
GitHub Secondary Concurrent request limit HTTP 403 + message body Retry-After header (seconds) MED
Slack Tier 1 1 req/min (heavy methods) HTTP 429 {"ok":false,"error":"ratelimited"} Retry-After header HIGH
Slack Tier 2 20 req/min HTTP 429 Retry-After header MED
Slack Tier 3 50 req/min (most read methods) HTTP 429 Retry-After header LOW
PostgreSQL max_connections (default 100) Set by pool size (use max 5–10) pg error: too many connections Pool queue (automatic) MED
🎯
Bottleneck's reservoir pattern gives you burst capacity (short bursts above the sustained rate) while staying within hourly limits. Set reservoir = requestsPerSecond × 10 for 10-second burst tolerance and reservoirRefreshInterval = 10_000 to refill every 10 seconds.
Secrets Management for Integrations
Each integration server needs different credentials. The pattern that scales from local dev to production cloud: always environment variables, never hardcode, rotate without redeployment.
// src/shared/secrets.ts — typed secrets loader with validation
import { z } from 'zod';

// ── GitHub MCP secrets ────────────────────────────────────────────────────
const githubSecrets = z.object({
  GITHUB_TOKEN: z.string().min(1, 'GITHUB_TOKEN is required'),
});

// ── Slack MCP secrets ─────────────────────────────────────────────────────
const slackSecrets = z.object({
  SLACK_BOT_TOKEN: z.string().startsWith('xoxb-', 'Must be a Slack Bot Token (xoxb-)'),
  SLACK_APP_TOKEN: z.string().startsWith('xapp-', 'App-level token for Socket Mode').optional(),
});

// ── PostgreSQL MCP secrets ────────────────────────────────────────────────
const pgSecrets = z.object({
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid PostgreSQL connection string'),
  DATABASE_SSL:  z.enum(['true','false']).default('false'),
});

export type ServerType = 'github' | 'slack' | 'pg';

export function loadSecrets(type: ServerType) {
  const schemas = { github: githubSecrets, slack: slackSecrets, pg: pgSecrets };
  const result = schemas[type].safeParse(process.env);
  if (!result.success) {
    console.error(`[${type}-mcp] Missing secrets:\n${result.error.issues.map(i => `  ✗ ${i.path.join('.')}: ${i.message}`).join('\n')}`);
    process.exit(1);
  }
  return result.data;
}

// Call at startup — fails fast with clear error messages
// const secrets = loadSecrets('github');
// const { GITHUB_TOKEN } = secrets;src/shared/secrets.ts
# .env.github
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# .env.slack
SLACK_BOT_TOKEN=xoxb-000000000000-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx

# .env.pg
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DATABASE_SSL=false

# Claude Desktop config — separate server per integration
# claude_desktop_config.json
{
  "mcpServers": {
    "github": {
      "command": "node",
      "args": ["./src/github-mcp/dist/index.js"],
      "env": { "GITHUB_TOKEN": "ghp_your_token_here" }
    },
    "slack": {
      "command": "node",
      "args": ["./src/slack-mcp/dist/index.js"],
      "env": { "SLACK_BOT_TOKEN": "xoxb-your-token-here" }
    },
    "pg": {
      "command": "node",
      "args": ["./src/pg-mcp/dist/index.js"],
      "env": { "DATABASE_URL": "postgresql://localhost/mydb" }
    }
  }
}env + config
Cross-API Aggregation Patterns
The real power emerges when Claude orchestrates multiple integration servers together — searching GitHub for open issues, pulling related Slack threads, and querying the database for affected customers, all in one conversation. Here's how to structure tools that fan out across APIs.
// src/composite/dev-briefing.ts
// A "meta" tool that aggregates data from GitHub + Slack + Postgres
// This runs inside a composite server that connects to all three

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { z } from 'zod';
import { createServer, startStdio } from '../shared/base-server.js';

// Pre-connected clients (created at startup)
declare const githubClient: Client;
declare const slackClient:  Client;
declare const pgClient:     Client;

const server = createServer('composite-mcp');

server.tool(
  'get_incident_briefing',
  'Aggregate incident context: open GitHub issues + Slack #incidents messages + affected-user count from DB.',
  {
    label:   z.string().describe('GitHub issue label (e.g. "bug", "incident")'),
    channel: z.string().describe('Slack channel ID for incident discussion'),
    since:   z.string().describe('ISO date — look back from this date (e.g. 2026-04-01)'),
  },
  async ({ label, channel, since }) => {
    // Fan out to three APIs concurrently
    const [issuesResult, messagesResult, dbResult] = await Promise.allSettled([
      githubClient.callTool({
        name: 'list_issues',
        arguments: { owner: 'myorg', repo: 'myapp', state: 'open', labels: label },
      }),
      slackClient.callTool({
        name: 'search_messages',
        arguments: { query: `in:${channel} after:${since}`, count: 20 },
      }),
      pgClient.callTool({
        name: 'query_database',
        arguments: {
          sql: `SELECT COUNT(*) AS affected FROM users WHERE last_error_at > $1`,
          params: [since],
        },
      }),
    ]);

    const sections: string[] = ['## Incident Briefing\n'];

    // GitHub issues
    if (issuesResult.status === 'fulfilled' && !issuesResult.value.isError) {
      sections.push('### Open Issues\n' + (issuesResult.value.content[0] as any).text);
    } else {
      sections.push('### Open Issues\n⚠ Failed to fetch GitHub issues.');
    }

    // Slack messages
    if (messagesResult.status === 'fulfilled' && !messagesResult.value.isError) {
      sections.push('\n### Slack Discussion\n' + (messagesResult.value.content[0] as any).text);
    } else {
      sections.push('\n### Slack Discussion\n⚠ Failed to fetch Slack messages.');
    }

    // DB count
    if (dbResult.status === 'fulfilled' && !dbResult.value.isError) {
      sections.push('\n### Affected Users\n' + (dbResult.value.content[0] as any).text);
    } else {
      sections.push('\n### Affected Users\n⚠ Failed to query database.');
    }

    return { content: [{ type: 'text' as const, text: sections.join('\n') }] };
  }
);

await startStdio(server);src/composite/dev-briefing.ts
💡
Promise.allSettled is essential for aggregation. Unlike Promise.all, it never rejects — each API call either fulfills or fails independently. You always get a result with partial data rather than a total failure if one API is down. Always use allSettled for cross-API fan-out.
Testing Integration Servers
You shouldn't call real external APIs in CI. Use Vitest with intercepted fetch calls to simulate API responses — your tool handlers run with full business logic, just against mocked HTTP instead of live endpoints.
// tests/github-mcp.test.ts — test with mocked Octokit import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { createInProcessTransportPair } from '../src/shared/in-process-transport.js'; // Mock Octokit before importing the server vi.mock('@octokit/rest', () => ({ Octokit: vi.fn().mockImplementation(() => ({ rest: { search: { repos: vi.fn().mockResolvedValue({ data: { total_count: 2, items: [ { full_name: 'org/repo-a', description: 'Test repo A', stargazers_count: 100, language: 'TypeScript', forks_count: 10, html_url: 'https://github.com/org/repo-a' }, { full_name: 'org/repo-b', description: 'Test repo B', stargazers_count: 50, language: 'JavaScript', forks_count: 5, html_url: 'https://github.com/org/repo-b' }, ], }, }), }, issues: { listForRepo: vi.fn().mockResolvedValue({ data: [ { number: 42, state: 'open', title: 'Bug: crash on startup', labels: [{ name: 'bug' }], created_at: '2026-01-01T00:00:00Z', user: { login: 'alice' }, html_url: 'https://github.com/org/repo/issues/42', pull_request: undefined }, ], }), }, }, })), })); describe('GitHub MCP Server', () => { let client: Client; beforeEach(async () => { const [clientTransport, serverTransport] = createInProcessTransportPair(); const { buildGitHubServer } = await import('../src/github-mcp/server.js'); const server = buildGitHubServer(); await server.connect(serverTransport); client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: {} }); await client.connect(clientTransport); }); it('search_repositories returns formatted results', async () => { const result = await client.callTool({ name: 'search_repositories', arguments: { query: 'mcp typescript', sort: 'stars', per_page: 10, page: 1 }, }); expect(result.isError).toBeFalsy(); const text = (result.content[0] as any).text; expect(text).toContain('2 repositories'); expect(text).toContain('org/repo-a'); expect(text).toContain('⭐ 100'); }); it('list_issues excludes pull requests', async () => { const result = await client.callTool({ name: 'list_issues', arguments: { owner: 'org', repo: 'repo', state: 'open', limit: 20 }, }); expect(result.isError).toBeFalsy(); const text = (result.content[0] as any).text; expect(text).toContain('#42'); expect(text).toContain('Bug: crash on startup'); }); });tests/github-mcp.test.ts
Webhooks as MCP Resource Subscriptions
GitHub and Slack can push events to your server via webhooks. By bridging webhooks to MCP's notifications/resources/updated, your MCP clients receive live updates whenever a PR is merged or a Slack message arrives in a watched channel.
// src/webhook-bridge/index.ts
// Bridge GitHub webhooks → MCP resource update notifications
import express from 'express';
import { createHmac } from 'crypto';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';

const app = express();
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));

// Active SSE sessions
const sessions = new Map<string, SSEServerTransport>();
// Track subscribed resources per session
const subscriptions = new Map<string, Set<string>>();

function buildMcpServer(): McpServer {
  const server = new McpServer(
    { name: 'webhook-bridge', version: '1.0.0' },
    { capabilities: { resources: { subscribe: true, listChanged: false } } }
  );

  // Expose repo events as subscribable resources
  server.resource(
    'github-pr-events',
    new ResourceTemplate('github://events/{owner}/{repo}/prs', { list: undefined }),
    { mimeType: 'application/json' },
    async (uri, { owner, repo }) => {
      // Return current open PRs as initial content
      const data = { owner, repo, message: 'Subscribe to receive live PR events' };
      return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }] };
    }
  );
  return server;
}

// GitHub webhook endpoint
app.post('/webhook/github', (req, res) => {
  // Verify HMAC signature
  const sig = req.headers['x-hub-signature-256'] as string ?? '';
  const hmac = createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '');
  hmac.update((req as any).rawBody);
  const expected = `sha256=${hmac.digest('hex')}`;
  if (sig !== expected) { res.status(401).send('Invalid signature'); return; }

  const event = req.headers['x-github-event'] as string;
  const payload = req.body;

  if (event === 'pull_request') {
    const uri = `github://events/${payload.repository.owner.login}/${payload.repository.name}/prs`;
    // Notify all sessions subscribed to this resource URI
    sessions.forEach((transport, sessionId) => {
      if (subscriptions.get(sessionId)?.has(uri)) {
        transport.send({
          jsonrpc: '2.0',
          method: 'notifications/resources/updated',
          params: { uri },
        });
      }
    });
  }
  res.status(200).send('ok');
});

app.listen(3000, () => console.error('[webhook-bridge] listening on :3000'));src/webhook-bridge/index.ts
Integration Design Patterns
Four patterns that apply across any API integration — not just the three you built today.
🎭
Adapter Pattern
Wrap each third-party client behind a narrow interface (interface GitHubAdapter). Swap real clients for mocks in tests with zero impact on tool handlers. The adapter owns rate limiting, retry, and auth.
🔀
Pagination Cursor Forwarding
Don't fetch all pages upfront — return the raw API cursor in the tool response. Let Claude decide whether to call the tool again for the next page. This avoids unnecessary API calls and respects rate limits.
📦
Response Shaping
Don't return raw API JSON to Claude — it's often huge and noisy. Shape the response to include only the fields Claude needs for the current task. Reduces token usage and improves response quality.
⏱️
Cache-Aside for Read-Heavy Tools
Tools that read the same data repeatedly (repo metadata, channel list, table schema) should cache results with a TTL. AI conversations often call the same tool multiple times in a single context window.
Integration Production Checklist
Before connecting any real API integration to a production AI system, verify every item below. One missed item can cause data loss, runaway API costs, or a security incident.
Real API Integration Check
5 questions on integration architecture, rate limiting, database safety, and aggregation patterns. Score 5/5 to complete the section.
Q1Your query_database tool wraps the SELECT in BEGIN READ ONLY … COMMIT. What does a read-only transaction add beyond just running the SELECT directly?
AIt makes the query run faster by using a query cache
BIt prevents any write operations (INSERT/UPDATE/DELETE) that might be injected via a subquery or CTE, providing an extra safety layer at the database level
CIt locks the table so no other connection can write while the query runs
DIt enables read replicas to serve the query instead of the primary
Q2Bottleneck's reservoir pattern is set to requestsPerSecond × 10. What behaviour does this enable?
AIt caches the last 10 responses to avoid duplicate API calls
BIt allows a short burst above the sustained rate — up to 10 seconds worth of requests can be sent immediately before the limiter kicks in
CIt retries each failed request up to 10 times
DIt queues up to 10 requests before dropping the rest
Q3You use Promise.allSettled instead of Promise.all in the get_incident_briefing aggregation tool. Why?
APromise.allSettled is faster because it doesn't wait for slow promises
BPromise.allSettled always resolves even if some promises reject, letting you return partial results when one API is down instead of failing the entire briefing
CPromise.allSettled retries rejected promises automatically
DPromise.all doesn't work with async/await in tool handlers
Q4The execute_statement tool blocks DDL keywords (DROP, ALTER, TRUNCATE) even though it's a write tool. Why not just rely on database-level permissions?
ADatabase permissions can't block DDL from authenticated connections
BDefense in depth — blocking at the MCP layer provides an additional safety net, gives clearer error messages to the AI, and protects against misconfigured database roles
CThe pg library doesn't support DDL statements
DDDL statements are not valid SQL in PostgreSQL
Q5In the pagination cursor forwarding pattern, instead of fetching all pages and returning them at once, you return the cursor to Claude. What is the main benefit?
AIt makes the tool response smaller so it fits in the MCP message size limit
BClaude can decide whether additional pages are needed for the task — avoiding unnecessary API calls, respecting rate limits, and reducing token usage when one page is sufficient
CExternal APIs don't support fetching multiple pages in sequence
DCursor forwarding enables parallel page fetching across multiple tool calls
← Previous Day
Day 11: OAuth 2.0 & Authentication
PKCE, JWT validation & token management
Next Day →
Day 13: Streamable HTTP Transport
The next-gen MCP transport — bidirectional streaming & resumability