HubSpot MCP Operator
by holmjames
README.md
# HubSpot MCP Operator (demo)
A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI agent **author HubSpot automation end-to-end** — build a workflow, set who gets enrolled, wire up the steps, define the conversion goal, and segment contacts into lists — entirely through tool calls, with every mutation **verified by reading state back**.
> **About this repo.** This is a sanitized, standalone reconstruction of an internal tool I built and ran in production against a live HubSpot portal. All organization-specific data — real object IDs, list/workflow IDs, customer segments, credentials — has been removed. It targets only HubSpot's *standard* objects and ships with an `.env.example` you point at your own test portal. It builds, boots, and registers all 24 tools (verified); running the write tools requires your own HubSpot private-app token.
---
## Why this exists
HubSpot's UI is the normal way to build a marketing workflow. But "build a workflow" is exactly the kind of multi-step, schema-heavy task an agent *should* be able to do — if it has tools that are safe to hand to a model.
The hard part isn't calling the API. It's making the tools **trustworthy enough to act autonomously**:
- A `200 OK` from HubSpot does **not** mean the thing you intended actually happened. The flows API will accept a malformed filter and silently store it as "always false." A list filter can be saved and quietly match zero records.
- A model that fuzzy-matches "the onboarding workflow" and mutates the wrong flow is a disaster.
- The v4 flows API is **read-modify-write**: to change one field you must PUT the entire flow back, minus the fields the API rejects on write.
This server is built around those realities.
## Design principles
**1. Every tool returns the same envelope — and writes verify themselves.**
```jsonc
{
"ok": true,
"operation": "workflows.set_enrollment_criteria",
"data": { /* request echo + the re-fetched object */ },
"audit": {
"attempted": true,
"verified": true, // ← we re-read the object and confirmed the change
"targetType": "workflow",
"targetId": "1234567890",
"targetName": "Trial → Activation Nurture"
}
}
```
`audit.verified` is the contract. A write tool performs the mutation, then **re-fetches the object and checks the change is actually present** before claiming success. `create` reads the new record back; `delete` re-lists to confirm the object is gone; `lists.members.add` re-reads each record's memberships. An agent can branch on `audit.verified` instead of trusting a status code.
**2. Mutations require an unambiguous target.** Workflow write tools resolve their target by ID or by *exact* (case-insensitive) name only. No partial match ever selects a flow to mutate — if the name isn't unique, the tool returns the candidate list and refuses to act.
**3. Read-modify-write is encapsulated.** `sanitizeWorkflowForUpdate` / `sanitizeWorkflowForCreate` strip the server-managed fields (`id`, `revisionId`, timestamps) that HubSpot rejects on write, so a single field change round-trips the whole flow safely. Callers just say "set these actions" or "set this enrollment criteria."
**4. Failure modes are distinguished.** "You sent a bad request" and "this portal can't do this via the API" are different signals to an agent. `workflows.add_go_to_workflow_step` detects the unsupported-step failure and flags `unsupported_via_api: true` rather than returning a generic error.
**5. Raw filter branches pass straight through.** Workflow enrollment, workflow goals, and list filters all share HubSpot's nested AND/OR `filterBranch` shape. The tools don't invent a DSL on top — they pass the raw branch through, so the full expressiveness of HubSpot filters is available. (See the gotcha below for how to validate one.)
## Tool surface (24 tools)
### Workflows (`automation/v4/flows`)
| Tool | What it does |
|---|---|
| `workflows.search` | List / find by ID, exact name, or partial query |
| `workflows.get` | Full flow detail |
| `workflows.create_manual` | Create an empty, disabled flow shell |
| `workflows.set_enrollment_criteria` | Set who is enrolled (raw `enrollmentCriteria` branch) |
| `workflows.set_actions` | Replace the action graph; auto-derives `startActionId` |
| `workflows.set_goal_criteria` | Set the conversion goal (`goalFilterBranch`) |
| `workflows.rename` / `set_enabled` / `delete` | Lifecycle, by ID or exact name |
| `workflows.clone_basic` | Copy criteria + steps into a new disabled flow |
| `workflows.add_go_to_workflow_step` | Append a cross-workflow jump (flags `unsupported_via_api`) |
### Lists (`crm/v3/lists`)
| Tool | What it does |
|---|---|
| `lists.search` / `lists.get` | Find lists; `get` can read the `filterBranch` back |
| `lists.create` | Create a DYNAMIC (or MANUAL/SNAPSHOT) list with a raw `filterBranch` |
| `lists.update_filters` | Replace an existing dynamic list's filters **in place** (full replace) |
| `lists.rename` / `lists.delete` | Lifecycle (delete is a verified soft-delete) |
| `lists.members.list` / `add` / `remove` | Manual membership, verified per record |
### CRM (`crm/v3/objects`)
| Tool | What it does |
|---|---|
| `crm.search` | Query / by-ID / structured `filterGroups`; `count:true` returns only the total |
| `crm.get` | One record with optional properties + associations |
| `crm.update_properties` | Patch a record, verified by readback |
| `crm.associations.get` | Associated records of another object type |
## A worked example: an agent builds a nurture from one prompt
> *"Enroll trial contacts who haven't activated into a 3-step nurture, and mark them converted when they activate."*
```
crm.search { objectType: "contacts", count: true, → total: 4,812 (sizing the audience)
filterGroups: [{ filters: [
{ propertyName: "lifecyclestage", operator: "EQ", value: "trial" },
{ propertyName: "activated", operator: "NEQ", value: "true" } ] }] }
workflows.create_manual { name: "Trial → Activation Nurture" } → id 1234567890, verified
workflows.set_enrollment_criteria { workflowId: "1234567890", ... } → verified
workflows.set_actions { workflowId: "1234567890", actions: [email, delay, email, delay, email] } → verified
workflows.set_goal_criteria { workflowId: "1234567890", → verified
goalFilterBranch: { activated EQ "true" } }
workflows.set_enabled { workflowId: "1234567890", isEnabled: true } → verified
```
Each step returns `audit.verified: true` only after the change is re-read from HubSpot. If `set_enrollment_criteria` had stored an "always false" branch, the agent could catch it before turning the flow on.
## The filter-validation gotcha (the most useful thing I learned)
HubSpot will **accept and store an invalid `filterBranch`** with a `200`, then silently treat it as *always false* — the workflow enrolls no one and gives no error. You cannot trust the write echo.
The reliable validation is to create a throwaway **dynamic list** with the same branch: a list returns precise per-filter validation errors *and* a real member count. Confirm the count is sane, then apply the branch to the workflow and delete the probe list. `lists.create` + `lists.get` + `lists.delete` exist partly to make this loop cheap.
## Setup
```bash
npm install
cp .env.example .env # then paste your HubSpot private-app token
npm run build # compiles src/ -> dist/
npm start # runs the MCP server over stdio
```
Register it with any MCP client (e.g. Claude Desktop / Claude Code):
```jsonc
{
"mcpServers": {
"hubspot-operator": {
"command": "node",
"args": ["/absolute/path/to/hubspot-mcp-operator-demo/dist/server.js"]
}
}
}
```
> ⚠️ The write tools mutate real HubSpot objects. Point this at a **test/sandbox portal**, and scope the private-app token to only what you need.
## Layout
```
src/
server.ts MCP bootstrap — registers all 24 tools, one envelope renderer
hubspot.ts the single fetch choke point + typed HubSpotApiError
config.ts .env loading + requireEnv
types.ts ToolEnvelope / ToolAudit — the shared contract
utils.ts envelopes, exact-match targeting, read-modify-write sanitizers
workflows.ts the 11 flow tools (the centerpiece)
lists.ts the 9 list tools, incl. per-record membership verification
crm.ts the 4 CRM tools, incl. count-mode search
```
## Tech
TypeScript (strict, NodeNext ESM) · `@modelcontextprotocol/server` · `zod` for tool input schemas · HubSpot REST v3/v4. No build step beyond `tsc`.
## License
MIT — see [LICENSE](./LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues