Snippets.

January 13, 2026

AIClaude API33 min read

Connecting Claude to External Tools with MCP: A Real-World Example

Connecting Claude to external tools with MCP

I wrote a tutorial on building MCP servers a while back. It covered the fundamentals: how MCP works, how to define tools and resources, how to set up transports. But it was a GitHub Issues integration, useful for learning but not exactly a client project.

This article is different. We're going to build something you'd actually deploy for a client: a Claude-powered customer analytics agent that connects to a Postgres database and a CRM via MCP. The agent can query customer data, analyze churn risk, generate revenue reports, and update CRM records, all through natural language.

This is the kind of project that sells Claude integrations to businesses. The CEO types "show me which customers are at risk of churning" and gets an actual answer backed by real data.

Everything runs locally: Docker starts a seeded Postgres database, and a small mock CRM stands in for HubSpot, Salesforce, or your client's own system, so you don't need any credentials to follow along.

The architecture

Before writing code, let's sketch the system:

┌─────────────────┐
│  Claude Desktop  │  (or your custom chat UI)
│  / Custom Agent  │
└────────┬────────┘
         │ MCP Protocol
         ▼
┌─────────────────┐
│   MCP Server    │  (our TypeScript server)
│                 │
│  ┌───────────┐  │
│  │ Tools     │  │  query_customers, analyze_churn_risk,
│  │           │  │  get_revenue_report, update_crm_status
│  └───────────┘  │
│  ┌───────────┐  │
│  │ Resources │  │  customer segments
│  └───────────┘  │
└────────┬────────┘
         │
    ┌────┴────┐
    ▼         ▼
┌────────┐ ┌────────┐
│Postgres│ │  CRM   │
│  (read)│ │(write) │
└────────┘ └────────┘

The MCP server sits between Claude and your data layer. Claude discovers the available tools, decides which ones to use based on the user's question, and chains them together to produce a complete answer. The server reads from the database and writes to the CRM, and that split drives most of the security decisions below.

Setting up the project

You'll need Node.js 20.12 or newer and Docker.

mkdir customer-analytics-mcp
cd customer-analytics-mcp
npm init -y
npm pkg set type=module
npm pkg set scripts.build=tsc scripts.start="node dist/index.js" scripts.mock-crm="node dist/mock-crm/server.js" scripts.inspect="npx @modelcontextprotocol/inspector node dist/index.js"
npm install @modelcontextprotocol/sdk zod pg
npm install -D typescript @types/node @types/pg

npm pkg set type=module marks the project as an ES module. The MCP SDK ships ES modules, and without it TypeScript treats your code as CommonJS and refuses to compile the imports.

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}

outDir sends the compiled JavaScript to dist/, which is what Claude Desktop will run later. The "types": ["node"] line matters because recent TypeScript versions no longer load @types/node automatically.

Project structure:

database/
├── 01-schema.sql             # customers and orders tables
├── 02-seed.sql               # demo data
└── 03-read-only-role.sql     # the role the MCP server connects as
src/
├── index.ts                  # stdio entry point
├── server.ts                 # createServer(): registers tools and resources
├── configuration.ts          # loads .env and validates environment variables
├── database.ts               # PostgreSQL client
├── queries.ts                # parameterized SQL
├── churn-risk.ts             # risk level and factors for one customer
├── crm-client.ts             # CRM API wrapper
├── tool-result.ts            # error boundary for every tool call
├── audit-log.ts              # one JSON line per tool call
├── types.ts
├── tools/
│   ├── query-customers.ts
│   ├── analyze-churn.ts
│   ├── revenue-report.ts
│   └── update-crm.ts
├── resources/
│   └── segments.ts
└── mock-crm/
    └── server.ts             # local stand-in for a CRM API

The database

The agent needs data to talk about. We keep two tables: customers with the plan and current monthly fee, and orders with every payment. Revenue reports come from orders, so "last quarter" really means money received last quarter.

-- database/01-schema.sql

-- Schema for the customer analytics demo database.
-- Runs automatically the first time the Postgres container starts (docker-entrypoint-initdb.d).

CREATE TABLE customers (
  id               TEXT PRIMARY KEY,
  name             TEXT NOT NULL,
  email            TEXT NOT NULL UNIQUE,
  plan             TEXT NOT NULL CHECK (plan IN ('free', 'starter', 'pro', 'enterprise')),
  monthly_revenue  NUMERIC(10, 2) NOT NULL DEFAULT 0 CHECK (monthly_revenue >= 0),
  signup_date      DATE NOT NULL,
  last_active_date DATE NOT NULL
);

CREATE TABLE orders (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers (id),
  amount      NUMERIC(10, 2) NOT NULL CHECK (amount >= 0),
  ordered_at  TIMESTAMPTZ NOT NULL
);

CREATE INDEX orders_customer_id_index ON orders (customer_id);
CREATE INDEX orders_ordered_at_index ON orders (ordered_at);
CREATE INDEX customers_last_active_date_index ON customers (last_active_date);

The seed script inserts 24 fictional customers and one order per month for every paying customer. Dates are relative to the day the database is created, so the churn and revenue numbers stay meaningful whenever you run it.

-- database/02-seed.sql

-- Fictional demo data. Dates are relative to the day the container is first created,
-- so churn and revenue numbers stay meaningful whenever you run the demo.

INSERT INTO customers (id, name, email, plan, monthly_revenue, signup_date, last_active_date)
SELECT id, name, email, plan, monthly_revenue,
       CURRENT_DATE - signup_days_ago,
       CURRENT_DATE - inactive_days
