Model Context Protocol (MCP): Connect AI to Your Tools and DataCapstone: an MCP server for a marketing CRM · Lesson 17 of 18
Capstone part 2: build crescent-crm in Python
Video lecture
Capstone part 2: build crescent-crm in Python
The narrated lecture is in production
Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.
Chapters
Transcript of the narration, chapter by chapter.
0:00 Capstone part 2: build
Time to build. In this lesson you'll turn the crescent CRM design into a working Python server with the official SDK: five read tools, two safe write tools, two resources and two prompts, running on a fictional SQLite dataset you can swap for Postgres later.
0:20 Why build carefully?
Why build it with such care when it's a capstone? Because the habits you practice here are the habits that matter in production: parameterized queries, capped results, helpful errors, idempotent writes and data minimization. Think of practicing scales on a piano. They're not the concert, but every concert depends on them. By the end of this lesson, those habits will be in your fingers, and you'll write your next server this way without thinking.
0:52 Setup
Set up with uv: create the project and add the MCP SDK with its command line extra, plus Pydantic. Then create a small fictional seed dataset: companies in Pakistan, the UAE, Saudi Arabia and the UK, contacts with and without marketing consent, deals at different stages, and two months of campaign stats. Never use real customer data for a learning project. The server reads the database path from an environment variable.
1:23 Foundations
Look at the foundations first. The server's instructions tell hosts to start with find companies, then the brief, and to never convert currencies. Literal types define countries, deal stages and channels, so they become enums in the schema. Annotations mark read tools as read only, and write tools as additive and idempotent. Pydantic models define every output, so clients receive structured, validated data.
1:50 Read tools
Now the read tools. Find companies searches by name with an optional country, returns at most twenty five, and adds a note when there are more. The company brief is the workhorse: profile, up to ten contacts with names and roles only, open deals with days since last update, the last five activities with notes cut to three hundred characters, and active campaigns. Unknown ids raise a tool error that says use find companies first.
2:23 Simple example: the unknown ID
A simple example from the code. Look at what happens when the model calls company brief with a made up id, like co underscore acme two. The tool doesn't crash and doesn't return an empty brief. It raises a tool error saying, unknown company id, use find companies first. The model reads that, calls find companies with the name, gets the real id, and tries again. One helpful sentence turns a dead end into a two step recovery.
2:57 More read tools
Find deals filters by stage, minimum value and staleness, building a parameterized query step by step. Campaign performance aggregates metrics by currency for a date range of up to ninety two days, and can also return the previous period of equal length for comparison. If the range is too long, it raises a tool error telling the model to split it into several calls. Every query uses placeholders, never string formatting, because tool arguments come from a model that untrusted text can influence.
3:33 Safe writes
Two safe write tools. Log activity adds to a company's timeline, and it's idempotent: a unique constraint on the idempotency key means a retry returns, already logged, instead of creating a duplicate. Draft client email saves a draft for a human to review and send from the CRM. It never sends. And for marketing emails, it checks consent first and refuses if the contact hasn't opted in. That rule lives in code, so no clever prompt can talk the model around it.
4:09 Resources and prompts
Resources and prompts round it out. The schema resource explains metrics like cost per acquisition and click through rate. The playbooks resource template serves meeting prep and campaign retro guides. The account review prompt follows the meeting prep playbook using the brief and thirty days of performance, and the campaign retro prompt compares periods and asks for three recommendations.
4:35 Try it
Now try it. Seed the database and open the Inspector with mcp dev. Find a company, then get its brief. Ask for a hundred and twenty day performance range and confirm you get a helpful error. Log the same activity twice with the same key and confirm there's no duplicate. Then connect it to Claude Code and ask, brief me on Noor Trading and tell me which deals are stale. Watch which tools the model chooses.
5:08 Dates the model gets wrong
What if the model asks for a date range in the wrong format, or in words, like last month? The Pydantic date type rejects invalid formats automatically with a clear validation message, and the model usually retries correctly. For relative ranges, you have two options: add a helper tool that converts phrases like last month into exact dates, or state in the description that dates must be absolute, in year month day format. Either way, test it with a few natural phrasings.
5:44 Deeper: a realistic session
Let's deepen the build with a realistic test session for Crescent Growth. An account manager asks Claude Code, brief me on Al Noor Trading and tell me which deals are stale. The model calls find companies with Noor, gets the id, calls company brief, and notices a proposal untouched for forty days. It then asks, should I log that you'll follow up on Friday? The manager says yes, and the model calls log activity with a fresh idempotency key. The network hiccups, the host retries, and the second call returns, already logged. The CRM shows exactly one activity, attributed correctly. That small sequence exercises nearly every design decision you made in part one.
6:33 Watch me do it: key build pieces
Watch me do it. Let's step through three pieces of the build. First, the database helper: a context manager opens SQLite with row objects and always closes the connection. Second, company brief: it looks up the company and raises a tool error if missing. Then four queries: up to ten contacts with only id, name and role; up to ten open deals, largest first; the last five activities; and active campaigns. It computes days since update, truncates notes to three hundred characters, and returns the brief model. Third, log activity: it checks the company exists, then inserts the activity with the idempotency key. If SQLite raises an integrity error because the key already exists, the tool returns, already logged, instead of failing. Now I run the four Inspector checks: brief works, the hundred and twenty day range errors, the duplicate log is harmless, and a marketing draft to a non consented contact is refused.
7:40 Try this now
Try this now. Create a small fictional dataset, build the server, and run the four Inspector checks: find a company and get its brief, request a hundred and twenty day range and read the error, log the same activity twice with the same key, and try a marketing draft to a contact without consent. Then connect it to Claude Code and ask a real question. Note which tools the model calls and in what order. That order tells you whether your descriptions are doing their job.
8:17 Recap
To recap: you've built a server with compact, structured read tools, idempotent and consent aware writes, parameterized queries, caps and truncation, plus resources and prompts. One placeholder remains: the created by field, which part three connects to the authenticated user. Your next step: build it with fictional data, run the four Inspector checks, and connect it to Claude Code or VS Code.
Setup
uv init crescent-crm && cd crescent-crm
uv add "mcp[cli]" pydantic
# optional for tests: uv add --dev pytest anyioThis build uses SQLite so you can run it anywhere; swap in Postgres for production by changing the data-access layer. Create a small fictional seed dataset (companies in PK, AE, SA and UK, a few contacts with and without marketing consent, deals in various stages, two months of campaign stats). Never use real customer data for the capstone.
server.py
import os, sqlite3
from contextlib import contextmanager
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
DB_PATH = os.environ.get("CRM_DB", "crescent.db")
MAX_ROWS = 25
READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
WRITE_ADDITIVE = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True,
open_world_hint=False)
Country = Literal["PK", "AE", "SA", "UK", "US"]
Stage = Literal["lead", "qualified", "proposal", "negotiation", "won", "lost"]
mcp = MCPServer(
"crescent-crm",
instructions=("Crescent Growth CRM and campaign data. Start with crm_find_companies to get IDs, "
"then crm_company_brief. Money is reported in each record's currency; never convert. "
"Writes are limited to logging activities and creating email drafts."),
)
@contextmanager
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
# ---------- models ----------
class CompanyHit(BaseModel):
company_id: str
name: str
industry: str
country: str
class CompanyList(BaseModel):
companies: list[CompanyHit]
note: str = ""
class ContactSummary(BaseModel):
contact_id: str
full_name: str
role: str
class Deal(BaseModel):
deal_id: str
name: str
stage: str
value: float
currency: str
expected_close: str | None
days_since_update: int
class Activity(BaseModel):
type: str
note: str
created_at: str
class CompanyBrief(BaseModel):
company: CompanyHit
key_contacts: list[ContactSummary]
open_deals: list[Deal]
recent_activities: list[Activity]
active_campaigns: list[str]
class Performance(BaseModel):
start: date
end: date
currency_breakdown: dict[str, dict[str, float]]
previous_period: dict[str, dict[str, float]] | None = None
note: str = ""
# ---------- read tools ----------
@mcp.tool(title="Find companies", annotations=READ)
def crm_find_companies(
query: Annotated[str, Field(description="Part of the company name, e.g. 'Noor'")],
country: Annotated[Country | None, Field(description="Optional country code filter")] = None,
) -> CompanyList:
"""Find client companies by name. Returns IDs to use with crm_company_brief and other tools."""
with db() as c:
rows = c.execute("SELECT id, name, industry, country FROM companies WHERE name LIKE ? "
"AND (? IS NULL OR country = ?) ORDER BY name LIMIT ?",
(f"%{query}%", country, country, MAX_ROWS + 1)).fetchall()
note = f"Showing first {MAX_ROWS}; refine the query." if len(rows) > MAX_ROWS else ""
return CompanyList(companies=[CompanyHit(company_id=r["id"], name=r["name"], industry=r["industry"],
country=r["country"]) for r in rows[:MAX_ROWS]], note=note)
@mcp.tool(title="Company brief", annotations=READ)
def crm_company_brief(company_id: Annotated[str, Field(description="ID from crm_find_companies")]) -> CompanyBrief:
"""One-call meeting prep: profile, key contacts (names and roles only), open deals,
last 5 activities and active campaigns."""
with db() as c:
co = c.execute("SELECT id, name, industry, country FROM companies WHERE id=?", (company_id,)).fetchone()
if not co:
raise ToolError(f"Unknown company_id {company_id!r}. Use crm_find_companies first.")
contacts = c.execute("SELECT id, full_name, role FROM contacts WHERE company_id=? "
"ORDER BY role LIMIT 10", (company_id,)).fetchall()
deals = c.execute("SELECT id, name, stage, value, currency, expected_close, updated_at FROM deals "
"WHERE company_id=? AND stage NOT IN ('won','lost') ORDER BY value DESC LIMIT 10",
(company_id,)).fetchall()
acts = c.execute("SELECT type, note, created_at FROM activities WHERE company_id=? "
"ORDER BY created_at DESC LIMIT 5", (company_id,)).fetchall()
camps = c.execute("SELECT name FROM campaigns WHERE company_id=? AND end_date >= date('now') "
"ORDER BY start_date", (company_id,)).fetchall()
today = datetime.now(timezone.utc).date()
return CompanyBrief(
company=CompanyHit(company_id=co["id"], name=co["name"], industry=co["industry"], country=co["country"]),
key_contacts=[ContactSummary(contact_id=r["id"], full_name=r["full_name"], role=r["role"]) for r in contacts],
open_deals=[Deal(deal_id=r["id"], name=r["name"], stage=r["stage"], value=r["value"], currency=r["currency"],
expected_close=r["expected_close"],
days_since_update=(today - date.fromisoformat(r["updated_at"][:10])).days) for r in deals],
recent_activities=[Activity(type=r["type"], note=r["note"][:300], created_at=r["created_at"]) for r in acts],
active_campaigns=[r["name"] for r in camps],
)
@mcp.tool(title="Find deals", annotations=READ)
def crm_find_deals(
stage: Stage | None = None,
min_value: Annotated[float | None, Field(ge=0, description="Minimum deal value in the deal's currency")] = None,
stale_days: Annotated[int | None, Field(ge=1, le=365, description="Only deals not updated for N days")] = None,
) -> list[Deal]:
"""Find open deals by stage, minimum value and staleness. Returns at most 25, largest first."""
sql = ("SELECT id, name, stage, value, currency, expected_close, updated_at FROM deals "
"WHERE stage NOT IN ('won','lost')")
args: list = []
if stage:
sql += " AND stage = ?"; args.append(stage)
if min_value is not None:
sql += " AND value >= ?"; args.append(min_value)
if stale_days:
sql += " AND updated_at <= ?"; args.append((date.today() - timedelta(days=stale_days)).isoformat())
sql += " ORDER BY value DESC LIMIT ?"; args.append(MAX_ROWS)
with db() as c:
rows = c.execute(sql, args).fetchall()
today = date.today()
return [Deal(deal_id=r["id"], name=r["name"], stage=r["stage"], value=r["value"], currency=r["currency"],
expected_close=r["expected_close"],
days_since_update=(today - date.fromisoformat(r["updated_at"][:10])).days) for r in rows]
def _perf(c, start: date, end: date, market, channel, company_id) -> dict[str, dict[str, float]]:
rows = c.execute(
"SELECT k.currency, SUM(d.spend) spend, SUM(d.impressions) imp, SUM(d.clicks) clk, SUM(d.conversions) conv "
"FROM campaign_daily d JOIN campaigns k ON k.id = d.campaign_id "
"WHERE d.day BETWEEN ? AND ? AND (? IS NULL OR k.market = ?) AND (? IS NULL OR k.channel = ?) "
"AND (? IS NULL OR k.company_id = ?) GROUP BY k.currency",
(start.isoformat(), end.isoformat(), market, market, channel, channel, company_id, company_id)).fetchall()
out = {}
for r in rows:
conv = r["conv"] or 0
out[r["currency"]] = {"spend": round(r["spend"] or 0, 2), "impressions": r["imp"] or 0,
"clicks": r["clk"] or 0, "conversions": conv,
"cpa": round((r["spend"] or 0) / conv, 2) if conv else None}
return out
@mcp.tool(title="Campaign performance", annotations=READ)
def crm_campaign_performance(
start: date, end: date,
market: Country | None = None,
channel: Literal["search", "meta", "tiktok", "linkedin", "youtube", "email"] | None = None,
company_id: str | None = None,
compare_previous: Annotated[bool, Field(description="Also return the preceding period of equal length")] = False,
) -> Performance:
"""Aggregated campaign metrics for a date range (max 92 days), grouped by currency."""
if end < start or (end - start).days > 92:
raise ToolError("Use a valid range of at most 92 days; split longer periods into several calls.")
with db() as c:
current = _perf(c, start, end, market, channel, company_id)
previous = None
if compare_previous:
length = end - start + timedelta(days=1)
previous = _perf(c, start - length, start - timedelta(days=1), market, channel, company_id)
return Performance(start=start, end=end, currency_breakdown=current, previous_period=previous,
note="" if current else "No campaign data for these filters.")
# ---------- write tools ----------
@mcp.tool(title="Log activity", annotations=WRITE_ADDITIVE)
def crm_log_activity(
company_id: str,
type: Literal["call", "meeting", "email", "note"],
note: Annotated[str, Field(max_length=1000)],
idempotency_key: Annotated[str, Field(min_length=8, description="Unique per intended log entry; reuse on retry")],
contact_id: str | None = None,
) -> str:
"""Add an activity to a company's timeline. Additive and idempotent. Requires the crm.write scope."""
created_by = "mcp-user" # part 3 replaces this with the authenticated user's identity
with db() as c:
if not c.execute("SELECT 1 FROM companies WHERE id=?", (company_id,)).fetchone():
raise ToolError(f"Unknown company_id {company_id!r}.")
try:
c.execute("INSERT INTO activities(id, company_id, contact_id, type, note, created_by, created_at, "
"idempotency_key) VALUES (lower(hex(randomblob(8))),?,?,?,?,?,datetime('now'),?)",
(company_id, contact_id, type, note, created_by, idempotency_key))
c.commit()
return "Activity logged."
except sqlite3.IntegrityError:
return "Activity already logged for this idempotency_key (no duplicate created)."
@mcp.tool(title="Draft client email", annotations=WRITE_ADDITIVE)
def crm_draft_client_email(contact_id: str, subject: Annotated[str, Field(max_length=120)],
body: Annotated[str, Field(max_length=3000)],
purpose: Literal["service", "marketing"]) -> str:
"""Create an email DRAFT for a human to review and send from the CRM. Never sends.
Marketing drafts require the contact's marketing consent."""
with db() as c:
ct = c.execute("SELECT consent_marketing FROM contacts WHERE id=?", (contact_id,)).fetchone()
if not ct:
raise ToolError("Unknown contact_id; use crm_company_brief to find contacts.")
if purpose == "marketing" and not ct["consent_marketing"]:
raise ToolError("This contact has not consented to marketing emails. Draft a service email or stop.")
c.execute("INSERT INTO email_drafts(contact_id, subject, body, created_at) VALUES (?,?,?,datetime('now'))",
(contact_id, subject, body))
c.commit()
return "Draft saved for human review in the CRM drafts queue."
# ---------- resources and prompts ----------
@mcp.resource("crm://schema", mime_type="text/markdown")
def schema_doc() -> str:
"""Tables and metric definitions for crescent-crm."""
return ("CPA = spend / conversions. CTR = clicks / impressions. Money is in the record's currency. "
"Deal stages: lead, qualified, proposal, negotiation, won, lost.")
PLAYBOOKS = {"meeting-prep": "1) Brief 2) Open deals and blockers 3) Campaign results 4) Three questions to ask",
"campaign-retro": "1) Results vs previous period 2) What worked 3) What to change 4) Next tests"}
@mcp.resource("crm://playbooks/{name}", mime_type="text/markdown")
def playbook(name: str) -> str:
"""Agency playbooks: meeting-prep, campaign-retro."""
if name not in PLAYBOOKS:
raise ToolError(f"Unknown playbook; choose from {sorted(PLAYBOOKS)}")
return PLAYBOOKS[name]
@mcp.prompt(title="Account review")
def account_review(company_id: str) -> str:
"""Prepare for a client meeting."""
return (f"Follow the crm://playbooks/meeting-prep playbook for company {company_id}. "
"Use crm_company_brief and crm_campaign_performance (last 30 days, compare_previous=true).")
@mcp.prompt(title="Campaign retro")
def campaign_retro(company_id: str, start: str, end: str) -> str:
"""Campaign retrospective for a period."""
return (f"Run the campaign-retro playbook for {company_id} from {start} to {end} using "
"crm_campaign_performance with compare_previous=true. Present a table and three recommendations.")
if __name__ == "__main__":
mcp.run()(Create an email_drafts(id INTEGER PRIMARY KEY, contact_id, subject, body, created_at) table in your seed script alongside the others.)
Try it
- Seed the database, then run
uv run mcp dev server.py. - In the Inspector: call
crm_find_companies("Noor"), thencrm_company_briefwith the returned ID; request a 120-day performance range and confirm the tool error; callcrm_log_activitytwice with the same key and confirm no duplicate. - Connect to Claude Code:
claude mcp add crescent-crm -- uv run --with "mcp[cli]" mcp run /absolute/path/server.pyand ask: "Brief me on Noor Trading and tell me which of their deals are stale."
Code review notes
- Parameterized SQL everywhere (no string formatting of user input into queries).
- Literals become enums in the schema; Pydantic models become output schemas.
- Notes in the brief are truncated to 300 characters, and contact details never leave the server.
created_byis a placeholder until part 3 wires in the authenticated identity.
Key takeaways
- The capstone server implements five read tools, two safe write tools, two resources and two prompts.
- Parameterized SQL, row caps, range limits and truncation keep outputs safe and compact.
- Idempotency keys with a unique constraint make activity logging retry-safe.
- Consent checks enforce data-protection rules inside the tool, not in the prompt.
- Test interactively in the Inspector, then connect a real host.
Check your understanding
Quick questions to lock in the lesson. They don’t count towards your certificate.
Put it into practice
Build crescent-crm with a fictional seed dataset, verify the four Inspector checks, and connect it to Claude Code or VS Code.
Enrol for free to save your progress
Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.