Build a Compliance MCP Server
From pip install to a working agent in Claude Desktop, in under an hour.
What you'll build
A compliance-toolkit MCP server exposing three tools that any KYC/AML team would actually use:
lookup_sanctions_hit— checks a name against a hardcoded sample SDN listcheck_jurisdiction_risk— returns an AML risk tier for a country codesummarize_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.
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.
mkdir compliance-mcp && cd compliance-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"
mkdir compliance-mcp; cd compliance-mcp
python -m venv .venv
.venv\Scripts\activate
pip install "mcp[cli]"
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.
"""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()
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.
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.
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:
{
"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.
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
# 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
(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:
{
"mcpServers": {
"compliance-toolkit": {
"command": "/absolute/path/to/compliance-mcp/.venv/bin/python",
"args": ["/absolute/path/to/compliance-mcp/server.py"]
}
}
}
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:
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.
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:
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):
@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:
# 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
@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
"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.
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.
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 →✓ Check yourself
Answer from memory before opening each one.
-
In Step 3 you send summarize_alert the Petrov payload: counterparty Vladimir Petrov, country RU, 25,000 dollars, wire. What recommendation and which flags should you see?
Show answer
A halt-and-escalate recommendation with three flags: a sanctions hit on the OFAC SDN list under the Russia program, a high-risk jurisdiction flag for RU, and the 10,000 dollar threshold flag. The human-approval field is always true. If a flag is missing, check the dict keys and the f-string braces. See test the server standalone.
-
Predict the result of summarize_alert for a 500 dollar wire to "Atlas Crypto Exchange" in AE, and for a 500 dollar wire to "Joe Smith" in KP. Would "ATLAS CRYPTO EXCHANGE" change the first?
Show answer
Atlas gives one flag, a sanctions hit on the EU consolidated list, and halts even though AE is only medium risk. Joe Smith gives a prohibited-jurisdiction flag for KP and also halts. Upper case changes nothing, because the name is stripped and lower-cased before lookup. See write the server.
-
What do the Literal type on transaction_type and the docstring on each tool become at runtime, and why does that matter?
Show answer
The SDK builds the JSON schema from the type hints, so a Literal becomes an enum and the model can only pick listed values. The docstring becomes the description the model reads when choosing a tool. A weak docstring or loose type quietly degrades selection and arguments. See write the server.
-
After editing the Claude Desktop config you reload and see no compliance-toolkit tools. Name the three most likely causes from the guide.
Show answer
A relative path in the config, because Claude Desktop runs from its own directory; system Python instead of the venv's Python, so the mcp import fails; and an app that was not fully quit before relaunching. See wire into Claude Desktop.
-
In exercise three you change check_jurisdiction_risk to raise an error for unknown codes and then ask about "ZZ". What changes for the agent, and why?
Show answer
The agent loses the structured answer. The graceful version returns an unknown tier with a rationale that says to flag for review, so the model can tell you to escalate. A raw exception gives it nothing to reason from and tends to derail the session. See break it.
-
A manager asks you to let freeze_account really freeze when the model is very sure. What do you answer, and what exactly do you keep?
Show answer
Keep it proposal-only, with requires_approval true and executed false. A wrong freeze harms a customer and creates regulator exposure, and model confidence is not calibrated. Execution belongs in a separate path where an investigator confirms, with an idempotency key and an audit record. See break it.
Takeaway
- A working MCP server is a few decorated Python functions: the SDK builds the schema from type hints and the description from the docstring.
- Tool descriptions matter as much as the code, because a vague docstring makes the model pick tools less reliably.
- Return structured errors and explicit unknown states instead of raising, so the model can recover or escalate.
- Test with the inspector before wiring a client, and use absolute paths and the venv's Python in the client config.
- Keep stdout for the protocol and log to stderr, so every call leaves a trace you can tail.
- A tool that would act in the world returns a proposal that needs approval, and a human executes it elsewhere.
You have run the stdio loop end to end; the stretch tutorials extend it, starting with read-only resources. → Add an MCP Resource
Further reading
- Build an MCP server — Model Context Protocol, accessed 2026. The official walkthrough for a first server, in several languages.
- MCP Python SDK — Model Context Protocol, accessed 2026. The package the tutorial uses, with its decorators and transports.
- MCP Inspector — Model Context Protocol, accessed 2026. How to browse and call a server's tools without a client.
- Tools — Model Context Protocol, 2025-06-18 revision. What a tool definition and a tool result must contain.