FROM (VALUES
  ('cus_0001', 'Northwind Traders',     'ops@northwind.example',       'enterprise', 2400.00, 900,   2),
  ('cus_0002', 'Contoso Logistics',     'it@contoso.example',          'enterprise', 1800.00, 720,  75),
  ('cus_0003', 'Globex Manufacturing',  'admin@globex.example',        'enterprise', 3100.00, 640, 120),
  ('cus_0004', 'Initech Software',      'billing@initech.example',     'enterprise', 1500.00, 400,  10),
  ('cus_0005', 'Umbrella Health',       'finance@umbrella.example',    'enterprise', 2750.00, 300,  45),
  ('cus_0006', 'Stark Components',      'hello@stark.example',         'pro',         650.00, 500,   1),
  ('cus_0007', 'Wayne Analytics',       'data@wayne.example',          'pro',         540.00, 450,  95),
  ('cus_0008', 'Hooli Media',           'team@hooli.example',          'pro',         420.00, 380,  33),
  ('cus_0009', 'Pied Piper Studio',     'richard@piedpiper.example',   'pro',         390.00, 200,   5),
  ('cus_0010', 'Vandelay Imports',      'art@vandelay.example',        'pro',         610.00, 610,  62),
  ('cus_0011', 'Soylent Foods',         'orders@soylent.example',      'pro',         480.00, 150,  14),
  ('cus_0012', 'Tyrell Robotics',       'lab@tyrell.example',          'pro',         720.00,  90,   3),
  ('cus_0013', 'Acme Bakery',           'owner@acmebakery.example',    'starter',      49.00, 365,  20),
  ('cus_0014', 'Blue Harbor Cafe',      'cafe@blueharbor.example',     'starter',      49.00, 280,  91),
  ('cus_0015', 'Maple Street Dental',   'desk@mapledental.example',    'starter',      79.00, 240,  40),
  ('cus_0016', 'Riverbend Yoga',        'namaste@riverbend.example',   'starter',      29.00, 180,  66),
  ('cus_0017', 'Copperleaf Books',      'books@copperleaf.example',    'starter',      49.00, 120,   7),
  ('cus_0018', 'Summit Bikes',          'shop@summitbikes.example',    'starter',      79.00,  60,   2),
  ('cus_0019', 'Lighthouse Tutors',     'hi@lighthouse.example',       'starter',      29.00,  45,  31),
  ('cus_0020', 'Pixel Pantry',          'founder@pixelpantry.example', 'free',          0.00, 300, 150),
  ('cus_0021', 'Quiet Fox Design',      'studio@quietfox.example',     'free',          0.00, 200,   4),
  ('cus_0022', 'Orbit Garden Co',       'grow@orbitgarden.example',    'free',          0.00, 100,  58),
  ('cus_0023', 'Nimbus Weather Lab',    'sky@nimbus.example',          'free',          0.00,  30,   1),
  ('cus_0024', 'Fjord Freight',         'dispatch@fjord.example',      'free',          0.00, 500, 200)
) AS seed (id, name, email, plan, monthly_revenue, signup_days_ago, inactive_days);

-- One order per month for every paying customer, from signup until their last activity.
INSERT INTO orders (customer_id, amount, ordered_at)
SELECT customers.id, customers.monthly_revenue, order_date
FROM customers
CROSS JOIN LATERAL generate_series(
  customers.signup_date::timestamptz,
  customers.last_active_date::timestamptz,
  INTERVAL '1 month'
) AS order_date
WHERE customers.monthly_revenue > 0;

The MCP server only ever reads from the database, so it shouldn't connect as a user that can write. This script creates a role that can only SELECT. Even a buggy query or a prompt-injected tool call can't change data through it.

-- database/03-read-only-role.sql

-- The MCP server only reads from the database, so it connects as a role that cannot write.
-- Even a buggy query or a prompt-injected tool call cannot modify data through this role.
-- The password is for local development only. Use a secret manager in production.

CREATE ROLE analytics_reader WITH LOGIN PASSWORD 'analytics_reader_local';
ALTER ROLE analytics_reader SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE analytics TO analytics_reader;
GRANT USAGE ON SCHEMA public TO analytics_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_reader;

Docker Compose runs the three scripts in order the first time the container starts:

# docker-compose.yml

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: analytics
      POSTGRES_USER: analytics_admin
      # Local development only. Never reuse this password anywhere real.
      POSTGRES_PASSWORD: analytics_admin_local
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    volumes:
      - ./database:/docker-entrypoint-initdb.d:ro
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U analytics_admin -d analytics"]
      interval: 2s
      timeout: 5s
      retries: 15

volumes:
  postgres-data:

Start it:

docker compose up -d --wait

Finally, create a .env file for the server. The values point at the local database (as the read-only role) and at the mock CRM we'll build at the end:

# .env

# Read-only Postgres role created by database/03-read-only-role.sql (local Docker values)
DATABASE_URL=postgresql://analytics_reader:analytics_reader_local@localhost:5432/analytics

# CRM API. The defaults point at the local mock CRM (npm run mock-crm).
# For a real CRM, use its base URL and an API key with the fewest permissions possible.
CRM_BASE_URL=http://localhost:4010/v1
CRM_API_KEY=local-mock-crm-key

# Port for the local mock CRM
MOCK_CRM_PORT=4010

Types first

// src/types.ts

export const CUSTOMER_PLANS = ["free", "starter", "pro", "enterprise"] as const;
export const CRM_STATUSES = ["active", "at_risk", "churned", "win_back"] as const;

export type CustomerPlan = (typeof CUSTOMER_PLANS)[number];
export type CrmStatus = (typeof CRM_STATUSES)[number];
export type RiskLevel = "low" | "medium" | "high" | "critical";

export interface Customer {
  id: string;
  name: string;
  email: string;
  plan: CustomerPlan;
  monthlyRevenue: number;
  signupDate: string;
  lastActiveDate: string;
  totalOrders: number;
}

export interface CustomerFilters {
  plan?: CustomerPlan | undefined;
  minRevenue?: number | undefined;
  maxRevenue?: number | undefined;
  inactiveDays?: number | undefined;
  limit: number;
}

export interface ChurnRiskAssessment {
  customerId: string;
  customerName: string;
  plan: CustomerPlan;
  monthlyRevenue: number;
  riskLevel: RiskLevel;
  daysSinceActive: number;
  factors: string[];
}

export interface ChurnRiskResult {
  totalAtRisk: number;
  assessments: ChurnRiskAssessment[];
}

export interface RevenueReport {
  period: string;
  totalRevenue: number;
  previousPeriodRevenue: number;
  growthRatePercent: number | null;
  customerCount: number;
  averageRevenuePerCustomer: number;
  topCustomers: Array<{ customerId: string; name: string; revenue: number }>;
}

export interface SegmentSummary {
  plan: CustomerPlan;
  count: number;
  totalMonthlyRevenue: number;
  averageMonthlyRevenue: number;
}

export interface CrmStatusUpdate {
  customerId: string;
  status: CrmStatus;
  updatedAt: string;
}

export interface ServerConfiguration {
  databaseUrl: string;
  crmApiKey: string;
  crmBaseUrl: string;
}

