airbyte_ops_mcp.mcp.zendesk_ops

MCP tools for Zendesk Support operations.

This module exposes tools for pulling Zendesk tickets (and their comments), posting internal (private, agent-only) notes, and notifying Slack reviewers after spam triage through the hosted Ops MCP server.

MCP reference

MCP primitives registered by the zendesk_ops module of the airbyte-internal-ops server: 7 tool(s), 0 prompt(s), 0 resource(s).

Tools (7)

add_zendesk_ticket_tags

Add tags to a Zendesk ticket by its numeric ID.

Tags are Zendesk's label mechanism. This reads the ticket's current tags and writes back the union (existing + supplied) via a ticket update, so the ticket's existing tags — and any tagger-backed custom fields — are preserved and never dropped. Adding a tag that is already present is a no-op.

The numeric ticket_id must already be known; this tool does not search for tickets. Credentials are read from the server environment (ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN); this tool never accepts or logs them.

Parameters:

Name Type Required Default Description
ticket_id integer yes — The numeric Zendesk ticket ID to tag.
tags array<string> yes — Tags to add to the ticket. Appended to the ticket's existing tags (never clobbered). At least one non-empty tag is required.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "ticket_id": {
      "description": "The numeric Zendesk ticket ID to tag.",
      "type": "integer"
    },
    "tags": {
      "description": "Tags to add to the ticket. Appended to the ticket's existing tags (never clobbered). At least one non-empty tag is required.",
      "items": {
        "type": "string"
      },
      "type": "array"
    }
  },
  "required": [
    "ticket_id",
    "tags"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the `add_zendesk_ticket_tags` tool.",
  "properties": {
    "success": {
      "description": "Whether the tags were added.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "ticket_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk ticket ID."
    },
    "tags": {
      "description": "The ticket's full tag list after the update.",
      "items": {
        "type": "string"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

create_zendesk_outreach_ticket

Create an idempotent ticket for the approved outreach workflow only.

This tool cannot send anything public. The first comment is always private and visible only to Zendesk agents. CCs are applied in a separate update with no comment because Zendesk does not update email CCs when an internal note is added in the same update. Re-running on an existing ticket ensures the requested CCs without posting another comment. Callers must not run concurrent calls with the same external_id; duplicates are detected after creation but not prevented.

Parameters:

Name Type Required Default Description
external_id string yes — Idempotency key beginning with outreach:.
ticket_type enum("problem", "incident") yes — Zendesk ticket type.
subject string yes — Non-empty ticket subject.
internal_note_html string yes — Non-empty first comment, posted as a private HTML note.
problem_id integer | null no null Required parent problem ticket ID for an incident.
tags array<string> no [] Ticket tags.
ticket_form_name string no "Support Outreach" Active ticket form name.
requester object | null no null Requester contact; required for incidents; not allowed for problems.
cc array<object> no [] Requester contacts to CC; incidents only; Zendesk allows at most 48.
assignee_email string | null no null Optional assignee email address.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "external_id": {
      "description": "Idempotency key beginning with `outreach:`.",
      "type": "string"
    },
    "ticket_type": {
      "description": "Zendesk ticket type.",
      "enum": [
        "problem",
        "incident"
      ],
      "type": "string"
    },
    "subject": {
      "description": "Non-empty ticket subject.",
      "type": "string"
    },
    "internal_note_html": {
      "description": "Non-empty first comment, posted as a private HTML note.",
      "type": "string"
    },
    "problem_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Required parent problem ticket ID for an incident."
    },
    "tags": {
      "default": [],
      "description": "Ticket tags.",
      "items": {
        "type": "string"
      },
      "type": "array"
    },
    "ticket_form_name": {
      "default": "Support Outreach",
      "description": "Active ticket form name.",
      "type": "string"
    },
    "requester": {
      "anyOf": [
        {
          "description": "A Zendesk outreach requester or CC contact.",
          "properties": {
            "email": {
              "description": "Contact email address.",
              "type": "string"
            },
            "name": {
              "description": "Contact name.",
              "type": "string"
            }
          },
          "required": [
            "email",
            "name"
          ],
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Requester contact; required for incidents; not allowed for problems."
    },
    "cc": {
      "default": [],
      "description": "Requester contacts to CC; incidents only; Zendesk allows at most 48.",
      "items": {
        "description": "A Zendesk outreach requester or CC contact.",
        "properties": {
          "email": {
            "description": "Contact email address.",
            "type": "string"
          },
          "name": {
            "description": "Contact name.",
            "type": "string"
          }
        },
        "required": [
          "email",
          "name"
        ],
        "type": "object"
      },
      "type": "array"
    },
    "assignee_email": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional assignee email address."
    }
  },
  "required": [
    "external_id",
    "ticket_type",
    "subject",
    "internal_note_html"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from `create_zendesk_outreach_ticket`.",
  "properties": {
    "success": {
      "description": "Whether the ticket was found or created.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "created": {
      "description": "Whether a new ticket was created.",
      "type": "boolean"
    },
    "ticket_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk ticket ID."
    },
    "url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Agent-facing ticket URL."
    },
    "status": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket status."
    },
    "type": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket type."
    },
    "problem_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Problem ticket ID."
    },
    "requester_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Requester user ID."
    },
    "submitter_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Submitter user ID."
    },
    "assignee_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Assignee user ID."
    },
    "organization_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Associated organization ID."
    },
    "ticket_form_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket form ID."
    },
    "email_cc_ids": {
      "anyOf": [
        {
          "items": {
            "type": "integer"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk user IDs included as email CCs."
    },
    "tags": {
      "description": "Ticket tags.",
      "items": {
        "type": "string"
      },
      "type": "array"
    },
    "duplicate_ticket_ids": {
      "description": "Existing ticket IDs when an external ID has duplicates.",
      "items": {
        "type": "integer"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message",
    "created"
  ],
  "type": "object"
}

find_zendesk_organization_by_airbyte_org_id

Find Zendesk organizations by their airbyte_org_id custom field.

Parameters:

Name Type Required Default Description
airbyte_org_id string yes — Airbyte organization UUID stored on the Zendesk organization.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "airbyte_org_id": {
      "description": "Airbyte organization UUID stored on the Zendesk organization.",
      "type": "string"
    }
  },
  "required": [
    "airbyte_org_id"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from `find_zendesk_organization_by_airbyte_org_id`.",
  "properties": {
    "success": {
      "description": "Whether the search succeeded.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "organizations": {
      "description": "Matching Zendesk organizations.",
      "items": {
        "description": "A concise Zendesk organization search result.",
        "properties": {
          "id": {
            "description": "Zendesk organization ID.",
            "type": "integer"
          },
          "name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Organization name."
          },
          "url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Zendesk API organization URL."
          }
        },
        "required": [
          "id"
        ],
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

get_zendesk_ticket

Retrieve a Zendesk Support ticket by its numeric ID.

Returns the ticket's subject, status, description, tags, and requester/ organization identifiers. Set include_comments to True to also pull the ticket's comment thread (public replies and internal notes), oldest first; only the first page (up to 100 comments) is returned.

Credentials are read from the server environment (ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN); this tool never accepts or logs them.

Parameters:

Name Type Required Default Description
ticket_id integer yes — The numeric Zendesk ticket ID to retrieve.
include_comments boolean no false When True, also fetch the ticket's comments (oldest first), including any attachment metadata. Only the first page (up to 100 comments) is returned. Defaults to False to keep responses small.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "ticket_id": {
      "description": "The numeric Zendesk ticket ID to retrieve.",
      "type": "integer"
    },
    "include_comments": {
      "default": false,
      "description": "When `True`, also fetch the ticket's comments (oldest first), including any attachment metadata. Only the first page (up to 100 comments) is returned. Defaults to `False` to keep responses small.",
      "type": "boolean"
    }
  },
  "required": [
    "ticket_id"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the `get_zendesk_ticket` tool.",
  "properties": {
    "success": {
      "description": "Whether the ticket was retrieved.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "ticket_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk ticket ID."
    },
    "subject": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket subject line."
    },
    "status": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket status (e.g. `open`, `pending`, `solved`, `closed`)."
    },
    "description": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The ticket's first comment / description text."
    },
    "priority": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket priority, if set."
    },
    "tags": {
      "description": "Ticket tags.",
      "items": {
        "type": "string"
      },
      "type": "array"
    },
    "requester_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "User ID of the ticket requester."
    },
    "type": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk ticket type."
    },
    "problem_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Associated problem ticket ID, if any."
    },
    "assignee_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "User ID of the ticket assignee."
    },
    "submitter_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "User ID of the ticket submitter."
    },
    "ticket_form_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket form ID used by the ticket."
    },
    "email_cc_ids": {
      "anyOf": [
        {
          "items": {
            "type": "integer"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk user IDs included as email CCs."
    },
    "external_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "External idempotency identifier for the ticket."
    },
    "organization_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Organization ID associated with the ticket."
    },
    "created_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ISO-8601 ticket creation timestamp."
    },
    "updated_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ISO-8601 ticket last-updated timestamp."
    },
    "url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Agent-facing Zendesk URL for the ticket."
    },
    "via_channel": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Channel the ticket came in through (e.g. `web`, `email`)."
    },
    "via_source_rel": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Source relationship (e.g. `follow_up` for follow-up tickets)."
    },
    "follow_up_source_ticket_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "For follow-up tickets, the ID of the original (closed) ticket this one follows up on. `None` when the ticket is not a follow-up."
    },
    "custom_fields": {
      "description": "Ticket custom fields (`id`/`value` pairs).",
      "items": {
        "description": "A single custom field value on a Zendesk ticket.",
        "properties": {
          "id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Custom field ID."
          },
          "value": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "integer"
              },
              {
                "type": "number"
              },
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Custom field value (may be null when unset)."
          }
        },
        "type": "object"
      },
      "type": "array"
    },
    "comments": {
      "description": "Ticket comments, oldest first. Empty unless requested.",
      "items": {
        "description": "A single comment on a Zendesk ticket.",
        "properties": {
          "id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Comment ID."
          },
          "author_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Author user ID."
          },
          "public": {
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "`True` for public replies, `False` for internal notes."
          },
          "body": {
            "default": "",
            "description": "Plain-text comment body.",
            "type": "string"
          },
          "created_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "ISO-8601 timestamp when the comment was created."
          },
          "attachments": {
            "description": "Files attached to the comment (metadata only).",
            "items": {
              "description": "An attachment on a Zendesk ticket comment.",
              "properties": {
                "id": {
                  "anyOf": [
                    {
                      "type": "integer"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "default": null,
                  "description": "Attachment ID."
                },
                "file_name": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "default": null,
                  "description": "Original file name."
                },
                "content_type": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "default": null,
                  "description": "MIME content type."
                },
                "content_url": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "default": null,
                  "description": "URL to download the attachment content."
                },
                "size": {
                  "anyOf": [
                    {
                      "type": "integer"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "default": null,
                  "description": "File size in bytes."
                }
              },
              "type": "object"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

notify_zendesk_spam_ticket

Post a spam alert to #team-support mentioning the spam reviewers.

Call this once per ticket after the triage note and tags are written. This tool does not modify the Zendesk ticket. Failures are returned, never raised, so triage can continue.

Parameters:

Name Type Required Default Description
ticket_id integer yes — The numeric Zendesk ticket ID to alert reviewers about.
reason string yes — One or two sentences on why the ticket was triaged as spam.
subject string | null no null Ticket subject, if known.
agent_session_url string | null no null URL of the Devin session that triaged the ticket.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "ticket_id": {
      "description": "The numeric Zendesk ticket ID to alert reviewers about.",
      "type": "integer"
    },
    "reason": {
      "description": "One or two sentences on why the ticket was triaged as spam.",
      "type": "string"
    },
    "subject": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Ticket subject, if known."
    },
    "agent_session_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL of the Devin session that triaged the ticket."
    }
  },
  "required": [
    "ticket_id",
    "reason"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the `notify_zendesk_spam_ticket` tool.",
  "properties": {
    "success": {
      "description": "Whether the Slack alert was posted.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "ticket_id": {
      "description": "Zendesk ticket ID.",
      "type": "integer"
    },
    "slack_permalink": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Permalink to the posted Slack alert."
    }
  },
  "required": [
    "success",
    "message",
    "ticket_id"
  ],
  "type": "object"
}

