# =zendesk — Zendesk Support (community MCP: KalchevS/zendesk-mcp).
#
# Written against the REAL wire shapes of the community server
# KalchevS/zendesk-mcp (verified against mcp_server.py TOOLS list +
# tools.py/tools_extra.py source, 2026-06-03):
#
#   search_tickets(query, page=1, per_page=25, resolve_names=true) returns:
#     { "page": N, "per_page": N, "total": N, "returned": N,
#       "items": [ { id, subject, status, priority, requester_id,
#                    assignee_id, organization_id, tags, created_at,
#                    updated_at, url, requester_name, assignee_name }, ... ],
#       "truncated"?: bool, "hint"?: str }
#     i.e. the ticket array is at `.items` (NOT `.tickets` / `.results`),
#     and EACH item carries an absolute agent URL at `.url`.
#
#   get_ticket(ticket_id) returns { "ticket": { id, subject, description,
#     status, priority, type, requester_id, requester_name, assignee_id,
#     assignee_name, organization_id, tags, custom_fields, created_at,
#     updated_at, url, comments? } }  — single object under `.ticket`.
#
#   search_users(query, page=1, per_page=25) returns
#     { page, per_page, total, returned, users: [ {id,name,email,role,...} ] }.
#
#   On error every tool returns { "error": { type, message, hint } } — the
#   defensive unwrap below tolerates that (yields an empty row set).
#
# Pagination arg is `per_page` (1..100, default 25), NOT `limit`. Bolt's
# {{limit}} (1..50) is mapped onto per_page in every call.
#
# ─────────────────────────────────────────────────────────────────────────
# ADMIN SETUP REQUIRED (one-time, org-wide) — this is a self-hosted community
# server, so it will not work until an operator completes ALL of the below:
#   1. Deploy KalchevS/zendesk-mcp (https://github.com/KalchevS/zendesk-mcp)
#      and register it in the org MCP catalog under the id `zendesk` so the
#      `requires:` gate and `mcp:` refs below resolve.
#   2. Configure the server's ZD_SUBDOMAIN (your Zendesk account subdomain,
#      i.e. the `yourco` in yourco.zendesk.com) plus its OAuth credentials
#      (ZD_OAUTH_*) — see auth_profile_ref `zendesk_user`.
#   3. Set the tenant config value `org.zendesk_subdomain` (used ONLY to build
#      the agent "Open in Zendesk" deep-link below). See the # ADMIN comment on
#      url_template.
# Once 1-3 are done this manifest functions org-wide; hence enabled: true.
# ─────────────────────────────────────────────────────────────────────────
schema_version: 1
id: zendesk
chip: =zendesk
title: Zendesk Support
icon: life-buoy
description: Search Zendesk support tickets, look up a ticket by number, and find users.
placeholder_examples:
  - "12345"
  - "billing"
  - "status:open priority:high"
  - "anything in a ticket subject or description"
emits: Issue
auth_profile_ref: zendesk_user

requires:
  - mcp: zendesk
    version: ">=1"
    tools:
      - search_tickets
      - get_ticket
      - search_users
    scopes:
      - tickets.read

# MCP/network backend → confirm dispatch (don't hammer Zendesk on every
# keystroke). The default scope (your open tickets) always auto-loads; a bare
# ticket number auto-fires a direct lookup; free text searches on Enter.
dispatch:
  mode: confirm
  confirm_label: 'Search Zendesk for "{{query}}"'
  confirm_hint: Press Enter to search Zendesk tickets
  auto_when:
    - regex: '^[0-9]{1,12}$'
      reason: A bare ticket number is an unambiguous, cheap direct lookup.