The plan and status lists are defined once as constants, and the Zod schemas later reuse them, so the database, the types, and the tool inputs can't drift apart.

The database layer

We wrap PostgreSQL in a small client class. In a real project, you'd probably use an ORM like Prisma or Drizzle, but raw pg keeps the example focused.

The SQL lives in its own file. Every value is a bound parameter ($1, $2, ...), never pasted into the string. It's tempting to write INTERVAL '${days} days' because Zod already guarantees a number, but that guarantee lives in a different file, and one schema change later you have SQL injection. A NULL parameter switches its filter off, so one static query covers every filter combination.

// src/queries.ts

// Every value reaches Postgres as a bound parameter ($1, $2, ...), never through string interpolation.
// A NULL parameter disables its filter, so one static query covers every filter combination.

export const QUERY_CUSTOMERS_SQL = `
  SELECT customers.id,
         customers.name,
         customers.email,
         customers.plan,
         customers.monthly_revenue,
         customers.signup_date::text AS signup_date,
         customers.last_active_date::text AS last_active_date,
         COUNT(orders.id) AS total_orders
  FROM customers
  LEFT JOIN orders ON orders.customer_id = customers.id
  WHERE ($1::text IS NULL OR customers.plan = $1::text)
    AND ($2::numeric IS NULL OR customers.monthly_revenue >= $2::numeric)
    AND ($3::numeric IS NULL OR customers.monthly_revenue <= $3::numeric)
    AND ($4::int IS NULL OR customers.last_active_date <= CURRENT_DATE - $4::int)
  GROUP BY customers.id
  ORDER BY customers.monthly_revenue DESC, customers.id
  LIMIT $5::int
`;

export const CHURN_RISK_SQL = `
  SELECT customers.id,
         customers.name,
         customers.plan,
         customers.monthly_revenue,
         CURRENT_DATE - customers.last_active_date AS days_inactive,
         (SELECT COUNT(*) FROM orders WHERE orders.customer_id = customers.id) AS total_orders,
         COUNT(*) OVER () AS total_at_risk
  FROM customers
  WHERE customers.last_active_date <= CURRENT_DATE - $1::int
  ORDER BY customers.monthly_revenue DESC, customers.id
  LIMIT $2::int
`;

// Compares the last N months of orders with the N months before them.
export const REVENUE_SUMMARY_SQL = `
  SELECT COALESCE(SUM(amount) FILTER (WHERE ordered_at > NOW() - make_interval(months => $1::int)), 0) AS total_revenue,
         COALESCE(SUM(amount) FILTER (WHERE ordered_at <= NOW() - make_interval(months => $1::int)), 0) AS previous_revenue,
         COUNT(DISTINCT customer_id) FILTER (WHERE ordered_at > NOW() - make_interval(months => $1::int)) AS customer_count
  FROM orders
  WHERE ordered_at > NOW() - make_interval(months => $1::int * 2)
    AND ordered_at <= NOW()
`;

export const TOP_CUSTOMERS_SQL = `
  SELECT customers.id, customers.name, SUM(orders.amount) AS revenue
  FROM orders
  JOIN customers ON customers.id = orders.customer_id
  WHERE orders.ordered_at > NOW() - make_interval(months => $1::int)
    AND orders.ordered_at <= NOW()
  GROUP BY customers.id, customers.name
  ORDER BY revenue DESC, customers.id
  LIMIT $2::int
`;

export const SEGMENTS_SQL = `
  SELECT plan, COUNT(*) AS customer_count, SUM(monthly_revenue) AS total_monthly_revenue
  FROM customers
  GROUP BY plan
`;

export const CUSTOMER_EXISTS_SQL = `
  SELECT EXISTS (SELECT 1 FROM customers WHERE id = $1::text) AS customer_exists
`;

The churn rules are plain business logic, so they get their own pure function. A customer gets one inactivity factor (the highest threshold reached), not "90+", "60+", and "30+" all at once.

// src/churn-risk.ts

import type { RiskLevel } from "./types.js";

const HIGH_VALUE_MONTHLY_REVENUE = 500;
const LOW_ENGAGEMENT_ORDER_COUNT = 3;

// Ordered from most to least severe: the first threshold a customer reaches wins.
const INACTIVITY_THRESHOLDS: ReadonlyArray<{ minimumDays: number; riskLevel: RiskLevel }> = [
  { minimumDays: 90, riskLevel: "critical" },
  { minimumDays: 60, riskLevel: "high" },
  { minimumDays: 30, riskLevel: "medium" },
];

export interface ChurnSignals {
  daysInactive: number;
  monthlyRevenue: number;
  totalOrders: number;
}

export interface ChurnRisk {
  riskLevel: RiskLevel;
  factors: string[];
}

export function assessChurnRisk(signals: ChurnSignals): ChurnRisk {
  const factors: string[] = [];
  const reachedThreshold = INACTIVITY_THRESHOLDS.find(
    (threshold: { minimumDays: number }) => signals.daysInactive >= threshold.minimumDays
  );

  if (reachedThreshold) {
    factors.push(`Inactive for ${reachedThreshold.minimumDays}+ days`);
  }
  if (signals.monthlyRevenue > HIGH_VALUE_MONTHLY_REVENUE) {
    factors.push("High-value customer");
  }
  if (signals.totalOrders < LOW_ENGAGEMENT_ORDER_COUNT) {
    factors.push("Low engagement (few orders)");
  }

  return { riskLevel: reachedThreshold?.riskLevel ?? "low", factors };
}

The client itself maps rows to our types. Note that node-postgres returns NUMERIC and COUNT(*) as strings, which is why they go through Number(), and that dates are selected as ::text so they come back as 2026-01-31 rather than a time-zone-dependent JavaScript Date.

// src/database.ts

import { Pool } from "pg";
import { assessChurnRisk } from "./churn-risk.js";
import {
  CHURN_RISK_SQL,
  CUSTOMER_EXISTS_SQL,
  QUERY_CUSTOMERS_SQL,
  REVENUE_SUMMARY_SQL,
  SEGMENTS_SQL,
  TOP_CUSTOMERS_SQL,
} from "./queries.js";
import {
  CUSTOMER_PLANS,
  type ChurnRiskAssessment,
  type ChurnRiskResult,
  type Customer,
  type CustomerFilters,
  type CustomerPlan,
  type RevenueReport,
  type SegmentSummary,
} from "./types.js";

