Add Zendesk Support ticketing integration - #225
Conversation
Expose tickets, comments, users, organizations, views, and Help Center articles through Switchboard so agents can triage customer support without leaving the MCP workflow. Co-Authored-By: Crush <noreply@anthropic.com>
Keep the Zendesk adapter on current main so CI can run against a clean, up-to-date branch. Co-Authored-By: Crush <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid new adapter — wiring, dispatch/compaction/markdown parity, and auth dual-path all look clean, and CI is green (build/test/lint/security/compose/rust-sdk). Three things worth tightening before this hits real tenants, mainly around search query shaping and create/update payloads.
| return mcp.ErrResult(err) | ||
| } | ||
| if !strings.Contains(query, "type:") { | ||
| query = "type:ticket " + query |
There was a problem hiding this comment.
strings.Contains(query, "type:") will false-positive on free-text that just happens to include that substring. Something like prototype: broken login skips the type:ticket prefix and searches across users/orgs too, which is the opposite of what this tool promises.
A token-aware check is safer:
hasType := false
for _, tok := range strings.Fields(query) {
if strings.HasPrefix(tok, "type:") {
hasType = true
break
}
}
if !hasType {
query = "type:ticket " + query
}Worth a regression case in TestSearchTickets for a query containing prototype:.
There was a problem hiding this comment.
Fixed in 92f1389: hasTypeFilter now tokenizes on whitespace and only matches type: prefixes, so prototype: broken login still gets type:ticket.
| if tags != "" { | ||
| ticket["tags"] = splitCSV(tags) | ||
| } | ||
| if customFieldsRaw != "" { |
There was a problem hiding this comment.
custom_fields only accepts a JSON string. When an LLM (or script) passes a native array — which is the natural shape for [{id, value}, ...] — r.Str fails with a type error and the whole create/update bails.
Other adapters that take structured blobs usually accept both string and already-parsed values. Something like:
if raw, ok := args["custom_fields"]; ok && raw != nil {
switch v := raw.(type) {
case string:
if v != "" {
if err := json.Unmarshal([]byte(v), &fields); err != nil {
return nil, fmt.Errorf("invalid custom_fields JSON: %w", err)
}
}
case []any:
fields = v
default:
return nil, fmt.Errorf("custom_fields must be a JSON array or string")
}
ticket["custom_fields"] = fields
}There was a problem hiding this comment.
Fixed in 92f1389: parseCustomFields accepts a JSON string or a native array/object so LLM/script payloads no longer 400 on type conversion.
| ticket["type"] = typ | ||
| } | ||
| if requesterID != "" { | ||
| ticket["requester_id"] = requesterID |
There was a problem hiding this comment.
requester_id / assignee_id / group_id / organization_id are typed as integers in the Zendesk Ticket API, but we always ship them as JSON strings via r.Str. Most tenants coerce, but some requests 422 on the type mismatch — especially once IDs come back as numbers from a prior tool call and we re-stringify them.
Prefer parsing to int64 when present so the outbound body matches the schema:
if requesterID != "" {
id, err := strconv.ParseInt(requesterID, 10, 64)
if err != nil {
return nil, fmt.Errorf("requester_id: %w", err)
}
ticket["requester_id"] = id
}Same idea for assignee/group/org (and the empty-assignee unassign path can stay nil).
There was a problem hiding this comment.
Fixed in 92f1389: optionalIntID now emits integer IDs for requester/assignee/group/organization so the outbound ticket body matches the Zendesk schema.
Keep type:ticket from matching free-text like prototype, send numeric IDs as integers, and accept native custom_fields arrays so create and update requests match the Zendesk API. Co-Authored-By: Crush <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Nice adapter — dispatch/compaction/markdown parity, dual auth, and the follow-up hardening for search + ticket payloads all look solid. CI is green across build/test/lint/security/compose/rust-sdk. Two small LLM-input edges on public and tags inline; neither is blocking.
| body := r.Str("body") | ||
| public := true | ||
| if v := r.Str("public"); v != "" { | ||
| public = v != "false" |
There was a problem hiding this comment.
public is parsed as a string and only treated as private when it equals exactly "false". A native bool works because ArgStr lowercases it, but values like "False", "FALSE", "0", or "f" all stay public — the opposite of what the agent intended for an internal note.
r.Bool / mcp.ArgBool already accept those forms. Presence-check + Bool keeps the default-true behavior:
public := true
if _, ok := args["public"]; ok {
public = r.Bool("public")
}Same pattern would help ticketPayload's public / verified fields below.
There was a problem hiding this comment.
Fixed in 4884e90: public and verified now use r.Bool with a presence check, so False/FALSE/0/f become private notes as intended.
| ticket["organization_id"] = id | ||
| } | ||
| if tags != "" { | ||
| ticket["tags"] = splitCSV(tags) |
There was a problem hiding this comment.
tags only comes through r.Str + splitCSV, so a native array (the shape tickets already return) fails the type conversion and aborts create/update.
r.StrSlice accepts CSV strings and []string/[]any the same way Datadog/GitHub do for labels/tags:
if tags := r.StrSlice("tags"); len(tags) > 0 {
ticket["tags"] = tags
}Worth doing in userPayload too so update-user doesn't hit the same wall.
There was a problem hiding this comment.
Fixed in 4884e90: tickets and users now take r.StrSlice("tags"), so native arrays and CSV both work.
Parse public/verified with Bool so False/0/f become internal notes, and accept tag slices as well as CSV so create/update matches the shapes tickets already return. Co-Authored-By: Crush <noreply@anthropic.com>
Bring HubSpot and other main updates onto the Zendesk branch so the PR stays mergeable. Co-Authored-By: Crush <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid Zendesk adapter — dual auth, dispatch/compaction/markdown parity, and the earlier search/payload hardening all look good. CI is green across build/test/lint/security/compose/rust-sdk. One non-blocking edge on clearing tags inline.
| ticket["organization_id"] = id | ||
| } | ||
| if len(tags) > 0 { | ||
| ticket["tags"] = tags |
There was a problem hiding this comment.
update_ticket advertises tags as replacing the existing set, but we only send tags when len(tags) > 0. An explicit clear (tags: [] or tags: "") gets dropped, so the previous tags stick around — same shape as assignee_id empty-to-unassign, just without the presence check.
if _, present := args["tags"]; present {
if tags == nil {
tags = []string{}
}
ticket["tags"] = tags
}Worth the same treatment in userPayload a few lines down.
There was a problem hiding this comment.
Fixed in c41c0a0: ticket and user payloads now send tags whenever the argument is present, including empty lists, so an explicit clear replaces the existing set.
Keep Zendesk alongside Intercom and Okta so the branch stays current with main. Co-Authored-By: Crush <noreply@anthropic.com>
Honor explicit empty tags on ticket and user updates so agents can replace the existing set instead of leaving stale tags in place. Co-Authored-By: Crush <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
PR #225 Review: Add Zendesk Support ticketing integration
Verification (existing CI)
| Check | Result |
|---|---|
| build | Pass |
| test | Pass |
| lint | Pass |
| security | Pass |
| rust-sdk | Pass |
| compose | Pass |
What'''s Good
- Clean hexagonal adapter: unexported struct, exported
New(), dual auth (OAuth + API token Basic), dispatch map with full parity tests - Compaction + markdown coverage for document-shaped tools, shared
mcphelpers throughout - Earlier review rounds landed solid hardening (token-aware
type:filter, int IDs, native tags/custom_fields, empty-tag clears) with regression tests
Must Fix (Blocking)
No issues found
Should Fix (Non-Blocking)
No issues found
Consider (Nice to Have)
- Tool param copy still says tags are comma-separated only; handlers also accept native arrays — a one-line description tweak would match behavior
subdomainis required in the UI credential schema even whenbase_urlalone is enough for Configure (test/proxy setups)
Clean PR — CI is green across build/test/lint/security/compose/rust-sdk and I didn'''t find any blocking issues. LGTM.
Summary
Test plan
go test ./integrations/zendesk/)make ci(build, vet, race tests, lint, gosec); govulncheck still reports existing Go 1.26.5 stdlib findingssearch/executeforzendesk_search_tickets