Content
# zoho-analytics-mcp-worker
The [Zoho Analytics](https://www.zoho.com/analytics/) [API v2](https://www.zoho.com/analytics/api/v2/introduction.html) exposed as a **remote [MCP](https://modelcontextprotocol.io) server**, running as a **Cloudflare Worker** (Streamable HTTP + SSE). Built on the [`agents`](https://github.com/cloudflare/agents) `McpAgent`.
Its client implements **ALL 161 endpoints of the Zoho Analytics v2 API** (verified against Zoho's [official OpenAPI specs](https://github.com/zoho/analytics-oas)), exposed through **144 tools** — discovery & metadata, the Data API, the sync/async/batch Bulk API, modeling (tables, query tables, reports, columns, lookups, formulas, folders, view lifecycle, dependents), workspace administration, sharing & collaboration (groups, admins), user management, publishing & embedding (URLs, slideshows), email schedules, variables, favorites, data-source sync, template export, and **AutoML** — plus conveniences the raw API lacks: a one-call **workspace schema map**, an end-to-end **SQL-query helper** that drives the async export job for you, bounded-concurrency batch reads, bounded job polling, and `dry_run` previews on destructive operations. The tool definitions live in [`src/tools.ts`](src/tools.ts) and the Zoho Analytics REST client in [`src/zohoanalytics.ts`](src/zohoanalytics.ts); both are transport-agnostic, so every build shares an identical tool surface. Set `MCP_READONLY=true` to register only the ~51 read tools.
This repo ships **two deployments from the same code**:
| Worker | Auth | Use it from | Entry |
|--------|------|-------------|-------|
| **`zoho-analytics-mcp`** | static bearer token | Claude Code, Claude Desktop, programmatic | [`src/index.ts`](src/index.ts) · [`wrangler.jsonc`](wrangler.jsonc) |
| **`zoho-analytics-mcp-oauth`** | OAuth 2.1 (single-user passphrase) | **Claude.ai web** custom connector | [`src/oauth.ts`](src/oauth.ts) · [`wrangler.oauth.jsonc`](wrangler.oauth.jsonc) |
> **Status:** both deployments typecheck, bundle, and pass tests clean (`npm audit` = 0 vulnerabilities). Secrets are set via `wrangler secret put` and are **never** committed.
---
## Contents
- [Architecture](#architecture)
- [Endpoints](#endpoints)
- [Authentication](#authentication)
- [Tools](#tools)
- [Zoho Analytics API coverage](#zoho-analytics-api-coverage)
- [Configuration](#configuration)
- [Getting Zoho OAuth credentials](#getting-zoho-oauth-credentials)
- [Deploy](#deploy)
- [Verify](#verify)
- [Testing](#testing)
- [Connect a client](#connect-a-client) — [Claude Code](#claude-code) · [Claude Desktop](#claude-desktop) · [Claude Web](#claude-web) · [Programmatic](#programmatic)
- [OAuth worker (Claude.ai web)](#oauth-worker-claudeai-web)
- [Caveats](#caveats)
- [Project layout](#project-layout)
---
## Architecture
```
client bearer worker (src/index.ts)
Claude Code / Desktop / curl ── Authorization: Bearer <MCP_AUTH_TOKEN> ──┐
│
Claude.ai web ── OAuth 2.1 (passphrase) ── oauth worker (src/oauth.ts) ──┤
▼
ZohoAnalyticsMCP (McpAgent, Durable Object)
• registerTools(server, client) ← tools.ts
• ZohoAnalyticsClient ← zohoanalytics.ts
│
mints access tokens from a refresh token (accounts.zoho.*),
then Authorization: Zoho-oauthtoken <access_token>
ZANALYTICS-ORGID: <org id>
▼
Zoho Analytics REST API v2
```
- **`McpAgent` + Durable Object.** MCP session state lives in a Cloudflare Durable Object (SQLite-backed, migration `v1`). Both workers bind it as `MCP_OBJECT` → class `ZohoAnalyticsMCP`.
- **Shared tool layer.** `registerTools()` and `ZohoAnalyticsClient` are plain TypeScript with no Worker-specific imports, so the bearer worker and the OAuth worker share them.
- **OAuth token management.** Zoho access tokens expire hourly, so the client stores the refresh token + client id/secret and mints/caches access tokens itself, with a transparent refresh-and-retry on `401`.
- **Lean bundle.** Only the `agents/mcp` server path is imported; the `ai`/`react` peer dependencies of `agents` are never reached by the bundle.
---
## Endpoints
Bearer worker (`zoho-analytics-mcp`):
| Method | Path | Auth | Purpose |
|--------|----------------|--------|------------------------------------------|
| `GET` | `/` | none | Health check → `zoho-analytics-mcp worker: ok` |
| `POST` | `/mcp` | Bearer | MCP Streamable HTTP (modern clients) |
| `GET` | `/sse` | Bearer | MCP SSE (legacy clients) |
OAuth worker (`zoho-analytics-mcp-oauth`) — see [OAuth worker](#oauth-worker-claudeai-web).
---
## Authentication
Two layers, kept separate:
1. **Connector auth — who may call this MCP server.**
- **Bearer worker** is gated by a **shared bearer token** (`MCP_AUTH_TOKEN`): fails closed (no secret → everything 401s), constant-time comparison. Right for programmatic use, `mcp-remote`, Claude Code/Desktop.
- **OAuth worker** implements **OAuth 2.1** (PKCE + dynamic client registration) via [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider), gated by a single shared **passphrase** (`APP_PASSPHRASE`) on the consent screen — the only model Claude.ai web accepts.
2. **Zoho auth — how the server calls Zoho Analytics.** The server holds your Zoho **refresh token** + **client id/secret** (`ZOHO_*` secrets) and mints short-lived access tokens at the matching accounts domain (`accounts.zoho.<dc>/oauth/v2/token`), sending them as `Authorization: Zoho-oauthtoken <token>` along with the `ZANALYTICS-ORGID` header. Tokens are cached in memory and refreshed automatically; a `401` triggers one transparent refresh-and-retry. See [Getting Zoho OAuth credentials](#getting-zoho-oauth-credentials).
**Data centers.** Set `ZOHO_DC` to the data center your Zoho account lives in. Both the API host and the OAuth host are resolved from it:
| `ZOHO_DC` | API host | Accounts (OAuth) host |
|-----------|----------|------------------------|
| `com` (default) | `analyticsapi.zoho.com` | `accounts.zoho.com` |
| `eu` | `analyticsapi.zoho.eu` | `accounts.zoho.eu` |
| `in` | `analyticsapi.zoho.in` | `accounts.zoho.in` |
| `au` | `analyticsapi.zoho.com.au` | `accounts.zoho.com.au` |
| `jp` | `analyticsapi.zoho.jp` | `accounts.zoho.jp` |
| `sa` | `analyticsapi.zoho.sa` | `accounts.zoho.sa` |
| `ca` | `analyticsapi.zohocloud.ca` | `accounts.zohocloud.ca` |
| `uk` | `analyticsapi.zoho.uk` | `accounts.zoho.uk` |
| `cn` | `analyticsapi.zoho.com.cn` | `accounts.zoho.com.cn` |
---
## Tools
All tools call an external service (`openWorldHint: true`). IDs (`workspace_id`, `view_id`) come from the discovery tools. Filters use Zoho's `criteria` syntax — fully-qualified, double-quoted identifiers with single-quoted values, e.g. `"Sales"."Region"='East'`.
### Discovery & metadata (read-only)
| Tool | Purpose | Input |
|------|---------|-------|
| `zoho_whoami` | Health check — confirms the OAuth credentials work; lists accessible orgs; echoes the configured org id + data center | — |
| `zoho_get_orgs` | List the organizations the token can access (find your org id) | — |
| `zoho_list_workspaces` | List workspaces (owned + shared); compact id/name by default | `verbose?` |
| `zoho_get_workspace_details` | Details of one workspace | `workspace_id` |
| `zoho_list_views` | List views (tables, query tables, charts, pivots, dashboards) in a workspace; filter by type/keyword | `workspace_id`, `view_types?`, `keyword?`, `verbose?` |
| `zoho_get_view_details` | Details of one view; includes column metadata by default | `view_id`, `with_columns?` |
| `zoho_get_metadata` | Look up workspace/view + columns **by name** instead of id | `workspace_name`, `view_name?` |
| `zoho_describe_workspace` | **Schema map** — lists views and fetches each table's columns (bounded concurrency) so you can understand a workspace before querying | `workspace_id`, `include_columns?`, `max_views?`, `concurrency?` |
### Read & query
| Tool | Purpose | Input | Class |
|------|---------|-------|-------|
| `zoho_query_data` | **The headline tool** — run an ad-hoc SQL `SELECT`. Creates an async bulk export job from your SQL, polls it to completion, downloads & parses the rows | `workspace_id`, `sql_query`, `max_rows?`, `timeout_seconds?` | read-only |
| `zoho_export_data` | Synchronously export a table/view's rows (optionally filtered/projected) as parsed rows | `workspace_id`, `view_id`, `criteria?`, `selected_columns?`, `response_format?`, `max_rows?` | read-only |
| `zoho_create_export_job` | Start an **async** export (by SQL or by view) for large results; returns a `job_id` | `workspace_id`, `sql_query?`/`view_id?`, `response_format?`, `criteria?` | read-only · not idempotent |
| `zoho_get_export_job` | Check an export job (`1001`/`1002` running · `1003` **failed** · `1004` done · `1005` invalid) and optionally download its data | `workspace_id`, `job_id`, `download?`, `max_rows?` | read-only |
| `zoho_get_import_job` | Check an import job's status + summary | `workspace_id`, `job_id` | read-only |
> **Sync vs. async export.** `zoho_export_data` is synchronous and convenient, but Zoho disallows it for views over 1,000,000 rows, live-connect workspaces, and Dashboard/Query-Table views. For those — and for ad-hoc SQL — use `zoho_query_data` (waits inline) or `zoho_create_export_job` + `zoho_get_export_job` (for results too big to wait on).
### Writes (gated by `MCP_READONLY`)
When `MCP_READONLY=true`, none of these are registered — they never even appear in `tools/list`.
| Tool | Purpose | Input | Class |
|------|---------|-------|-------|
| `zoho_add_row` | Add one row to a table | `workspace_id`, `view_id`, `columns`, `date_format?`, `dry_run?` | write |
| `zoho_update_rows` | Update rows matching `criteria` (or all rows with explicit `update_all_rows`); `add_if_not_exist` upserts | `workspace_id`, `view_id`, `columns`, `criteria?`, `update_all_rows?`, `add_if_not_exist?`, `dry_run?` | **destructive** |
| `zoho_delete_rows` | Delete rows matching `criteria` (or all rows with explicit `delete_all_rows`) | `workspace_id`, `view_id`, `criteria?`, `delete_all_rows?`, `dry_run?` | **destructive** |
| `zoho_import_data` | Bulk-import CSV/JSON text into a table: `append` / `truncateadd` (replace) / `updateadd` (upsert). Waits for the job by default | `workspace_id`, `view_id`, `data`, `file_type?`, `import_type?`, `matching_columns?`, `auto_identify?`, `on_error?`, `wait?`, `dry_run?` | **destructive** |
| `zoho_create_workspace` | Create a new workspace (database) | `workspace_name`, `workspace_desc?` | write |
| `zoho_create_table` | Create a table with a column design (typed columns) | `workspace_id`, `table_name`, `columns[]`, `description?`, `folder_name?`, `dry_run?` | write |
| `zoho_delete_view` | Move a view/table to the **trash** (restore with `zoho_restore_view`; erase permanently with `zoho_delete_trash_view`) | `workspace_id`, `view_id`, `dry_run?` | **destructive** |
**Safety:** the destructive tools (delete row/column/folder/view/workspace, remove share/users, etc.) accept `dry_run` to preview without changing anything. "Update/delete all rows" requires an explicit `update_all_rows`/`delete_all_rows` flag — an empty criteria is rejected. `truncateadd` imports warn that they replace all data. Write calls are **never** auto-retried.
### Modeling & schema (writes)
Build and reshape data models. Query tables: `zoho_get_query_table`, `zoho_create_query_table`, `zoho_edit_query_table`. Reports: `zoho_create_report`, `zoho_update_report`. Columns: `zoho_add_column`, `zoho_rename_column`, `zoho_delete_column`, `zoho_hide_columns`, `zoho_show_columns`, `zoho_reorder_columns`, `zoho_add_lookup`, `zoho_remove_lookup`. Formulas: `zoho_add_formula_column`, `zoho_delete_formula_column`, `zoho_add_aggregate_formula`, `zoho_delete_aggregate_formula`. Folders: `zoho_create_folder`, `zoho_rename_folder`, `zoho_delete_folder`. View lifecycle: `zoho_rename_view`, `zoho_save_as_view`, `zoho_move_views_to_folder`, `zoho_sort_data`, `zoho_create_table_from_data`, and trash ops `zoho_get_trash` · `zoho_restore_view` · `zoho_delete_trash_view`. Workspace admin: `zoho_rename_workspace`, `zoho_delete_workspace`, `zoho_copy_workspace`, `zoho_copy_views`, `zoho_regenerate_workspace_secret_key` (rotates/invalidates the key). Plus reads `zoho_get_workspace_secret_key`, `zoho_list_folders`, `zoho_get_view_metadata`, `zoho_list_dashboards`, `zoho_list_recent_views`, `zoho_list_datasources`.
### Sharing & collaboration
`zoho_share_views`, `zoho_update_shared_views`, `zoho_remove_share`, `zoho_get_shared_details`, `zoho_get_my_permissions`. Groups: `zoho_list_groups`, `zoho_create_group`, `zoho_rename_group`, `zoho_delete_group`, `zoho_add_group_members`, `zoho_remove_group_members`. Admins: `zoho_get_workspace_admins`, `zoho_add_workspace_admins`, `zoho_remove_workspace_admins`, `zoho_get_org_admins`. Permissions are a boolean map (`read` required; also `export`, `vud`, `addRow`, `drillDown`, `share`, …).
### User management
Org: `zoho_list_users`, `zoho_add_users`, `zoho_remove_users`, `zoho_set_users_status`, `zoho_change_user_role` (USER/VIEWER/ORGADMIN), plus `zoho_get_subscription` and `zoho_get_resources`. Workspace: `zoho_list_workspace_users`, `zoho_add_workspace_users`, `zoho_remove_workspace_users`, `zoho_change_workspace_user_status`, `zoho_change_workspace_user_role` (USER/WORKSPACEADMIN/custom).
### Publishing & embedding
`zoho_get_view_url`, `zoho_get_embed_url`, `zoho_get_private_url`, `zoho_create_private_url`, `zoho_remove_private_url`, `zoho_make_view_public`, `zoho_remove_public`, `zoho_get_publish_config`, `zoho_update_publish_config`. Slideshows: `zoho_list_slideshows`, `zoho_get_slideshow`, `zoho_create_slideshow`, `zoho_update_slideshow`, `zoho_delete_slideshow`, `zoho_get_slideshow_url`.
### Variables
`zoho_list_variables`, `zoho_get_variable`, `zoho_create_variable`, `zoho_update_variable`, `zoho_delete_variable`.
### Dependents, formulas & analysis
Reads: `zoho_get_dependents` (view/column), `zoho_list_formula_columns`, `zoho_list_aggregate_formulas`, `zoho_get_aggregate_formula_value`, `zoho_get_aggregate_formula_dependents`, `zoho_get_last_import_details`, `zoho_export_workspace_template` (base64 ZIP). Writes: `zoho_edit_formula_column`, `zoho_edit_aggregate_formula`, `zoho_copy_formulas` (cross-workspace/org), `zoho_create_similar_views`, `zoho_auto_analyse` (view or column), folder placement (`zoho_make_default_folder`, `zoho_move_folder`, `zoho_reorder_folder`).
### Favorites, defaults & data sources
`zoho_set_favorite_workspace`, `zoho_set_favorite_view`, `zoho_set_default_workspace`, `zoho_set_workspace_domain_access` (white-label), `zoho_sync_datasource`, `zoho_update_datasource_connection`, `zoho_refetch_view_data`.
### Email schedules
`zoho_list_email_schedules`, `zoho_create_email_schedule`, `zoho_update_email_schedule`, `zoho_delete_email_schedule`, `zoho_trigger_email_schedule` (sends real email; `dry_run`), `zoho_set_email_schedule_status`.
### AutoML
Reads: `zoho_list_automl_analysis` (org/workspace), `zoho_get_automl_analysis`, `zoho_list_automl_deployments`, `zoho_automl_whatif` (prediction for one input). Writes: `zoho_create_automl_analysis` (train models — REGRESSION/CLASSIFICATION/CLUSTERING), `zoho_create_automl_deployment`, `zoho_run_automl_deployment`, and deletes for analysis/model/deployment (all with `dry_run`).
### Operational modes
- **Read-only deploys.** Set `MCP_READONLY=true` for a reporting/dashboard connector — only the discovery, query, and read tools are registered.
- **Audit trail.** Every state-changing call is logged at the HTTP layer (`[zoho-analytics-mcp] POST /workspaces/.../rows` — method + path), and write tools add an action-level audit line with resource names/counts (workspace/table/column names, email counts — never row data or email addresses), visible via `wrangler tail`.
- **Resilience.** Idempotent GETs retry on `429`/`5xx` with `Retry-After`-aware backoff; writes never auto-retry; batch reads use bounded concurrency to respect Zoho's per-minute frequency limits.
---
## Zoho Analytics API coverage
The tools map onto these Zoho Analytics v2 endpoints (relative to `https://<api-host>/restapi/v2`). Operation options ride in the `CONFIG` query parameter (a URL-encoded JSON object); responses use the `{status, summary, data}` envelope, except the export/download endpoints which stream raw file bytes.
| Tool | Method & path |
|------|---------------|
| `zoho_get_orgs` / `zoho_whoami` | `GET /orgs` |
| `zoho_list_workspaces` | `GET /workspaces` |
| `zoho_get_workspace_details` | `GET /workspaces/{workspace-id}` |
| `zoho_list_views` | `GET /workspaces/{workspace-id}/views` |
| `zoho_get_view_details` / `zoho_describe_workspace` | `GET /views/{view-id}` (`withInvolvedMetaInfo`) |
| `zoho_get_metadata` | `GET /metadetails` |
| `zoho_export_data` | `GET /workspaces/{workspace-id}/views/{view-id}/data` |
| `zoho_add_row` | `POST /workspaces/{workspace-id}/views/{view-id}/rows` |
| `zoho_update_rows` | `PUT /workspaces/{workspace-id}/views/{view-id}/rows` |
| `zoho_delete_rows` | `DELETE /workspaces/{workspace-id}/views/{view-id}/rows` |
| `zoho_query_data` / `zoho_create_export_job` (SQL) | `GET /bulk/workspaces/{workspace-id}/data` (`sqlQuery`) |
| `zoho_create_export_job` (view) | `GET /bulk/workspaces/{workspace-id}/views/{view-id}/data` |
| `zoho_get_export_job` | `GET /bulk/workspaces/{workspace-id}/exportjobs/{job-id}` (+ `/data` to download) |
| `zoho_import_data` | `POST /bulk/workspaces/{workspace-id}/views/{view-id}/data` (multipart `FILE`) |
| `zoho_get_import_job` | `GET /bulk/workspaces/{workspace-id}/importjobs/{job-id}` |
| `zoho_create_workspace` | `POST /workspaces` |
| `zoho_create_table` | `POST /workspaces/{workspace-id}/tables` |
| `zoho_delete_view` | `DELETE /workspaces/{workspace-id}/views/{view-id}` |
The table above lists the core data/metadata endpoints; the rest of the v2 surface is covered too — modeling (`/querytables`, `/reports`, `/views/{id}/columns`, `/customformulas`, `/aggregateformulas`, `/folders`, `/views/{id}/saveas`, `/trash/{id}`, `/similarviews`, `/autoanalyse`, …), workspace admin (`/secretkey`, copy/rename/delete, `/views/copy`, `/template/data`, favorites/default/`/wlaccess`), sharing (`/share`, per-view `/views/{id}/share` updates, `/groups`, `/admins`, `/orgadmins`), user management (`/users`, `/users/role`, `/subscription`, `/resources`, `/workspaces/{id}/users`), publishing/embed (`/publish`, `/publish/embed`, `/publish/privatelink`, `/publish/public`, `/slides`), email schedules (`/emailschedules` CRUD + trigger), data sources (`/datasources`, `/datasource/{id}/sync`), `/variables`, and AutoML (`/automl/...` analysis, models, deployments, what-if) — see the matching tool categories above and the `ZohoAnalyticsClient` methods in [`src/zohoanalytics.ts`](src/zohoanalytics.ts).
**Coverage: 161/161 endpoints** across Zoho's seven published OpenAPI spec files (data, bulk, metadata, modeling, share, user-management, embed), verified by diffing every spec `(method, path)` pair against the client. Full API: <https://www.zoho.com/analytics/api/v2/introduction.html> · specs: <https://github.com/zoho/analytics-oas>.
---
## Configuration
Set via `npx wrangler secret put <NAME>` (secrets) or a `[vars]` block (non-secret). Add `-c wrangler.oauth.jsonc` to target the OAuth worker.
| Name | Worker | Required | Purpose |
|------|--------|----------|---------|
| `ZOHO_CLIENT_ID` | both | ✅ | Zoho OAuth client id |
| `ZOHO_CLIENT_SECRET` | both | ✅ | Zoho OAuth client secret (may be per-DC) |
| `ZOHO_REFRESH_TOKEN` | both | ✅ | Long-lived refresh token (`access_type=offline`) |
| `ZOHO_ORG_ID` | both | ✅ | `ZANALYTICS-ORGID` — get it from `zoho_get_orgs` |
| `MCP_AUTH_TOKEN` | bearer | ✅ | Shared bearer clients send as `Authorization: Bearer <…>` |
| `APP_PASSPHRASE` | oauth | ✅ | Passphrase entered on the OAuth consent screen |
| `OAUTH_KV` (binding) | oauth | ✅ | KV namespace storing OAuth grants/registrations |
| `ZOHO_DC` | both | optional | Data center: `com` (default) `eu in au jp sa ca uk cn` |
| `TOKEN_KV` (binding) | bearer | recommended | KV namespace sharing ONE Zoho access token across all sessions (the OAuth worker reuses `OAUTH_KV` for this automatically). Without it, every new MCP session mints its own token — Zoho caps token creation at ~10 per 10 min per refresh token, so >10 new sessions in 10 minutes will start failing auth. `npx wrangler kv namespace create TOKEN_KV`, then uncomment the block in [wrangler.jsonc](wrangler.jsonc). |
| `ZOHO_ACCESS_TOKEN` | both | optional | Static access token (expires hourly; testing only — skips refresh) |
| `ZOHO_ANALYTICS_BASE_URL` | both | optional | Override the API host |
| `ZOHO_ACCOUNTS_BASE_URL` | both | optional | Override the accounts/OAuth host |
| `ZOHO_MAX_RETRIES` | both | optional | Max retries for idempotent calls (default `3`) |
| `MCP_READONLY` | both | optional | `true` ⇒ register read tools only |
| `MCP_CORE` | both | optional | `true` ⇒ register only the curated ~26 everyday tools (best tool-selection accuracy for daily LLM use) |
| `EXPORTS` (binding) + `PUBLIC_BASE_URL` | bearer | optional | R2 spill: truncated exports persist fully and return 24h signed `/download/` URLs (enable R2 once in the dashboard, then uncomment in wrangler.jsonc) |
| `USAGE` (binding) | bearer | optional | Workers Analytics Engine per-tool usage telemetry |
| `RATE_LIMITER` (binding) | bearer | optional | Per-IP flood cap (300 req/min) |
| `ZOHO_MULTI_USER` | oauth | optional | `true` ⇒ per-user Zoho login on consent instead of the shared passphrase; each user's refresh token lives in encrypted grant props (needs a server-based Zoho client with redirect `<origin>/zoho/callback`) |
---
## Getting Zoho OAuth credentials
One-time setup to produce `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, and `ZOHO_REFRESH_TOKEN`. Do this on the **API console of your data center** (e.g. `api-console.zoho.eu` for EU).
1. **Register a client** at <https://api-console.zoho.com/> → **Self Client** (simplest for a single-user server) or a Server-based app. Note the **Client ID** and **Client Secret**.
2. **Pick scopes.** For the full tool surface use `ZohoAnalytics.fullaccess.all`, or scope down to:
`ZohoAnalytics.data.all,ZohoAnalytics.metadata.read,ZohoAnalytics.modeling.all` (drop `data.create/update/delete` and `modeling.all` for a read-only connector).
3. **Generate a grant token (code)** with `access_type=offline` (Self Client: "Generate Code"; or run the `/oauth/v2/auth` consent flow with `response_type=code&access_type=offline`).
4. **Exchange the code for a refresh token** (once) at your accounts domain:
```bash
curl -X POST "https://accounts.zoho.com/oauth/v2/token" \
-d "grant_type=authorization_code" \
-d "client_id=$ZOHO_CLIENT_ID" \
-d "client_secret=$ZOHO_CLIENT_SECRET" \
-d "code=<grant_token>"
# → { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }
```
Save the `refresh_token` — that's `ZOHO_REFRESH_TOKEN`. (The server refreshes access tokens from it automatically; the refresh token itself doesn't expire unless revoked.)
5. **Find your org id** after deploying by calling the `zoho_get_orgs` / `zoho_whoami` tool, and set it as `ZOHO_ORG_ID`.
---
## Deploy
```bash
npm install
# Zoho credentials — paste each value at the prompt (do NOT put secrets on the command line):
npx wrangler secret put ZOHO_CLIENT_ID
npx wrangler secret put ZOHO_CLIENT_SECRET
npx wrangler secret put ZOHO_REFRESH_TOKEN
npx wrangler secret put ZOHO_ORG_ID
npx wrangler secret put MCP_AUTH_TOKEN
# Non-secret data center (if not 'com'): add to wrangler.jsonc as "vars": { "ZOHO_DC": "eu" }
npx wrangler deploy
```
Generate a strong `MCP_AUTH_TOKEN` without a trailing newline (a stray newline breaks the constant-time compare):
```bash
MCP_TOKEN=$(openssl rand -hex 32); printf '%s' "$MCP_TOKEN" | npx wrangler secret put MCP_AUTH_TOKEN; echo "MCP_AUTH_TOKEN=$MCP_TOKEN"
```
The first deploy creates the Durable Object (migration `v1`) and prints your `https://zoho-analytics-mcp.<subdomain>.workers.dev` URL. For the OAuth worker, see [OAuth worker](#oauth-worker-claudeai-web).
---
## Verify
```bash
URL="https://zoho-analytics-mcp.<subdomain>.workers.dev"
# 1) Health (no auth) → "zoho-analytics-mcp worker: ok"
curl "$URL/"
# 2) Fail-closed — no token → 401
curl -i -X POST "$URL/mcp"
# 3) MCP initialize (with token) → JSON-RPC result, not 401
curl -X POST "$URL/mcp" \
-H "Authorization: Bearer <MCP_AUTH_TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```
Or run the end-to-end smoke test (initialize → tools/list → `zoho_whoami`, which validates the Zoho credentials live):
```bash
MCP_URL="$URL/mcp" MCP_TOKEN="<MCP_AUTH_TOKEN>" npm run smoke
```
---
## Testing
```bash
npm run typecheck # tsc --noEmit (strict)
npm test # vitest — OAuth refresh + 401 retry, DC routing, CONFIG/header encoding, envelope errors, raw export, helpers
npm run smoke # live smoke vs a deployed worker (set MCP_URL + MCP_TOKEN)
```
CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) runs typecheck + tests on every push and PR. The client is fully unit-testable with a mocked `fetch` (injectable `backoffBaseMs` keeps retry tests fast); the pure helpers (row parsing, column extraction, view-type mapping) are tested against fixtures.
---
## Connect a client
### Claude Code
```bash
claude mcp add --transport http --scope user zoho-analytics \
https://zoho-analytics-mcp.<subdomain>.workers.dev/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
claude mcp list # → zoho-analytics: … (HTTP) - ✓ Connected
```
### Claude Desktop
Claude Desktop speaks stdio, so bridge the remote server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote). Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`) and fully restart the app:
```jsonc
{
"mcpServers": {
"zoho-analytics": {
"command": "npx",
"args": [
"mcp-remote",
"https://zoho-analytics-mcp.<subdomain>.workers.dev/mcp",
"--header", "Authorization: Bearer <MCP_AUTH_TOKEN>"
]
}
}
}
```
> If a bare `npx` doesn't resolve inside the Desktop app (common when Node is installed via a version manager), use the absolute path from `which npx`.
### Claude Web
The **bearer** worker can't be added to Claude.ai web (its connector UI requires OAuth, with no field for a static token). Use the **`zoho-analytics-mcp-oauth`** deployment instead:
1. Deploy it and set its secrets — see [OAuth worker](#oauth-worker-claudeai-web).
2. In Claude.ai → **Settings → Connectors → Add custom connector**, enter the MCP URL:
`https://zoho-analytics-mcp-oauth.<subdomain>.workers.dev/mcp`
3. Claude.ai discovers OAuth automatically (dynamic client registration) and sends you to the consent screen. Enter your **passphrase** to authorize.
### Programmatic
Any HTTP MCP client works — send `Authorization: Bearer <MCP_AUTH_TOKEN>` and `Accept: application/json, text/event-stream` to `POST /mcp`.
---
## OAuth worker (Claude.ai web)
`zoho-analytics-mcp-oauth` is the same MCP server fronted by an OAuth 2.1 provider, gated by a single shared passphrase. Routes:
| Route | Purpose |
|-------|---------|
| `GET /` | health check (no auth) |
| `GET`/`POST` `/authorize` | passphrase consent screen |
| `/token`, `/register`, `/.well-known/oauth-*` | OAuth endpoints (handled by the provider) |
| `POST /mcp`, `GET /sse` | MCP transports, OAuth-protected |
Deploy alongside the bearer worker:
```bash
# One-time: create the KV namespace the provider needs, then paste its id into wrangler.oauth.jsonc
npx wrangler kv namespace create OAUTH_KV # → { "binding": "OAUTH_KV", "id": "<paste into kv_namespaces>" }
# Secrets (paste at the prompt; -c selects the OAuth worker):
npx wrangler secret put ZOHO_CLIENT_ID -c wrangler.oauth.jsonc
npx wrangler secret put ZOHO_CLIENT_SECRET -c wrangler.oauth.jsonc
npx wrangler secret put ZOHO_REFRESH_TOKEN -c wrangler.oauth.jsonc
npx wrangler secret put ZOHO_ORG_ID -c wrangler.oauth.jsonc
npx wrangler secret put APP_PASSPHRASE -c wrangler.oauth.jsonc
npx wrangler deploy -c wrangler.oauth.jsonc
```
`APP_PASSPHRASE` fails closed — if unset, the consent screen rejects every attempt. Then add the connector in Claude.ai per [Claude Web](#claude-web).
---
## Caveats
- **Bring your own OAuth app.** You need a registered Zoho client and a refresh token (see [Getting Zoho OAuth credentials](#getting-zoho-oauth-credentials)). `ZOHO_DC` must match the data center your account lives in, or token refresh fails.
- **Ad-hoc SQL is async.** Zoho runs ad-hoc SQL through a bulk export *job*. `zoho_query_data` hides this (create → poll → download) but bounds its wait (default 50s, cap 120s; Zoho jobs routinely take 30–90s); for very large result sets it returns a `job_id` to poll with `zoho_get_export_job`.
- **API units & frequency limits.** Calls consume daily API units and are subject to per-minute frequency limits (≈100 req/min overall). Batch tools use bounded concurrency, but heavy use can still hit quota errors (`6043`/`6044`/`6045`).
- **Large tool surface.** Full coverage means ~144 tools. That's a lot for one connector — if your client struggles with the count or you only need reporting, deploy with `MCP_READONLY=true` for the ~51 read tools. Advanced/long-tail CONFIG keys on the modeling, sharing, publish, and variable tools are passed through an `options` object (documented per tool) rather than enumerated as parameters.
- **OAuth worker: single-user by default, multi-user opt-in.** Out of the box the OAuth worker gates on one shared passphrase and acts with one worker-wide Zoho token — fine for a personal connector. Set `ZOHO_MULTI_USER=true` (with a *server-based* Zoho client whose redirect URI is `<origin>/zoho/callback`) to make the consent screen a real per-user Zoho login: each user's own refresh token is held in the encrypted OAuth grant, access-token and schema caches are namespaced per user, and the server acts as — and is rate-limited per — that user. Multi-user mode ships off and, while it has its own end-to-end consent + isolation tests, has not been exercised against a live multi-tenant org — validate it before relying on it.
- **Spec ambiguities.** A number of Zoho endpoints are inconsistent between their OpenAPI specs and their live API docs/SDK. Where they conflict, this client follows the **live docs + SDK**: email-schedule writes are workspace-scoped with no `/trigger` suffix; datasource paths are workspace-scoped and plural; share/remove-share are `/workspaces/{id}/share` with a per-view PUT for updates; `makeDefaultFolder` is PUT; `sortData` uses the CONFIG transport with column IDs. Remaining unverified niche path: `get_my_permissions` (`mypermissions` per OAS vs `userpermissions` per SDK). Verify niche endpoints with a live call.
- **Memory bounds.** Sync exports/downloads are refused above ~10MB (use the async CSV export and page it), imports above ~25MB (split the file), and template ZIPs above ~256KB (export fewer views) — Durable Objects have a 128MB memory ceiling and tool results land in an LLM context.
- **Dependency versions.** `agents@^0.14.5` + `@modelcontextprotocol/sdk@^1.29.0` + `zod@^4`; `npm audit` is clean. `ai`/`react` are required peers of `agents` (npm auto-installs them) but nothing on the `agents/mcp` server path imports them, so they never enter the Worker bundle.
---
## Project layout
```
src/
index.ts Bearer worker entry — routing, bearer gate, McpAgent/Durable Object, clientFromEnv
oauth.ts OAuth worker entry — OAuthProvider + passphrase consent + McpAgent
tools.ts registerTools() — 100+ tool definitions + Zod schemas + helpers (shared)
zohoanalytics.ts ZohoAnalyticsClient — dependency-free REST client (fetch-only): OAuth refresh,
CONFIG-param + envelope handling, DC routing, retry/backoff, mapLimit (shared)
tests/ vitest unit tests (client OAuth/retry/DC/CONFIG, helpers)
vitest.config.ts test config (resolves NodeNext .js specifiers to .ts)
scripts/smoke.mjs smoke test against a deployed worker (npm run smoke); auto-detects CORE/READONLY modes
scripts/live-test.mjs guarded LIVE write-path integration test (MCP_URL + MCP_TOKEN + LIVE_WRITE=1) —
creates a disposable table, exercises rows/SQL/dry-runs, cleans up via trash + purge
.github/workflows/ci.yml CI — typecheck + tests + syntax checks for both scripts on push/PR
.github/workflows/deploy.yml gated manual deploy (typecheck + tests -> wrangler deploy -> live smoke); needs CLOUDFLARE_API_TOKEN secret
wrangler.jsonc bearer worker config (zoho-analytics-mcp)
wrangler.oauth.jsonc OAuth worker config (zoho-analytics-mcp-oauth) — adds OAUTH_KV
package.json deps (SDK ^1.29.0, agents ^0.14.5, zod ^4, workers-oauth-provider, wrangler, vitest)
```
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available