const CHURN_RESULT_LIMIT = 50;
const TOP_CUSTOMER_LIMIT = 5;

// Row shapes as node-postgres returns them: NUMERIC and COUNT(*) arrive as strings.
interface CustomerRow {
  id: string;
  name: string;
  email: string;
  plan: CustomerPlan;
  monthly_revenue: string;
  signup_date: string;
  last_active_date: string;
  total_orders: string;
}

interface ChurnRiskRow {
  id: string;
  name: string;
  plan: CustomerPlan;
  monthly_revenue: string;
  days_inactive: number;
  total_orders: string;
  total_at_risk: string;
}

interface RevenueSummaryRow {
  total_revenue: string;
  previous_revenue: string;
  customer_count: string;
}

interface TopCustomerRow {
  id: string;
  name: string;
  revenue: string;
}

interface SegmentRow {
  plan: CustomerPlan;
  customer_count: string;
  total_monthly_revenue: string;
}

function roundTo(value: number, decimals: number): number {
  const factor = 10 ** decimals;

  return Math.round(value * factor) / factor;
}

function calculateGrowthRatePercent(current: number, previous: number): number | null {
  if (previous === 0) {
    return null;
  }

  return roundTo(((current - previous) / previous) * 100, 1);
}

export class DatabaseClient {
  private readonly pool: Pool;

  constructor(connectionString: string) {
    this.pool = new Pool({ connectionString });
  }

  async queryCustomers(filters: CustomerFilters): Promise<Customer[]> {
    const parameters = [
      filters.plan ?? null,
      filters.minRevenue ?? null,
      filters.maxRevenue ?? null,
      filters.inactiveDays ?? null,
      filters.limit,
    ];
    const result = await this.pool.query<CustomerRow>(QUERY_CUSTOMERS_SQL, parameters);

    return result.rows.map((row: CustomerRow) => ({
      id: row.id,
      name: row.name,
      email: row.email,
      plan: row.plan,
      monthlyRevenue: Number(row.monthly_revenue),
      signupDate: row.signup_date,
      lastActiveDate: row.last_active_date,
      totalOrders: Number(row.total_orders),
    }));
  }

  async getChurnRiskCustomers(inactiveDays: number): Promise<ChurnRiskResult> {
    const result = await this.pool.query<ChurnRiskRow>(CHURN_RISK_SQL, [inactiveDays, CHURN_RESULT_LIMIT]);
    const assessments = result.rows.map((row: ChurnRiskRow): ChurnRiskAssessment => {
      const monthlyRevenue = Number(row.monthly_revenue);
      const churnRisk = assessChurnRisk({
        daysInactive: row.days_inactive,
        monthlyRevenue,
        totalOrders: Number(row.total_orders),
      });

      return {
        customerId: row.id,
        customerName: row.name,
        plan: row.plan,
        monthlyRevenue,
        riskLevel: churnRisk.riskLevel,
        daysSinceActive: row.days_inactive,
        factors: churnRisk.factors,
      };
    });

    return { totalAtRisk: Number(result.rows[0]?.total_at_risk ?? 0), assessments };
  }

  async getRevenueReport(periodMonths: number): Promise<RevenueReport> {
    const [summaryResult, topCustomersResult] = await Promise.all([
      this.pool.query<RevenueSummaryRow>(REVENUE_SUMMARY_SQL, [periodMonths]),
      this.pool.query<TopCustomerRow>(TOP_CUSTOMERS_SQL, [periodMonths, TOP_CUSTOMER_LIMIT]),
    ]);
    const summary = summaryResult.rows[0];
    const totalRevenue = Number(summary?.total_revenue ?? 0);
    const previousPeriodRevenue = Number(summary?.previous_revenue ?? 0);
    const customerCount = Number(summary?.customer_count ?? 0);

    return {
      period: `Last ${periodMonths} month(s) compared with the ${periodMonths} month(s) before`,
      totalRevenue,
      previousPeriodRevenue,
      growthRatePercent: calculateGrowthRatePercent(totalRevenue, previousPeriodRevenue),
      customerCount,
      averageRevenuePerCustomer: customerCount > 0 ? roundTo(totalRevenue / customerCount, 2) : 0,
      topCustomers: topCustomersResult.rows.map((row: TopCustomerRow) => ({
        customerId: row.id,
        name: row.name,
        revenue: Number(row.revenue),
      })),
    };
  }

  async getSegmentSummaries(): Promise<SegmentSummary[]> {
    const result = await this.pool.query<SegmentRow>(SEGMENTS_SQL);

    return CUSTOMER_PLANS.map((plan: CustomerPlan) => {
      const row = result.rows.find((segmentRow: SegmentRow) => segmentRow.plan === plan);
      const count = Number(row?.customer_count ?? 0);
      const totalMonthlyRevenue = Number(row?.total_monthly_revenue ?? 0);

      return {
        plan,
        count,
        totalMonthlyRevenue,
        averageMonthlyRevenue: count > 0 ? roundTo(totalMonthlyRevenue / count, 2) : 0,
      };
    });
  }

  async customerExists(customerId: string): Promise<boolean> {
    const result = await this.pool.query<{ customer_exists: boolean }>(CUSTOMER_EXISTS_SQL, [customerId]);

    return result.rows[0]?.customer_exists === true;
  }

  async close(): Promise<void> {
    await this.pool.end();
  }
}

The revenue report compares the last N months of orders with the N months before them. When there was no revenue in the previous period, the growth rate is null rather than a made-up number. The churn query uses COUNT(*) OVER () so the summary can report the true number of at-risk customers even though it returns at most 50.

Tool errors and audit logging

Before we get to the CRM, we need two small helpers that every tool call goes through.

The MCP SDK already catches errors thrown by a tool handler and returns them with isError: true, so an exception won't crash the server. The problem is the message: the SDK forwards it as-is, and a message like password authentication failed for user "analytics_reader" is not something you want in the conversation. runTool shows the model errors we wrote on purpose (ToolExecutionError), replaces everything else with a generic message, and keeps isError: true so Claude knows the call failed.

// src/tool-result.ts

import { writeAuditLog } from "./audit-log.js";

export interface TextToolResult {
  [key: string]: unknown;
  content: Array<{ type: "text"; text: string }>;
  isError?: boolean;
}

// An error whose message is safe to show to the model and the user.
// Any other error is logged in full but reported to the client with a generic message,
// so database internals do not leak into the conversation.
export class ToolExecutionError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = "ToolExecutionError";
  }
}

