# 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` |
| Email | `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:

```text
# 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:

```text
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.

```json
{
  "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"]
}
```

```text
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`/`linkedin` add identities (and merge duplicates); custom properties append (old value kept); `do_not_contact: "all"` or a channel, with `do_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.

```text
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:

```text
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. |

```text
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. |

```text
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").
```

```text
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.
```

```text
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

```text
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.
