csa-zendesk
Provides a local stdio MCP server over the Zendesk REST API, targeting broad API coverage for ticketing, Help Center, and status operations, with policy-based capability gating and OAuth-authenticated access.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@csa-zendeskshow my open high-priority Zendesk tickets"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
csa-zendesk
project_tracker_base: CINO Project Tracker:appf7fRQUvY9Iy7sL
project_tracker_table: Projects:tblchmbxSAavvJKaY
project_tracker_record: csa-zendesk:recvtmgqPccgLvuXz
project_source: github:CloudSecurityAlliance-Internal/CINO-Projects/projects/CloudSecurityAlliance/csa-zendeskA Python library and local stdio MCP server over the Zendesk REST API, targeting 100% API coverage.
Status: Block 0 (foundations), Block 0b (OAuth), Block 0e (a read-only MCP server) and Block 1 (the write surface and attachments) are complete.
src/holds the typed error hierarchy, the error parser, the pagination guard, a transport with OAuth end to end (connect()), theBackendseam with an offlineFakeBackend, the fail-closed capability policy, and a thinZendeskClient.What exists:
csa-zendesk-mcp, a stdio MCP server at rung E2 — "work tickets for real:
note, write" — see Using the MCP server below. Ten tools: four reads (
get_ticket,search_tickets,list_comments,get_attachment), six writes (update_ticket,assign_ticket,add_internal_note,solve_ticket,upload_file,delete_upload), plus three auth-lifecycle tools (authenticate,auth_status,logout) that sit outside the capability model by design (ADR-017) so a user never has to leave the session to sign in or out.reply_publicly,merge_ticketsandclose_ticketare deliberately not registered — see "What does not exist" below.What has not been verified: any write, against a live ticket. The read path — OAuth, the MCP handshake, the allowlist failing closed, ticket text wrapped as untrusted data — was confirmed live against the real Zendesk tenant on 2026-09-21, from a separate, unmodified clone at
0.1.0. No tool in the write surface (update_ticket,assign_ticket,add_internal_note,solve_ticket,upload_file,delete_upload) has been exercised against live Zendesk — Block 1 was built and reviewed entirely offline, againstFakeBackendand mocked transports. The live end-to-end write walkthrough isTODO.mdF1 and happens after this branch merges, with a human present; do not read "Block 1 is complete" as "Block 1 is live-verified" — they are separate claims.What does not exist: reach, admin, and the rest of the B1–B5 track.
reply_publicly(public reply) andmerge_ticketsboth email a requester and are held for rung E5 (CSA_ZD_ALLOW_REACH);close_ticketis terminal (analysis/API-SURFACE.md§5.4d — Zendesk acceptsstatus: "closed"and never lets it change again) and is held back deliberately, not because it is unbuilt. No admin capability (ADMIN_READ/ADMIN_WRITE) is granted. Of the 54 tools in the whole-project design, ten are built; the rest is still to come. Do not describe any tool beyond those ten as working: the Scope table below is the coverage target this project is building toward, not the built surface.
Scope
Capability | Operations | 1.0.0 |
Ticketing | 640 | yes |
Help Center | 182 | yes |
Status | 3 | yes |
Voice (Talk) | 60 | post-1.0 |
Live Chat, Messaging, AI Agents, Custom Data, Sales CRM | no published spec | post-1.0 |
822 of 882 machine-readable operations at 1.0.0 — which is coverage of the published
specs, not of the API. The 60 deferred are all Voice. Status's three operations have no
published spec and are not in the inventory at all, so they sit outside both figures — which
is what made the earlier count wrong: 825 counted them in a numerator measured against a
denominator that excludes them. Guarded by scripts/check_counts.py.
The Help Center spec describes roughly 18 of ~30 documented families;
about a dozen, several of them live, have no spec entry and need hand-written methods. See
analysis/API-SURFACE.md §4b.
Related MCP server: agenzax-mcp
Why this exists
The third-party Zendesk MCP servers are thin. The most popular — 115★, actively maintained, genuinely well built — ships 7 tools. The largest ships ~47. Against 882 operations.
More consequentially, the whole field shares four weaknesses:
No capability policy. OAuth scopes, fixed at client registration, are the only control. Zendesk has 54 granular scopes, which gives a local fail-closed policy layer something real to bind to.
Offset pagination everywhere. Zendesk caps offset paging at 100 pages / 10,000 records and then returns HTTP 400. Large result sets truncate, and the tools present the truncation as an answer.
API-token auth. Zendesk permanently deactivates all API tokens on 2027-04-30, and no account can create a new one after 2026-10-27.
Pre-2.0 MCP SDK. Hand-rolled
list_tools/call_tooldispatch means no structured output and no tool annotations, so a client cannot tell a read from a destructive write.
Architecture
Third in the line after csa-skilljar
and csa-google-workspace, on the same spine:
Backend (Protocol) the seam - keyword-only args, returns raw upstream envelopes
^ wrapped by
PolicyBackend capability gating; FAILS CLOSED - an ungated method is refused
^ consumed by
ZendeskClient thin typed library surface (the public product)
^ consumed by
mcp/_tools/*.py per-family register_*(app, get_client) producersmcp/_tools/*.py is the target layout for the full 54-tool surface, not what exists today:
csa-zendesk-mcp (src/csa_zendesk/server.py) registers its thirteen tools flat, with no mcp/
package and no per-family producer modules yet.
Enforcement lives in the wrapper around the seam, not in the tools, so a library embedder gets the same guarantee an MCP client does.
What is here
Path | What |
| Three upstream OpenAPI snapshots + |
| Start here. The enumeration and the probe findings |
| 882 rows, one per operation |
| 49 live availability probes |
| Regenerates the inventory from |
| Re-runs the live probes (GET only) |
| Access audit: what the credential actually reaches |
| The design. Start here before proposing anything |
| The index of all open work. Start here for what is unfinished |
| Decision log index; entries in |
| Conditions with observable triggers; entries in |
Zendesk publishes the OpenAPI specs but links to none of them; all three were found by probing URL shapes. They are snapshots of someone else's moving target — re-fetch and diff before trusting them.
Findings that constrain the design
Six are recorded in analysis/API-SURFACE.md. The three that change how the code must be
written:
users/me.jsonreturns HTTP 200 with"name": "Anonymous user"when wholly unauthenticated. It serves anonymous Help Center visitors, so it degrades instead of refusing — and it is the endpoint every client uses as a credential health check. Validating against it reports healthy for a missing credential. Credential validation probes a resource endpoint and asserts a non-nulluser.id.Search takes offset pagination only and stops at 1000 results, while reporting a
countof six figures. A tool that surfacescountas if the caller could page to it is lying.search/exportis the cursor-paginated, uncapped alternative.Pagination cannot be generated from the specs. The Help Center spec declares paging on 0 of 96 GET operations, yet cursor paging demonstrably works. The generator must not read silence as "unpaginated".
Deliberate exclusions
Six families — 47 operations, 5% of the surface — are out of scope and will not be built: IT Asset Management, Group SLA Policies, Workspaces, Ticket Form Statuses, Audit Logs, and Help Center Service Catalog Items. The development account cannot reach them (403: a plan boundary), so they cannot be tested, and this project does not ship API code it has never called. See ADR-001 for the reasoning and WAITING-FOR-001 for what would reopen it.
This does not relax the error layer: plan boundaries differ per account, so any deployment can meet a 403 on an endpoint we did implement, and the taxonomy must say "your plan does not include this" rather than "this is broken".
Configuration
The library authenticates by OAuth and by nothing else (ADR-015).
HttpClient takes a token_provider callable and sends a Bearer header; there is no API-token
code path, no fallback, and no environment variable the library reads. A fallback that silently
activates when OAuth is misconfigured turns an auth failure into something that reads like a
permissions failure, which is the confusion the 401 handling goes out of its way to prevent.
OAuth client
Registered in Zendesk Admin Center (Apps and integrations › APIs › OAuth clients) as
csa-zendesk, 2026-09-18. Redirect URIs are the three loopback candidates the callback listener
binds, in order; the client's scope list is a ceiling, and what a token actually receives is
whatever CSA_ZENDESK_SCOPES requests within it.
Local variable | Zendesk's own label | What it is |
| Subdomain | the |
| Identifier | the OAuth |
| Secret | issued to every client regardless of kind. Retained, unused — see API-SURFACE §7.3 |
| scope (request) | space-separated, defaults to |
| — | override for the |
Why the names are long. They are local names, not vendor names, and they are explicit on purpose: one machine runs many CSA projects against many vendors, so a variable has to say which project, which role, and which vendor without context. Where a name maps to something an operator reads off a vendor screen, it takes the vendor's own label for the last segment — Zendesk calls the client id the Identifier, so the variable does too, and nobody has to translate while looking at the form.
Registered ceiling: read tickets:write ticket_attachments:write ticket_views:write triggers:write (corrected 2026-09-21 — a live screenshot showed triggers:write was already on
the client and missing from this line; see analysis/API-SURFACE.md §7.2b). triggers:write is
not requested by anything this project ships at rung E2; it sits on the ceiling unused until an
admin-configuration tool needs it. impersonate is deliberately absent — it is the one scope that
would break the invariant that this tool can do nothing in Zendesk that its operator could not
already do.
Getting a token
OAuth needs a browser, and that is not a limitation to engineer around. The consent screen is where a human proves who they are, and on an account with passkeys, biometrics or an SSO step that is the point — there is no headless path that preserves it, and inventing one would mean asking for a weaker credential than the account deserves. So: a browser is a requirement, stated once here rather than apologised for repeatedly below.
What --paste changes is which machine the browser is on, not whether there is one. On a
remote shell it prints the URL, you complete sign-in in the browser on your own laptop — passkey
and all — and paste the redirect back. The browser is still doing the work.
csa-zendesk (src/csa_zendesk/cli.py) is a small console script, a door into OAuth rather than
a product: auth login runs the flow once and persists the result — opening a browser, or with
--paste printing a URL for a browser elsewhere to finish —
auth status reports whether a token file exists, its path, its expiry and its granted scope
without a network call, auth whoami confirms live which Zendesk identity it resolves to, and
auth logout revokes the stored token server-side and then clears the local file. All four print
human-facing text to stderr except whoami's and status's own answer, which goes to stdout
since either might reasonably be piped; none of the four can print the token itself.
Token lifetimes are requested at their documented maxima, on every login and every refresh:
expires_in at 172,800 seconds (2 days) and refresh_token_expires_in at 7,776,000 seconds (90
days) — the ceilings Zendesk's OAuth token endpoint documents, not arbitrary choices (see
_flow.MAX_ACCESS_TOKEN_LIFETIME_SECONDS / MAX_REFRESH_TOKEN_LIFETIME_SECONDS). Both fields are
resent on every refresh, not just at login, because Zendesk rotates the refresh token on every use
(single-use, confirmed against the live tenant): re-requesting the maximum each time makes the
90-day window slide forward instead of shrinking back to Zendesk's 30-day default on first refresh.
This is deliberately paired with auth logout: both tokens already live in the same 0600 file, so
a short access-token lifetime buys nothing against file theft while costing a refresh every 30
minutes instead — maximising lifetimes without a real revoke path would be careless (TODO.md E11,
E15). auth logout revokes the access token via DELETE /api/v2/oauth/tokens/current; this also
invalidates the paired refresh token — not stated by Zendesk's API spec, but confirmed 2026-09-19
against the live tenant (TODO.md E20, analysis/API-SURFACE.md §7.4). This is what makes the
maximal lifetimes above defensible: a stolen token file does not survive a logout.
The research scripts under scripts/ — zd.py, ui_actions.py, probe_families.py,
probe_access.py — which refresh analysis/ and ship in no package, authenticate the same way
as everything else: OAuth, through the token file above (ADR-009),
using the same CSA_ZENDESK_SUBDOMAIN and CSA_ZENDESK_MCP_SERVER_IDENTIFIER variables. ./.env
is not a credential source for anything in this repo (ADR-015) —
the interim API-token path (CINO_CSA_ZENDESK + CINO_CSA_ZENDESK_EMAIL, basic auth as
EMAIL/token:TOKEN) was removed once the scripts were ported off it. An operator's old token may
still physically sit in a local ./.env; nothing here reads it, and removing it is the operator's
own call.
export CSA_ZENDESK_SUBDOMAIN=<subdomain>
export CSA_ZENDESK_MCP_SERVER_IDENTIFIER=<client-id>
csa-zendesk auth login # once, per operator - opens a browser
python3 scripts/inventory.py # 882 operations
python3 scripts/probe_families.py # 43/49 families reachable (as last measured, under the API-token path)Using the MCP server
This rung is E2 — "work tickets for real: + note, write." csa-zendesk-mcp (the console
script src/csa_zendesk/server.py registers) exposes thirteen tools — ten that touch ticket
data, plus the three auth-lifecycle tools (authenticate, auth_status, logout). The ten:
four reads — get_ticket,
search_tickets, list_comments, get_attachment — and six writes — update_ticket,
assign_ticket, add_internal_note, solve_ticket, upload_file, delete_upload — and
connects with TICKET_READ, TICKET_WRITE, TICKET_NOTE, TICKET_SOLVE and TICKET_ATTACH
(server.E2_CAPABILITIES), nothing more. reply_publicly, merge_tickets and close_ticket
are not registered at all — a tool the model can see but must not use is worse than one that is
simply absent — so no capability grant here can reach them. policy.py's gate refuses any
capability E2_CAPABILITIES does not grant, independent of what the tool table lists.
get_attachment is a read, not a write, even though it is documented here and not above: it
gates on TICKET_READ, the same capability rung E1 already grants, so it works at E1 too —
reading a ticket's attachments is no more privileged than reading the ticket itself. It is also
scoped by neither allowlist: an attachment is named by attachment_id, not ticket_id, so
there is no ticket for CSA_ZD_ALLOWLIST_READ to check against — only the capability gates it.
The server extra is not installed by default — the library itself has no dependency on the
MCP SDK, so a consumer who only wants the typed ZendeskClient never pulls it in:
pip install -e '.[server]'E2 needs two things E1 did not, and neither is guessable
1. The token must carry write scope, and the client must be re-authenticated. A token
minted for E1 requested only read. Every one of the six write tools calls a PUT/POST/
DELETE endpoint, and a read-scoped token fails all of them with a plain 403 — which reads
exactly like a permissions misconfiguration, not like "this token was never asked for write."
Widen CSA_ZENDESK_SCOPES before re-running login:
export CSA_ZENDESK_SCOPES='read tickets:write ticket_attachments:write'
csa-zendesk auth login # re-run — a wider scope only takes effect on a fresh granttickets:write covers update_ticket/assign_ticket/add_internal_note/solve_ticket (all
PUT /api/v2/tickets/{id}, constrained per-tool by tools.py, not by the scope);
ticket_attachments:write covers upload_file/delete_upload (/api/v2/uploads). Both are
within the OAuth client's registered ceiling (read tickets:write ticket_attachments:write ticket_views:write triggers:write — see OAuth client above); requesting
anything outside that ceiling fails closed with 400 invalid_scope at login, not silently.
auth_status reports the token's granted scope with no network call — check it after
re-running login if a write still 403s.
2. CSA_ZD_ALLOWLIST_WRITE must name the ticket ids writes may touch, and unset permits
nothing. Exactly the same shape as CSA_ZD_ALLOWLIST_READ at E1, and the same failure mode
that was a Critical finding in Block 0e: unset does not mean unrestricted, it means every
write is refused with a PolicyError and no clue why, since the token, the capability grant and
the tool registration are all otherwise correct. Set it explicitly — * to permit every ticket,
or a comma-separated list of ticket ids to scope this install narrowly (the safer default for a
write-capable install, unlike the read side's usual *-for-triage posture):
export CSA_ZD_ALLOWLIST_WRITE='<ticket-id>,<ticket-id>'upload_file, delete_upload and get_attachment are not scoped by either allowlist —
none of the three takes a ticket_id (an upload is not yet attached to any ticket; an
attachment is named by its own id) — so CSA_ZD_ALLOWLIST_WRITE governs exactly the other four
write tools, the ones that act on a named ticket.
Know what that means for get_attachment before you rely on the allowlist. An install
pinned to one ticket can still read the content of any attachment in the tenant, because an
attachment_id does not say which ticket it belongs to and this server does not go looking.
Attachments are where the sensitive material usually is, so this is the one place the allowlist
does not deliver what it otherwise does. It is not a hole in a security boundary — the real
boundary is the OAuth token's own scope, and anyone holding this credential could open the same
attachment in the Zendesk UI by hand — but it is a hole in the blast-radius narrowing that is
the whole reason to set an allowlist. There is no setting that turns this one tool off — E2_CAPABILITIES is fixed in
the code — so if it matters for your install, the only remedy available today is not to grant
this server the credential. TODO.md G3 tracks the decision about scoping it properly.
One further thing, which changes the size of this rather than its shape: get_attachment
returns a content_url, and Zendesk attachment content URLs are fetchable without
authentication unless the tenant has enabled "require authentication to download
attachments" (it is off by default). So the exposure is not only "whoever holds this
credential can read any attachment" — it is that the server emits a URL anything else with
sight of the model's context can fetch, outside the credential entirely. Check that tenant
setting before relying on this rung.
CSA_ZD_ALLOWLIST_READ is still not optional (unchanged from E1): unset means nothing is
permitted for get_ticket/list_comments, even though search_tickets and get_attachment
(neither carries a subject_var) work regardless. Set both allowlists explicitly rather than
relying on this asymmetry.
Then register the server with Claude Code. The registration name is csa-zendesk — a
different namespace from the executable, matching the rest of this fleet (csa-google-workspace,
csa-skilljar, customer360, firecrawl — none carries an -mcp suffix) — and it is what
prefixes every tool the model sees, so get_ticket shows up as mcp__csa-zendesk__get_ticket.
-s user registers it for every session rather than binding it to one project directory —
without it (the default, local scope), running this from inside a git worktree resolves to the
worktree's parent repository, so the server registers against a path you didn't type and never
shows up in the session you're working in:
claude mcp add csa-zendesk -s user \
-e CSA_ZENDESK_SUBDOMAIN=<subdomain> \
-e CSA_ZENDESK_MCP_SERVER_IDENTIFIER=<client-id> \
-e CSA_ZD_ALLOWLIST_READ='*' \
-e CSA_ZD_ALLOWLIST_WRITE='<ticket-id>,<ticket-id>' \
-- /abs/path/to/csa-zendesk/.venv/bin/csa-zendesk-mcpUse an absolute path to the installed csa-zendesk-mcp executable, not the bare command
name — from a source checkout it lives in that checkout's own venv, and a bare name resolves
through PATH, which may find a different install or none at all. The equivalent
claude_desktop_config.json stanza (the JSON key is the registration name, csa-zendesk, not
the executable):
{
"mcpServers": {
"csa-zendesk": {
"command": "/abs/path/to/csa-zendesk/.venv/bin/csa-zendesk-mcp",
"env": {
"CSA_ZENDESK_SUBDOMAIN": "<subdomain>",
"CSA_ZENDESK_MCP_SERVER_IDENTIFIER": "<client-id>",
"CSA_ZD_ALLOWLIST_READ": "*",
"CSA_ZD_ALLOWLIST_WRITE": "<ticket-id>,<ticket-id>"
}
}
}
}CSA_ZENDESK_SCOPES (see the OAuth client table above) is read at authenticate
time — _cmd_authenticate, defaulting to read — which is why widening it and re-running
auth login (above) is a step of its own, not something this registration triggers on its own.
There is no separate login step to run first. authenticate, auth_status and logout are
themselves tools, reachable from inside the session at every rung — including before this one
has a working credential — so a user who is logged out, or whose credential has lapsed, fixes it
from inside Claude Code: the server's own instructions tell the model to call authenticate the
moment another tool reports it is not authorized.
The one case that leaves the session is a machine where no browser can open — a container, a
bare remote shell. authenticate cannot offer the --paste path, because reading pasted input
means reading sys.stdin, which under stdio MCP is the JSON-RPC channel itself. Run
csa-zendesk auth login --paste in a terminal instead and finish sign-in in a browser wherever
you have one; the CLI and the server read the same credential file, so the session picks it up
with no further action. logout sits alongside them
rather than being left to the CLI, per ADR-017 — a surface that can
acquire a credential must also expose a way to relinquish it, reachable at least as easily as the
tool that acquires it.
Verify the install worked before relying on it: ask the model to call auth_status (confirms
a token is on disk, with its expiry and granted scope, no network call — check the scope here
first if a write is about to 403), then get_ticket on a ticket id you know exists. A
PolicyError naming CSA_ZD_ALLOWLIST_READ or CSA_ZD_ALLOWLIST_WRITE at that step, or at a
write, means the corresponding allowlist above is still unset or too narrow — set it and retry
the same call before assuming anything else is wrong. A plain 403 on a write (not a
PolicyError) means the token itself lacks the scope — re-check auth_status's reported scope
against the two above.
Uploading and attaching a file — two steps, not one
upload_file alone does not put a file on a ticket. It sends the file's bytes to Zendesk and
gets back a token naming bytes that exist on Zendesk's side attached to nothing — invisible
everywhere else in this server's surface, including get_ticket and list_comments on the
ticket you meant to attach it to. Calling only upload_file and stopping looks like it did
nothing, because from the ticket's point of view it did. The file becomes visible on a ticket
only on a second call, add_internal_note(ticket_id=..., uploads=[token]), which carries the
token onto the ticket as a comment attachment. delete_upload(token=...) exists specifically to
clean up a token from a failed or abandoned first step — an unattached upload is litter nothing
else in this surface will ever show you, so without a deliberate delete_upload call it stays on
Zendesk's side indefinitely.
upload_file's bytes travel over MCP as content_base64 — a base64-encoded string, not raw
bytes or a file path — because MCP tool arguments are JSON, which has no binary type. Decoding
uses validate=True, so malformed base64 is refused with an error rather than silently decoding
to truncated or empty bytes. upload_file also refuses empty content outright, which is not the
ordinary empty-write refusal every other write tool has: Zendesk accepts a zero-byte upload and
hands back a real, usable-looking token for it, so without this refusal the failure would be
silent — a token that names an attachment which downloads as nothing.
html_body is Markdown, not HTML
Every tool whose result can carry a ticket or comment envelope converts Zendesk's html_body
field to Markdown before it reaches a model (_markdown.to_markdown, wired in at the
Backend seam — DEC-018): the three reads (get_ticket, list_comments, search_tickets) and,
since the final whole-branch review, the four writes too (update_ticket, assign_ticket,
add_internal_note, solve_ticket) — each of those returns a TicketUpdateResponse envelope
whose audit trail can carry a fresh html_body authored by a trigger or automation firing on
that very update, not just by this call's own note. It is read from html_body, never Zendesk's
own body field: Zendesk's plain-text rendering is a naive tag strip that keeps CSS-hidden text
as ordinary prose while discarding the CSS that would have revealed it was hidden, which is
strictly worse than converting the HTML ourselves. A comment's Markdown sits under the same
html_body key as before — the type changed, the key did not — and it is still wrapped as
untrusted data by _untrusted.py exactly like every other requester-authored string.
A sibling hidden_text key appears beside html_body only when a comment contained text a
reader would not see — an element hidden by an inline display:none, visibility:hidden,
zero size, zero opacity, or an off-canvas position. That text is removed from the Markdown and
surfaced separately rather than silently dropped or emitted as ordinary prose, so concealment
itself is the signal a caller gets to act on. When a comment has no concealed text, there is no
hidden_text key at all — it is never present-but-empty.
KNOWN GAP: white-on-white hidden text is not detected. Whether a piece of text visually
matches its own background needs the ancestor chain's resolved background — the CSS cascade —
and a rule that only inspects one element's own style attribute cannot see it. This is a real,
open gap, not a footnote: a ticket comment can hide text by setting color to match an ancestor's
background-color and this server will not catch it. Pinned by a test so it stays a visible,
tracked absence rather than a silent one.
KNOWN LIMIT: the observed zero-width-signature attack (a name with U+200C interleaved through
it) is NOT defanged. U+200C (ZWNJ) is semantic in Persian — میرود ("mi-ravad") and میرود are
different words — and is required for Indic conjuncts and Arabic letter shaping, so it cannot be
stripped unconditionally without breaking real text in those scripts. Catching that specific
attack needs a density-based detector (how often the character recurs relative to ordinary use),
which is deliberately out of scope for this block.
No homoglyph or mixed-script detection, and this is a decision with a reason, not an oversight: a rule that flags mixed scripts within a word also flags Indigenous orthographies (Musqueam contains a Greek theta, because IPA-derived characters are its standard written form) and IPA transcriptions — measured at 6 of 8 false positives against legitimate fixtures.
Only inline style attributes are inspected for hidden content. A <style> block's
selectors need a cascade to resolve against the document, which this module does not build —
under-reporting concealment there is the honest failure direction, not a claim that no such
ticket exists.
Also not in this block: classification hooks, attachment content reading, and any alerting on concealed or suspicious text. This block converts and reports; it does not decide what to do about what it finds.
Fleet decisions
Which cross-project decisions this server has adopted, which it has not assessed, and the two places it deliberately deviates — with the reasoning and what would bring it back — are in DECISIONS-INHERITED.md. Decisions this project made for itself are in DECISIONS-ADR/.
Development
python3 -m venv .venv
./.venv/bin/pip install -e '.[dev,server]'server is an optional extra ([project.optional-dependencies]), not a hard dependency — the
library stays importable without the MCP SDK. Install it anyway in a dev environment: it backs
src/csa_zendesk/server.py (the csa-zendesk-mcp console script), and
tests/test_public_api.py's import-time stdout guard imports every module in the package,
server.py included, so the test suite fails to collect without it.
./.venv/bin/pytest --cov=csa_zendesk --cov-fail-under=100 -q
./.venv/bin/ruff check .
./.venv/bin/ruff format --check src tests
./.venv/bin/mypy --strict src
python3 scripts/check_public_safe.py
python3 scripts/check_boundaries.pyruff check . lints the whole tree, not a directory list. ruff check src tests scripts
once left tracked-but-unnamed experiments/ unlinted, hiding a NameError in all three scripts
that write to a live ticket. Anything that needs to be exempted from lint or format is an
exclusion in pyproject.toml, where it is reviewable — not an absence from the command line.
check_boundaries.py fails if a tool is not bucket-pure: every operation shared by more than one
tool must carry a real constraint distinguishing them (ADR-016).
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Read tickets, users, orgs, macros and satisfaction ratings; create, update and comment on tickets.
Read tickets, contacts, companies, agents and groups; create, update and reply to tickets.
Zendesk MCP Pack — tickets, users, organizations via OAuth.
Read incidents, services, teams, on-call schedules; acknowledge, resolve and note incidents.
Related MCP Servers
AlicenseAqualityBmaintenanceEnables any MCP-compatible agent to query a local OpenRhyme activity timeline, search history, and issue control commands over stdio while keeping all data on-machine.5MIT- AlicenseAqualityBmaintenanceEnables MCP clients to connect to Agenzax's REST API over stdio, providing tools for messaging, session management, and review-mode oversight.18303 npmMIT
- FlicenseAqualityCmaintenanceEnables local, deterministic management and search of point-of-sale support data—branches, terminals, incidents, and incident history—with read-only queries and mutating tools over stdio using simulated SQLite data.11-
- AlicenseNot gradedqualityBmaintenanceEnables role-gated management of ops desk contacts, cases, and tasks via MCP, with tool schemas and availability filtered per role to enforce permission boundaries, over stdio or Streamable HTTP.MIT