mcp-hubspot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. mcp_hubspot-0.1.0/.dockerignore +9 -0
  2. mcp_hubspot-0.1.0/.env.example +16 -0
  3. mcp_hubspot-0.1.0/.github/workflows/ci.yml +32 -0
  4. mcp_hubspot-0.1.0/.gitignore +14 -0
  5. mcp_hubspot-0.1.0/.mcp.json +8 -0
  6. mcp_hubspot-0.1.0/Dockerfile +21 -0
  7. mcp_hubspot-0.1.0/LICENSE +21 -0
  8. mcp_hubspot-0.1.0/PKG-INFO +247 -0
  9. mcp_hubspot-0.1.0/README.md +230 -0
  10. mcp_hubspot-0.1.0/docs/COMPARISON.md +148 -0
  11. mcp_hubspot-0.1.0/pyproject.toml +37 -0
  12. mcp_hubspot-0.1.0/scripts/generate_comparison.py +323 -0
  13. mcp_hubspot-0.1.0/server.json +89 -0
  14. mcp_hubspot-0.1.0/src/mcp_crm/__init__.py +15 -0
  15. mcp_hubspot-0.1.0/src/mcp_crm/audit.py +80 -0
  16. mcp_hubspot-0.1.0/src/mcp_crm/auth.py +171 -0
  17. mcp_hubspot-0.1.0/src/mcp_crm/cache.py +97 -0
  18. mcp_hubspot-0.1.0/src/mcp_crm/config.py +101 -0
  19. mcp_hubspot-0.1.0/src/mcp_crm/errors.py +71 -0
  20. mcp_hubspot-0.1.0/src/mcp_crm/hubspot_client.py +208 -0
  21. mcp_hubspot-0.1.0/src/mcp_crm/idempotency.py +54 -0
  22. mcp_hubspot-0.1.0/src/mcp_crm/models.py +169 -0
  23. mcp_hubspot-0.1.0/src/mcp_crm/naive.py +47 -0
  24. mcp_hubspot-0.1.0/src/mcp_crm/redaction.py +48 -0
  25. mcp_hubspot-0.1.0/src/mcp_crm/runtime.py +32 -0
  26. mcp_hubspot-0.1.0/src/mcp_crm/scopes.py +15 -0
  27. mcp_hubspot-0.1.0/src/mcp_crm/server.py +519 -0
  28. mcp_hubspot-0.1.0/src/mcp_crm/service.py +419 -0
  29. mcp_hubspot-0.1.0/src/mcp_crm/webhooks.py +70 -0
  30. mcp_hubspot-0.1.0/tests/conftest.py +69 -0
  31. mcp_hubspot-0.1.0/tests/fake_hubspot.py +352 -0
  32. mcp_hubspot-0.1.0/tests/fixtures/contacts.json +67 -0
  33. mcp_hubspot-0.1.0/tests/fixtures/deals.json +41 -0
  34. mcp_hubspot-0.1.0/tests/fixtures/pipelines.json +24 -0
  35. mcp_hubspot-0.1.0/tests/test_auth.py +98 -0
  36. mcp_hubspot-0.1.0/tests/test_batch.py +39 -0
  37. mcp_hubspot-0.1.0/tests/test_cache.py +54 -0
  38. mcp_hubspot-0.1.0/tests/test_comparison.py +52 -0
  39. mcp_hubspot-0.1.0/tests/test_contacts.py +74 -0
  40. mcp_hubspot-0.1.0/tests/test_credential_lazy.py +63 -0
  41. mcp_hubspot-0.1.0/tests/test_deals_pipelines.py +55 -0
  42. mcp_hubspot-0.1.0/tests/test_idempotency.py +37 -0
  43. mcp_hubspot-0.1.0/tests/test_redaction_audit.py +71 -0
  44. mcp_hubspot-0.1.0/tests/test_retries.py +54 -0
  45. mcp_hubspot-0.1.0/tests/test_server.py +103 -0
  46. mcp_hubspot-0.1.0/tests/test_webhook_signature.py +46 -0
  47. mcp_hubspot-0.1.0/uv.lock +737 -0
@@ -0,0 +1,9 @@
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ .pytest_cache
5
+ .ruff_cache
6
+ dist
7
+ build
8
+ *.egg-info
9
+ .env
@@ -0,0 +1,16 @@
1
+ # Option A: HubSpot private app (simplest; free tier).
2
+ # Create a private app in HubSpot and paste its access token here.
3
+ HUBSPOT_PRIVATE_APP_TOKEN=pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
4
+
5
+ # Option B: OAuth app (leave the private-app token unset to use this).
6
+ # HUBSPOT_CLIENT_ID=00000000-0000-0000-0000-000000000000
7
+ # HUBSPOT_CLIENT_SECRET=00000000-0000-0000-0000-000000000000
8
+ # HUBSPOT_REFRESH_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
9
+ # HUBSPOT_REDIRECT_URI=https://your-app.example/oauth/callback
10
+
11
+ # Optional tuning.
12
+ # HUBSPOT_API_BASE=https://api.hubapi.com
13
+ # HUBSPOT_WEBHOOK_SECRET=your-app-client-secret
14
+ # MCP_CRM_CACHE_TTL=300
15
+ # MCP_CRM_MAX_RETRIES=4
16
+ # MCP_CRM_ACTOR=mcp-hubspot
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ run: uv venv --python ${{ matrix.python-version }}
22
+
23
+ - name: Install dependencies
24
+ run: uv pip install -e ".[dev]"
25
+
26
+ - name: Run tests
27
+ run: uv run pytest -q
28
+
29
+ - name: Verify comparison doc is reproducible
30
+ run: |
31
+ uv run python scripts/generate_comparison.py
32
+ git diff --exit-code docs/COMPARISON.md
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ node_modules/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .DS_Store
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "hubspot": {
4
+ "command": "uvx",
5
+ "args": ["mcp-hubspot"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,21 @@
1
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
2
+
3
+ ENV UV_COMPILE_BYTECODE=1 \
4
+ UV_LINK_MODE=copy \
5
+ UV_NO_CACHE=1 \
6
+ UV_PYTHON_DOWNLOADS=never \
7
+ PYTHONUNBUFFERED=1
8
+
9
+ WORKDIR /app
10
+
11
+ COPY pyproject.toml uv.lock README.md ./
12
+ COPY src ./src
13
+
14
+ RUN uv sync --frozen --no-dev
15
+
16
+ ENV PATH="/app/.venv/bin:$PATH"
17
+
18
+ RUN useradd --create-home --uid 10001 runner
19
+ USER runner
20
+
21
+ ENTRYPOINT ["mcp-hubspot"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amin Ale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-hubspot
3
+ Version: 0.1.0
4
+ Summary: HubSpot CRM MCP server: contacts, deals, pipelines. Idempotent writes and a full audit trail.
5
+ Author-email: Amin Ale <amin.ale.business@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: crm,hubspot,hubspot-crm,llm-tools,mcp,model-context-protocol
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: httpx==0.28.1
11
+ Requires-Dist: mcp==1.28.1
12
+ Requires-Dist: pydantic==2.13.4
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
15
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # HubSpot CRM MCP Server
19
+
20
+ [![CI](https://github.com/amin-ale/hubspot-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/amin-ale/hubspot-mcp-server/actions/workflows/ci.yml)
21
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
22
+
23
+ <!-- mcp-name: io.github.amin-ale/hubspot-crm-mcp -->
24
+
25
+ HubSpot CRM MCP server for Claude Desktop and any MCP client, free-tier compatible. 15 tools
26
+ over contacts, deals, and pipelines, authenticated with either a HubSpot private-app token or a
27
+ full OAuth app. Writes are idempotent, so a retried tool call replays its first result instead of
28
+ creating a duplicate record, and every tool call is written to a PII-redacted audit trail,
29
+ including the calls denied for a missing scope and the calls that errored.
30
+
31
+ Related: [QuickBooks Online MCP Server](https://github.com/amin-ale/mcp-quickbooks) ·
32
+ [MCP Audit Gateway](https://github.com/amin-ale/mcp-audit-gateway) ·
33
+ [What production MCP actually requires](https://amin-ale.github.io/portfolio-site/what-production-mcp-actually-requires.html)
34
+
35
+ HubSpot runs a hosted MCP server of its own, with wider object coverage than this one. It is built for self-hosting:
36
+ you run the process, the source is short enough to read in a sitting,
37
+ and the audit trail, the cache, and the credentials never leave your infrastructure.
38
+
39
+ The rest is what a REST wrapper usually leaves out. It prompts for the exact missing scope
40
+ instead of leaking a raw `403`, retries rate limits with backoff, serves record reads from a
41
+ local cache (TTL plus write-invalidation, with a signature-verified webhook handler you can wire
42
+ to your own HTTP ingress for out-of-band changes), walks cursor pagination, and returns per-item
43
+ results when a batch partially fails.
44
+
45
+ See [`docs/COMPARISON.md`](docs/COMPARISON.md) for side-by-side transcripts of a naive
46
+ API-wrapper MCP versus this one. Every transcript is generated by running both against the
47
+ mocked test suite.
48
+
49
+ ## Architecture
50
+
51
+ ```mermaid
52
+ flowchart TD
53
+ Agent["MCP client / agent"] -->|"stdio (JSON-RPC)"| Server["FastMCP server<br/>server.py"]
54
+ Server --> Service["CrmService<br/>scope checks · audit · orchestration"]
55
+
56
+ Service --> Cache["LocalCache<br/>TTL + write invalidation"]
57
+ Service --> Idem["Idempotency store"]
58
+ Service --> Audit["Audit log<br/>PII redaction"]
59
+ Service --> Client["HubSpotClient<br/>retries · pagination · error mapping"]
60
+
61
+ Client --> Auth["Token provider<br/>private-app · OAuth refresh"]
62
+ Client -->|HTTPS| HubSpot["HubSpot CRM API"]
63
+
64
+ Ingress["Your HTTP ingress<br/>(optional, host-provided)"] -->|"signed v3 payload"| Processor["WebhookProcessor<br/>verify_signature"]
65
+ Processor -->|"invalidate(object)"| Cache
66
+ ```
67
+
68
+ The stdio server speaks JSON-RPC only; it does not listen for webhooks. `WebhookProcessor` and
69
+ `verify_signature` are shipped as a tested component you mount on your own HTTP ingress (see
70
+ [Webhook cache invalidation](#webhook-cache-invalidation)).
71
+
72
+ ## Tools
73
+
74
+ Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
75
+ `openWorldHint`), a described input schema, and an output schema.
76
+
77
+ | Tool | What it does | Scope |
78
+ | --- | --- | --- |
79
+ | `crm_search_contacts` | Search contacts by free-text term, one page at a time | `crm.objects.contacts.read` |
80
+ | `crm_list_contacts` | List contacts in id order with cursor pagination | `crm.objects.contacts.read` |
81
+ | `crm_get_contact` | Fetch one contact by id, served from the local cache | `crm.objects.contacts.read` |
82
+ | `crm_create_contact` | Create a contact, idempotent on a supplied or derived key | `crm.objects.contacts.write` |
83
+ | `crm_update_contact` | Overwrite the properties passed and invalidate the cache entry | `crm.objects.contacts.write` |
84
+ | `crm_delete_contact` | Archive (soft-delete) a contact | `crm.objects.contacts.write` |
85
+ | `crm_batch_create_contacts` | Create up to 100 contacts and report per-row failures | `crm.objects.contacts.write` |
86
+ | `crm_search_deals` | Search deals by free-text term with cursor pagination | `crm.objects.deals.read` |
87
+ | `crm_get_deal` | Fetch one deal by id, served from the local cache | `crm.objects.deals.read` |
88
+ | `crm_create_deal` | Create a deal, idempotent on a supplied or derived key | `crm.objects.deals.write` |
89
+ | `crm_update_deal` | Update deal properties, including moving it to another stage | `crm.objects.deals.write` |
90
+ | `crm_delete_deal` | Archive (soft-delete) a deal | `crm.objects.deals.write` |
91
+ | `crm_list_pipelines` | List deal pipelines with stages in display order (cached) | `crm.schemas.deals.read` |
92
+ | `crm_get_pipeline` | Fetch one pipeline with its ordered stages | `crm.schemas.deals.read` |
93
+ | `crm_export_audit_log` | Export the session audit trail as JSON Lines | none |
94
+
95
+ ## Quickstart
96
+
97
+ Run it without installing anything permanent:
98
+
99
+ ```bash
100
+ uvx mcp-hubspot
101
+ ```
102
+
103
+ Or install it:
104
+
105
+ ```bash
106
+ pip install mcp-hubspot
107
+ mcp-hubspot
108
+ ```
109
+
110
+ Either form speaks MCP over stdio on stdin and stdout. Authenticate with **either** a private-app
111
+ token (`HUBSPOT_PRIVATE_APP_TOKEN`) **or** an OAuth app (`HUBSPOT_CLIENT_ID` +
112
+ `HUBSPOT_CLIENT_SECRET` + `HUBSPOT_REFRESH_TOKEN`). The server auto-detects which is present.
113
+
114
+ Credentials are resolved lazily. The server starts and answers `initialize` and `tools/list` with
115
+ nothing configured, which is what lets a directory or a sandbox introspect it; the first tool call
116
+ is where a missing credential turns into an actionable error.
117
+
118
+ ### Wiring into an MCP client
119
+
120
+ Add to your MCP host config (for example Claude Desktop's `claude_desktop_config.json`):
121
+
122
+ ```json
123
+ {
124
+ "mcpServers": {
125
+ "hubspot": {
126
+ "command": "uvx",
127
+ "args": ["mcp-hubspot"],
128
+ "env": { "HUBSPOT_PRIVATE_APP_TOKEN": "pat-na1-..." }
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ### Docker
135
+
136
+ ```bash
137
+ docker build -t mcp-hubspot .
138
+ docker run --rm -i -e HUBSPOT_PRIVATE_APP_TOKEN=pat-na1-... mcp-hubspot
139
+ ```
140
+
141
+ ### From source
142
+
143
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
144
+
145
+ ```bash
146
+ uv venv
147
+ uv pip install -e ".[dev]"
148
+
149
+ cp .env.example .env # then fill in your HubSpot credentials
150
+ uv run mcp-hubspot
151
+ ```
152
+
153
+ ### OAuth authorization URL
154
+
155
+ For the OAuth flow, `mcp_crm.auth.build_authorization_url(...)` builds the consent URL with the
156
+ scopes the tools need; exchange the returned code for a refresh token and set
157
+ `HUBSPOT_REFRESH_TOKEN`.
158
+
159
+ ### Webhook cache invalidation
160
+
161
+ The stdio server does not receive webhooks. To invalidate the cache on changes made outside this
162
+ process (edits in the HubSpot UI, other integrations), mount `WebhookProcessor` on your own HTTP
163
+ endpoint and verify HubSpot's v3 signature with `verify_signature` (using the
164
+ `HUBSPOT_WEBHOOK_SECRET` you configure). Point it at the same `LocalCache` your `CrmService` uses:
165
+
166
+ ```python
167
+ from mcp_crm.webhooks import WebhookProcessor, verify_signature
168
+
169
+ processor = WebhookProcessor(cache)
170
+
171
+ def handle_hubspot_webhook(request):
172
+ ok = verify_signature(
173
+ secret=webhook_signing_secret,
174
+ method="POST",
175
+ uri=request.url,
176
+ body=request.raw_body,
177
+ signature=request.headers["X-HubSpot-Signature-v3"],
178
+ timestamp=request.headers["X-HubSpot-Request-Timestamp"],
179
+ )
180
+ if not ok:
181
+ return 401
182
+ processor.process(request.json())
183
+ return 200
184
+ ```
185
+
186
+ `verify_signature` rejects tampered bodies, wrong secrets, and stale timestamps;
187
+ `WebhookProcessor.process` maps each subscription to the object it invalidates and reports what it
188
+ touched.
189
+
190
+ ## Design decisions
191
+
192
+ - **Cache scope.** Only object-detail reads (`crm_get_contact`, `crm_get_deal`) and the pipeline
193
+ list are cached; list/search results are query-dependent and left uncached to avoid serving
194
+ stale result sets. Writes invalidate the relevant object immediately. For out-of-band changes, a
195
+ signature-verified `WebhookProcessor` ships as a component you mount on your own HTTP ingress
196
+ (see [Webhook cache invalidation](#webhook-cache-invalidation)); the stdio server itself does not
197
+ listen for webhooks.
198
+ - **Idempotency is client-side.** HubSpot's create endpoints are not natively idempotent, so a key
199
+ (supplied or derived from the payload) is stored and replayed. This makes at-least-once tool
200
+ retries safe without duplicating records. The store lives in the process, so it covers retries
201
+ within a session rather than across restarts, and `crm_batch_create_contacts` deliberately does
202
+ not use it; both facts are stated in the tool descriptions.
203
+ - **Scope prompting happens twice.** The service pre-checks granted scopes (via token
204
+ introspection) for a fast, actionable error, and the HTTP client also maps a server-side
205
+ `MISSING_SCOPES` `403` to the same typed error (belt and suspenders).
206
+ - **Credentials load lazily.** Nothing reads a token at import or at startup. A missing credential
207
+ surfaces as a typed error on the first tool call, and that failure is audited like any other, so
208
+ the server is still introspectable in a sandbox with an empty environment.
209
+ - **Backoff and clocks are injectable.** Retry sleep, RNG jitter, and time sources are constructor
210
+ parameters, which is why the whole suite runs offline in well under a second.
211
+
212
+ ## Testing
213
+
214
+ Every external HubSpot call is served by an in-memory fake (`tests/fake_hubspot.py`) backed by
215
+ JSON fixtures (`tests/fixtures/`), wired in through `httpx.MockTransport`. No network, no
216
+ credentials, deterministic.
217
+
218
+ ```bash
219
+ uv run pytest -q
220
+ ```
221
+
222
+ Regenerate the comparison document (CI also checks it stays in sync):
223
+
224
+ ```bash
225
+ uv run python scripts/generate_comparison.py
226
+ ```
227
+
228
+ ## Registry metadata
229
+
230
+ `server.json` describes this package for the Model Context Protocol registry
231
+ (`io.github.amin-ale/hubspot-crm-mcp`, PyPI `mcp-hubspot`, stdio transport). `.mcp.json` is the
232
+ minimal client config for tools that auto-detect MCP servers from a repository root.
233
+
234
+ ## Scope and safety
235
+
236
+ This is a client for HubSpot data you own or are authorized to access. Point it only at HubSpot
237
+ accounts you control or have written permission to operate. The audit log redacts emails and phone
238
+ numbers before writing records; treat exported audit logs as sensitive regardless. Behaviour
239
+ documented here is point-in-time against the included fixtures, not a guarantee about any live
240
+ HubSpot account.
241
+
242
+ ## Hire me
243
+
244
+ I build MCP servers and API integrations that survive a senior-dev code review: auth, retries,
245
+ idempotency, and audit trails included, not bolted on later. Portfolio and contact:
246
+ [https://amin-ale.github.io/portfolio-site](https://amin-ale.github.io/portfolio-site) ·
247
+ [amin.ale.business@gmail.com](mailto:amin.ale.business@gmail.com).
@@ -0,0 +1,230 @@
1
+ # HubSpot CRM MCP Server
2
+
3
+ [![CI](https://github.com/amin-ale/hubspot-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/amin-ale/hubspot-mcp-server/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ <!-- mcp-name: io.github.amin-ale/hubspot-crm-mcp -->
7
+
8
+ HubSpot CRM MCP server for Claude Desktop and any MCP client, free-tier compatible. 15 tools
9
+ over contacts, deals, and pipelines, authenticated with either a HubSpot private-app token or a
10
+ full OAuth app. Writes are idempotent, so a retried tool call replays its first result instead of
11
+ creating a duplicate record, and every tool call is written to a PII-redacted audit trail,
12
+ including the calls denied for a missing scope and the calls that errored.
13
+
14
+ Related: [QuickBooks Online MCP Server](https://github.com/amin-ale/mcp-quickbooks) ·
15
+ [MCP Audit Gateway](https://github.com/amin-ale/mcp-audit-gateway) ·
16
+ [What production MCP actually requires](https://amin-ale.github.io/portfolio-site/what-production-mcp-actually-requires.html)
17
+
18
+ HubSpot runs a hosted MCP server of its own, with wider object coverage than this one. It is built for self-hosting:
19
+ you run the process, the source is short enough to read in a sitting,
20
+ and the audit trail, the cache, and the credentials never leave your infrastructure.
21
+
22
+ The rest is what a REST wrapper usually leaves out. It prompts for the exact missing scope
23
+ instead of leaking a raw `403`, retries rate limits with backoff, serves record reads from a
24
+ local cache (TTL plus write-invalidation, with a signature-verified webhook handler you can wire
25
+ to your own HTTP ingress for out-of-band changes), walks cursor pagination, and returns per-item
26
+ results when a batch partially fails.
27
+
28
+ See [`docs/COMPARISON.md`](docs/COMPARISON.md) for side-by-side transcripts of a naive
29
+ API-wrapper MCP versus this one. Every transcript is generated by running both against the
30
+ mocked test suite.
31
+
32
+ ## Architecture
33
+
34
+ ```mermaid
35
+ flowchart TD
36
+ Agent["MCP client / agent"] -->|"stdio (JSON-RPC)"| Server["FastMCP server<br/>server.py"]
37
+ Server --> Service["CrmService<br/>scope checks · audit · orchestration"]
38
+
39
+ Service --> Cache["LocalCache<br/>TTL + write invalidation"]
40
+ Service --> Idem["Idempotency store"]
41
+ Service --> Audit["Audit log<br/>PII redaction"]
42
+ Service --> Client["HubSpotClient<br/>retries · pagination · error mapping"]
43
+
44
+ Client --> Auth["Token provider<br/>private-app · OAuth refresh"]
45
+ Client -->|HTTPS| HubSpot["HubSpot CRM API"]
46
+
47
+ Ingress["Your HTTP ingress<br/>(optional, host-provided)"] -->|"signed v3 payload"| Processor["WebhookProcessor<br/>verify_signature"]
48
+ Processor -->|"invalidate(object)"| Cache
49
+ ```
50
+
51
+ The stdio server speaks JSON-RPC only; it does not listen for webhooks. `WebhookProcessor` and
52
+ `verify_signature` are shipped as a tested component you mount on your own HTTP ingress (see
53
+ [Webhook cache invalidation](#webhook-cache-invalidation)).
54
+
55
+ ## Tools
56
+
57
+ Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
58
+ `openWorldHint`), a described input schema, and an output schema.
59
+
60
+ | Tool | What it does | Scope |
61
+ | --- | --- | --- |
62
+ | `crm_search_contacts` | Search contacts by free-text term, one page at a time | `crm.objects.contacts.read` |
63
+ | `crm_list_contacts` | List contacts in id order with cursor pagination | `crm.objects.contacts.read` |
64
+ | `crm_get_contact` | Fetch one contact by id, served from the local cache | `crm.objects.contacts.read` |
65
+ | `crm_create_contact` | Create a contact, idempotent on a supplied or derived key | `crm.objects.contacts.write` |
66
+ | `crm_update_contact` | Overwrite the properties passed and invalidate the cache entry | `crm.objects.contacts.write` |
67
+ | `crm_delete_contact` | Archive (soft-delete) a contact | `crm.objects.contacts.write` |
68
+ | `crm_batch_create_contacts` | Create up to 100 contacts and report per-row failures | `crm.objects.contacts.write` |
69
+ | `crm_search_deals` | Search deals by free-text term with cursor pagination | `crm.objects.deals.read` |
70
+ | `crm_get_deal` | Fetch one deal by id, served from the local cache | `crm.objects.deals.read` |
71
+ | `crm_create_deal` | Create a deal, idempotent on a supplied or derived key | `crm.objects.deals.write` |
72
+ | `crm_update_deal` | Update deal properties, including moving it to another stage | `crm.objects.deals.write` |
73
+ | `crm_delete_deal` | Archive (soft-delete) a deal | `crm.objects.deals.write` |
74
+ | `crm_list_pipelines` | List deal pipelines with stages in display order (cached) | `crm.schemas.deals.read` |
75
+ | `crm_get_pipeline` | Fetch one pipeline with its ordered stages | `crm.schemas.deals.read` |
76
+ | `crm_export_audit_log` | Export the session audit trail as JSON Lines | none |
77
+
78
+ ## Quickstart
79
+
80
+ Run it without installing anything permanent:
81
+
82
+ ```bash
83
+ uvx mcp-hubspot
84
+ ```
85
+
86
+ Or install it:
87
+
88
+ ```bash
89
+ pip install mcp-hubspot
90
+ mcp-hubspot
91
+ ```
92
+
93
+ Either form speaks MCP over stdio on stdin and stdout. Authenticate with **either** a private-app
94
+ token (`HUBSPOT_PRIVATE_APP_TOKEN`) **or** an OAuth app (`HUBSPOT_CLIENT_ID` +
95
+ `HUBSPOT_CLIENT_SECRET` + `HUBSPOT_REFRESH_TOKEN`). The server auto-detects which is present.
96
+
97
+ Credentials are resolved lazily. The server starts and answers `initialize` and `tools/list` with
98
+ nothing configured, which is what lets a directory or a sandbox introspect it; the first tool call
99
+ is where a missing credential turns into an actionable error.
100
+
101
+ ### Wiring into an MCP client
102
+
103
+ Add to your MCP host config (for example Claude Desktop's `claude_desktop_config.json`):
104
+
105
+ ```json
106
+ {
107
+ "mcpServers": {
108
+ "hubspot": {
109
+ "command": "uvx",
110
+ "args": ["mcp-hubspot"],
111
+ "env": { "HUBSPOT_PRIVATE_APP_TOKEN": "pat-na1-..." }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### Docker
118
+
119
+ ```bash
120
+ docker build -t mcp-hubspot .
121
+ docker run --rm -i -e HUBSPOT_PRIVATE_APP_TOKEN=pat-na1-... mcp-hubspot
122
+ ```
123
+
124
+ ### From source
125
+
126
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
127
+
128
+ ```bash
129
+ uv venv
130
+ uv pip install -e ".[dev]"
131
+
132
+ cp .env.example .env # then fill in your HubSpot credentials
133
+ uv run mcp-hubspot
134
+ ```
135
+
136
+ ### OAuth authorization URL
137
+
138
+ For the OAuth flow, `mcp_crm.auth.build_authorization_url(...)` builds the consent URL with the
139
+ scopes the tools need; exchange the returned code for a refresh token and set
140
+ `HUBSPOT_REFRESH_TOKEN`.
141
+
142
+ ### Webhook cache invalidation
143
+
144
+ The stdio server does not receive webhooks. To invalidate the cache on changes made outside this
145
+ process (edits in the HubSpot UI, other integrations), mount `WebhookProcessor` on your own HTTP
146
+ endpoint and verify HubSpot's v3 signature with `verify_signature` (using the
147
+ `HUBSPOT_WEBHOOK_SECRET` you configure). Point it at the same `LocalCache` your `CrmService` uses:
148
+
149
+ ```python
150
+ from mcp_crm.webhooks import WebhookProcessor, verify_signature
151
+
152
+ processor = WebhookProcessor(cache)
153
+
154
+ def handle_hubspot_webhook(request):
155
+ ok = verify_signature(
156
+ secret=webhook_signing_secret,
157
+ method="POST",
158
+ uri=request.url,
159
+ body=request.raw_body,
160
+ signature=request.headers["X-HubSpot-Signature-v3"],
161
+ timestamp=request.headers["X-HubSpot-Request-Timestamp"],
162
+ )
163
+ if not ok:
164
+ return 401
165
+ processor.process(request.json())
166
+ return 200
167
+ ```
168
+
169
+ `verify_signature` rejects tampered bodies, wrong secrets, and stale timestamps;
170
+ `WebhookProcessor.process` maps each subscription to the object it invalidates and reports what it
171
+ touched.
172
+
173
+ ## Design decisions
174
+
175
+ - **Cache scope.** Only object-detail reads (`crm_get_contact`, `crm_get_deal`) and the pipeline
176
+ list are cached; list/search results are query-dependent and left uncached to avoid serving
177
+ stale result sets. Writes invalidate the relevant object immediately. For out-of-band changes, a
178
+ signature-verified `WebhookProcessor` ships as a component you mount on your own HTTP ingress
179
+ (see [Webhook cache invalidation](#webhook-cache-invalidation)); the stdio server itself does not
180
+ listen for webhooks.
181
+ - **Idempotency is client-side.** HubSpot's create endpoints are not natively idempotent, so a key
182
+ (supplied or derived from the payload) is stored and replayed. This makes at-least-once tool
183
+ retries safe without duplicating records. The store lives in the process, so it covers retries
184
+ within a session rather than across restarts, and `crm_batch_create_contacts` deliberately does
185
+ not use it; both facts are stated in the tool descriptions.
186
+ - **Scope prompting happens twice.** The service pre-checks granted scopes (via token
187
+ introspection) for a fast, actionable error, and the HTTP client also maps a server-side
188
+ `MISSING_SCOPES` `403` to the same typed error (belt and suspenders).
189
+ - **Credentials load lazily.** Nothing reads a token at import or at startup. A missing credential
190
+ surfaces as a typed error on the first tool call, and that failure is audited like any other, so
191
+ the server is still introspectable in a sandbox with an empty environment.
192
+ - **Backoff and clocks are injectable.** Retry sleep, RNG jitter, and time sources are constructor
193
+ parameters, which is why the whole suite runs offline in well under a second.
194
+
195
+ ## Testing
196
+
197
+ Every external HubSpot call is served by an in-memory fake (`tests/fake_hubspot.py`) backed by
198
+ JSON fixtures (`tests/fixtures/`), wired in through `httpx.MockTransport`. No network, no
199
+ credentials, deterministic.
200
+
201
+ ```bash
202
+ uv run pytest -q
203
+ ```
204
+
205
+ Regenerate the comparison document (CI also checks it stays in sync):
206
+
207
+ ```bash
208
+ uv run python scripts/generate_comparison.py
209
+ ```
210
+
211
+ ## Registry metadata
212
+
213
+ `server.json` describes this package for the Model Context Protocol registry
214
+ (`io.github.amin-ale/hubspot-crm-mcp`, PyPI `mcp-hubspot`, stdio transport). `.mcp.json` is the
215
+ minimal client config for tools that auto-detect MCP servers from a repository root.
216
+
217
+ ## Scope and safety
218
+
219
+ This is a client for HubSpot data you own or are authorized to access. Point it only at HubSpot
220
+ accounts you control or have written permission to operate. The audit log redacts emails and phone
221
+ numbers before writing records; treat exported audit logs as sensitive regardless. Behaviour
222
+ documented here is point-in-time against the included fixtures, not a guarantee about any live
223
+ HubSpot account.
224
+
225
+ ## Hire me
226
+
227
+ I build MCP servers and API integrations that survive a senior-dev code review: auth, retries,
228
+ idempotency, and audit trails included, not bolted on later. Portfolio and contact:
229
+ [https://amin-ale.github.io/portfolio-site](https://amin-ale.github.io/portfolio-site) ·
230
+ [amin.ale.business@gmail.com](mailto:amin.ale.business@gmail.com).