Zoho CRM MCP Server
README.md
# Zoho CRM MCP Server (FastAPI + FastMCP)
A production-grade Model Context Protocol (MCP) server built with **FastAPI + FastMCP** that gives Claude and other AI clients complete, authenticated access to the **Zoho CRM REST API v8** โ from reading records to designing modules and authoring workflow automation.
**211 MCP tools** covering records, COQL, schema design, workflow rules and their actions, webhooks, bulk/mass operations, tags, notes, email, security settings, and bulk import/export โ plus a **dashboard and report engine**, **function authoring** and attachment to workflow rules, **org hierarchy** tools, and **teamspaces** โ and a generic `zoho_api_request` escape hatch for anything Zoho exposes that has no dedicated tool.
---
## ๐ Key Features
- **FastAPI Web Framework:** High-performance, production-ready ASGI app powered by Uvicorn.
- **Dual Transport:** Runs as a **Streamable HTTP MCP server** (for remote/cloud hosting) and as a **STDIO MCP server** (for local Claude Desktop).
- **Full OAuth 2.0 Lifecycle:** Automatic code exchange, browser redirect handler (`/auth/callback`), encrypted token storage, and a background loop that keeps the token fresh while the server runs.
- **Chat-Configurable Credentials:** Supply a Zoho Client ID/Secret from chat (`set_zoho_credentials`, or inline on `get_auth_url`/`exchange_auth_code`) instead of `.env` โ useful for switching Zoho accounts without restarting.
- **Dashboard Report Home Page:** `/` serves a live dashboard โ record counts per module, workflow and function inventory, org chart, teamspaces, pinned reports, and the activity feed โ all backed by `GET /api/dashboard/summary`. Panels load concurrently and fail independently, so one missing OAuth scope never blanks the page.
- **Report Engine:** Save report definitions (`list`, `count`, `summary` with count/sum/avg/min/max and group-by) that compile to COQL and run against live CRM data. Pin one to the dashboard, re-run it on demand, or export it to CSV. Zoho has no Reports API, so this is the working substitute.
- **Function Authoring (v8 Functions API):** Create, read, update, publish, and delete Deluge/Java/NodeJS/Python functions, and download their source.
- **Functions Attached to Workflows:** `functions` is a first-class workflow action type, so a function can be wired into a rule as an instant or scheduled action โ and detached again โ without touching the Zoho UI.
- **Org Hierarchy:** Role and user-reporting trees assembled from Zoho's flat lists, with an ASCII org chart, subordinate/manager lookups, and the writes that reparent a role or reassign a manager.
- **Teamspaces:** Group modules and people into named teamspaces, optionally projected onto a real CRM user group so the same roster drives sharing and assignment in Zoho.
- **Complete Automation Authoring:** Build workflow rules end to end โ create field-update, email-notification, task, and webhook actions, then wire them into a rule with triggers and criteria.
- **Schema Design:** Create custom modules (with the profiles Zoho mandates), fields, global picklists, layouts, and sales pipelines.
- **Scoped Session Mode:** ID-based safety filter (`activate_scope`) restricting operations to specific record IDs.
- **Human-In-The-Loop Approvals:** Destructive actions queue a pending request instead of executing. Toggle with `ZOHO_REQUIRE_APPROVAL`.
- **Structured Activity Logging:** Every auth event, API call, and approval decision logged as JSON, retrievable via `get_logs()` / `GET /logs`.
- **Encrypted Token Storage:** OAuth tokens encrypted at rest (Fernet/AES), never plaintext.
- **Resilient Network Client:** Pooled `httpx` client with automatic 401 refresh-and-retry, clamped 429 backoff, exponential 5xx retry, an outbound rate limiter, and partial-failure detection on Zoho's per-record responses.
- **Multi-Account Isolation:** Each Zoho `client_id` is an isolated tenant - its own tokens, scope, approval queue, reports, and teamspaces - resolved per MCP session or per REST header. Concurrent callers on different accounts cannot read or write each other's org.
- **Automated Test Suite:** 93 `pytest` tests covering the HTTP surface, tool registration, request-payload shapes, client guards, the COQL report compiler, hierarchy tree assembly, teamspace rostering, and function-to-workflow attachment.
---
## ๐ Repository Structure
```
zoho-crm-mcp/
โโโ server.py # FastAPI app + all FastMCP tool definitions & REST endpoints
โโโ auth_manager.py # OAuth 2.0 flow, scopes & token refresh
โโโ zoho_client.py # Async HTTP client for Zoho CRM API v8 (193 methods)
โโโ models.py # Pydantic state & validation models
โโโ token_store.py # Encrypted (Fernet) token persistence
โโโ approval_manager.py # HITL approval queue for high-risk actions
โโโ activity_log.py # Structured JSON activity logger
โโโ dashboard.py # Dashboard aggregation (concurrent, per-panel errors)
โโโ report_store.py # Report definitions + COQL compiler, runner & CSV export
โโโ hierarchy.py # Role/user tree assembly and hierarchy mutations
โโโ teamspace_store.py # Teamspace registry + CRM user-group projection
โโโ web_ui.py # The dashboard home page (no CDN, no build step)
โโโ tenancy.py # Per-account isolation: tenant registry, ContextVar, proxies
โโโ test_server.py # pytest suite
โโโ requirements.txt # Dependencies
โโโ .env.example # Environment configuration template
โโโ pyproject.toml # Package metadata
โโโ README.md
```
---
## โ๏ธ Setup & Installation
### 1. Prerequisites
- Python 3.10+
- A Zoho CRM API Console app ([Zoho API Console](https://api-console.zoho.com/))
- **Client Type:** Server-based Applications
- **Redirect URI:** `http://localhost:8000/auth/callback` (or your deployment callback URL)
### 2. Environment Setup
```bash
cp .env.example .env
```
Minimum configuration:
```ini
ZOHO_CLIENT_ID=1000.xxxxxxx
ZOHO_CLIENT_SECRET=xxxxxxx
ZOHO_REDIRECT_URI=http://localhost:8000/auth/callback
ZOHO_DATA_CENTER=com
PORT=8000
```
See `.env.example` for every supported variable, including the approval gate, OAuth scope override, rate limit, and timeout settings.
> **Working with more than one Zoho account?** `ZOHO_CLIENT_ID`/`ZOHO_CLIENT_SECRET` are optional. Leave them blank and ask Claude to call `set_zoho_credentials(client_id, client_secret, redirect_uri?, data_center?)`, or pass `client_id`/`client_secret` directly to `get_auth_url` / `exchange_auth_code`. Switching `client_id` clears tokens saved for the previous account, avoiding Zoho's `invalid_client` error from reusing a refresh token issued to a different app.
### 3. Install Dependencies
```bash
pip install -r requirements.txt
```
---
## ๐ Running & Deploying
### Option A: Local FastAPI Web Server
```bash
python server.py
```
Or with Uvicorn directly:
```bash
uvicorn server:app --host 0.0.0.0 --port 8000
```
Once running:
- **Web Dashboard:** http://localhost:8000/
- **Swagger Docs:** http://localhost:8000/docs
- **Health Check:** http://localhost:8000/health
- **MCP Endpoint:** `http://localhost:8000/mcp/`
> The MCP endpoint is served at the mount root, so the path is `/mcp/` โ **not** `/mcp/mcp`.
> Keep the trailing slash: `/mcp` answers with a 307 redirect, which not every MCP client
> follows correctly on a POST.
### Option B: Local STDIO
```bash
python server.py --stdio
```
### Option C: Cloud Deployment (Render, Railway, Docker, AWS, Heroku)
- **Start Command:** `uvicorn server:app --host 0.0.0.0 --port $PORT`
- **Health Check Path:** `/health`
- **Environment Variables:** set `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REDIRECT_URI`, `ZOHO_DATA_CENTER`, and `ZOHO_TOKEN_ENCRYPTION_KEY` (so tokens survive restarts on ephemeral filesystems).
**Live deployment**
| | |
|---|---|
| Dashboard | https://zoho-crm-mcp-07t7.onrender.com/ |
| Health | https://zoho-crm-mcp-07t7.onrender.com/health |
| MCP endpoint | `https://zoho-crm-mcp-07t7.onrender.com/mcp/` |
**Staying authenticated on an ephemeral host.** Render wipes the filesystem on every
deploy and restart, taking `~/.zoho_crm_tokens.json` with it โ and the Fernet key at
`~/.zoho_crm_mcp.key` is regenerated on each boot, so even a surviving token file could
not be decrypted. Set both of these in the host's environment and the server
re-authenticates itself instead of asking for tokens again:
- `ZOHO_REFRESH_TOKEN` โ picked up on cold boot, so a fresh access token is minted
automatically. Refresh tokens do not expire unless revoked.
- `ZOHO_TOKEN_ENCRYPTION_KEY` โ a stable Fernet key, so the cached access token survives
too. Generate one with:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Token refresh itself is automatic and needs no configuration: `get_valid_access_token()`
renews anything expiring within 5 minutes, and a background loop re-checks every
`ZOHO_TOKEN_REFRESH_INTERVAL` seconds (default 120).
---
## ๐ฅ๏ธ Claude Desktop Integration
### Mode 1: HTTP / Remote MCP Connection
Local:
```json
{
"mcpServers": {
"zoho-crm": {
"url": "http://localhost:8000/mcp/"
}
}
}
```
Deployed:
```json
{
"mcpServers": {
"zoho-crm": {
"url": "https://zoho-crm-mcp-07t7.onrender.com/mcp/"
}
}
}
```
The server has no MCP-level authentication, so **leave any "OAuth Client ID" field in the
connector settings blank**. The Zoho OAuth inside the server authenticates it to Zoho; it
does not gate the MCP endpoint. Pointing a client at the wrong path (`/mcp/mcp`) returns
404, which some clients report as a misleading sign-in or registration failure rather than
as a bad URL.
### Mode 2: Local STDIO Connection
```json
{
"mcpServers": {
"zoho-crm": {
"command": "python",
"args": ["C:/Users/Lenovo/Desktop/zoho MCP/server.py", "--stdio"],
"env": {
"ZOHO_CLIENT_ID": "1000.YOUR_CLIENT_ID",
"ZOHO_CLIENT_SECRET": "YOUR_CLIENT_SECRET",
"ZOHO_REDIRECT_URI": "http://localhost:8000/auth/callback",
"ZOHO_DATA_CENTER": "com"
}
}
}
}
```
---
## ๐ First-Run OAuth Flow
1. Start the server: `python server.py`
2. Open `http://localhost:8000/auth/url`, or ask Claude to run `get_auth_url()`.
3. Open the returned URL, sign in to Zoho CRM, click **Accept**.
4. Zoho redirects to `/auth/callback?code=...`; the server exchanges the code and saves encrypted tokens to `~/.zoho_crm_tokens.json`.
---
## ๐งฉ Building Automation: The Workflow Recipe
Zoho models a workflow rule as a **trigger** plus **conditions**, where each condition points at pre-created **action** objects. Build them in that order:
```
1. get_workflow_configurations(module="Leads")
-> see which triggers, comparators, and action types this org supports
2. create_field_update_action(
name="Mark as Hot", module="Leads",
field_api_name="Rating", value="Hot")
-> returns the action id
3. create_workflow(
name="Hot Lead Router",
module="Leads",
execute_when={"type": "create_or_edit"},
conditions=[{
"sequence_number": 1,
"criteria_details": {"criteria": {"group_operator": "and", "group": [
{"comparator": "equal",
"field": {"api_name": "Lead_Source"},
"value": "Web Form"}]}},
"instant_actions": {"actions": [
{"id": "<action id from step 2>", "type": "field_updates"}]}}])
4. activate_workflow(workflow_id="...")
```
The same pattern applies with `create_email_notification_action`, `create_automation_task`, and `create_webhook` as the action source.
---
---
## ๐ Dashboard & Report Home Page
`GET /` is a live dashboard, not a status card. It shows record counts per module, the
workflow and function inventory (broken down by runtime and category), the org chart,
teamspaces, every pinned report, and the recent activity feed โ with an in-page report
runner for ad-hoc queries.
Panels are fetched concurrently and each carries its own error, so a missing OAuth scope
costs you one panel rather than the whole page. The same payload is available as JSON:
```bash
curl localhost:8000/api/dashboard/summary
curl "localhost:8000/api/dashboard/summary?modules=Deals,Leads&include_hierarchy=false"
```
From an MCP client:
```python
get_dashboard_summary() # everything
get_module_record_counts(["Deals", "Leads"]) # just the counts
```
### Reports
A report is a definition stored on this server that compiles to COQL and runs against
live CRM data. Three shapes:
```python
# 1. list โ rows of fields
create_report(name="Big open deals", module="Deals", type="list",
fields=["Deal_Name", "Stage", "Amount"],
criteria="(Amount > 50000) and (Stage != 'Closed Won')",
sort_by="Amount", sort_order="desc")
# 2. count โ how many match
create_report(name="Web leads", module="Leads", type="count",
criteria="(Lead_Source = 'Web Form')")
# 3. summary โ aggregate, grouped; pinned to the dashboard home page
create_report(name="Pipeline by stage", module="Deals", type="summary",
group_by=["Stage"],
aggregates=[{"function": "sum", "field": "Amount", "alias": "pipeline"}],
pinned=True)
run_report("Pipeline by stage") # by name or id
run_adhoc_report(module="Deals", type="count") # iterate without saving
export_report("Pipeline by stage", save_to="./pipeline.csv")
```
Aggregates take `count`, `sum`, `avg`, `min`, and `max`; `alias` is the column name you
get back. `criteria` is a COQL where clause, and omitting it matches every record. Rows
cap at 2000 per report โ use `bulk_read_create_job` for a full export.
`create_dashboard_widget(...)` is the same thing pinned in one step.
---
## โก Functions, and Attaching Them to Workflows
v8 exposes a real Functions API, so functions are authored over the wire. Deluge source
goes inline; Java, NodeJS, and Python ship as a ZIP and then need publishing.
```python
get_function_runtimes() # what this org allows, and what's deprecated
get_function_categories() # 'Automation' is the workflow-callable one
create_function(
name="Flag big deal",
api_name="flag_big_deal",
category="Automation",
runtime="Deluge 1.0",
code='''void automation.flag_big_deal(Map deal)
{
// Deluge body
}''')
get_function_code("flag_big_deal") # read it back
update_function("flag_big_deal", code="...") # Deluge auto-publishes
publish_function("nightly_sync") # Java/NodeJS/Python drafts
```
`api_name`, `runtime`, and `category` are immutable in Zoho โ to change any of them,
create a new function.
`functions` is a first-class workflow action type, so a function can be wired straight
into a rule:
```python
# instant: runs the moment the rule fires
attach_function_to_workflow(workflow_id="...", function="flag_big_deal", module="Deals")
# scheduled: runs two days later
attach_function_to_workflow(workflow_id="...", function="flag_big_deal", module="Deals",
scheduled_after={"period": "days", "unit": 2})
list_workflow_actions(workflow_id="...", module="Deals")
detach_function_from_workflow(workflow_id="...", function="flag_big_deal", module="Deals")
```
A rule's actions hang off its conditions, so attaching one is read-modify-write on the
target condition โ existing actions on that condition are preserved. `condition_sequence`
picks which condition (default 1). `attach_action_to_workflow` does the same for the other
action types: pass `action_id` for an existing field update, email notification, task, or
webhook, or `details` for an inline action like `add_tags` or `create_record`. Check
`get_workflow_configurations(module=...)` first โ a trigger only accepts certain actions.
---
## ๐ข Hierarchy & Teamspaces
### Hierarchy
Zoho returns roles and users as flat lists with `reporting_to` pointers and no tree
endpoint, so these tools assemble one:
```python
get_hierarchy_preference() # Role_Hierarchy or Reporting_Hierarchy (read-only in Zoho)
get_role_hierarchy() # nested tree + an ASCII org chart + depth
get_user_hierarchy() # same, from each user's reporting_to
get_subordinates("4876...001", of="user", deep=True) # + the managers above them
```
Both trees come with an `ascii` org chart, which is what an AI client can actually read
back:
```
CEO
โโโ VP Sales
โ โโโ Manager EU
โ โ โโโ AE Berlin
โ โ โโโ AE Paris
โ โโโ Manager US
โโโ VP Marketing
```
A role whose parent is outside your permission scope still appears, listed under
`orphans` rather than silently dropped. The mutations are real Zoho writes:
```python
create_role_under(name="Manager EU", parent_role_id="...")
set_role_parent(role_id="...", parent_role_id="...") # moves the whole subtree
set_user_manager(user_id="...", manager_user_id="...") # omit to clear
assign_user_role(user_id="...", role_id="...")
```
### Teamspaces
Teamspaces group modules and people. Zoho's REST API has no teamspaces endpoint, so the
roster lives on this server โ and can be projected onto a real CRM **user group**, which
is Zoho's own way to name a set of people for sharing and assignment:
```python
create_teamspace(
name="EMEA Sales",
modules=["Deals", "Contacts"],
members=["4876...001", {"user_id": "4876...002", "role": "viewer"}],
lead_user_id="4876...001",
create_crm_user_group=True) # also creates the Zoho user group
add_teamspace_members("EMEA Sales", [{"user_id": "...", "role": "member"}])
set_teamspace_modules("EMEA Sales", add=["Tasks"], remove=["Contacts"])
sync_teamspace_to_crm("EMEA Sales") # push the roster onto the linked group
get_user_teamspaces("4876...001")
```
Member roles are `lead`, `member`, or `viewer`. If the CRM user group can't be created
(a duplicate name, say), the teamspace is still kept and the response says the CRM-side
binding failed โ the registry is the source of truth, the group is its projection.
The underlying Zoho user groups are also directly addressable: `get_user_groups`,
`create_user_group`, `update_user_group`, `delete_user_group`,
`get_user_group_associations`.
---
## ๐ REST Surface
Beyond the MCP endpoint, the dashboard's data is plain JSON:
| Endpoint | Purpose |
|----------|---------|
| `GET /api/dashboard/summary` | The whole dashboard (`?modules=`, `?include_reports=`, `?include_hierarchy=`) |
| `GET /api/dashboard/modules` | Record counts per module |
| `GET /api/dashboard/automation` | Workflow rules, limits, and the function inventory |
| `GET /api/reports` ยท `POST /api/reports` | List and save report definitions |
| `POST /api/reports/run` | Run an ad-hoc definition |
| `GET /api/reports/{ref}` ยท `POST /api/reports/{ref}/run` ยท `DELETE /api/reports/{ref}` | One saved report |
| `GET /api/hierarchy/preference` ยท `/roles` ยท `/users` | Hierarchy model and trees |
| `GET /api/hierarchy/subordinates/{id}` | Subordinates (`?of=user\|role`, `?deep=`) |
| `GET /api/teamspaces` ยท `POST /api/teamspaces` | List and create teamspaces |
| `GET /api/teamspaces/{ref}` ยท `DELETE /api/teamspaces/{ref}` | One teamspace (`?resolve_members=true`) |
| `GET /api/functions` | The org's functions |
| `GET /api/workflows/{id}/actions` | Every action attached to a rule |
| `GET /tenants` | Resident accounts and their state, credentials masked |
Every endpoint accepts an `X-Zoho-Client-Id` header to select which account it acts for.
---
## ๐ฅ Multi-Account Isolation (Tenancy)
This server is safe to share between many Zoho accounts and many AI clients at
once. Each **Zoho `client_id` is a tenant** with its own tokens, scope, approval
queue, reports, and teamspaces. Concurrent callers using different credentials
cannot read or write each other's org.
### How a caller picks its account
**MCP clients** โ call `set_zoho_credentials` once; that session is bound to the
account for its lifetime, and every later tool call routes there automatically:
```python
set_zoho_credentials(client_id="1000.AAAA", client_secret="...")
get_tenant_info() # confirm which account you are pointed at
create_record(...) # goes to 1000.AAAA, whatever other sessions are doing
```
**REST callers** โ send the account's client_id as a header:
```bash
curl localhost:8000/api/reports -H 'X-Zoho-Client-Id: 1000.AAAA'
curl localhost:8000/tenants # resident accounts, credentials masked
```
Resolution order, first match wins: an explicit binding on the current task โ
the calling MCP session's binding โ the `X-Zoho-Client-Id` header โ the `.env`
account. A deployment that never calls `set_zoho_credentials` behaves exactly as
a single-account server, on the same file paths as before.
### What is isolated, and why each one matters
| Per tenant | Why |
|---|---|
| `AuthManager` + token file | Otherwise a second caller's credentials overwrite the first's mid-request, and calls execute against the wrong org |
| `ZohoClient` | Its `_module_id_cache` holds Zoho's **numeric module ids, which are per-org** โ sharing it corrupts workflow, webhook, and field-update payloads |
| Rate limiter | One account's burst would otherwise throttle all the others |
| `ScopeState` | A scope activated by one account would filter and block another's records |
| Approval queue | Approvals are per-account, and a queued action executes pinned to the tenant that raised it |
| Reports & teamspaces | Definitions reference one org's modules and users |
| Activity log reads | One shared audit trail, tagged per tenant; reads are filtered to the caller (`all_tenants=True` for diagnostics) |
### How the isolation works
The current tenant lives in a `contextvars.ContextVar`. ContextVars are copied
per asyncio task, so two interleaved requests each see their own value โ
isolation by construction rather than by locking. The module-level names
(`zoho_client`, `auth_manager`, โฆ) are thin proxies that resolve the calling
task's tenant on every attribute access, which is why all 211 tool bodies are
unchanged.
OAuth needed one extra step: a browser callback carries no session, so
`get_auth_url` puts the tenant key in the OAuth `state` parameter and
`/auth/callback` reads it back to store the tokens against the right account.
### Operating notes
- **Tenant state** lives under `ZOHO_STATE_DIR` (default `~/.zoho_crm_mcp/tenants/<hash>/`),
one directory per account, named by a hash of the client_id rather than the
client_id itself. The `.env` account keeps the original home-directory paths.
- **Residency** is capped by `ZOHO_MAX_TENANTS` (default 250); idle tenants past
the cap have their connection pools closed by the background loop. Tenants
still bound to a live session are never evicted.
- **Token refresh** runs per tenant inside that tenant's context, so a refresh
can never write one account's token into another's store.
- **`get_tenant_info()`** reports which account a session resolved to and how.
Worth calling before anything destructive.
## ๐ฏ Scoped Session Mode (Safety Filter)
Restrict every operation to specific record IDs:
- **Activate:** `activate_scope(module="Deals", record_ids=["4153...001", "4153...002"])`
- **REST:** `POST /scope/activate` with `{"module": "Leads", "record_ids": ["123", "456"]}`
- **Deactivate:** `deactivate_scope()` or `POST /scope/deactivate`
While active, reads on that module are filtered to those IDs, and writes to any other ID are refused with `OUT_OF_SCOPE`.
---
## โ
Human-In-The-Loop (HITL) Approvals
By default, destructive actions queue a pending request and return a `request_id` instead of running:
`delete_record`, `bulk_update_records`, `bulk_delete_records`, `mass_update_records`, `mass_delete_records`, `change_owner`, `mass_change_owner`, `merge_records`, `delete_workflow`, `delete_workflows`, `execute_blueprint`, `update_layout`, `activate_layout`, `delete_layout`, `delete_field`, `delete_user`, `delete_tag`, `bulk_write_create_job`.
- **Review:** `list_pending_approvals()` or `GET /approvals`
- **Approve & execute:** `approve_action(request_id="...")` or `POST /approvals/{id}/approve`
- **Reject & discard:** `reject_action(request_id="...")` or `POST /approvals/{id}/reject`
- **Disable the gate entirely:** set `ZOHO_REQUIRE_APPROVAL=false` so these tools execute immediately.
Every request, approval, and rejection is written to the activity log.
---
## ๐ Activity Logging
Auth events, outbound Zoho API calls, function executions, and approval decisions are recorded as `{timestamp, action, status, details}` entries โ held in memory and appended to `~/.zoho_crm_mcp_activity.log.jsonl`.
- **Retrieve:** `get_logs(limit=50, action=None, status=None)` or `GET /logs`
---
## ๐ Token Security
- Tokens are encrypted at rest (Fernet/AES) in `~/.zoho_crm_tokens.json`.
- The key is auto-generated into `~/.zoho_crm_mcp.key` on first run (user-only permissions on POSIX), or set explicitly via `ZOHO_TOKEN_ENCRYPTION_KEY` for a stable key across container restarts.
- Tokens are tagged with the `client_id` that issued them and discarded on mismatch, which prevents Zoho's `invalid_client` error after switching accounts.
- Outbound calls are self-throttled (`ZOHO_RATE_LIMIT_PER_SEC`, default 10/sec) on top of 429/5xx backoff.
---
## ๐งช Testing
```bash
pytest -v
```
Covers the HTTP surface (`/health`, `/`, `/auth/*`, `/scope/*`, `/approvals/*`, `/logs`, `/api/*`), registration of all 211 MCP tools plus a regression guard that the original 167 are still there, the exact request payloads sent for workflows/modules/notes/calls/webhooks/merges/locks and the Functions API's multipart body, the COQL compiler (including its limit clamp and the mandatory WHERE clause), aggregate-alias relabelling, hierarchy tree assembly (orphans and parent cycles included), per-panel dashboard error isolation, teamspace membership rules, function-to-workflow attach/detach across instant and scheduled actions, and multi-account isolation - including a concurrency test that drives overlapping interleaved calls from many accounts and asserts every request carried its own credentials (the old shared-singleton design failed that on 96 of 100 calls).
Tests run entirely offline โ no Zoho credentials required.
---
## ๐ ๏ธ MCP Tool Reference
| Category | Tools |
|----------|-------|
| **OAuth & Auth** | `get_auth_url`, `exchange_auth_code`, `set_zoho_credentials`, `get_auth_status`, `get_access_token`, `refresh_access_token`, `validate_token`, `get_token_expiry` |
| **Scoped Mode** | `activate_scope`, `deactivate_scope`, `get_scope_status` |
| **Tenancy** | `get_tenant_info` โ which Zoho account this session is bound to, and how it resolved |
| **HITL & Logging** | `list_pending_approvals`, `approve_action`, `reject_action`, `get_logs` |
| **Escape Hatch** | `zoho_api_request` โ call any Zoho v8 endpoint with full auth/retry handling |
| **Record CRUD** | `create_record`, `get_record`, `update_record`, `delete_record`โ , `list_records`, `search_records`, `upsert_record`, `clone_record`, `get_record_count`, `get_deleted_records`, `get_record_timeline` |
| **Bulk (โค100/call)** | `bulk_create_records`, `bulk_update_records`โ , `bulk_upsert_records`, `bulk_delete_records`โ |
| **Mass (async jobs)** | `mass_update_records`โ , `get_mass_update_status`, `mass_delete_records`โ , `get_mass_delete_status`, `change_owner`โ , `mass_change_owner`โ , `merge_records`โ |
| **Locking & Sharing** | `lock_record`, `unlock_record`, `get_record_locking_info`, `share_record`, `get_shared_record_details`, `revoke_shared_record` |
| **Related Records** | `get_related_records`, `get_related_records_count`, `link_related_records`, `delink_related_record` |
| **Query** | `execute_coql`, `composite_request` |
| **Metadata & Discovery** | `get_modules`, `get_module_details`, `get_fields`, `get_field_details`, `get_picklist_values`, `get_layouts`, `get_layout_structure`, `get_related_lists`, `get_custom_views`, `get_custom_view_details`, `get_features`, `get_organizations`, `get_business_hours`, `get_currencies`, `get_email_templates`, `get_recycle_bin` |
| **Schema Design** | `create_module`, `update_module`, `create_field`, `create_fields`, `update_field`, `delete_field`โ , `get_global_picklists`, `create_global_picklist`, `update_layout`โ , `activate_layout`โ , `deactivate_layout`, `delete_layout`โ , `get_pipelines`, `create_pipeline`, `update_pipeline` |
| **Workflow Rules** | `get_workflows`, `get_workflow`, `get_workflow_configurations`, `create_workflow`, `update_workflow`, `activate_workflow`, `deactivate_workflow`, `delete_workflow`โ , `delete_workflows`โ |
| **Workflow Actions** | `get_field_update_actions`, `create_field_update_action`, `update_field_update_action`, `delete_field_update_action`, `get_email_notification_actions`, `create_email_notification_action`, `delete_email_notification_action`, `get_automation_tasks`, `create_automation_task`, `update_automation_task`, `get_assignment_rules` |
| **Webhooks** | `create_webhook`, `get_webhooks`, `update_webhook`, `delete_webhook` |
| **Files** | `upload_attachment`, `get_attachments`, `download_attachment`, `delete_attachment`, `upload_photo`, `delete_photo` |
| **Notes, Calls & Email** | `create_note`, `get_notes`, `update_note`, `delete_note`, `create_call`, `send_mail`, `get_from_addresses`, `get_emails` |
| **Tags** | `get_tags`, `create_tags`, `update_tag`, `delete_tag`โ , `merge_tags`, `get_tag_record_count`, `add_tags`, `remove_tags`, `add_tags_to_multiple_records` |
| **Lead Conversion** | `get_lead_conversion_options`, `convert_lead`, `mass_convert_leads`, `get_mass_convert_status` |
| **Blueprint** | `get_blueprints`, `execute_blueprint`โ , `create_blueprint`*, `update_blueprint`* |
| **Bulk Read/Write** | `bulk_read_create_job`, `bulk_read_job_status`, `bulk_read_download_result`, `bulk_write_upload_file`, `bulk_write_create_job`โ , `bulk_write_job_status` |
| **Security & Users** | `get_users`, `create_user`, `update_user`, `delete_user`โ , `get_profiles`, `create_profile`, `get_roles`, `create_role`, `update_role`, `get_territories`, `get_variables`, `create_variables` |
| **Notifications** | `get_notification_details`, `enable_notifications`, `disable_notifications` |
| **Functions (v8 API)** | `get_functions`, `get_function`, `get_function_code`, `get_function_runtimes`, `get_function_categories`, `create_function`, `update_function`, `publish_function`, `delete_function`โ , `execute_function` |
| **Functions โ Workflows** | `attach_function_to_workflow`, `detach_function_from_workflow`, `attach_action_to_workflow`, `detach_action_from_workflow`, `list_workflow_actions` |
| **Workflow Insight** | `get_workflow_usage_report`, `get_workflow_module_counts`, `get_workflow_limits` |
| **Reports** โก | `create_report`, `get_reports`, `get_report`, `update_report`, `delete_report`, `run_report`, `run_adhoc_report`, `export_report` |
| **Dashboard** โก | `get_dashboard_summary`, `get_dashboard`, `get_module_record_counts`, `create_dashboard_widget` |
| **Hierarchy** โก | `get_hierarchy_preference`, `get_role_hierarchy`, `get_user_hierarchy`, `get_subordinates`, `set_user_manager`, `set_role_parent`, `create_role_under`, `assign_user_role` |
| **Teamspaces** โก | `create_teamspace`, `get_teamspaces`, `get_teamspace`, `update_teamspace`, `delete_teamspace`, `add_teamspace_members`, `remove_teamspace_members`, `set_teamspace_modules`, `sync_teamspace_to_crm`, `get_user_teamspaces` |
| **User Groups** | `get_user_groups`, `create_user_group`, `update_user_group`, `delete_user_group`โ , `get_user_group_associations` |
โ Approval-gated by default. Set `ZOHO_REQUIRE_APPROVAL=false` to execute immediately.
\* Zoho CRM's public REST API has no endpoint for this operation โ blueprint *authoring* is UI-only. These tools return a clear `NOT_SUPPORTED_BY_ZOHO_API` message naming a working alternative, rather than failing against a URL that doesn't exist. (Driving records through an existing blueprint does work: `get_blueprints` / `execute_blueprint`.)
โก Zoho exposes no Reports, Dashboards, Teamspaces, or hierarchy-tree endpoint, so these features are implemented **on this server** against real CRM data:
- **Reports** are definitions stored here that compile to COQL (or to `/actions/count`) and execute live. `get_reports(module=...)` also returns that module's Custom Views, which are the closest thing Zoho itself exposes.
- **The dashboard** is computed from live figures โ module counts, workflow and function inventory, hierarchy, teamspaces, pinned reports.
- **Hierarchy** tools assemble a tree from `/settings/roles` and `/users` (Zoho returns flat lists with `reporting_to` pointers); the mutations are real Zoho writes. `hierarchy_preferences` is read-only in Zoho's API.
- **Teamspaces** are stored here and can be projected onto a real CRM user group (`/settings/user_groups`) so the roster is usable inside Zoho.
Definitions persist as JSON beside the token store (`~/.zoho_crm_mcp_reports.json`, `~/.zoho_crm_mcp_teamspaces.json`). On an ephemeral host, mount a disk or accept that they reset on redeploy โ the CRM data they read is never affected.
---
## ๐งญ Reaching Anything Not Listed
Zoho's API is larger than any hand-written wrapper. `zoho_api_request` covers the rest with the same auth, throttling, and retry handling:
```python
zoho_api_request(
method="GET",
endpoint="settings/territories")
zoho_api_request(
method="POST",
endpoint="settings/automation/scoring_rules",
body={"scoring_rules": [{...}]})
zoho_api_request(
method="GET",
endpoint="read/1234567890",
api_root="bulk")
```
`api_root` selects the URL base: `crm` โ `{domain}/crm/v8` (default), `bulk` โ `{domain}/crm/bulk/v8`, `root` โ `{domain}`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues