MCP tools
Endpoint: https://undocrm.com/mcp (Streamable HTTP, stateless). Auth: Authorization: Bearer ucrm_agt_.... Owner keys are refused; MCP is for agents.
Five tools: read, create, update, delete, and can_contact. The first four are plain CRUD over HubSpot-style objects (contact, company, deal, task, interaction, pipeline). can_contact is separate because it can't be CRUD: it checks and claims a person in one locked step.
Results are short plain text written for a model, always carrying the ids the next call needs. Errors come back as tool errors that say what to do instead.
person / target strings:
| You have | Pass |
|---|---|
| Contact id | ct_3f9a0c2b7e1d4a58 |
jane@acme.com |
|
| Phone | +14155550100 (any formatting; E.164 matches most reliably) |
| Handle or profile URL | linkedin:janedoe, x:@jane, https://linkedin.com/in/janedoe |
| Other records | co_… company, dl_… deal, fu_… task, ix_… interaction |
| Collections (read only) | contacts, companies, deals, tasks, interactions, pipelines, properties |
Property names follow HubSpot: firstname, lastname, email, phone, jobtitle, company, lifecyclestage; dealname, dealstage, amount, closedate, pipeline. Anything else you set on a contact is a custom property: stored append-only with history, and registered so other agents reuse the same name.
read
| Param | ||
|---|---|---|
target |
string | A person, an id, or a collection. Omit with query to search contacts. |
view |
brief | logs | facts | deals | tasks |
For a person. Default brief. |
query |
string | Search text for contacts, companies, or interactions (full-text over every conversation). |
purpose |
string | For briefs: why you're looking. Ranks what goes in. |
max_tokens |
80–8000 | For briefs. Default 500. Rules are always included. |
filter |
object | For collections: tasks {due: overdue|today|week|all, mine}, deals {pipeline, dealstage, open}, contacts {lifecyclestage}. |
limit |
1–100 | Default 20. |
A person's brief — the read every agent should do before acting:
# Jane Doe — VP Customer Support, Acme (acme.com)
ct_b2b23e68a58e3aa2 · jane.doe@acme.com · +14155550142 · linkedin:janedoe-acme · lifecyclestage: opportunity
## Rules
- Claimed by sdr-1 until 2026-10-19 02:09Z (send SOC2 + order form). Don't reach out; they are handling this person.
- Last outbound: meeting 1d ago by sdr-1.
## Open tasks
- fu_2c26f224f1c50252 due 2026-10-18: Send SOC2 report and pilot order form [email] (sdr-1)
## Deals
- dl_bbcd208139c1ddc5 Acme — support pilot: Contract Sent · 80,000 · close 2026-10-31
## Facts
- budget: $80k/yr (was: $50k/yr) — sdr-1, 2026-10-17
- decision_maker: CFO (Mark Ellis) — sdr-1, 2026-10-17
## Recent interactions (4 of 4; → outbound, ← inbound)
- 2026-10-15 meeting → sdr-1: 30-min call. Budget approved at $80k/yr after the Zendesk quote came in higher.
Searching every conversation:
read(target: "interactions", query: "SOC2")
2 conversations mention "SOC2":
ix_9f2… 2026-10-13 phone ← Jane Doe (ct_b2b…): …wanted to know if «SOC2» is done.
ix_41c… 2026-10-15 meeting → Jane Doe (ct_b2b…): …needs «SOC2» Type II before signing.
Your tasks: read(target: "tasks", filter: {due: "today"}). The deal board: read(target: "deals", filter: {dealstage: "contractsent"}). Which property names exist: read(target: "properties").
create
| Param | ||
|---|---|---|
type |
interaction | contact | company | deal | task | pipeline |
required |
person |
string | Who it's about (interaction, deal, task). Unknown people are created. |
properties |
object | The record's fields (below). |
facts |
object | interaction only: what you learned, as {budget: "$50k", timeline: "Q1"}. Never overwrites. |
tasks |
[{due, subject, channel?}] |
interaction only: what you owe. due: ISO date, datetime, or "3d", "2w", "4h". |
completes_tasks |
string[] |
interaction only: task ids this interaction fulfills. |
interaction — the main write, after every touchpoint. One call records the conversation, what you learned and what you owe.
{
"type": "interaction",
"person": "jane@acme.com",
"properties": {
"channel": "email", "direction": "inbound",
"summary": "Replied: budget approved at $80k, needs our SOC2 report before signing.",
"external_id": "<CAF=abc123@mail.gmail.com>"
},
"facts": { "budget": "$80k/yr", "blocker": "Needs SOC2 report" },
"tasks": [{ "due": "2026-10-20", "subject": "Send the SOC2 report", "channel": "email" }],
"completes_tasks": ["fu_1a2b3c4d5e6f7a8b"]
}
Logged ix_e543dde288ec2cfd with Jane Doe (ct_8c77757b98080b2f).
Facts: budget (was: $50k/yr), blocker.
Tasks: fu_9d0e1f2a3b4c5d6e due 2026-10-20 "Send the SOC2 report".
Completed: fu_1a2b3c4d5e6f7a8b.
properties for an interaction: channel, direction (inbound, outbound, internal), summary (1–3 sentences for a future agent), and optionally occurred_at, content (raw text, never put in briefs), external_id (makes retries safe), thread_id, deal_id, name (if the person is new).
If a new fact name looks like one that already exists, the result says so — "annual_budget" is new; "budget" already exists — so agents converge on one vocabulary.
contact — {email, phone, linkedin, firstname, lastname, jobtitle, company, lifecyclestage, …any custom property}. Matches existing people by identity; if the identities belong to different records they're merged into one. If a company was inferred from an email domain, the result says so.
deal — person + {dealname, dealstage, amount, closedate, pipeline?, currency?}. Without pipeline the deal goes in the first one, HubSpot's default Sales Pipeline: appointmentscheduled → qualifiedtobuy → presentationscheduled → decisionmakerboughtin → contractsent → closedwon / closedlost. Stage keys or labels both work ("Contract sent").
task — person + {subject, due, channel?, deal_id?}.
company — {name, domain, …any custom property}.
pipeline — {label, stages: ["Applied", "Screen", "Onsite", "Offer", "Hired", "Rejected"]}. For recruiting, support queues, anything with stages.
update
| Param | ||
|---|---|---|
target |
string | A person or an id. |
properties |
object | Only what changes. |
- person —
jobtitle,company,lifecyclestage,name;email/phone/linkedinadd identities (and merge duplicates); custom properties append (old value kept);do_not_contact: "all"or a channel, withdo_not_contact_reason. Agents can't lift do-not-contact. - dl_ deal —
dealstage,amount,closedate,dealname,pipeline. - fu_ task —
{status: "done" | "cancelled", outcome}, or{due, subject}to reschedule. - co_ company —
name,domain, custom properties.
update("dl_bbcd208139c1ddc5", {dealstage: "closedwon", amount: 85000})
→ Updated dl_bbcd208139c1ddc5 "Acme — support pilot": stage Contract Sent → Closed Won, amount 85000.
Opt-outs, the moment they happen:
update("marcus@globex.io", {do_not_contact: "email", do_not_contact_reason: "Replied: please stop emailing me"})
→ Now do-not-contact on email; every agent's can_contact will say BLOCKED.
delete
Nothing is destroyed: records are archived or retracted, and a human can restore them from the audit log.
| Param | ||
|---|---|---|
target |
string | Person, dl_, fu_, or ix_. |
property |
string | For a person: retract only this property's current value. |
reason |
string | Required to archive a person. |
delete("jane@acme.com", property: "budget", reason: "typo")
→ Retracted budget = "$500k" for Jane Doe. Current value is now the previous one: "$50k". History is kept.
can_contact
The gate before every outbound message.
| Param | ||
|---|---|---|
person |
string | required |
channel |
string | required unless releasing |
purpose |
string | Shown to agents your claim blocks. |
claim |
bool | Default true. false only checks. |
release |
bool | true drops your claim. |
ttl_minutes |
int | Claim length. Default 1440 (24h), max 43200. |
ALLOWED — Jane Doe (ct_8c77757b98080b2f) on email. Claimed for you until 2026-10-18T09:00:00Z.
Last outbound: email 2d ago by sdr-1.
After sending, log it with create(type: "interaction").
BLOCKED — Jane Doe (ct_8c77757b98080b2f) is claimed by sdr-1 until 2026-10-18T09:00:00Z (purpose: send SOC2 report). Another agent is handling them; don't message.
BLOCKED — Marcus Chen (ct_bb1e9ab17925a336) is do-not-contact on email: "Replied: please stop emailing me" (sdr-1, 2026-10-17). Do not message them on email.
Errors
Error: "@jane" is ambiguous. Prefix handles with their platform, e.g. x:@jane or linkedin:jane.
Error: "on the moon" is not a stage of "Sales Pipeline". Stages: appointmentscheduled, qualifiedtobuy, …
Error: Agents can't lift do-not-contact; only a human can, in the web app. Everything else was saved.
Classic tool set
The original nine task-named tools (get_brief, log_interaction, save_contact, search_contacts, set_do_not_contact, release_contact, list_follow_ups, complete_follow_up, can_contact) are still served at https://undocrm.com/mcp?tools=classic for existing integrations. They have no deals, pipelines, conversation search or property correction. See DECISIONS.md for how the two sets compared in evals.