// AggregateError (for example ECONNREFUSED on both IPv4 and IPv6) has an empty message,
// and fetch hides the network failure in `cause`, so unwrap both for the log.
function describeError(error: unknown): string {
  if (error instanceof AggregateError) {
    return error.errors.map((innerError: unknown) => describeError(innerError)).join("; ");
  }
  if (error instanceof Error) {
    const cause = error.cause === undefined ? "" : ` (cause: ${describeError(error.cause)})`;

    return `${error.message || error.name}${cause}`;
  }

  return String(error);
}

function toTextResult(data: unknown): TextToolResult {
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}

function toErrorResult(toolName: string, error: unknown): TextToolResult {
  const message =
    error instanceof ToolExecutionError
      ? error.message
      : "An unexpected error occurred. Check the server logs for details.";

  return { content: [{ type: "text", text: `${toolName} failed: ${message}` }], isError: true };
}

// Error boundary and audit log for every tool call.
export async function runTool(
  toolName: string,
  input: unknown,
  handler: () => Promise<unknown>
): Promise<TextToolResult> {
  const startedAt = Date.now();

  try {
    const data = await handler();

    writeAuditLog({ toolName, input, outcome: "success", durationMilliseconds: Date.now() - startedAt });

    return toTextResult(data);
  } catch (error: unknown) {
    writeAuditLog({
      toolName,
      input,
      outcome: "error",
      durationMilliseconds: Date.now() - startedAt,
      errorMessage: describeError(error),
    });

    return toErrorResult(toolName, error);
  }
}

runTool also writes an audit line for every call: who, which tool, the input, the outcome, and how long it took. It deliberately does not log the output, because tool outputs here contain customer emails and revenue, and log files are rarely protected like a database.

// src/audit-log.ts

import { userInfo } from "node:os";

const SERVER_VERSION = "1.0.0";
// A stdio server runs as the local user who started the MCP client. Behind an HTTP transport,
// take the identity from the authenticated request instead.
const LOCAL_USER = userInfo().username;

export interface AuditLogEntry {
  toolName: string;
  input: unknown;
  outcome: "success" | "error";
  durationMilliseconds: number;
  errorMessage?: string;
}

// Writes one JSON line per tool call to stderr (stdout is reserved for the MCP protocol).
// Tool outputs are not logged because they contain customer data. In production, ship these
// lines to your log store.
export function writeAuditLog(entry: AuditLogEntry): void {
  const logLine = {
    timestamp: new Date().toISOString(),
    userId: LOCAL_USER,
    serverVersion: SERVER_VERSION,
    ...entry,
  };

  process.stderr.write(`${JSON.stringify(logLine)}\n`);
}

The CRM client

This wraps a generic CRM API (think HubSpot, Salesforce, or a custom system). Our server only needs one CRM call: setting a customer's status with a note.

// src/crm-client.ts

import { z } from "zod";
import { ToolExecutionError } from "./tool-result.js";
import type { CrmStatus, CrmStatusUpdate } from "./types.js";

const REQUEST_TIMEOUT_MILLISECONDS = 10_000;

// Validate what the external API sends back instead of trusting its shape.
const statusUpdateResponseSchema = z.object({
  updated_at: z.string(),
});

export class CrmClient {
  private readonly apiKey: string;
  private readonly baseUrl: string;

  constructor(apiKey: string, baseUrl: string) {
    this.apiKey = apiKey;
    this.baseUrl = baseUrl.replace(/\/+$/, "");
  }

  async updateCustomerStatus(customerId: string, status: CrmStatus, notes: string): Promise<CrmStatusUpdate> {
    // encodeURIComponent stops an ID like "../../admin" from rewriting the request path.
    const url = `${this.baseUrl}/contacts/${encodeURIComponent(customerId)}/status`;
    const response = await this.request(url, {
      method: "PATCH",
      body: JSON.stringify({ status, notes }),
    });

    if (!response.ok) {
      throw new ToolExecutionError(`CRM API error: ${response.status} ${response.statusText}`);
    }

    const data = statusUpdateResponseSchema.parse(await response.json());

    return { customerId, status, updatedAt: data.updated_at };
  }

  private async request(url: string, init: RequestInit): Promise<Response> {
    try {
      return await fetch(url, {
        ...init,
        headers: {
          Authorization: `Bearer ${this.apiKey}`,
          "Content-Type": "application/json",
        },
        signal: AbortSignal.timeout(REQUEST_TIMEOUT_MILLISECONDS),
      });
    } catch (error: unknown) {
      // Network failures and timeouts: tell the model the CRM is unreachable, keep details in the log.
      throw new ToolExecutionError("The CRM API is unreachable. Try again later.", { cause: error });
    }
  }
}

Three details make this safe to hand to an AI:

  • The customer ID is encoded. Without encodeURIComponent, an ID like ../../admin turns the request into PATCH /admin/status, because URLs normalize .. segments. The model, or a prompt injection hidden in data it read, could then call any endpoint with your API key.
  • The response is validated. response.json() returns any, so a changed API would silently hand back undefined. Zod checks the shape at the boundary.
  • There's a timeout. A hanging CRM shouldn't hang the conversation. Network failures become a clear "unreachable" message for the model, and the details go to the log.

Defining the MCP tools

Now the fun part. Each tool connects Claude to a specific capability. The Zod schemas validate every input before any query runs, so be strict: integers where you mean integers, and minimums wherever a negative number makes no sense.

// src/tools/query-customers.ts

import { z } from "zod";
import { CUSTOMER_PLANS } from "../types.js";

export const queryCustomersSchema = z.object({
  plan: z
    .enum(CUSTOMER_PLANS)
    .optional()
    .describe("Filter by subscription plan."),
  minRevenue: z
    .number()
    .nonnegative()
    .optional()
    .describe("Minimum monthly revenue in dollars."),
  maxRevenue: z
    .number()
    .nonnegative()
    .optional()
    .describe("Maximum monthly revenue in dollars."),
  inactiveDays: z
    .number()
    .int()
    .nonnegative()
    .optional()
    .describe("Only include customers inactive for at least this many days."),
  limit: z
    .number()
    .int()
    .min(1)
    .max(100)
    .default(50)
    .describe("Maximum number of results. Defaults to 50."),
});

export type QueryCustomersInput = z.infer<typeof queryCustomersSchema>;
// src/tools/analyze-churn.ts