post_zendesk_internal_comment

Post an internal (private) HTML note to a Zendesk ticket by its numeric ID.

The comment is added with public=False, so it is an internal agent note and is not visible to the ticket requester/end user. Use it to record triage findings, cross-references, or context for other agents. This server has no tool that can post a customer-visible/public reply, so it is impossible to answer the customer through the MCP server. When asked to "reply to the customer", write the proposed reply as an internal note clearly labelled as a draft for a human support agent to send, and tell the requesting human that a support agent still has to post it publicly. The note is sent as HTML (html_body), so bold text and links render as intended.

This tool only posts the note; it does not modify tags. To route/tag a ticket, call add_zendesk_ticket_tags separately.

The numeric ticket_id must already be known; this tool does not search for tickets. Credentials are read from the server environment (ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN); this tool never accepts or logs them.

Parameters:

Name Type Required Default Description
ticket_id integer yes — The numeric Zendesk ticket ID to comment on.
html_body string yes — The internal note as an HTML fragment. Posted as a private (non-public) comment visible only to agents — NOT to the ticket requester/end user. This server cannot post a public customer reply; if asked to reply, write a clearly labelled draft for a human agent to send. Provide HTML: use <br>/<p> for line breaks, <strong> for bold, and <a href> for links. Literal <, >, and & must be HTML-escaped; Zendesk sanitizes to a limited HTML subset, so keep markup basic.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "ticket_id": {
      "description": "The numeric Zendesk ticket ID to comment on.",
      "type": "integer"
    },
    "html_body": {
      "description": "The internal note as an HTML fragment. Posted as a private (non-public) comment visible only to agents \u2014 NOT to the ticket requester/end user. This server cannot post a public customer reply; if asked to reply, write a clearly labelled draft for a human agent to send. Provide HTML: use `<br>`/`<p>` for line breaks, `<strong>` for bold, and `<a href>` for links. Literal `<`, `>`, and `&` must be HTML-escaped; Zendesk sanitizes to a limited HTML subset, so keep markup basic.",
      "type": "string"
    }
  },
  "required": [
    "ticket_id",
    "html_body"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the `post_zendesk_internal_comment` tool.",
  "properties": {
    "success": {
      "description": "Whether the internal note was posted.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "ticket_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Zendesk ticket ID."
    },
    "comment_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ID of the created comment, when Zendesk reports it."
    },
    "public": {
      "default": false,
      "description": "Always `False`: this tool can only create a private, agent-only note, never a customer-visible reply.",
      "type": "boolean"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

search_zendesk_tickets

Search Zendesk tickets, with results limited to 1,000.

If query has no positive type:ticket filter, type:ticket is prepended. Negated type filters and queries that explicitly select another record type are rejected.

Parameters:

Name Type Required Default Description
query string yes — Zendesk search query; ticket searches are scoped to tickets.
sort_by enum("created_at", "updated_at") | null no null Optional ticket field to sort by.
sort_order enum("asc", "desc") no "desc" Sort direction.
limit integer no 100 Maximum number of tickets to return.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "query": {
      "description": "Zendesk search query; ticket searches are scoped to tickets.",
      "type": "string"
    },
    "sort_by": {
      "anyOf": [
        {
          "enum": [
            "created_at",
            "updated_at"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional ticket field to sort by."
    },
    "sort_order": {
      "default": "desc",
      "description": "Sort direction.",
      "enum": [
        "asc",
        "desc"
      ],
      "type": "string"
    },
    "limit": {
      "default": 100,
      "description": "Maximum number of tickets to return.",
      "maximum": 1000,
      "minimum": 1,
      "type": "integer"
    }
  },
  "required": [
    "query"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from `search_zendesk_tickets`.",
  "properties": {
    "success": {
      "description": "Whether the search succeeded.",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message.",
      "type": "string"
    },
    "count": {
      "description": "Number of tickets returned.",
      "type": "integer"
    },
    "tickets": {
      "description": "Matching Zendesk tickets.",
      "items": {
        "description": "A concise Zendesk ticket search result.",
        "properties": {
          "id": {
            "description": "Zendesk ticket ID.",
            "type": "integer"
          },
          "subject": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Ticket subject."
          },
          "status": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Ticket status."
          },
          "type": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Ticket type."
          },
          "requester_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Requester user ID."
          },
          "assignee_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Assignee user ID."
          },
          "organization_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Associated organization ID."
          },
          "tags": {
            "description": "Ticket tags.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "external_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "External ticket ID."
          },
          "created_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Ticket creation time."
          },
          "updated_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Ticket update time."
          },
          "url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Agent-facing ticket URL."
          }
        },
        "required": [
          "id"
        ],
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message",
    "count"
  ],
  "type": "object"
}

   1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
   2"""MCP tools for Zendesk Support operations.
   3
   4This module exposes tools for pulling Zendesk tickets (and their comments),
   5posting internal (private, agent-only) notes, and notifying Slack reviewers
   6after spam triage through the hosted Ops MCP server.
   7
   8## MCP reference
   9
  10.. include:: ../../../docs/mcp-generated/zendesk_ops.md
  11    :start-line: 2
  12"""
  13
  14# NOTE: We intentionally do NOT use `from __future__ import annotations` here.
  15# FastMCP has issues resolving forward references when PEP 563 deferred
  16# annotations are used. See: https://github.com/jlowin/fastmcp/issues/905
  17
  18__all__: list[str] = []
  19
  20import os
  21import re
  22from typing import Annotated, Any, Literal
  23
  24import requests
  25from fastmcp import FastMCP
  26from fastmcp_extensions import mcp_tool, register_mcp_tools
  27from pydantic import BaseModel, Field
  28
  29from airbyte_ops_mcp.slack_api import SlackAPIError
  30from airbyte_ops_mcp.slack_posting import post_channel_message
  31from airbyte_ops_mcp.zendesk_api import (
  32    ENV_ZENDESK_SUBDOMAIN,
  33    ZendeskAPIError,
  34    add_internal_note,
  35    add_ticket_tags,
  36    create_ticket,
  37    find_organizations_by_airbyte_org_id,
  38    find_ticket_form_id,
  39    get_current_user,
  40    get_ticket,
  41    get_ticket_comments,
  42    list_tickets_by_external_id,
  43    search,
  44    set_ticket_email_ccs,
  45)
  46
  47# Private #team-support channel; the hydra bot must stay a member.
  48_SPAM_ALERT_SLACK_CHANNEL = "C02U1H7M71A"
  49_SPAM_ALERT_MENTION_IDS = (
  50    "U04QENY1EDV",
  51    "U033PU4TMHA",
  52    "U0960FC2DEG",
  53)  # Michael Moon, Kylee Soper, Vai Ignatavicius
  54_SAFE_SESSION_URL_RE = re.compile(r"^https://[^\s<>|]+$")
  55
  56
  57class ZendeskAttachment(BaseModel):
  58    """An attachment on a Zendesk ticket comment."""
  59
  60    id: int | None = Field(default=None, description="Attachment ID.")
  61    file_name: str | None = Field(default=None, description="Original file name.")
  62    content_type: str | None = Field(default=None, description="MIME content type.")
  63    content_url: str | None = Field(
  64        default=None, description="URL to download the attachment content."
  65    )
  66    size: int | None = Field(default=None, description="File size in bytes.")
  67
  68
  69class ZendeskCustomField(BaseModel):
  70    """A single custom field value on a Zendesk ticket."""
  71
  72    id: int | None = Field(default=None, description="Custom field ID.")
  73    value: str | int | float | bool | None = Field(
  74        default=None, description="Custom field value (may be null when unset)."
  75    )
  76
  77
  78class ZendeskComment(BaseModel):
  79    """A single comment on a Zendesk ticket."""
  80
  81    id: int | None = Field(default=None, description="Comment ID.")
  82    author_id: int | None = Field(default=None, description="Author user ID.")
  83    public: bool | None = Field(
  84        default=None,
  85        description="`True` for public replies, `False` for internal notes.",
  86    )
  87    body: str = Field(default="", description="Plain-text comment body.")
  88    created_at: str | None = Field(
  89        default=None,
  90        description="ISO-8601 timestamp when the comment was created.",
  91    )
  92    attachments: list[ZendeskAttachment] = Field(
  93        default_factory=list,
  94        description="Files attached to the comment (metadata only).",
  95    )
  96
  97
  98class ZendeskTicketResponse(BaseModel):
  99    """Response from the `get_zendesk_ticket` tool."""
 100
 101    success: bool = Field(description="Whether the ticket was retrieved.")
 102    message: str = Field(description="Human-readable status message.")
 103    ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.")
 104    subject: str | None = Field(default=None, description="Ticket subject line.")
 105    status: str | None = Field(
 106        default=None,
 107        description="Ticket status (e.g. `open`, `pending`, `solved`, `closed`).",
 108    )
 109    description: str | None = Field(
 110        default=None,
 111        description="The ticket's first comment / description text.",
 112    )
 113    priority: str | None = Field(default=None, description="Ticket priority, if set.")
 114    tags: list[str] = Field(default_factory=list, description="Ticket tags.")
 115    requester_id: int | None = Field(
 116        default=None, description="User ID of the ticket requester."
 117    )
 118    type: str | None = Field(default=None, description="Zendesk ticket type.")
 119    problem_id: int | None = Field(
 120        default=None, description="Associated problem ticket ID, if any."
 121    )
 122    assignee_id: int | None = Field(
 123        default=None, description="User ID of the ticket assignee."
 124    )
 125    submitter_id: int | None = Field(
 126        default=None, description="User ID of the ticket submitter."
 127    )
 128    ticket_form_id: int | None = Field(
 129        default=None, description="Ticket form ID used by the ticket."
 130    )
 131    email_cc_ids: list[int] | None = Field(
 132        default=None, description="Zendesk user IDs included as email CCs."
 133    )
 134    external_id: str | None = Field(
 135        default=None, description="External idempotency identifier for the ticket."
 136    )
 137    organization_id: int | None = Field(
 138        default=None, description="Organization ID associated with the ticket."
 139    )
 140    created_at: str | None = Field(
 141        default=None, description="ISO-8601 ticket creation timestamp."
 142    )
 143    updated_at: str | None = Field(
 144        default=None, description="ISO-8601 ticket last-updated timestamp."
 145    )
 146    url: str | None = Field(
 147        default=None,
 148        description="Agent-facing Zendesk URL for the ticket.",
 149    )
 150    via_channel: str | None = Field(
 151        default=None,
 152        description="Channel the ticket came in through (e.g. `web`, `email`).",
 153    )
 154    via_source_rel: str | None = Field(
 155        default=None,
 156        description="Source relationship (e.g. `follow_up` for follow-up tickets).",
 157    )
 158    follow_up_source_ticket_id: int | None = Field(
 159        default=None,
 160        description=(
 161            "For follow-up tickets, the ID of the original (closed) ticket this "
 162            "one follows up on. `None` when the ticket is not a follow-up."
 163        ),
 164    )
 165    custom_fields: list[ZendeskCustomField] = Field(
 166        default_factory=list,
 167        description="Ticket custom fields (`id`/`value` pairs).",
 168    )
 169    comments: list[ZendeskComment] = Field(
 170        default_factory=list,
 171        description="Ticket comments, oldest first. Empty unless requested.",
 172    )
 173
 174
 175class ZendeskInternalNoteResponse(BaseModel):
 176    """Response from the `post_zendesk_internal_comment` tool."""
 177
 178    success: bool = Field(description="Whether the internal note was posted.")
 179    message: str = Field(description="Human-readable status message.")
 180    ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.")
 181    comment_id: int | None = Field(
 182        default=None,
 183        description="ID of the created comment, when Zendesk reports it.",
 184    )
 185    public: bool = Field(
 186        default=False,
 187        description=(
 188            "Always `False`: this tool can only create a private, agent-only "
 189            "note, never a customer-visible reply."
 190        ),
 191    )
 192
 193
 194class ZendeskSpamAlertResponse(BaseModel):
 195    """Response from the `notify_zendesk_spam_ticket` tool."""
 196
 197    success: bool = Field(description="Whether the Slack alert was posted.")
 198    message: str = Field(description="Human-readable status message.")
 199    ticket_id: int = Field(description="Zendesk ticket ID.")
 200    slack_permalink: str | None = Field(
 201        default=None, description="Permalink to the posted Slack alert."
 202    )
 203
 204
 205class ZendeskTagsResponse(BaseModel):
 206    """Response from the `add_zendesk_ticket_tags` tool."""
 207
 208    success: bool = Field(description="Whether the tags were added.")
 209    message: str = Field(description="Human-readable status message.")
 210    ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.")
 211    tags: list[str] = Field(
 212        default_factory=list,
 213        description="The ticket's full tag list after the update.",
 214    )
 215
 216
 217class ZendeskTicketSummary(BaseModel):
 218    """A concise Zendesk ticket search result."""
 219
 220    id: int = Field(description="Zendesk ticket ID.")
 221    subject: str | None = Field(default=None, description="Ticket subject.")
 222    status: str | None = Field(default=None, description="Ticket status.")
 223    type: str | None = Field(default=None, description="Ticket type.")
 224    requester_id: int | None = Field(default=None, description="Requester user ID.")
 225    assignee_id: int | None = Field(default=None, description="Assignee user ID.")
 226    organization_id: int | None = Field(
 227        default=None, description="Associated organization ID."
 228    )
 229    tags: list[str] = Field(default_factory=list, description="Ticket tags.")
 230    external_id: str | None = Field(default=None, description="External ticket ID.")
 231    created_at: str | None = Field(default=None, description="Ticket creation time.")
 232    updated_at: str | None = Field(default=None, description="Ticket update time.")
 233    url: str | None = Field(default=None, description="Agent-facing ticket URL.")
 234
 235
 236class ZendeskTicketSearchResponse(BaseModel):
 237    """Response from `search_zendesk_tickets`."""
 238
 239    success: bool = Field(description="Whether the search succeeded.")
 240    message: str = Field(description="Human-readable status message.")
 241    count: int = Field(description="Number of tickets returned.")
 242    tickets: list[ZendeskTicketSummary] = Field(
 243        default_factory=list, description="Matching Zendesk tickets."
 244    )
 245
 246
 247class ZendeskOrganizationSummary(BaseModel):
 248    """A concise Zendesk organization search result."""
 249
 250    id: int = Field(description="Zendesk organization ID.")
 251    name: str | None = Field(default=None, description="Organization name.")
 252    url: str | None = Field(default=None, description="Zendesk API organization URL.")
 253
 254
 255class ZendeskOrganizationSearchResponse(BaseModel):
 256    """Response from `find_zendesk_organization_by_airbyte_org_id`."""
 257
 258    success: bool = Field(description="Whether the search succeeded.")
 259    message: str = Field(description="Human-readable status message.")
 260    organizations: list[ZendeskOrganizationSummary] = Field(
 261        default_factory=list, description="Matching Zendesk organizations."
 262    )
 263
 264
 265class OutreachContact(BaseModel):
 266    """A Zendesk outreach requester or CC contact."""
 267
 268    email: str = Field(description="Contact email address.")
 269    name: str = Field(description="Contact name.")
 270
 271
 272class ZendeskOutreachTicketResponse(BaseModel):
 273    """Response from `create_zendesk_outreach_ticket`."""
 274
 275    success: bool = Field(description="Whether the ticket was found or created.")
 276    message: str = Field(description="Human-readable status message.")
 277    created: bool = Field(description="Whether a new ticket was created.")
 278    ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.")
 279    url: str | None = Field(default=None, description="Agent-facing ticket URL.")
 280    status: str | None = Field(default=None, description="Ticket status.")
 281    type: str | None = Field(default=None, description="Ticket type.")
 282    problem_id: int | None = Field(default=None, description="Problem ticket ID.")
 283    requester_id: int | None = Field(default=None, description="Requester user ID.")
 284    submitter_id: int | None = Field(default=None, description="Submitter user ID.")
 285    assignee_id: int | None = Field(default=None, description="Assignee user ID.")
 286    organization_id: int | None = Field(
 287        default=None, description="Associated organization ID."
 288    )
 289    ticket_form_id: int | None = Field(default=None, description="Ticket form ID.")
 290    email_cc_ids: list[int] | None = Field(
 291        default=None, description="Zendesk user IDs included as email CCs."
 292    )
 293    tags: list[str] = Field(default_factory=list, description="Ticket tags.")
 294    duplicate_ticket_ids: list[int] = Field(
 295        default_factory=list,
 296        description="Existing ticket IDs when an external ID has duplicates.",
 297    )
 298
 299
 300def _agent_ticket_url(raw_url: str | None, ticket_id: int | None) -> str | None:
 301    """Derive the agent-facing ticket URL from the API `url` field."""
 302    if not raw_url or ticket_id is None:
 303        return None
 304    # API url looks like https://<subdomain>.zendesk.com/api/v2/tickets/123.json
 305    if "/api/v2/" not in raw_url:
 306        return None
 307    host = raw_url.split("/api/v2/", 1)[0]
 308    if not host:
 309        return None
 310    return f"{host}/agent/tickets/{ticket_id}"
 311
 312
 313def _configured_agent_ticket_url(ticket_id: int) -> str | None:
 314    """Build the agent-facing URL from the configured Zendesk subdomain."""
 315    subdomain = os.environ.get(ENV_ZENDESK_SUBDOMAIN, "").strip()
 316    if not subdomain:
 317        return None
 318    raw_url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json"
 319    return _agent_ticket_url(raw_url, ticket_id)
 320
 321
 322def _escape_slack_text(value: str) -> str:
 323    """Escape user-provided text for Slack mrkdwn."""
 324    return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
 325
 326
 327def _normalize_slack_text(value: str, *, limit: int | None = None) -> str:
 328    """Collapse whitespace and optionally truncate text for a Slack alert."""
 329    normalized = re.sub(r"\s+", " ", value).strip()
 330    if limit is not None and len(normalized) > limit:
 331        normalized = f"{normalized[: limit - 3].rstrip()}..."
 332    return _escape_slack_text(normalized)
 333
 334
 335def _map_attachments(raw_comment: dict[str, Any]) -> list[ZendeskAttachment]:
 336    """Map the `attachments` array of a raw comment into typed models."""
 337    raw_attachments = raw_comment.get("attachments")
 338    if not isinstance(raw_attachments, list):
 339        return []
 340    return [
 341        ZendeskAttachment(
 342            id=a.get("id"),
 343            file_name=a.get("file_name"),
 344            content_type=a.get("content_type"),
 345            content_url=a.get("content_url"),
 346            size=a.get("size"),
 347        )
 348        for a in raw_attachments
 349        if isinstance(a, dict)
 350    ]
 351
 352
 353def _map_custom_fields(ticket: dict[str, Any]) -> list[ZendeskCustomField]:
 354    """Map the ticket's `custom_fields` array into typed models."""
 355    raw_fields = ticket.get("custom_fields")
 356    if not isinstance(raw_fields, list):
 357        return []
 358    return [
 359        ZendeskCustomField(id=f.get("id"), value=f.get("value"))
 360        for f in raw_fields
 361        if isinstance(f, dict)
 362    ]
 363
 364
 365def _as_dict(value: Any) -> dict[str, Any]:
 366    """Return `value` when it is a dict, otherwise an empty dict."""
 367    return value if isinstance(value, dict) else {}
 368
 369
 370def _follow_up_source_ticket_id(via_source: dict[str, Any]) -> int | None:
 371    """Return the original ticket ID when the ticket is a follow-up."""
 372    if via_source.get("rel") != "follow_up":
 373        return None
 374    ticket_id = _as_dict(via_source.get("from")).get("ticket_id")
 375    return ticket_id if isinstance(ticket_id, int) else None
 376
 377
 378@mcp_tool(
 379    read_only=True,
 380    idempotent=True,
 381    open_world=True,
 382)
 383def get_zendesk_ticket(
 384    ticket_id: Annotated[
 385        int,
 386        Field(description="The numeric Zendesk ticket ID to retrieve."),
 387    ],
 388    include_comments: Annotated[
 389        bool,
 390        Field(
 391            description=(
 392                "When `True`, also fetch the ticket's comments (oldest first), "
 393                "including any attachment metadata. Only the first page (up to "
 394                "100 comments) is returned. Defaults to `False` to keep "
 395                "responses small."
 396            )
 397        ),
 398    ] = False,
 399) -> ZendeskTicketResponse:
 400    """Retrieve a Zendesk Support ticket by its numeric ID.
 401
 402    Returns the ticket's subject, status, description, tags, and requester/
 403    organization identifiers. Set `include_comments` to `True` to also pull the
 404    ticket's comment thread (public replies and internal notes), oldest first;
 405    only the first page (up to 100 comments) is returned.
 406
 407    Credentials are read from the server environment (`ZENDESK_SUBDOMAIN`,
 408    `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool never accepts or logs them.
 409    """
 410    try:
 411        ticket: dict[str, Any] = get_ticket(ticket_id)
 412    except ZendeskAPIError as exc:
 413        return ZendeskTicketResponse(
 414            success=False,
 415            message=str(exc),
 416            ticket_id=ticket_id,
 417        )
 418
 419    comments: list[ZendeskComment] = []
 420    comments_note = ""
 421    if include_comments:
 422        try:
 423            raw_comments = get_ticket_comments(ticket_id)
 424            comments = [
 425                ZendeskComment(
 426                    id=c.get("id"),
 427                    author_id=c.get("author_id"),
 428                    public=c.get("public"),
 429                    body=c.get("plain_body") or c.get("body") or "",
 430                    created_at=c.get("created_at"),
 431                    attachments=_map_attachments(c),
 432                )
 433                for c in raw_comments
 434                if isinstance(c, dict)
 435            ]
 436        except ZendeskAPIError as exc:
 437            comments_note = f" (comments could not be retrieved: {exc})"
 438
 439    resolved_id = ticket.get("id", ticket_id)
 440    via = _as_dict(ticket.get("via"))
 441    via_source = _as_dict(via.get("source"))
 442    return ZendeskTicketResponse(
 443        success=True,
 444        message=f"Retrieved Zendesk ticket {resolved_id}.{comments_note}",
 445        ticket_id=resolved_id,
 446        subject=ticket.get("subject"),
 447        status=ticket.get("status"),
 448        description=ticket.get("description"),
 449        priority=ticket.get("priority"),
 450        tags=ticket.get("tags", []) or [],
 451        requester_id=ticket.get("requester_id"),
 452        type=ticket.get("type"),
 453        problem_id=ticket.get("problem_id"),
 454        assignee_id=ticket.get("assignee_id"),
 455        submitter_id=ticket.get("submitter_id"),
 456        ticket_form_id=ticket.get("ticket_form_id"),
 457        email_cc_ids=ticket.get("email_cc_ids"),
 458        external_id=ticket.get("external_id"),
 459        organization_id=ticket.get("organization_id"),
 460        created_at=ticket.get("created_at"),
 461        updated_at=ticket.get("updated_at"),
 462        url=_agent_ticket_url(ticket.get("url"), resolved_id),
 463        via_channel=via.get("channel"),
 464        via_source_rel=via_source.get("rel"),
 465        follow_up_source_ticket_id=_follow_up_source_ticket_id(via_source),
 466        custom_fields=_map_custom_fields(ticket),
 467        comments=comments,
 468    )
 469
 470
 471def _ticket_summary(ticket: dict[str, Any]) -> ZendeskTicketSummary:
 472    """Map a raw Zendesk ticket into a concise search result."""
 473    ticket_id = ticket.get("id")
 474    if not isinstance(ticket_id, int):
 475        raise ZendeskAPIError("Zendesk search result is missing a valid ticket ID.")
 476    return ZendeskTicketSummary(
 477        id=ticket_id,
 478        subject=ticket.get("subject"),
 479        status=ticket.get("status"),
 480        type=ticket.get("type"),
 481        requester_id=ticket.get("requester_id"),
 482        assignee_id=ticket.get("assignee_id"),
 483        organization_id=ticket.get("organization_id"),
 484        tags=ticket.get("tags", []) or [],
 485        external_id=ticket.get("external_id"),
 486        created_at=ticket.get("created_at"),
 487        updated_at=ticket.get("updated_at"),
 488        url=_agent_ticket_url(ticket.get("url"), ticket_id),
 489    )
 490
 491
 492def _outreach_ticket_response(
 493    ticket: dict[str, Any],
 494    *,
 495    created: bool,
 496    message: str,
 497    fallbacks: dict[str, Any] | None = None,
 498    success: bool = True,
 499    duplicate_ticket_ids: list[int] | None = None,
 500) -> ZendeskOutreachTicketResponse:
 501    """Map a raw Zendesk ticket into an outreach response."""
 502    fallbacks = fallbacks or {}
 503    ticket_id = ticket.get("id")
 504    return ZendeskOutreachTicketResponse(
 505        success=success,
 506        message=message,
 507        created=created,
 508        ticket_id=ticket_id,
 509        url=_agent_ticket_url(ticket.get("url"), ticket_id),
 510        status=ticket.get("status") or fallbacks.get("status"),
 511        type=ticket.get("type") or fallbacks.get("type"),
 512        problem_id=ticket.get("problem_id", fallbacks.get("problem_id")),
 513        requester_id=ticket.get("requester_id", fallbacks.get("requester_id")),
 514        submitter_id=ticket.get("submitter_id", fallbacks.get("submitter_id")),
 515        assignee_id=ticket.get("assignee_id"),
 516        organization_id=ticket.get("organization_id"),
 517        ticket_form_id=ticket.get("ticket_form_id", fallbacks.get("ticket_form_id")),
 518        email_cc_ids=ticket.get("email_cc_ids", fallbacks.get("email_cc_ids")),
 519        tags=ticket.get("tags", fallbacks.get("tags", [])) or [],
 520        duplicate_ticket_ids=duplicate_ticket_ids or [],
 521    )
 522
 523
 524@mcp_tool(
 525    read_only=True,
 526    idempotent=True,
 527    open_world=True,
 528)
 529def search_zendesk_tickets(
 530    query: Annotated[
 531        str,
 532        Field(
 533            description="Zendesk search query; ticket searches are scoped to tickets."
 534        ),
 535    ],
 536    sort_by: Annotated[
 537        Literal["created_at", "updated_at"] | None,
 538        Field(description="Optional ticket field to sort by."),
 539    ] = None,
 540    sort_order: Annotated[
 541        Literal["asc", "desc"],
 542        Field(description="Sort direction."),
 543    ] = "desc",
 544    limit: Annotated[
 545        int,
 546        Field(description="Maximum number of tickets to return.", ge=1, le=1000),
 547    ] = 100,
 548) -> ZendeskTicketSearchResponse:
 549    """Search Zendesk tickets, with results limited to 1,000.
 550
 551    If `query` has no positive `type:ticket` filter, `type:ticket` is prepended.
 552    Negated type filters and queries that explicitly select another record type
 553    are rejected.
 554    """
 555    if re.search(r"(^|\s)-\s*type\s*:", query, flags=re.IGNORECASE):
 556        return ZendeskTicketSearchResponse(
 557            success=False,
 558            message="Zendesk ticket search does not accept negated type filters.",
 559            count=0,
 560        )
 561    type_filters = re.findall(r"\btype\s*:\s*([^\s]+)", query, flags=re.IGNORECASE)
 562    if type_filters and any(value.casefold() != "ticket" for value in type_filters):
 563        return ZendeskTicketSearchResponse(
 564            success=False,
 565            message="Zendesk ticket search only accepts `type:ticket` queries.",
 566            count=0,
 567        )
 568    search_query = query.strip()
 569    if not type_filters:
 570        search_query = f"type:ticket {search_query}".strip()
 571
 572    try:
 573        results = search(
 574            search_query,
 575            sort_by=sort_by,
 576            sort_order=sort_order,
 577            max_results=limit,
 578        )
 579        tickets = [_ticket_summary(ticket) for ticket in results]
 580    except ZendeskAPIError as exc:
 581        return ZendeskTicketSearchResponse(
 582            success=False,
 583            message=str(exc),
 584            count=0,
 585        )
 586
 587    return ZendeskTicketSearchResponse(
 588        success=True,
 589        message=f"Found {len(tickets)} Zendesk ticket(s).",
 590        count=len(tickets),
 591        tickets=tickets,
 592    )
 593
 594
 595@mcp_tool(
 596    read_only=True,
 597    idempotent=True,
 598    open_world=True,
 599)
 600def find_zendesk_organization_by_airbyte_org_id(
 601    airbyte_org_id: Annotated[
 602        str,
 603        Field(
 604            description="Airbyte organization UUID stored on the Zendesk organization."
 605        ),
 606    ],
 607) -> ZendeskOrganizationSearchResponse:
 608    """Find Zendesk organizations by their `airbyte_org_id` custom field."""
 609    try:
 610        organizations = find_organizations_by_airbyte_org_id(airbyte_org_id)
 611    except ZendeskAPIError as exc:
 612        return ZendeskOrganizationSearchResponse(
 613            success=False,
 614            message=str(exc),
 615        )
 616
 617    mapped = [
 618        ZendeskOrganizationSummary(
 619            id=organization["id"],
 620            name=organization.get("name"),
 621            url=organization.get("url"),
 622        )
 623        for organization in organizations
 624        if isinstance(organization.get("id"), int)
 625    ]
 626    return ZendeskOrganizationSearchResponse(
 627        success=True,
 628        message=f"Found {len(mapped)} Zendesk organization(s).",
 629        organizations=mapped,
 630    )
 631
 632
 633def _outreach_tags(tags: list[str]) -> list[str]:
 634    """Trim and deduplicate tags, ensuring the `outreach` tag is present."""
 635    cleaned: list[str] = []
 636    seen: set[str] = set()
 637    has_outreach = False
 638    for raw_tag in tags:
 639        tag = raw_tag.strip()
 640        if not tag:
 641            continue
 642        normalized = tag.casefold()
 643        if normalized in seen:
 644            continue
 645        seen.add(normalized)
 646        if normalized == "outreach":
 647            cleaned.append("outreach")
 648            has_outreach = True
 649        else:
 650            cleaned.append(tag)
 651    if not has_outreach:
 652        cleaned.append("outreach")
 653    return cleaned
 654
 655
 656def _outreach_cc_contacts(
 657    cc: list[OutreachContact],
 658    requester_email: str,
 659) -> list[dict[str, str]]:
 660    """Build deduped Zendesk email CC contacts, excluding the requester."""
 661    requester_email_normalized = requester_email.strip().casefold()
 662    contacts: list[dict[str, str]] = []
 663    seen_emails: set[str] = set()
 664    for contact in cc:
 665        email = contact.email.strip()
 666        if not email:
 667            raise ZendeskAPIError("CC contact email must not be empty.")
 668        normalized_email = email.casefold()
 669        if normalized_email == requester_email_normalized:
 670            continue
 671        if normalized_email in seen_emails:
 672            continue
 673        seen_emails.add(normalized_email)
 674        contacts.append(
 675            {
 676                "user_email": email,
 677                "user_name": contact.name.strip(),
 678                "action": "put",
 679            }
 680        )
 681    if len(contacts) > 48:
 682        raise ZendeskAPIError("At most 48 unique CC contacts are allowed.")
 683    return contacts
 684
 685
 686@mcp_tool(
 687    read_only=False,
 688    idempotent=True,
 689    open_world=True,
 690)
 691def create_zendesk_outreach_ticket(
 692    external_id: Annotated[
 693        str,
 694        Field(description="Idempotency key beginning with `outreach:`."),
 695    ],
 696    ticket_type: Annotated[
 697        Literal["problem", "incident"],
 698        Field(description="Zendesk ticket type."),
 699    ],
 700    subject: Annotated[str, Field(description="Non-empty ticket subject.")],
 701    internal_note_html: Annotated[
 702        str,
 703        Field(description="Non-empty first comment, posted as a private HTML note."),
 704    ],
 705    problem_id: Annotated[
 706        int | None,
 707        Field(description="Required parent problem ticket ID for an incident."),
 708    ] = None,
 709    tags: Annotated[list[str], Field(description="Ticket tags.")] = [],  # noqa: B006
 710    ticket_form_name: Annotated[
 711        str,
 712        Field(description="Active ticket form name."),
 713    ] = "Support Outreach",
 714    requester: Annotated[
 715        OutreachContact | None,
 716        Field(
 717            description="Requester contact; required for incidents; not allowed for problems."
 718        ),
 719    ] = None,
 720    cc: Annotated[
 721        list[OutreachContact],
 722        Field(
 723            description="Requester contacts to CC; incidents only; Zendesk allows at most 48."
 724        ),
 725    ] = [],  # noqa: B006
 726    assignee_email: Annotated[
 727        str | None,
 728        Field(description="Optional assignee email address."),
 729    ] = None,
 730) -> ZendeskOutreachTicketResponse:
 731    """Create an idempotent ticket for the approved outreach workflow only.
 732
 733    This tool cannot send anything public. The first comment is always private
 734    and visible only to Zendesk agents.
 735    CCs are applied in a separate update with no comment because Zendesk does
 736    not update email CCs when an internal note is added in the same update.
 737    Re-running on an existing ticket ensures the requested CCs without posting
 738    another comment.
 739    Callers must not run concurrent calls with the same `external_id`; duplicates
 740    are detected after creation but not prevented.
 741    """
 742    try:
 743        if (
 744            not external_id.startswith("outreach:")
 745            or not external_id[len("outreach:") :].strip()
 746        ):
 747            raise ZendeskAPIError("`external_id` must begin with `outreach:`.")
 748
 749        existing = list_tickets_by_external_id(external_id)
 750        if len(existing) > 1:
 751            duplicate_ids = [
 752                ticket["id"] for ticket in existing if isinstance(ticket.get("id"), int)
 753            ]
 754            return ZendeskOutreachTicketResponse(
 755                success=False,
 756                message=(
 757                    "Multiple Zendesk tickets share this external ID: "
 758                    f"{', '.join(map(str, duplicate_ids))}."
 759                ),
 760                created=False,
 761                duplicate_ticket_ids=duplicate_ids,
 762            )
 763        if existing:
 764            ticket = existing[0]
 765            if ticket_type == "incident" and cc:
 766                if requester is None:
 767                    raise ZendeskAPIError("`requester` is required for an incident.")
 768                if not requester.email.strip():
 769                    raise ZendeskAPIError("Requester email must not be empty.")
 770                cc_contacts = _outreach_cc_contacts(cc, requester.email)
 771                if cc_contacts:
 772                    try:
 773                        ticket = set_ticket_email_ccs(ticket["id"], cc_contacts)
 774                    except ZendeskAPIError as exc:
 775                        return _outreach_ticket_response(
 776                            ticket,
 777                            created=False,
 778                            message=(
 779                                f"Failed to ensure CCs for existing Zendesk ticket "
 780                                f"{ticket.get('id')}: {exc}"
 781                            ),
 782                            success=False,
 783                        )
 784                return _outreach_ticket_response(
 785                    ticket,
 786                    created=False,
 787                    message=(
 788                        f"Found existing Zendesk ticket {ticket.get('id')}; "
 789                        f"ensured {len(cc_contacts)} CCs."
 790                    ),
 791                )
 792            return _outreach_ticket_response(
 793                ticket,
 794                created=False,
 795                message=f"Found existing Zendesk ticket {ticket.get('id')}; no ticket was created.",
 796            )
 797
 798        if ticket_type == "incident" and problem_id is None:
 799            raise ZendeskAPIError("`problem_id` is required for an incident.")
 800        if ticket_type == "problem" and problem_id is not None:
 801            raise ZendeskAPIError("`problem_id` is not allowed for a problem ticket.")
 802        if ticket_type == "incident" and requester is None:
 803            raise ZendeskAPIError("`requester` is required for an incident.")
 804        if ticket_type == "problem" and requester is not None:
 805            raise ZendeskAPIError(
 806                "`requester` is not allowed for a problem ticket; MoonBot is the requester."
 807            )
 808        if ticket_type == "problem" and cc:
 809            raise ZendeskAPIError("`cc` is not allowed for a problem ticket.")
 810        if not subject.strip():
 811            raise ZendeskAPIError("Ticket subject must not be empty.")
 812        if not internal_note_html.strip():
 813            raise ZendeskAPIError("Internal note body must not be empty.")
 814        if requester is not None and not requester.email.strip():
 815            raise ZendeskAPIError("Requester email must not be empty.")
 816        if assignee_email is not None and not assignee_email.strip():
 817            raise ZendeskAPIError("Assignee email must not be empty.")
 818
 819        current_user = get_current_user()
 820        current_user_id = current_user.get("id")
 821        if not isinstance(current_user_id, int):
 822            raise ZendeskAPIError(
 823                "Zendesk current-user response has an invalid user ID."
 824            )
 825
 826        requester_email = (
 827            requester.email.strip()
 828            if requester is not None
 829            else str(current_user.get("email") or "").strip()
 830        )
 831        cc_contacts = _outreach_cc_contacts(cc, requester_email)
 832
 833        ticket_form_id = find_ticket_form_id(ticket_form_name)
 834        ticket_payload: dict[str, Any] = {
 835            "subject": subject.strip(),
 836            "comment": {"html_body": internal_note_html.strip(), "public": False},
 837            "status": "new",
 838            "priority": "normal",
 839            "type": ticket_type,
 840            "tags": _outreach_tags(tags),
 841            "external_id": external_id,
 842            "ticket_form_id": ticket_form_id,
 843            "submitter_id": current_user_id,
 844        }
 845        if ticket_type == "incident":
 846            ticket_payload["problem_id"] = problem_id
 847        if requester is None:
 848            ticket_payload["requester_id"] = current_user_id
 849        else:
 850            ticket_payload["requester"] = {
 851                "name": requester.name.strip(),
 852                "email": requester.email.strip(),
 853            }
 854        if assignee_email is not None:
 855            ticket_payload["assignee_email"] = assignee_email.strip()
 856
 857        ticket = create_ticket(ticket_payload)
 858        try:
 859            post_create_tickets = list_tickets_by_external_id(external_id)
 860        except ZendeskAPIError as exc:
 861            post_create_tickets = None
 862            duplicate_check_error = str(exc)
 863        else:
 864            duplicate_check_error = None
 865    except ZendeskAPIError as exc:
 866        return ZendeskOutreachTicketResponse(
 867            success=False,
 868            message=str(exc),
 869            created=False,
 870        )
 871
 872    response_fallbacks: dict[str, Any] = {
 873        "status": "new",
 874        "type": ticket_type,
 875        "problem_id": problem_id,
 876        "submitter_id": current_user_id,
 877        "ticket_form_id": ticket_form_id,
 878        "email_cc_ids": [],
 879        "tags": ticket_payload["tags"],
 880    }
 881    if requester is None:
 882        response_fallbacks["requester_id"] = current_user_id
 883    if duplicate_check_error is not None:
 884        return _outreach_ticket_response(
 885            ticket,
 886            created=True,
 887            message=(
 888                f"Created ticket {ticket.get('id')} but could not check for "
 889                "duplicate tickets for this external ID: "
 890                f"{duplicate_check_error}."
 891            ),
 892            fallbacks=response_fallbacks,
 893            success=False,
 894        )
 895    if post_create_tickets is not None and len(post_create_tickets) > 1:
 896        duplicate_ids = [
 897            existing_ticket["id"]
 898            for existing_ticket in post_create_tickets
 899            if isinstance(existing_ticket.get("id"), int)
 900        ]
 901        return _outreach_ticket_response(
 902            ticket,
 903            created=True,
 904            message=(
 905                f"Created ticket {ticket.get('id')} but found duplicate tickets "
 906                f"for this external ID: {', '.join(map(str, duplicate_ids))}; "
 907                "resolve manually."
 908            ),
 909            fallbacks=response_fallbacks,
 910            success=False,
 911            duplicate_ticket_ids=duplicate_ids,
 912        )
 913    if cc_contacts:
 914        try:
 915            ticket = set_ticket_email_ccs(ticket["id"], cc_contacts)
 916        except ZendeskAPIError as exc:
 917            return _outreach_ticket_response(
 918                ticket,
 919                created=True,
 920                message=(
 921                    f"Created ticket {ticket.get('id')} but failed to add CCs: "
 922                    f"{exc}; re-run to retry."
 923                ),
 924                fallbacks=response_fallbacks,
 925                success=False,
 926            )
 927    return _outreach_ticket_response(
 928        ticket,
 929        created=True,
 930        message=f"Created private outreach ticket {ticket.get('id')}.",
 931        fallbacks=response_fallbacks,
 932    )
 933
 934
 935def _created_comment_id(audit: dict[str, Any]) -> int | None:
 936    """Return the created comment's ID from a ticket-update `audit`, if present."""
 937    events = audit.get("events")
 938    if not isinstance(events, list):
 939        return None
 940    for event in events:
 941        if not isinstance(event, dict):
 942            continue
 943        if event.get("type") == "Comment" and isinstance(event.get("id"), int):
 944            return event["id"]
 945    return None
 946
 947
 948@mcp_tool(
 949    read_only=False,
 950    idempotent=False,
 951    open_world=True,
 952)
 953def post_zendesk_internal_comment(
 954    ticket_id: Annotated[
 955        int,
 956        Field(description="The numeric Zendesk ticket ID to comment on."),
 957    ],
 958    html_body: Annotated[
 959        str,
 960        Field(
 961            description=(
 962                "The internal note as an HTML fragment. Posted as a private "
 963                "(non-public) comment visible only to agents \u2014 NOT to the "
 964                "ticket requester/end user. This server cannot post a public "
 965                "customer reply; if asked to reply, write a clearly labelled "
 966                "draft for a human agent to send. Provide HTML: use `<br>`/`<p>` for "
 967                "line breaks, `<strong>` for bold, and `<a href>` for links. "
 968                "Literal `<`, `>`, and `&` must be HTML-escaped; Zendesk "
 969                "sanitizes to a limited HTML subset, so keep markup basic."
 970            )
 971        ),
 972    ],
 973) -> ZendeskInternalNoteResponse:
 974    """Post an internal (private) HTML note to a Zendesk ticket by its numeric ID.
 975
 976    The comment is added with `public=False`, so it is an internal agent note
 977    and is **not** visible to the ticket requester/end user. Use it to record
 978    triage findings, cross-references, or context for other agents. This server
 979    has no tool that can post a customer-visible/public reply, so it is
 980    impossible to answer the customer through the MCP server. When asked to
 981    "reply to the customer", write the proposed reply as an internal note
 982    clearly labelled as a draft for a human support agent to send, and tell the
 983    requesting human that a support agent still has to post it publicly. The
 984    note is sent as HTML (`html_body`), so bold text and links render as intended.
 985
 986    This tool only posts the note; it does not modify tags. To route/tag a
 987    ticket, call `add_zendesk_ticket_tags` separately.
 988
 989    The numeric `ticket_id` must already be known; this tool does not search
 990    for tickets. Credentials are read from the server environment
 991    (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool
 992    never accepts or logs them.
 993    """
 994    try:
 995        result: dict[str, Any] = add_internal_note(ticket_id, html_body)
 996    except ZendeskAPIError as exc:
 997        return ZendeskInternalNoteResponse(
 998            success=False,
 999            message=str(exc),
1000            ticket_id=ticket_id,
1001        )
1002
1003    updated_ticket = _as_dict(result.get("ticket"))
1004    resolved_id = updated_ticket.get("id", ticket_id)
1005    comment_id = _created_comment_id(_as_dict(result.get("audit")))
1006    return ZendeskInternalNoteResponse(
1007        success=True,
1008        message=(
1009            f"Posted an internal-only note to Zendesk ticket {resolved_id}; "
1010            "it is not visible to the customer."
1011        ),
1012        ticket_id=resolved_id,
1013        comment_id=comment_id,
1014    )
1015
1016
1017@mcp_tool(
1018    read_only=False,
1019    idempotent=False,
1020    open_world=True,
1021)
1022def notify_zendesk_spam_ticket(
1023    ticket_id: Annotated[
1024        int,
1025        Field(description="The numeric Zendesk ticket ID to alert reviewers about."),
1026    ],
1027    reason: Annotated[
1028        str,
1029        Field(
1030            description="One or two sentences on why the ticket was triaged as spam."
1031        ),
1032    ],
1033    subject: Annotated[
1034        str | None,
1035        Field(default=None, description="Ticket subject, if known."),
1036    ] = None,
1037    agent_session_url: Annotated[
1038        str | None,
1039        Field(
1040            default=None,
1041            description="URL of the Devin session that triaged the ticket.",
1042        ),
1043    ] = None,
1044) -> ZendeskSpamAlertResponse:
1045    """Post a spam alert to `#team-support` mentioning the spam reviewers.
1046
1047    Call this once per ticket after the triage note and tags are written. This
1048    tool does not modify the Zendesk ticket. Failures are returned, never raised,
1049    so triage can continue.
1050    """
1051    ticket_url = _configured_agent_ticket_url(ticket_id)
1052    ticket_link = f"<{ticket_url}|#{ticket_id}>" if ticket_url else f"#{ticket_id}"
1053    subject_text = _normalize_slack_text(subject) if subject else ""
1054    subject_suffix = f": {subject_text}" if subject_text else ""
1055    mentions = " ".join(f"<@{user_id}>" for user_id in _SPAM_ALERT_MENTION_IDS)
1056    text = (
1057        f":no_entry_sign: :spam: MoonBot triaged Zendesk ticket {ticket_link}"
1058        f"{subject_suffix} as spam. {mentions}\n"
1059        f"*Reason:* {_normalize_slack_text(reason, limit=500)}"
1060    )
1061    if agent_session_url and _SAFE_SESSION_URL_RE.fullmatch(agent_session_url):
1062        text += f"\n<{_escape_slack_text(agent_session_url)}|Devin session>"
1063
1064    try:
1065        result = post_channel_message(_SPAM_ALERT_SLACK_CHANNEL, text)
1066    except (SlackAPIError, requests.RequestException) as exc:
1067        return ZendeskSpamAlertResponse(
1068            success=False,
1069            message=str(exc),
1070            ticket_id=ticket_id,
1071        )
1072
1073    return ZendeskSpamAlertResponse(
1074        success=True,
1075        message=f"Posted a Slack spam alert for Zendesk ticket {ticket_id}.",
1076        ticket_id=ticket_id,
1077        slack_permalink=result.permalink,
1078    )
1079
1080
1081@mcp_tool(
1082    read_only=False,
1083    idempotent=True,
1084    open_world=True,
1085)
1086def add_zendesk_ticket_tags(
1087    ticket_id: Annotated[
1088        int,
1089        Field(description="The numeric Zendesk ticket ID to tag."),
1090    ],
1091    tags: Annotated[
1092        list[str],
1093        Field(
1094            description=(
1095                "Tags to add to the ticket. Appended to the ticket's existing "
1096                "tags (never clobbered). At least one non-empty tag is required."
1097            )
1098        ),
1099    ],
1100) -> ZendeskTagsResponse:
1101    """Add tags to a Zendesk ticket by its numeric ID.
1102
1103    Tags are Zendesk's label mechanism. This reads the ticket's current tags
1104    and writes back the union (existing + supplied) via a ticket update, so
1105    the ticket's existing tags — and any tagger-backed custom fields — are
1106    preserved and never dropped. Adding a tag that is already present is a
1107    no-op.
1108
1109    The numeric `ticket_id` must already be known; this tool does not search
1110    for tickets. Credentials are read from the server environment
1111    (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool
1112    never accepts or logs them.
1113    """
1114    try:
1115        result_tags = add_ticket_tags(ticket_id, tags)
1116    except ZendeskAPIError as exc:
1117        return ZendeskTagsResponse(
1118            success=False,
1119            message=str(exc),
1120            ticket_id=ticket_id,
1121        )
1122
1123    return ZendeskTagsResponse(
1124        success=True,
1125        message=f"Added tags to Zendesk ticket {ticket_id}.",
1126        ticket_id=ticket_id,
1127        tags=result_tags,
1128    )
1129
1130
1131def register_zendesk_ops_tools(app: FastMCP) -> None:
1132    """Register zendesk_ops tools with the FastMCP app."""
1133    register_mcp_tools(app, mcp_module=__name__)