Skip to main content
Glama
holmjames

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).