import { z } from "zod";

export const analyzeChurnSchema = z.object({
  inactiveDays: z
    .number()
    .int()
    .min(7)
    .max(3650)
    .default(30)
    .describe(
      "Consider customers inactive for at least this many days as potential churn risks. Defaults to 30."
    ),
});

export type AnalyzeChurnInput = z.infer<typeof analyzeChurnSchema>;
// src/tools/revenue-report.ts

import { z } from "zod";

export const revenueReportSchema = z.object({
  periodMonths: z
    .number()
    .int()
    .min(1)
    .max(24)
    .default(1)
    .describe("Number of months to include in the report. Defaults to 1."),
});

export type RevenueReportInput = z.infer<typeof revenueReportSchema>;

The customer ID only accepts letters, digits, underscores, and hyphens. Together with the encoding in the CRM client, that's two layers against path tricks.

// src/tools/update-crm.ts

import { z } from "zod";
import { CRM_STATUSES } from "../types.js";

export const updateCrmSchema = z.object({
  customerId: z
    .string()
    .regex(/^[A-Za-z0-9_-]{1,64}$/, "Customer IDs contain only letters, digits, underscores, and hyphens.")
    .describe("The customer ID to update, as returned by the other tools (for example cus_0002)."),
  status: z
    .enum(CRM_STATUSES)
    .describe("The new status to set in the CRM."),
  notes: z
    .string()
    .min(1)
    .max(1000)
    .describe("Notes explaining why the status was changed. Be specific."),
});

export type UpdateCrmInput = z.infer<typeof updateCrmSchema>;

The segments resource

Resources are read-only context the client can load without calling a tool. This one gives Claude an overview of the customer base by plan, computed with a single GROUP BY query.

// src/resources/segments.ts

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { DatabaseClient } from "../database.js";

const SEGMENTS_URI = "analytics://segments";

export function registerSegmentsResource(server: McpServer, database: DatabaseClient): void {
  server.registerResource(
    "customer-segments",
    SEGMENTS_URI,
    {
      description: "Overview of customer segments by plan tier with counts and monthly revenue",
      mimeType: "application/json",
    },
    async (uri: URL) => ({
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(await database.getSegmentSummaries(), null, 2),
        },
      ],
    })
  );
}

Wiring it all together

createServer registers the tools and the resource. Notice how the tool descriptions tell Claude when and why to use each tool, not just what it does.

// src/server.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CrmClient } from "./crm-client.js";
import type { DatabaseClient } from "./database.js";
import { registerSegmentsResource } from "./resources/segments.js";
import { runTool, ToolExecutionError } from "./tool-result.js";
import { analyzeChurnSchema, type AnalyzeChurnInput } from "./tools/analyze-churn.js";
import { queryCustomersSchema, type QueryCustomersInput } from "./tools/query-customers.js";
import { revenueReportSchema, type RevenueReportInput } from "./tools/revenue-report.js";
import { updateCrmSchema, type UpdateCrmInput } from "./tools/update-crm.js";
import type { ChurnRiskAssessment, ChurnRiskResult, RiskLevel } from "./types.js";

const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, openWorldHint: false };

function summarizeChurnRisk(result: ChurnRiskResult): Record<string, unknown> {
  const countByRiskLevel = (riskLevel: RiskLevel): number =>
    result.assessments.filter((assessment: ChurnRiskAssessment) => assessment.riskLevel === riskLevel).length;

  return {
    totalAtRisk: result.totalAtRisk,
    returned: result.assessments.length,
    critical: countByRiskLevel("critical"),
    high: countByRiskLevel("high"),
    medium: countByRiskLevel("medium"),
    low: countByRiskLevel("low"),
    assessments: result.assessments,
  };
}

function registerReadTools(server: McpServer, database: DatabaseClient): void {
  server.registerTool(
    "query_customers",
    {
      description:
        "Search and filter customers by plan, revenue, and activity. Use this to find specific customer segments or answer questions about the customer base.",
      inputSchema: queryCustomersSchema.shape,
      annotations: READ_ONLY_ANNOTATIONS,
    },
    async (input: QueryCustomersInput) =>
      runTool("query_customers", input, async () => {
        const customers = await database.queryCustomers(input);

        return { count: customers.length, customers };
      })
  );

  server.registerTool(
    "analyze_churn_risk",
    {
      description:
        "Identify customers at risk of churning based on inactivity. Returns a risk assessment (risk level, days inactive, contributing factors) for up to 50 at-risk customers, highest revenue first. Use this when asked about churn, retention, or at-risk customers.",
      inputSchema: analyzeChurnSchema.shape,
      annotations: READ_ONLY_ANNOTATIONS,
    },
    async (input: AnalyzeChurnInput) =>
      runTool("analyze_churn_risk", input, async () =>
        summarizeChurnRisk(await database.getChurnRiskCustomers(input.inactiveDays))
      )
  );

  server.registerTool(
    "get_revenue_report",
    {
      description:
        "Generate a revenue report from orders placed in the last N months. Includes total revenue, growth rate versus the previous period of the same length, paying customer count, average revenue per customer, and the top customers in the period. Use this for financial questions or business reviews.",
      inputSchema: revenueReportSchema.shape,
      annotations: READ_ONLY_ANNOTATIONS,
    },
    async (input: RevenueReportInput) =>
      runTool("get_revenue_report", input, async () => database.getRevenueReport(input.periodMonths))
  );
}

function registerWriteTools(server: McpServer, database: DatabaseClient, crm: CrmClient): void {
  server.registerTool(
    "update_crm_status",
    {
      description:
        "Update a customer's status in the CRM, with a note explaining the change. This is a WRITE operation: always confirm the customer, the new status, and the note with the user before calling it. Use this after analyzing churn risk to mark customers as at_risk, or after a win-back campaign.",
      inputSchema: updateCrmSchema.shape,
      annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
    },
    async (input: UpdateCrmInput) =>
      runTool("update_crm_status", input, async () => {
        // Refuse IDs that are not in our database, so a guessed ID never reaches the CRM.
        if (!(await database.customerExists(input.customerId))) {
          throw new ToolExecutionError(`No customer with ID "${input.customerId}" exists.`);
        }

        return crm.updateCustomerStatus(input.customerId, input.status, input.notes);
      })
  );
}

