Skip to main content
Glama
HubSpot MCP Agent with Slack Approval

HubSpot MCP Agent with Slack Approval

by rubianaseem
README.md
# HubSpot MCP Agent with Slack Approval

A Claude agent that researches accounts through an MCP server, drafts HubSpot updates, and cannot write a single field until a named person clicks Approve in Slack on the exact change they were shown.

Built by Rubia Naseem, GTM Engineer (12+ yrs CRM/RevOps, HubSpot & Salesforce).

## The problem

Connecting Claude to a CRM through MCP is easy. Making it safe is the part most demos skip. "The agent asks before it writes" usually means a confirmation dialog in the chat client, and the MCP spec says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers", and there "SHOULD always be a human in the loop with the ability to deny tool invocations" ([MCP tools spec](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)).

In RevOps the failures are specific. A scraped web page tells the agent to change the owner. Someone approves Tuesday's draft and Wednesday's regenerated version goes in. A rep edits the record between approval and write. A 429 retry creates the same note twice. I've built signal-triggered routing in production, and these details decide whether anyone trusts the automation. So here the approval lives on the server, not in the chat window.

## What it does

- Exposes seven MCP tools for reading, drafting, requesting approval and applying. There is no approve tool, so the model cannot approve its own work.
- Scores buying signals, picks a play and drafts a property update, note and owner task. Optional Claude classifies messy signal text, and every quote it uses is checked against the source.
- Quarantines signal text that reads like instructions, redacts emails, and refuses any property outside a five-field allowlist.
- Posts a Slack Block Kit message whose button carries the proposal's SHA-256 content hash. Clicks are checked against Slack's signing secret, a 5-minute replay window and per-approver permissions.
- Releases approved changes with idempotency keys, Retry-After handling, backoff, stale-record checks and crash resume, all logged to a hash-chained audit file.

## Where each step runs

1. GitHub stores the code. You run it from Terminal, Mac Terminal or the Cursor terminal.
2. `agent.py draft` starts the agent, which talks to the MCP server (`mcp_server.py`). With `ANTHROPIC_API_KEY` set, Claude drives that tool loop. Without it, a scripted loop calls the same tools.
3. Approval requests go to a local Slack outbox by default, or to your Slack channel with `--live-slack`.
4. `slack_server.py` receives the button click and records the decision. It never writes to the CRM.
5. A person runs `agent.py apply --proposal ...` to release one approved proposal. Today that write goes to a mock HubSpot file (`state/crm.json`), not a real portal.

## Guardrails and human in the loop

- Planning, approval and execution are three separate steps, and only the last one writes.
- An approval binds to the content hash and record version. If anything changes, the old button stops working.
- Demo approvals are simulated, signed clicks on synthetic data. They are not permission to touch a real CRM or send anything.

## Quick start

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python run_demo.py          # full scenario, writes outputs/
python run_evals.py         # evaluation report, writes evals/report.md
python -m unittest discover -s tests -t .
```

No API key needed. See [SETUP.md](SETUP.md) for Claude, Slack and Claude Code setup.

## Example output

From `python run_demo.py` (synthetic accounts):

```
Approval clicks
  sales manager approves a lifecycle change      C-1001  denied    approver_lacks_permission:lifecyclestage
  RevOps admin approves                          C-1001  approved  recorded
  forged signature                               C-1003  denied    bad_signature
  button from an older draft (hash mismatch)     C-1003  denied    hash_mismatch
Writes
  crash after property write                     P-C-1001-3f5cfea6    crashed  left APPLYING
  resume after crash                             P-C-1001-3f5cfea6    applied  ok
  record edited after approval                   P-C-1005-c6bae183    stale    stale_record
```

## Evaluation (measured locally, synthetic data)

`python run_evals.py` scores 24 hand-labelled accounts (34 signals) and an 18-scenario attack and failure suite, against the same keyword rules without guardrails:

| Measure | Baseline | This agent |
|---|---|---|
| Play accuracy | 0.75 | 0.875 |
| Injection signals caught | 0 of 6 | 5 of 6 |
| Injected text reaching a proposal | 6 | 1 |
| Emails reaching a proposal | 1 | 0 |
| Unsafe writes, 13 attack scenarios | 11 (gate that only checks for a click) | 0 |

The injection that gets through is a paraphrase held out on purpose. This is my own small labelled set, not production data. Full report: [evals/report.md](evals/report.md).

## How this runs today (and what production would add)

Implemented and tested locally: MCP server (in-memory and stdio), approval gate, Slack signature handling, executor, audit log and evals. Implemented but not tested against a live workspace: Slack posting with `--live-slack`. Not built: a real HubSpot client, queue workers and hosting. See [docs/DETAILS.md](docs/DETAILS.md).

## More

- [Architecture and data contracts](docs/ARCHITECTURE.md)
- [Walk through it line by line](docs/WALKTHROUGH.md)
- [Sources and inspiration](docs/SOURCES.md)
- Related: [n8n-error-handling-orchestration](https://github.com/rubianaseem/n8n-error-handling-orchestration) (retry patterns), [forecast-commit-hygiene-agent](https://github.com/rubianaseem/forecast-commit-hygiene-agent), [gtm-engineering-q1-showcase](https://github.com/rubianaseem/gtm-engineering-q1-showcase)