HubSpot Read-Only Except Call Notes
Keeps the HubSpot connection read-only for agents, with one exception: the agent can log a call note against a contact.
On this page
What it does
Keeps the HubSpot connection read-only for agents, with one exception: the agent can log a call note against a contact. Reps get an assistant that can look anything up in the CRM and write up the call afterwards, without being able to change deals, contacts, campaigns, or anything else.
- Reads pass. Search, get, query, schema discovery, properties, owners, and reports are untouched.
- Writes are denied. Any HubSpot tool whose name contains a mutating verb
(
create,update,manage,delete,archive,merge,submit,unsubscribe, …) is blocked. - Call notes on contacts are allowed. A create of a Note or Call record whose associations all point at contacts goes through. Editing or deleting an existing note is still blocked.
Compliance alignment
- SOC 2 CC6.1; CC6.3 — logical access restriction and least privilege: the agent channel gets read access plus the single write the workflow needs (logging a call on a contact).
- ISO 27001 A.5.15 — access control: enforces the narrowed write decision at a technical control point.
Why ingress
Writes have permanent side effects on the CRM (workflow automation, reporting, downstream syncs). Whether a call is a permitted call note is fully determined by the tool name and its arguments, so denying at ingress guarantees no other mutation reaches HubSpot.
How it matches
Write detection. A tool is in scope when its (lowercased) name starts
with hubspot — the MCP server name configured on the gateway. It is a
write when any - / _-separated token in its name is a mutating verb.
Matching on verb tokens rather than a fixed tool list means new HubSpot
write tools (the remote server keeps adding manage-* tools) are blocked
without a policy edit.
The call-note exception. "Call note" means either a Note (notes,
0-46) or a Call (calls, 0-48) engagement record. It is allowed when:
- Remote server (
manage_crm_objects/-manage-crm-objects): the request carries only acreateRequest(no non-emptyupdateRequestor other request block); every object is a note or call; every object has at least one association; and every association targets a contact —targetObjectTypeofCONTACT(the remote server's shape), or, when HubSpot v4 associationtypesare given, only HubSpot-defined type202(note→contact) or194(call→contact). @hubspot/mcp-serverlocal beta (-create-engagement):typeisNOTEorCALL,associations.contactIdsis non-empty, and no company, deal, or ticket ids are attached.
Any argument shape the policy doesn't recognise is denied.
Examples
Allowed (call note on a contact)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "hubspot-manage-crm-objects", "type": "tool" },
"payload": {
"args": {
"createRequest": {
"objects": [
{ "objectType": "notes",
"properties": { "hs_note_body": "Call recap…", "hs_timestamp": "2026-10-03T14:20:00Z" },
"associations": [ { "targetObjectType": "CONTACT", "targetObjectId": 546680647389 } ] }
]
}
}
}
}
}
allow = true, no reason.
Denied (contact edit)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "hubspot-manage-crm-objects", "type": "tool" },
"payload": {
"args": {
"updateRequest": {
"objects": [ { "objectType": "contacts", "objectId": 1, "properties": { "jobtitle": "VP" } } ]
}
}
}
}
}
allow = false, reason = "Blocked: HubSpot is read-only through this gateway. The only change allowed is adding a call note (a new note or call record) to a contact. Ask your admin if you need anything else changed in HubSpot.".
Composition
Attach this instead of hubspot/read-only or
hubspot/role-gate-writes — read-only
would also block the call note. It composes cleanly with
hubspot/redact-pii (egress) and
hubspot/cap-bulk-export (ingress
transform on reads).
Known limitations
- Server name prefix. Scope is tools whose name starts with
hubspot. If your HubSpot MCP server is registered under a name that doesn't start withhubspot, change_is_hubspot. - Verb-token heuristic. A future HubSpot tool that mutates data without a
verb from
_write_verbsin its name would pass. Add the verb if that happens. - Note content is not inspected. The body of an allowed note is free text; pair with an egress or DLP policy if what gets written matters.
- Not exactly-once. Each create is judged on its own; the policy does not limit how many notes an agent writes.
- Community servers. shinzo's
notes_create/calls_createare denied (they're writes, and not on the exception path).
Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
Policy source (Rego)
package hubspot.ingress.readonly_except_call_notes
# Allow by default; deny HubSpot write tools, except a create that logs a
# call note (a Note or Call record) associated only with contacts.
# Non-HubSpot tools are never touched.
default allow := true
_tool := lower(object.get(input.resource, "name", ""))
_args := object.get(input.payload, "args", {})
# Scope: tools served by an MCP server whose gateway name starts with "hubspot".
_is_hubspot if startswith(_tool, "hubspot")
# A tool is a write if any `-`/`_`-separated token in its name is a mutating verb.
_write_verbs := {
"create", "update", "manage", "delete", "archive", "upsert", "merge",
"subscribe", "unsubscribe", "submit", "associate", "void", "purge",
"set", "add", "remove", "move", "patch", "write", "send", "import",
"restore", "cancel", "enroll", "unenroll", "publish", "clone",
}
_is_write_tool if {
_is_hubspot
some t in regex.split(`[-_]`, _tool)
_write_verbs[t]
}
# --- The exception: a call note on a contact ---
_note_types := {"notes", "note", "calls", "call", "0-46", "0-48"}
_contact_words := {"contact", "contacts", "0-1"}
# HubSpot-defined association type ids: note -> contact (202), call -> contact (194).
_contact_assoc_type_ids := {202, 194}
_has_types(a) if count(object.get(a, "types", [])) > 0
# When association types are given, every one must be a contact association.
_assoc_targets_contact(a) if {
_has_types(a)
every t in object.get(a, "types", []) {
_contact_assoc_type_ids[to_number(object.get(t, "associationTypeId", -1))]
upper(object.get(t, "associationCategory", "HUBSPOT_DEFINED")) == "HUBSPOT_DEFINED"
}
}
# Otherwise fall back to an explicit target object type.
_assoc_targets_contact(a) if {
not _has_types(a)
_contact_words[lower(object.get(a, "targetObjectType", ""))]
}
_assoc_targets_contact(a) if {
not _has_types(a)
_contact_words[lower(object.get(object.get(a, "to", {}), "objectType", ""))]
}
_assoc_targets_contact(a) if {
not _has_types(a)
_contact_words[lower(object.get(a, "toObjectType", ""))]
}
# Remote HubSpot MCP server: manage_crm_objects with createRequest only.
_is_manage_tool if endswith(_tool, "manage_crm_objects")
_is_manage_tool if endswith(_tool, "manage-crm-objects")
_create_objs := object.get(object.get(_args, "createRequest", {}), "objects", [])
_empty_request(v) if v == null
_empty_request(v) if {
is_object(v)
count(object.get(v, "objects", [])) == 0
}
# Any non-empty request block other than createRequest (updateRequest, etc.).
_other_requests contains k if {
some k, v in _args
k != "createRequest"
endswith(lower(k), "request")
not _empty_request(v)
}
_is_remote_call_note if {
_is_manage_tool
count(_other_requests) == 0
count(_create_objs) > 0
every obj in _create_objs {
_note_types[lower(object.get(obj, "objectType", ""))]
count(object.get(obj, "associations", [])) > 0
every a in object.get(obj, "associations", []) {
_assoc_targets_contact(a)
}
}
}
# @hubspot/mcp-server local beta: create-engagement of type NOTE or CALL,
# associated with contacts only.
_is_local_call_note if {
endswith(_tool, "create-engagement")
upper(object.get(_args, "type", "")) in {"NOTE", "CALL"}
assoc := object.get(_args, "associations", {})
count(object.get(assoc, "contactIds", [])) > 0
count(object.get(assoc, "companyIds", [])) == 0
count(object.get(assoc, "dealIds", [])) == 0
count(object.get(assoc, "ticketIds", [])) == 0
}
_is_call_note if _is_remote_call_note
_is_call_note if _is_local_call_note
# --- Decision ---
allow := false if {
_is_write_tool
not _is_call_note
}
reason := "Blocked: HubSpot is read-only through this gateway. The only change allowed is adding a call note (a new note or call record) to a contact. Ask your admin if you need anything else changed in HubSpot." if {
_is_write_tool
not _is_call_note
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)