Hands-On Tutorial

Build a Compliance MCP Server

From pip install to a working agent in Claude Desktop, in under an hour.

⏱ 45 minutes 🐍 Python 3.10+ 📦 1 dependency 🎯 3 working tools

What you'll build

A compliance-toolkit MCP server exposing three tools that any KYC/AML team would actually use:

  1. lookup_sanctions_hit — checks a name against a hardcoded sample SDN list
  2. check_jurisdiction_risk — returns an AML risk tier for a country code
  3. summarize_alert — takes a transaction-alert payload, composes the first two tools, returns a structured triage recommendation

Then you'll wire it into Claude Desktop and ask things like "Screen Vladimir Petrov for sanctions and flag if Russia is a high-risk jurisdiction", watching Claude pick the right tools and chain them.

Why this matters for the interview

After this exercise you can say "I've built MCP servers in Python" truthfully, and back it up with specifics: tool descriptions matter as much as code, error returns shape recovery behavior, the lifecycle is just JSON-RPC plus a capability handshake. That's a real, defensible claim.

Prerequisites

Click each to check it off. Your progress is saved locally.

  • Python 3.10+ — verify with python3 --version
  • Claude Desktop — installed and signed in from claude.ai/download
  • A terminal you're comfortable working in
  • A code editor (VS Code, Cursor, anything)

1Project setup~5 min

Create a project directory, set up a virtual environment, and install the MCP SDK.

shell
mkdir compliance-mcp && cd compliance-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"
PowerShell
mkdir compliance-mcp; cd compliance-mcp
python -m venv .venv
.venv\Scripts\activate
pip install "mcp[cli]"
What you just installed

The mcp package is the official Python SDK. The [cli] extra gives you the mcp dev CLI for testing without Claude Desktop — you'll use that in Step 3.

2Write the server~15 min

Create a file called server.py in your project directory with the contents below. Read as you type — the comments call out the patterns to notice.

server.py
"""compliance-toolkit MCP server — three tools for AI-assisted compliance triage."""

from mcp.server.fastmcp import FastMCP
from typing import Literal

mcp = FastMCP("compliance-toolkit")


# ---------- Mock data (in production: real sources) ----------

SDN_LIST = {
    "vladimir petrov": {"list": "OFAC SDN", "added": "2024-03-15", "program": "RUSSIA-EO14024"},
    "global shadow holdings": {"list": "OFAC SDN", "added": "2023-11-02", "program": "SDGT"},
    "atlas crypto exchange": {"list": "EU Consolidated", "added": "2025-01-20", "program": "EU-CYBER"},
}

JURISDICTION_RISK = {
    "US": ("low",  "FATF compliant, mature AML regime"),
    "GB": ("low",  "FATF compliant, FCA oversight"),
    "DE": ("low",  "FATF compliant, BaFin oversight"),
    "AE": ("medium", "FATF grey list 2022-2024, recently exited"),
    "KY": ("medium", "Offshore jurisdiction, enhanced due diligence advised"),
    "RU": ("high", "Comprehensive sanctions, enhanced screening required"),
    "IR": ("prohibited", "OFAC comprehensive sanctions — do not transact"),
    "KP": ("prohibited", "OFAC comprehensive sanctions — do not transact"),
}


# ---------- Tool 1: sanctions screening ----------

@mcp.tool()
def lookup_sanctions_hit(name: str) -> dict:
    """Screen a person or entity name against active sanctions lists (OFAC SDN, EU Consolidated).

    Use this for any KYC review, transaction counterparty check, or onboarding screening.
    Returns a hit record if matched, or an explicit no-match record otherwise.
    Do NOT use this for adverse-media screening — that is a separate tool.

    Args:
        name: Full legal name or entity name to screen. Case-insensitive.
    """
    key = name.strip().lower()
    if key in SDN_LIST:
        hit = SDN_LIST[key]
        return {
            "match": True,
            "name_queried": name,
            "list": hit["list"],
            "program": hit["program"],
            "added": hit["added"],
            "recommended_action": "halt-and-escalate",
        }
    return {
        "match": False,
        "name_queried": name,
        "screened_lists": ["OFAC SDN", "EU Consolidated"],
        "recommended_action": "proceed-with-standard-cdd",
    }


