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("&", "&").replace("<", "<").replace(">", ">") 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__)