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