# ---------- Tool 2: jurisdiction risk ----------

@mcp.tool()
def check_jurisdiction_risk(country_code: str) -> dict:
    """Return the AML risk tier for a country (ISO 3166-1 alpha-2 code).

    Tiers: 'low', 'medium', 'high', 'prohibited'.
    Prohibited jurisdictions must not be transacted with.

    Args:
        country_code: Two-letter ISO country code, e.g. 'US', 'DE', 'KP'.
    """
    code = country_code.strip().upper()
    if code not in JURISDICTION_RISK:
        return {
            "country_code": code,
            "tier": "unknown",
            "rationale": "Country code not in risk register — flag for review.",
        }
    tier, rationale = JURISDICTION_RISK[code]
    return {"country_code": code, "tier": tier, "rationale": rationale}


# ---------- Tool 3: alert summarization ----------

@mcp.tool()
def summarize_alert(
    alert_id: str,
    counterparty_name: str,
    counterparty_country: str,
    amount_usd: float,
    transaction_type: Literal["wire", "crypto-withdrawal", "crypto-deposit", "card"],
) -> dict:
    """Produce a structured triage summary for a transaction alert.

    Combines counterparty screening and jurisdiction risk into a single recommendation.
    The recommendation is advisory — a human investigator must approve any external action.
    """
    sanctions = lookup_sanctions_hit(counterparty_name)
    jurisdiction = check_jurisdiction_risk(counterparty_country)

    flags = []
    if sanctions["match"]:
        flags.append(f"SANCTIONS HIT: {sanctions['list']} ({sanctions['program']})")
    if jurisdiction["tier"] == "prohibited":
        flags.append(f"PROHIBITED JURISDICTION: {jurisdiction['country_code']}")
    if jurisdiction["tier"] == "high":
        flags.append(f"HIGH-RISK JURISDICTION: {jurisdiction['country_code']}")
    if amount_usd >= 10_000:
        flags.append(f"CTR THRESHOLD: amount ${amount_usd:,.2f} ≥ $10K")

    if any(f.startswith("SANCTIONS") or f.startswith("PROHIBITED") for f in flags):
        recommendation = "halt-and-escalate"
    elif flags:
        recommendation = "escalate-for-human-review"
    else:
        recommendation = "low-risk-suggest-dismiss"

    return {
        "alert_id": alert_id,
        "summary": (
            f"Alert {alert_id}: {transaction_type} of ${amount_usd:,.2f} with "
            f"counterparty '{counterparty_name}' ({counterparty_country})."
        ),
        "flags": flags or ["no automated flags"],
        "recommendation": recommendation,
        "requires_human_approval": True,  # always — agent never auto-acts
        "sources": {"sanctions": sanctions, "jurisdiction": jurisdiction},
    }


if __name__ == "__main__":
    mcp.run()
Four things to notice as you type

1. @mcp.tool() is doing the heavy lifting. It inspects your function signature, generates a JSON schema from type hints, and registers the tool. The docstring becomes the tool description the model reads.

2. Type hints are non-optional. Literal["wire", ...] becomes a JSON enum — the model can only pick from that list.

3. summarize_alert calls the other two tools directly as Python functions — composing tool logic without round-tripping through the model.

4. requires_human_approval: True is hardcoded. Deliberate signal to the model: I'm advisory, you don't get to auto-act on this.

Common transcription pitfalls

If you're typing this out rather than copy-pasting, three small bugs are easy to introduce inside lookup_sanctions_hit and summarize_alert — each surfaces only at test time:

1. if key in SDN_LIST[key]: — indexes the dict before checking membership. Should be if key in SDN_LIST:. Symptom: KeyError: '<name you queried>' on any non-matching name.

2. SDN_LIST["key"] — the quoted string "key" instead of the variable key. Symptom: KeyError: 'key' (the literal string), triggered when a matching name falls through a broken if.

3. f"... (sanctions['program'])" — missing braces around the interpolation. Silent: the literal text sanctions['program'] ends up in your flag string, no exception raised.

3Test the server standalone~5 min

Before connecting to Claude Desktop, sanity-check with the MCP inspector — a local web UI for browsing schemas and invoking tools manually.

shell
mcp dev server.py

This opens a browser window where you can see all three tools, invoke them with custom args, and watch JSON-RPC traffic. Try this payload on summarize_alert:

summarize_alert input
{
  "alert_id": "ALT-2026-00042",
  "counterparty_name": "Vladimir Petrov",
  "counterparty_country": "RU",
  "amount_usd": 25000,
  "transaction_type": "wire"
}

You should get a halt-and-escalate recommendation with multiple flags.

If it didn't work

mcp: command not found — your venv isn't active. Re-run source .venv/bin/activate.
Import errors — confirm pip list | grep mcp shows the package installed in this venv.

4Wire into Claude Desktop~5 min

Find your Claude Desktop config file, edit it to register your server, then restart Claude.

Config location: ~/Library/Application Support/Claude/claude_desktop_config.json

get your paths
# With the venv active in your project dir:
which python                      # → /Users/you/compliance-mcp/.venv/bin/python
realpath server.py                # → /Users/you/compliance-mcp/server.py

Config location: %APPDATA%\Claude\claude_desktop_config.json

PowerShell
(Get-Command python).Source       # path to python.exe in venv
(Resolve-Path server.py).Path     # absolute path to server.py

Config location: ~/.config/Claude/claude_desktop_config.json

Get your paths with which python and realpath server.py.

Edit (or create) the config file with your absolute paths:

claude_desktop_config.json
{
  "mcpServers": {
    "compliance-toolkit": {
      "command": "/absolute/path/to/compliance-mcp/.venv/bin/python",
      "args": ["/absolute/path/to/compliance-mcp/server.py"]
    }
  }
}
Common gotchas

Paths must be absolute. Claude Desktop runs from its own working directory.
Use the venv's Python, not system Python. Otherwise the mcp import will fail.
Fully quit Claude Desktop, then relaunch. Reload-from-tray isn't enough.

After restart, open a new chat and click the tool icon. You should see compliance-toolkit with three tools.

5Talk to it~10 min

Try natural-language prompts and watch Claude pick tools, fill in arguments, and chain calls:

example prompts
I need to triage alert ALT-2026-00042. A wire transfer of $25,000 from a
counterparty named Vladimir Petrov in Russia. What's your recommendation?

Screen the entity 'Global Shadow Holdings' for sanctions and tell me what
action to take.

What's the AML risk tier for transactions touching the Cayman Islands?

I'm reviewing three counterparties for onboarding: ACME Corp from Germany,
Atlas Crypto Exchange from the UAE, and Joe Smith from North Korea. Screen
each and tell me what to do.
Watch the tool calls

Click into Claude's response to expand the tool-call details. You'll see the exact JSON Claude sent to your server and the JSON your server returned. This is what an interviewer means by a "tool-use trace."

6Break it (the real learning)open-ended

Once it works, deliberately break things. These five exercises take you from "wrote a tutorial" to "understands the failure modes."

1Send malformed args

Add strict validation to lookup_sanctions_hit: require the name to be at least 3 characters and contain a space (i.e. force "first last"). Then ask Claude to screen a single-word name like "Petrov".

Watch: the tool returns an error, the model sees it, and often recovers by re-calling with a corrected argument. The model is robust to tool errors — IF you return structured ones it can read.

2Make a tool description ambiguous

Change summarize_alert's docstring to """Process an alert.""" with no detail. Ask Claude an alert question.

Watch: Claude becomes less reliable at picking this tool. Tool descriptions matter as much as code.

3Add an error path — raise vs return

