The CleverTap REST API as MCP tools. Runs as a stdio MCP server behind a gateway that owns login, per-user authorization and auditing; deployment lives with our infrastructure manifests, not here.
34 tools — 18 reads (profiles, events, counts, trends, campaign/message reports, wallet/voucher/coupon lookups, remote config, category groups) and 16 writes (upload profiles/events/device tokens, create/stop campaign, send message, external trigger, delete/demerge profile, bulletins, remote config, custom-list segments, catalog upload, wallet debit/credit, coupon redeem/revert).
Extracted from an earlier HTTP service that carried the same tools. The tool logic moved across unchanged; transport, authentication and auditing were dropped on purpose — see What this server does not do.
⚠️ The 16 write tools have never run against a live CleverTap project. Production is deployed read-only until they are validated against integration — see the warning below.
| File | Purpose |
|---|---|
server.py |
stdio entry point: newline-delimited JSON-RPC 2.0 on stdin/stdout. No dependencies. |
clevertap_mcp/config.py |
Region, timeouts, credentials from the environment, CleverTap path maps. |
clevertap_mcp/client.py |
Authenticated HTTP against one CleverTap project, plus the async-poll and error-shape quirks. Read the comments before "simplifying" anything here — each one records a live-API surprise. |
clevertap_mcp/errors.py |
MCPError. |
clevertap_mcp/profiles.py |
Profiles, events, device tokens — 7 tools. |
clevertap_mcp/analytics.py |
Counts, trends, req_id polling — 6 tools. |
clevertap_mcp/campaigns.py |
Campaigns and outbound messaging — 8 tools. The blast radius lives here. |
clevertap_mcp/segments.py |
Remote config, custom lists, catalog — 6 tools. |
clevertap_mcp/loyalty.py |
Wallet, vouchers, coupons — 7 tools. |
clevertap_mcp/reference.py |
The clevertap://reference resource. |
clevertap_mcp/app.py |
Assembles the 34 tools from the five domain modules, and CleverTapMCPServer — tool listing, resource listing, dispatch. |
test_server.py |
python3 test_server.py — self-check, no framework, no network. |
Each domain module owns three exports, and that co-location is the point of the split: a tool's JSON Schema, its handler, and whether it is a write all live in one file, so adding a tool cannot silently skip the write classification.
TOOLS # list of tool-definition dicts, sent verbatim on tools/list
HANDLERS # {tool name: (env, args) -> (text, is_error)}
WRITES # the subset of tool names that mutate CleverTap stateAdding a tool: add the schema, the handler and (if it writes) the name to WRITES, in the
domain module. Adding a domain: write the module, add it to _DOMAINS in app.py. See
CONTRIBUTING.md — it opens with the one rule that matters here.
All configuration is environment variables. No credentials are committed to this repo, and none should be — in production they are injected from a secret store.
| Variable | Required | Meaning |
|---|---|---|
CLEVERTAP_ACCOUNT_ID |
yes | X-CleverTap-Account-Id |
CLEVERTAP_PASSCODE |
yes | X-CleverTap-Passcode |
CLEVERTAP_TOKEN |
no | X-CleverTap-Token — only needed by clevertap_trigger_external_campaign when triggering more than one campaign at once. Single-campaign triggers work without it. |
CLEVERTAP_ENV |
no | integration | production (default integration). Selects the label stamped onto every tool description so the model can see which project it is talking to. It does not select credentials. |
CLEVERTAP_TOOLS |
no | all | read | write (default all). Which slice of the 34 tools this process exposes. |
CLEVERTAP_IDENTIFIER_TYPES |
no | Comma-separated subset of identity, email, objectId (default: all three). Restricts which user-lookup fields get_user_profiles/get_wallet_details/messaging tools advertise, to match this account's Settings → User Identity config — CleverTap returns an opaque 503 for a disabled identifier type rather than a clear error. An unrecognised value falls back to all three. |
One process serves exactly one CleverTap project. Which project is reached is decided by the
credentials in the environment, not by CLEVERTAP_ENV — run one container per project.
Region is pinned to eu1 in config.py. CleverTap's own docs are inconsistent about whether
EU needs that prefix; it was confirmed against a live account that it does. Do not change it
to api.clevertap.com without re-testing.
Missing credentials are not fatal: the process still starts and reports Ready, and every tool returns a "not configured" error instead. That is so a server can be deployed before its credentials are provisioned.
CLEVERTAP_TOOLS selects which slice of the 34 tools a process serves. Out-of-scope tools are
absent from tools/list and refused by tools/call — checked before the credential
check, so a refusal never depends on configuration having landed.
| Mode | Tools | Use for |
|---|---|---|
all |
34 | Integration |
read |
18 | Production |
write |
16 | Production, later — see the warning below |
read and write partition the 34 exactly, with no overlap (asserted in
test_server.py). That is what lets a read instance and a write instance sit behind one
gateway without duplicate tool names: a reader sees 18 tools at that URL, someone granted both
sees all 34, and there is only one endpoint to maintain.
Each write tool also enforces its own confirm=true argument, but confirm is set by the
model, so it guards against the model being careless — not against prompt injection in data
the model reads (an Instagram comment, a CleverTap profile custom field). The mode is the
boundary that holds, because there is no confirm left for injected text to flip.
Same idea as a server-level READ_ONLY_MODE, which beats an external allow-list for a tool
surface this size — 16 names to deny here rather than 18 to enumerate there. Layer an
allow-list on top anyway if you want default-deny.
Write tools are annotated destructiveHint: true, which is what lets a gateway policy
recognise them — such a policy is a silent no-op without the annotation.
None of the 16 write tools has been executed against a live CleverTap project. They were written from CleverTap's API docs and moved across from the earlier service unchanged, but that service's production credentials were never configured, so no write has ever reached CleverTap from this code. The reads are exercised; the writes are not.
Consequences, and they are not theoretical — several of these are irreversible:
clevertap_send_message and clevertap_trigger_external_campaign put real push/SMS/email on
real devices, clevertap_wallet_debit_credit moves value, clevertap_delete_user_profile is
processed ~24h after the call and cannot be recovered.
Gate: production write access (CLEVERTAP_TOOLS=write) does not get deployed until someone
has run the write tools against the integration project and confirmed they behave. Until
then production runs CLEVERTAP_TOOLS=read only. When validating on integration, work through
the confirm=false dry-run path first — the upload tools run CleverTap's native dryRun=1
and clevertap_create_campaign forces estimate_only=true, so the payload can be checked
before anything is written.
Record what was verified here when it happens, so the next person knows which of the 16 are actually trusted rather than merely deployed.
set -x CLEVERTAP_ACCOUNT_ID <account-id>
set -x CLEVERTAP_PASSCODE <passcode>
set -x CLEVERTAP_TOOLS read
claude mcp add clevertap -- python3 (pwd)/server.pyOr drive the protocol by hand:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 server.pySelf-check (never touches the network — it asserts loudly if a code path tries):
python3 test_server.pyThere are none. No requirements.txt, no mcp SDK: server.py speaks newline-delimited
JSON-RPC 2.0 over stdio using only the standard library, ported from the earlier dispatcher.
That means nothing to fetch at pod start, nothing to CVE-patch, and no third-party package in
a process holding a CleverTap passcode.
It is a deliberate trade, not an oversight. What the official SDK would give us and this does
not: streamable-http transport, tools/list pagination cursors, request cancellation,
progress notifications, _meta propagation, params validation, OTel hooks, and someone else
owning MCP spec drift. None are needed today, and stdio bridged to HTTP is the well-trodden
path for the servers we run, not a workaround.
Assessed 2026-07-28. The Python SDK had just shipped a breaking major:
mcp 2.0.0 |
released 2026-07-28 — zero patch releases; 2.0.0rc1 was the day before |
mcp 1.29.0 |
released the same day — the 1.x line is maintained, not abandoned |
| Breaking changes in 2.0 | decorator API removed from Server; low-level moved to constructor callbacks (on_list_tools=, on_call_tool=); high-level FastMCP renamed MCPServer; dependency moved httpx → httpx2 |
Adopting a major version on release day, for a server that reaches production CleverTap, was
not a good trade. The trigger to revisit: once 2.0.x patch releases exist (realistically
2.0.2 or later, a few weeks of real-world use). Check with:
pip index versions mcpWhen that happens, reconsider migrating — the destination was never in doubt, only the timing. What to know going in:
- The migration is ~55 lines in one file. Only
server.pychanges. Nothing inclevertap_mcp/moves: the 34 tool dicts pass throughmcp.types.Tool.model_validate()unmodified,annotations.destructiveHintincluded (verified against 2.0.0). The transport is already isolated behindtools()/call_tool()/read_resource()— that is what the package split bought. - Use the low-level
Server, not the high-levelMCPServer. The high-level API infers schemas from Python type hints, which would mean rewriting all 34 hand-written JSON Schemas. The same applies to the TypeScript SDK: its low-levelServertakes raw JSON Schema, itsMcpServerwants Zod. - Pin exactly —
mcp==2.0.0, never a>=range. An unpinnedmcp>=1.0.0is exactly how the community CleverTap MCP servers broke: their code no longer imports against 2.0.0. Pin so the break happens when you bump it and run the tests, not on a 3am pod restart. - Pin only bites in a built image. With the
pip install-at-startup pattern a re-tagged or yanked release can still break a restart. Another reason to prefer theDockerfilepath. - Don't adopt 1.x. Deliberately writing against the superseded major is the worst option: obsolete API now and a forced migration later.
- The reason to go is streamable-http (
app.streamable_http_app()) — it drops the stdio↔HTTP bridge and gives the operator real readiness probes. If that is not what you need, staying dependency-free is still the right answer.
Rerun python3 test_server.py after any migration; the stdio subprocess test will need
rewriting to drive the SDK client instead.
An earlier version of this layer ended with HTTP routes plus its own OAuth 2.1 authorization server, a per-user allow-list in a database table, and an access-log insert per call. None of that is here, because the gateway in front of the process does all three better: login, per-user authorization scoped per person, short-lived tokens it never stores in plaintext, and an append-only audit trail that captures request payloads.
This process sees no network socket, no bearer token and no user identity. Keep it that way — if it ever needs to know who is calling, that belongs in the gateway.
Deployment manifests, credential provisioning and access control live with our infrastructure configuration, not here. This repo is just the server.
CleverTap supports up to 100 account-level passcodes with expiries, and User Passcodes granted per dashboard user (Settings > Passcodes / Settings > Users > Grant). The shared forever-passcode this server inherits is not the only option — a dedicated expiring passcode per deployment is strictly better, and per-user passcodes would give CleverTap-side attribution to match the gateway's audit trail. Open question for the CSM: do User Passcodes inherit the dashboard user's role permissions (i.e. can one be genuinely read-only)?