export function createServer(database: DatabaseClient, crm: CrmClient): McpServer {
  const server = new McpServer({
    name: "customer-analytics",
    version: "1.0.0",
  });

  registerReadTools(server, database);
  registerWriteTools(server, database, crm);
  registerSegmentsResource(server, database);

  return server;
}

A few things worth pointing out:

  • Every handler goes through runTool, so every call is audited and every error is reported the same way.
  • The annotations tell MCP clients which tools only read (readOnlyHint) and which one changes something outside the server (destructiveHint, openWorldHint). Clients can use them to decide when to ask the user for permission. They are hints, not enforcement.
  • update_crm_status refuses customer IDs that aren't in the database, so a guessed or hallucinated ID never reaches the CRM.

Configuration comes from environment variables. Tools like the MCP Inspector and Claude Desktop start the server from a different directory and don't pass along your shell's environment, so the server loads .env itself, relative to the build folder. Variables that are already set win, which is what you want in production.

// src/configuration.ts

import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { ServerConfiguration } from "./types.js";

// Resolved from this file (dist/configuration.js), so .env is found no matter which
// directory the MCP client starts the server from.
const ENVIRONMENT_FILE_PATH = fileURLToPath(new URL("../.env", import.meta.url));

// Loads .env when present. Variables already set (for example by Claude Desktop's "env" block)
// are not overwritten.
export function loadEnvironmentFile(): void {
  if (existsSync(ENVIRONMENT_FILE_PATH)) {
    process.loadEnvFile(ENVIRONMENT_FILE_PATH);
  }
}

export function loadConfiguration(): ServerConfiguration {
  const databaseUrl = process.env.DATABASE_URL;
  const crmApiKey = process.env.CRM_API_KEY;
  const crmBaseUrl = process.env.CRM_BASE_URL;

  if (!databaseUrl || !crmApiKey || !crmBaseUrl) {
    throw new Error("Missing required environment variables: DATABASE_URL, CRM_API_KEY, CRM_BASE_URL");
  }
  if (!URL.canParse(crmBaseUrl)) {
    throw new Error("CRM_BASE_URL is not a valid URL.");
  }

  return { databaseUrl, crmApiKey, crmBaseUrl };
}

The entry point connects everything to the stdio transport and closes the database pool when the client disconnects:

// src/index.ts

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadConfiguration, loadEnvironmentFile } from "./configuration.js";
import { CrmClient } from "./crm-client.js";
import { DatabaseClient } from "./database.js";
import { createServer } from "./server.js";

// Closes the MCP server and the database pool when the client disconnects or the process is stopped.
function registerShutdownHandlers(server: McpServer, database: DatabaseClient): void {
  let isShuttingDown = false;

  const shutdown = async (): Promise<void> => {
    if (isShuttingDown) {
      return;
    }

    isShuttingDown = true;

    await server.close();
    await database.close();
    process.exit(0);
  };

  process.stdin.on("close", shutdown);
  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);
}

async function main(): Promise<void> {
  loadEnvironmentFile();

  const configuration = loadConfiguration();
  const database = new DatabaseClient(configuration.databaseUrl);
  const crm = new CrmClient(configuration.crmApiKey, configuration.crmBaseUrl);
  const server = createServer(database, crm);
  const transport = new StdioServerTransport();

  registerShutdownHandlers(server, database);
  await server.connect(transport);
}

main().catch((error: unknown) => {
  process.stderr.write(`Fatal error: ${String(error)}\n`);
  process.exit(1);
});

A mock CRM for local testing

To try the write tool without a real CRM account, here's a tiny in-memory CRM with the same endpoint. It checks the Bearer token, stores every status change, and has a GET endpoint so you can see what the agent did. If you're connecting a real CRM, skip this and point CRM_BASE_URL at it instead.

// src/mock-crm/server.ts

