Skip to content

Commit 7d8d122

Browse files
authored
feat(hubspot): read-only by default via HUBSPOT_READ_ONLY (#77)
Write tools (create/update/delete object, create/delete association, send/update thread) are only registered when HUBSPOT_READ_ONLY=false. Default exposes 11 read tools. Bumps to 1.1.0. Adds server.test.ts.
1 parent 881f77c commit 7d8d122

4 files changed

Lines changed: 166 additions & 64 deletions

File tree

packages/hubspot/README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,41 @@ export HUBSPOT_ACCESS_TOKEN="pat-na1-xxxxxxxx"
1212

1313
Suggested scopes: `crm.objects.contacts.*`, `crm.objects.companies.*`, `crm.objects.deals.*`, `tickets`, `crm.schemas.*`, `conversations.read`, `conversations.write`.
1414

15+
## Read-only mode
16+
17+
By default the server runs in **read-only mode** — only the 11 read tools are registered, write tools are not exposed. To enable the 7 write tools, set:
18+
19+
```bash
20+
export HUBSPOT_READ_ONLY=false
21+
```
22+
23+
Any value other than the literal string `false` (or unset) keeps read-only mode on.
24+
1525
## Tools
1626

1727
### CRM objects (generic by `objectType`)
1828

1929
`objectType` accepts standard objects (`contacts`, `companies`, `deals`, `tickets`), activities (`notes`, `tasks`, `calls`, `emails`, `meetings`) or a custom `objectTypeId` (e.g. `2-12345`).
2030

31+
> Write tools (marked ✏️) are only registered when `HUBSPOT_READ_ONLY=false`.
32+
2133
| Tool | Description |
2234
|------|-------------|
2335
| `list_objects` | List records of an object type with pagination |
2436
| `get_object` | Get a record by ID or unique `idProperty` |
25-
| `create_object` | Create a record with properties and optional associations |
26-
| `update_object` | Update a record's properties |
27-
| `delete_object` | Archive (soft-delete) a record |
2837
| `search_objects` | Search with `filterGroups`, free-text `query`, sorting and pagination |
2938
| `batch_read_objects` | Read up to 100 records in one request |
39+
| ✏️ `create_object` | Create a record with properties and optional associations |
40+
| ✏️ `update_object` | Update a record's properties |
41+
| ✏️ `delete_object` | Archive (soft-delete) a record |
3042

3143
### Associations (v4)
3244

3345
| Tool | Description |
3446
|------|-------------|
3547
| `list_associations` | List associated records of a target type for a source record |
36-
| `create_association` | Default (unlabeled) or labeled association between two records |
37-
| `delete_association` | Remove all associations between two records |
48+
| ✏️ `create_association` | Default (unlabeled) or labeled association between two records |
49+
| ✏️ `delete_association` | Remove all associations between two records |
3850

3951
### Metadata
4052

@@ -51,8 +63,8 @@ Suggested scopes: `crm.objects.contacts.*`, `crm.objects.companies.*`, `crm.obje
5163
| `list_threads` | List threads, optionally filtered by inbox/status |
5264
| `get_thread` | Get a thread by ID |
5365
| `list_thread_messages` | Message history of a thread |
54-
| `send_thread_message` | Send an outbound message through an existing channel |
55-
| `update_thread` | Update thread status (OPEN/CLOSED) or archived flag |
66+
| ✏️ `send_thread_message` | Send an outbound message through an existing channel |
67+
| ✏️ `update_thread` | Update thread status (OPEN/CLOSED) or archived flag |
5668

5769
## Usage examples
5870

packages/hubspot/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@arvoretech/hubspot-mcp",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "HubSpot CRM MCP Server",
55
"main": "dist/index.js",
66
"type": "module",
@@ -17,8 +17,13 @@
1717
"test": "vitest run",
1818
"test:cov": "vitest run --coverage",
1919
"lint": "eslint src/**/*.ts",
20-
"lint:fix": "eslint src/**/*.ts --fix"
20+
"lint:fix": "eslint src/**/*.ts --fix",
21+
"prepublishOnly": "pnpm build"
2122
},
23+
"files": [
24+
"dist",
25+
"README.md"
26+
],
2227
"keywords": [
2328
"mcp",
2429
"hubspot",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { HubSpotMCPServer } from "./server.js";
3+
4+
const READ_TOOLS = [
5+
"list_objects",
6+
"get_object",
7+
"search_objects",
8+
"batch_read_objects",
9+
"list_associations",
10+
"list_pipelines",
11+
"list_properties",
12+
"list_inboxes",
13+
"list_threads",
14+
"get_thread",
15+
"list_thread_messages",
16+
];
17+
18+
const WRITE_TOOLS = [
19+
"create_object",
20+
"update_object",
21+
"delete_object",
22+
"create_association",
23+
"delete_association",
24+
"send_thread_message",
25+
"update_thread",
26+
];
27+
28+
function registeredToolNames(server: HubSpotMCPServer): string[] {
29+
const internal = server as unknown as {
30+
server: { _registeredTools?: Record<string, unknown> };
31+
};
32+
return Object.keys(internal.server._registeredTools ?? {});
33+
}
34+
35+
describe("HubSpotMCPServer read-only mode", () => {
36+
beforeEach(() => {
37+
process.env.HUBSPOT_ACCESS_TOKEN = "fake-token";
38+
});
39+
40+
afterEach(() => {
41+
delete process.env.HUBSPOT_READ_ONLY;
42+
delete process.env.HUBSPOT_ACCESS_TOKEN;
43+
});
44+
45+
it("registers only read tools by default", () => {
46+
delete process.env.HUBSPOT_READ_ONLY;
47+
const names = registeredToolNames(new HubSpotMCPServer());
48+
49+
expect(names.sort()).toEqual([...READ_TOOLS].sort());
50+
for (const w of WRITE_TOOLS) {
51+
expect(names).not.toContain(w);
52+
}
53+
});
54+
55+
it("keeps read-only when HUBSPOT_READ_ONLY is any non-false value", () => {
56+
process.env.HUBSPOT_READ_ONLY = "true";
57+
const names = registeredToolNames(new HubSpotMCPServer());
58+
expect(names.sort()).toEqual([...READ_TOOLS].sort());
59+
});
60+
61+
it("registers write tools when HUBSPOT_READ_ONLY=false", () => {
62+
process.env.HUBSPOT_READ_ONLY = "false";
63+
const names = registeredToolNames(new HubSpotMCPServer());
64+
65+
for (const t of [...READ_TOOLS, ...WRITE_TOOLS]) {
66+
expect(names).toContain(t);
67+
}
68+
expect(names.length).toBe(READ_TOOLS.length + WRITE_TOOLS.length);
69+
});
70+
71+
it("throws without an access token", () => {
72+
delete process.env.HUBSPOT_ACCESS_TOKEN;
73+
expect(() => new HubSpotMCPServer()).toThrow(/HUBSPOT_ACCESS_TOKEN/);
74+
});
75+
});

packages/hubspot/src/server.ts

Lines changed: 65 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ import {
2626
export class HubSpotMCPServer {
2727
private readonly server: McpServer;
2828
private readonly tools: HubSpotMCPTools;
29+
private readonly readOnly: boolean;
2930

3031
constructor() {
3132
const accessToken = process.env.HUBSPOT_ACCESS_TOKEN;
3233
if (!accessToken) {
3334
throw new Error("Missing required env var: HUBSPOT_ACCESS_TOKEN");
3435
}
3536

37+
this.readOnly = process.env.HUBSPOT_READ_ONLY !== "false";
38+
3639
this.server = new McpServer({
3740
name: "hubspot-mcp-server",
3841
version: "1.0.0",
@@ -41,10 +44,13 @@ export class HubSpotMCPServer {
4144
const client = new HubSpotClient(accessToken);
4245
this.tools = new HubSpotMCPTools(client);
4346

44-
this.setupTools();
47+
this.setupReadTools();
48+
if (!this.readOnly) {
49+
this.setupWriteTools();
50+
}
4551
}
4652

47-
private setupTools(): void {
53+
private setupReadTools(): void {
4854
this.server.registerTool(
4955
"list_objects",
5056
{
@@ -66,37 +72,6 @@ export class HubSpotMCPServer {
6672
async (params) => this.tools.getObject(GetObjectParamsSchema.parse(params))
6773
);
6874

69-
this.server.registerTool(
70-
"create_object",
71-
{
72-
title: "Create CRM Object",
73-
description:
74-
"Create a CRM record (contact, company, deal, ticket, note, task, call, email, meeting, or custom) with properties and optional associations.",
75-
inputSchema: CreateObjectParamsSchema.shape,
76-
},
77-
async (params) => this.tools.createObject(CreateObjectParamsSchema.parse(params))
78-
);
79-
80-
this.server.registerTool(
81-
"update_object",
82-
{
83-
title: "Update CRM Object",
84-
description: "Update a CRM record's properties by ID (or by a unique idProperty).",
85-
inputSchema: UpdateObjectParamsSchema.shape,
86-
},
87-
async (params) => this.tools.updateObject(UpdateObjectParamsSchema.parse(params))
88-
);
89-
90-
this.server.registerTool(
91-
"delete_object",
92-
{
93-
title: "Delete CRM Object",
94-
description: "Archive (soft-delete) a CRM record by ID.",
95-
inputSchema: DeleteObjectParamsSchema.shape,
96-
},
97-
async (params) => this.tools.deleteObject(DeleteObjectParamsSchema.parse(params))
98-
);
99-
10075
this.server.registerTool(
10176
"search_objects",
10277
{
@@ -128,27 +103,6 @@ export class HubSpotMCPServer {
128103
async (params) => this.tools.listAssociations(ListAssociationsParamsSchema.parse(params))
129104
);
130105

131-
this.server.registerTool(
132-
"create_association",
133-
{
134-
title: "Create Association",
135-
description:
136-
"Associate two records. Omit types for a default (unlabeled) association, or provide associationTypeId(s) for labeled associations.",
137-
inputSchema: CreateAssociationParamsSchema.shape,
138-
},
139-
async (params) => this.tools.createAssociation(CreateAssociationParamsSchema.parse(params))
140-
);
141-
142-
this.server.registerTool(
143-
"delete_association",
144-
{
145-
title: "Delete Association",
146-
description: "Remove all associations between two specific records.",
147-
inputSchema: DeleteAssociationParamsSchema.shape,
148-
},
149-
async (params) => this.tools.deleteAssociation(DeleteAssociationParamsSchema.parse(params))
150-
);
151-
152106
this.server.registerTool(
153107
"list_pipelines",
154108
{
@@ -208,6 +162,60 @@ export class HubSpotMCPServer {
208162
},
209163
async (params) => this.tools.listThreadMessages(ListThreadMessagesParamsSchema.parse(params))
210164
);
165+
}
166+
167+
private setupWriteTools(): void {
168+
this.server.registerTool(
169+
"create_object",
170+
{
171+
title: "Create CRM Object",
172+
description:
173+
"Create a CRM record (contact, company, deal, ticket, note, task, call, email, meeting, or custom) with properties and optional associations.",
174+
inputSchema: CreateObjectParamsSchema.shape,
175+
},
176+
async (params) => this.tools.createObject(CreateObjectParamsSchema.parse(params))
177+
);
178+
179+
this.server.registerTool(
180+
"update_object",
181+
{
182+
title: "Update CRM Object",
183+
description: "Update a CRM record's properties by ID (or by a unique idProperty).",
184+
inputSchema: UpdateObjectParamsSchema.shape,
185+
},
186+
async (params) => this.tools.updateObject(UpdateObjectParamsSchema.parse(params))
187+
);
188+
189+
this.server.registerTool(
190+
"delete_object",
191+
{
192+
title: "Delete CRM Object",
193+
description: "Archive (soft-delete) a CRM record by ID.",
194+
inputSchema: DeleteObjectParamsSchema.shape,
195+
},
196+
async (params) => this.tools.deleteObject(DeleteObjectParamsSchema.parse(params))
197+
);
198+
199+
this.server.registerTool(
200+
"create_association",
201+
{
202+
title: "Create Association",
203+
description:
204+
"Associate two records. Omit types for a default (unlabeled) association, or provide associationTypeId(s) for labeled associations.",
205+
inputSchema: CreateAssociationParamsSchema.shape,
206+
},
207+
async (params) => this.tools.createAssociation(CreateAssociationParamsSchema.parse(params))
208+
);
209+
210+
this.server.registerTool(
211+
"delete_association",
212+
{
213+
title: "Delete Association",
214+
description: "Remove all associations between two specific records.",
215+
inputSchema: DeleteAssociationParamsSchema.shape,
216+
},
217+
async (params) => this.tools.deleteAssociation(DeleteAssociationParamsSchema.parse(params))
218+
);
211219

212220
this.server.registerTool(
213221
"send_thread_message",
@@ -235,7 +243,9 @@ export class HubSpotMCPServer {
235243
try {
236244
const transport = new StdioServerTransport();
237245
await this.server.connect(transport);
238-
console.error("✅ HubSpot MCP Server started");
246+
console.error(
247+
`✅ HubSpot MCP Server started (${this.readOnly ? "read-only" : "read-write"} mode)`
248+
);
239249
} catch (error) {
240250
console.error(
241251
"Failed to start HubSpot MCP Server:",

0 commit comments

Comments
 (0)