Make check_jurisdiction_risk raise ValueError for unknown codes instead of returning gracefully. Ask Claude about "ZZ".

Watch: a raw exception derails the agent more than a structured {"error": "..."}. Revert. Returning the error gives the model context to recover.

4Add audit logging

Drop this near the top of server.py:

server.py (top)
import logging, json, sys

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    stream=sys.stderr,  # stdout is reserved for MCP protocol!
)

Then add a logging.info line at the top of each tool — pass the actual arguments as a dict (not {...} — that's a Python set literal containing Ellipsis, which json.dumps will reject with a TypeError):

server.py — inside each tool
@mcp.tool()
def summarize_alert(alert_id: str, counterparty_name: str, ...) -> dict:
    logging.info(
        "tool_call=%s args=%s",
        "summarize_alert",
        json.dumps({
            "alert_id": alert_id,
            "counterparty_name": counterparty_name,
            # ...rest of args
        }),
    )
    # tool body follows

Watch the logs as Claude calls the tool:

tail Claude Desktop's MCP log
# macOS
tail -f ~/Library/Logs/Claude/mcp-server-compliance-toolkit.log

# Linux
tail -f ~/.config/Claude/logs/mcp-server-compliance-toolkit.log

# Windows (PowerShell)
Get-Content -Wait $env:APPDATA\Claude\Logs\mcp-server-compliance-toolkit.log

You'll see each tool_call=... line interleaved with the JSON-RPC traffic Claude Desktop logs — that's your audit trail forming. Or use Help → View Logs from Claude Desktop's menu bar to open the same file in your default editor.

5Sketch a side-effecting tool you wouldn't actually run
server.py
@mcp.tool()
def freeze_account(account_id: str, reason: str) -> dict:
    """PROPOSE freezing a customer account. This tool DOES NOT execute the freeze —
    it produces a freeze proposal that must be approved by a human investigator
    before any action is taken. Use this to draft an action, not to act.
    """
    return {
        "proposed_action": "freeze_account",
        "account_id": account_id,
        "reason": reason,
        "requires_approval": True,
        "executed": False,
        "next_step": "Investigator must review and confirm via the case management UI.",
    }

Ask Claude to recommend a freeze. Notice: the tool returns a proposal, never executes. Human-in-the-loop made concrete.

What you can say in the interview now

Sample answer — out loud, ~75 seconds

"I've built MCP servers in Python using the FastMCP wrapper. A small compliance toolkit I built had three tools: sanctions screening, jurisdiction risk, and an alert summarizer that composed the first two. A few things became visible only by building. One — tool descriptions matter as much as the code; the model picks tools by reading them, and ambiguous descriptions silently degrade selection. Two — error returns shape behavior; structured {"error": ...} responses let the model recover, while raw exceptions tend to derail the session. Three — the protocol's lifecycle (initialize, list, call, return) is just JSON-RPC plus a capability handshake, and once you've watched it once in the inspector, the spec clicks. For compliance, the build I'd extend has audit logging at every tool call, JWT-scoped auth for remote deployment, idempotency keys on any tool that mutates state, and human-in-the-loop on proposed actions — the agent drafts, never executes."

Stretch goals — each is its own tutorial

Each ~20-30 minute extension. Open the dedicated guide for step-by-step instructions.

Add an MCP resource

~25 min

Expose compliance://policies/kyc-tier-2 as a read-only resource. Watch how Claude treats it differently from a tool — passive context, not invocation.

Open tutorial →

Add a prompt template

~25 min

Use @mcp.prompt() to publish a draft_sar_narrative template with parameters. Appears in Claude Desktop as a slash command.

Open tutorial →

Hit a real public API

~30 min

Replace the mocked SDN list with calls to the OpenSanctions API. Add timeout and fallback. The path from demo to real.

Open tutorial →

Switch to HTTP transport

~30 min

Run the same server over Streamable HTTP on localhost. Wire a different client to it. Experience both transports — the stdio vs HTTP answer becomes obvious.

Open tutorial →