// A tiny in-memory stand-in for a real CRM API, so the write tool can be tried locally without
// credentials. It implements only the endpoint the MCP server calls, plus a GET to inspect results.
import { createHash, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import { loadEnvironmentFile } from "../configuration.js";
import { CRM_STATUSES } from "../types.js";

const DEFAULT_PORT = 4010;
const MAXIMUM_BODY_BYTES = 16_384;
const CONTACT_PATH_PATTERN = /^\/v1\/contacts\/([^/]+)(\/status)?$/;

const statusUpdateSchema = z.object({
  status: z.enum(CRM_STATUSES),
  notes: z.string().max(1000),
});

interface ContactStatusChange {
  status: string;
  notes: string;
  updated_at: string;
}

const contactHistory = new Map<string, ContactStatusChange[]>();

function hashValue(value: string): Buffer {
  return createHash("sha256").update(value).digest();
}

function isAuthorized(request: IncomingMessage, apiKey: string): boolean {
  const authorizationHeader = request.headers.authorization ?? "";

  // Hashing both sides gives equal-length buffers, which timingSafeEqual requires.
  return timingSafeEqual(hashValue(authorizationHeader), hashValue(`Bearer ${apiKey}`));
}

function sendJson(response: ServerResponse, status: number, body: unknown): void {
  response.writeHead(status, { "Content-Type": "application/json" });
  response.end(JSON.stringify(body));
}

async function readJsonBody(request: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let totalBytes = 0;

  for await (const chunk of request) {
    const buffer = Buffer.from(chunk);

    totalBytes += buffer.length;

    if (totalBytes > MAXIMUM_BODY_BYTES) {
      throw new Error("Request body too large");
    }

    chunks.push(buffer);
  }

  return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
}

async function handleStatusUpdate(
  request: IncomingMessage,
  response: ServerResponse,
  contactId: string
): Promise<void> {
  const parsedBody = statusUpdateSchema.safeParse(await readJsonBody(request).catch(() => null));

  if (!parsedBody.success) {
    sendJson(response, 400, { error: "Body must be { status, notes } with a valid status." });
    return;
  }

  const change: ContactStatusChange = { ...parsedBody.data, updated_at: new Date().toISOString() };

  contactHistory.set(contactId, [...(contactHistory.get(contactId) ?? []), change]);

  process.stderr.write(`Mock CRM: ${contactId} -> ${change.status} (${change.notes})\n`);

  sendJson(response, 200, { id: contactId, ...change });
}

async function handleRequest(request: IncomingMessage, response: ServerResponse, apiKey: string): Promise<void> {
  const pathMatch = CONTACT_PATH_PATTERN.exec(new URL(request.url ?? "/", "http://localhost").pathname);
  const contactId = pathMatch?.[1] ? decodeURIComponent(pathMatch[1]) : undefined;
  const isStatusPath = pathMatch?.[2] !== undefined;

  if (!isAuthorized(request, apiKey)) {
    sendJson(response, 401, { error: "Unauthorized" });
    return;
  }
  if (contactId === undefined) {
    sendJson(response, 404, { error: "Not found" });
    return;
  }
  if (isStatusPath && request.method === "PATCH") {
    await handleStatusUpdate(request, response, contactId);
    return;
  }
  if (!isStatusPath && request.method === "GET") {
    sendJson(response, 200, { id: contactId, history: contactHistory.get(contactId) ?? [] });
    return;
  }

  sendJson(response, 405, { error: "Method not allowed" });
}

function startMockCrm(): void {
  loadEnvironmentFile();

  const apiKey = process.env.CRM_API_KEY;
  const port = Number(process.env.MOCK_CRM_PORT ?? DEFAULT_PORT);

  if (!apiKey) {
    throw new Error("Missing required environment variable: CRM_API_KEY");
  }

  const server = createServer((request: IncomingMessage, response: ServerResponse) => {
    handleRequest(request, response, apiKey).catch((error: unknown) => {
      process.stderr.write(`Mock CRM error: ${String(error)}\n`);
      sendJson(response, 500, { error: "Internal error" });
    });
  });

  server.listen(port, () => {
    process.stderr.write(`Mock CRM listening on http://localhost:${port}/v1\n`);
  });
}

try {
  startMockCrm();
} catch (error: unknown) {
  process.stderr.write(`Fatal error: ${String(error)}\n`);
  process.exit(1);
}

Running it

Build the project and start the mock CRM:

npm run build
npm run mock-crm

In a second terminal, open the MCP Inspector:

npm run inspect

Use the Inspector to call every tool before you connect Claude. It catches schema issues, database errors, and edge cases without burning API tokens. After calling update_crm_status, you can check what reached the CRM:

curl -H "Authorization: Bearer local-mock-crm-key" http://localhost:4010/v1/contacts/cus_0002

Connecting to Claude Desktop

Add the server to your Claude Desktop configuration (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{
  "mcpServers": {
    "customer-analytics": {
      "command": "node",
      "args": ["/absolute/path/to/customer-analytics-mcp/dist/index.js"]
    }
  }
}

The server reads .env from the project folder, so no env block is needed locally. On a client's machine, put DATABASE_URL, CRM_API_KEY, and CRM_BASE_URL in an env block instead.

Restart Claude Desktop (with the mock CRM still running). Now you can have conversations like:

You: "Which enterprise customers haven't been active in the last 60 days?"

Claude calls query_customers with { plan: "enterprise", inactiveDays: 60 }, then presents the results in a readable format.

You: "Run a churn analysis and flag the high-risk ones in the CRM"

Claude calls analyze_churn_risk, reviews the results, proposes which customers to flag, and calls update_crm_status for each one with a note explaining why.

You: "How did revenue in the last three months compare with the three months before?"

Claude calls get_revenue_report with { periodMonths: 3 } and summarizes the total, the growth rate, and the top customers.

The power here is that Claude chains these tools together on its own. Ask "Which high-value customers are at risk and what should we do about them?" and Claude will query customers, analyze churn, cross-reference the data, and give you actionable recommendations, all in one conversation.

Safety: read vs. write operations

Notice that three of our four tools are read-only (query, analyze, report) and only one writes data (update CRM). This is intentional.

For production MCP servers, follow this principle: be generous with read access, cautious with write access. And enforce it in layers, because any single layer can fail:

  • Database permissions. The server connects as a role that can only SELECT. This is the only layer that holds no matter what the model does.
  • Input validation. Zod rejects malformed input, and update_crm_status only accepts customers that exist.
  • Tool annotations. readOnlyHint and destructiveHint let the client treat the write tool differently, for example by always asking for permission.
  • The tool description. "Always confirm with the user before calling it" makes Claude much more likely to ask first ("Should I mark Contoso Logistics as at_risk?").

Be honest with your client about that last layer: a description is an instruction to the model, not a guarantee. The model usually follows it, but nothing in the protocol forces it to. If a write must never happen without approval, enforce that in the client (most MCP clients can require approval per tool) or in the server itself.

Deploying for a client

When you're ready to deploy this for a client, here's the production checklist:

Credentials. Don't hardcode database credentials or API keys. Use environment variables or a secret manager, and give the CRM key the fewest permissions possible.

Authentication. This server uses stdio, so it runs as the local user. If you expose it over the MCP HTTP transport, add Bearer token authentication and take the user identity for the audit log from the authenticated request.

Rate limiting. Over HTTP, add rate limits to prevent the agent from hammering your database. A simple token bucket or sliding window works fine.

Audit logging. Already wired in through runTool. In production, ship those JSON lines to your log store instead of leaving them on stderr, and keep customer data out of them.

Error boundaries. Also already wired in. Make sure internal errors stay in the logs and the model only sees messages you chose.

Testing. Call every tool in the MCP Inspector, including invalid input and a stopped database, before connecting Claude.

Why clients love this

Here's the pitch: instead of building a traditional analytics dashboard with charts and filters that nobody uses, you give the client a natural language interface to their data. They ask questions in plain English and get answers backed by real queries.

The MCP server is the backend. Claude is the frontend. The client never sees SQL, never clicks through dropdowns, never waits for a dashboard to load. They just ask.

And because it's MCP, the same server works with Claude Desktop, a custom chat UI, or any other MCP-compatible client. Build once, connect anywhere.

Conclusion

Connecting Claude to external tools with MCP is where AI integration gets real. Instead of generating text about data, Claude works with actual data. Instead of suggesting actions, Claude takes them (with appropriate safeguards).

The patterns in this article apply to any domain:

  • E-commerce: Query orders, analyze return rates, update inventory
  • Healthcare: Look up patient records, check medication interactions, schedule appointments
  • Finance: Run portfolio analysis, check compliance, generate reports
  • DevOps: Query logs, check service health, trigger deployments

The architecture is always the same: MCP server with typed tools, a database or API layer underneath, read operations for analysis, and carefully gated write operations for actions.

Start with read-only tools and a read-only database role. Get the queries right. Then add write operations one at a time, with validation, confirmation, and audit logging. That's how you build AI integrations that clients trust with their real data.