scopes:
  # DEFAULT: your open tickets, most-recently-updated first, top ~{{limit}}.
  # query is a fixed, identity-anchored Zendesk search expression built from the
  # session user's own email — no user free-text is interpolated here, so there
  # is no search-DSL injection surface.
  - id: my_open
    label: My open tickets
    default: true
    flow:
      - call:
          kind: mcp
          mcp: zendesk
          tool: search_tickets
          args:
            query: 'type:ticket assignee:{{user.email}} status<solved'
            per_page: '{{limit}}'
            resolve_names: true
        out: r
      # Normalize whatever envelope the dispatcher/MCP produces
      # ({items:[...]}, an {error:{...}} object, or a bare array) into flat,
      # render-ready Issue rows. `?` suppresses index-on-non-array errors.
      # Flat top-level keys (statusName, requesterName, …) back the list + card;
      # the nested `fields` object backs canonical-Issue list paths.
      - transform: &tickets_to_rows |
          ((.r.structured_content?.data? // .r.structured_content? // .r) // {}) as $p
          | ( ($p.items?)
              // ($p.tickets?)
              // ($p.results?)
              // ($p.nodes?)
              // ($p | if type == "array" then . else empty end)
              // ($p.ticket? | if . == null then empty else [.] end)
              // ($p | if (type == "object" and has("id") and has("subject")) then [.] else empty end)
              // [] ) as $tix
          | ($tix | map(
                . as $t
                | (($t.tags // []) | join(", ")) as $tags
                | ([ ("#" + ($t.id|tostring)),
                     ($t.status // empty),
                     ($t.priority // empty),
                     ($t.assignee_name // empty) ]
                    | map(select(. != null and . != "")) | join("  ·  ")) as $subline
                | {
                    id: ($t.id // ""),
                    key: ("#" + (($t.id // "") | tostring)),
                    _url: ($t.url // ""),
                    subject: ($t.subject // "(no subject)"),
                    subline: $subline,
                    statusName: ($t.status // "—"),
                    priorityName: ($t.priority // "—"),
                    typeName: ($t.type // "—"),
                    requesterName: ($t.requester_name // "—"),
                    assigneeName: ($t.assignee_name // "Unassigned"),
                    orgId: ($t.organization_id // "—"),
                    tags: (if $tags == "" then "—" else $tags end),
                    created: ($t.created_at // "—"),
                    updated: ($t.updated_at // "—"),
                    description: ((if ($t.description | type) == "string" then $t.description else "" end)
                                  | if length > 280 then (.[0:277] + "…") else . end
                                  | if . == "" then "No description" else . end),
                    fields: {
                      summary: ($t.subject // ""),
                      status: { name: ($t.status // "") },
                      priority: { name: ($t.priority // "") },
                      assignee: { displayName: ($t.assignee_name // "Unassigned") },
                      reporter: { displayName: ($t.requester_name // "") },
                      updated: ($t.updated_at // "")
                    }
                  }
              ))
        out: enriched
      - emit: enriched

  # Every ticket assigned to you, any status.
  - id: all_mine
    label: All my tickets
    flow:
      - call:
          kind: mcp
          mcp: zendesk
          tool: search_tickets
          args:
            query: 'type:ticket assignee:{{user.email}}'
            per_page: '{{limit}}'
            resolve_names: true
        out: r
      - transform: *tickets_to_rows
        out: enriched
      - emit: enriched

  # Open tickets you requested.
  - id: requested
    label: Tickets I requested
    flow:
      - call:
          kind: mcp
          mcp: zendesk
          tool: search_tickets
          args:
            query: 'type:ticket requester:{{user.email}} status<solved'
            per_page: '{{limit}}'
            resolve_names: true
        out: r
      - transform: *tickets_to_rows
        out: enriched
      - emit: enriched

  # Typing after the chip: bare number → direct lookup; anything else → search.
  - id: search
    label: Search
    match:
      # A bare ticket number → get_ticket(ticket_id). The single {ticket:{…}}
      # object is normalized to one row by the shared transform.
      - regex: '^[0-9]{1,12}$'
        bind:
          ticket_id: '{{match}}'
        flow:
          - call:
              kind: mcp
              mcp: zendesk
              tool: get_ticket
              args:
                ticket_id: '{{ticket_id}}'
            out: r
          - transform: *tickets_to_rows
            out: enriched
          - emit: enriched
      # Any other text → keyword search. This hits the server's search_tickets
      # `query` arg, which proxies the Zendesk Search API keyword index — it is
      # the provider's own free-text search endpoint, not a clause-composed auth
      # DSL (SOQL/JQL/etc. that the spec requires an escape filter for, and none
      # of which Zendesk is). We scope it to tickets and pass the user text as
      # the keyword term; advanced users may type Zendesk search operators
      # (status:open priority:high tags:billing) which the server forwards.
      - text: '{{query}}'
        flow:
          - call:
              kind: mcp
              mcp: zendesk
              tool: search_tickets
              args:
                query: 'type:ticket {{query}}'
                per_page: '{{limit}}'
                resolve_names: true
            out: r
          - transform: *tickets_to_rows
            out: enriched
          - emit: enriched

filters:
  - id: status
    kind: enum
    label: Status
    apply: post
    matches_field: statusName
    static:
      - new
      - open
      - pending
      - hold
      - solved
      - closed

  - id: priority
    kind: enum
    label: Priority
    apply: post
    matches_field: priorityName
    static:
      - low
      - normal
      - high
      - urgent

actions:
  - id: open
    kind: url
    target: row
    label: Open in Zendesk
    icon: external_link
    # Each row carries the API `url` at row._url, but that is the JSON API
    # endpoint (…/api/v2/tickets/<id>.json), not an agent-browsable page, so we
    # build the agent deep-link from the tenant subdomain + ticket id.
    # ADMIN: set tenant config `org.zendesk_subdomain` to your Zendesk account
    # subdomain (the `yourco` in yourco.zendesk.com — Zendesk Admin Center →
    # Account → Brand management, or the host in your agent URL). Falls back to
    # the literal `yourco` placeholder until configured (link will 404).
    url_template: 'https://{{org.zendesk_subdomain | default:''yourco''}}.zendesk.com/agent/tickets/{{row.id}}'

presentation:
  widget: list
  title_field: subject
  subtitle_field: subline
  list_fields:
    - key
    - subject
    - statusName
    - priorityName
    - assigneeName
  search_fields:
    - key
    - subject
    - statusName
    - priorityName
    - typeName
    - requesterName
    - assigneeName
    - tags
    - description
  sort_field: updated
  sort_order: desc
  row_key_field: id
  searchable: true
  filterable: true
  right_widget:
    kind: entity_preview
    mode: selected_row
    bind:
      title: '{{row.subject}}'
      subtitle: '{{row.key}} · {{row.statusName}}'
      fields:
        - { label: Status,      value: '{{row.statusName}}' }
        - { label: Priority,    value: '{{row.priorityName}}' }
        - { label: Type,        value: '{{row.typeName}}' }
        - { label: Requester,   value: '{{row.requesterName}}' }
        - { label: Assignee,    value: '{{row.assigneeName}}' }
        - { label: Tags,        value: '{{row.tags}}' }
        - { label: Created,     value: '{{row.created | date:''rel''}}' }
        - { label: Updated,     value: '{{row.updated | date:''rel''}}' }
        - { label: Description, value: '{{row.description}}' }
      actions:
        - open

enabled: true
provisioning_mode: public
departments: []
roles: []
groups: []
