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: 3 tool(s), 0 prompt(s), 0 resource(s).
Tools (3)
add_zendesk_ticket_tags
Hints: idempotent · open-world
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"
}
get_zendesk_ticket
Hints: read-only · idempotent · open-world
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."
},
"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
Hints: open-world
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. 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. 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. 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`: the note is a private, agent-only comment.",
"type": "boolean"
}
},
"required": [
"success",
"message"
],
"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 21from typing import Annotated, Any 22 23from fastmcp import FastMCP 24from fastmcp_extensions import mcp_tool, register_mcp_tools 25from pydantic import BaseModel, Field 26 27from airbyte_ops_mcp.zendesk_api import ( 28 ZendeskAPIError, 29 add_internal_note, 30 add_ticket_tags, 31 get_ticket, 32 get_ticket_comments, 33) 34 35 36class ZendeskAttachment(BaseModel): 37 """An attachment on a Zendesk ticket comment.""" 38 39 id: int | None = Field(default=None, description="Attachment ID.") 40 file_name: str | None = Field(default=None, description="Original file name.") 41 content_type: str | None = Field(default=None, description="MIME content type.") 42 content_url: str | None = Field( 43 default=None, description="URL to download the attachment content." 44 ) 45 size: int | None = Field(default=None, description="File size in bytes.") 46 47 48class ZendeskCustomField(BaseModel): 49 """A single custom field value on a Zendesk ticket.""" 50 51 id: int | None = Field(default=None, description="Custom field ID.") 52 value: str | int | float | bool | None = Field( 53 default=None, description="Custom field value (may be null when unset)." 54 ) 55 56 57class ZendeskComment(BaseModel): 58 """A single comment on a Zendesk ticket.""" 59 60 id: int | None = Field(default=None, description="Comment ID.") 61 author_id: int | None = Field(default=None, description="Author user ID.") 62 public: bool | None = Field( 63 default=None, 64 description="`True` for public replies, `False` for internal notes.", 65 ) 66 body: str = Field(default="", description="Plain-text comment body.") 67 created_at: str | None = Field( 68 default=None, 69 description="ISO-8601 timestamp when the comment was created.", 70 ) 71 attachments: list[ZendeskAttachment] = Field( 72 default_factory=list, 73 description="Files attached to the comment (metadata only).", 74 ) 75 76 77class ZendeskTicketResponse(BaseModel): 78 """Response from the `get_zendesk_ticket` tool.""" 79 80 success: bool = Field(description="Whether the ticket was retrieved.") 81 message: str = Field(description="Human-readable status message.") 82 ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.") 83 subject: str | None = Field(default=None, description="Ticket subject line.") 84 status: str | None = Field( 85 default=None, 86 description="Ticket status (e.g. `open`, `pending`, `solved`, `closed`).", 87 ) 88 description: str | None = Field( 89 default=None, 90 description="The ticket's first comment / description text.", 91 ) 92 priority: str | None = Field(default=None, description="Ticket priority, if set.") 93 tags: list[str] = Field(default_factory=list, description="Ticket tags.") 94 requester_id: int | None = Field( 95 default=None, description="User ID of the ticket requester." 96 ) 97 organization_id: int | None = Field( 98 default=None, description="Organization ID associated with the ticket." 99 ) 100 created_at: str | None = Field( 101 default=None, description="ISO-8601 ticket creation timestamp." 102 ) 103 updated_at: str | None = Field( 104 default=None, description="ISO-8601 ticket last-updated timestamp." 105 ) 106 url: str | None = Field( 107 default=None, 108 description="Agent-facing Zendesk URL for the ticket.", 109 ) 110 via_channel: str | None = Field( 111 default=None, 112 description="Channel the ticket came in through (e.g. `web`, `email`).", 113 ) 114 via_source_rel: str | None = Field( 115 default=None, 116 description="Source relationship (e.g. `follow_up` for follow-up tickets).", 117 ) 118 follow_up_source_ticket_id: int | None = Field( 119 default=None, 120 description=( 121 "For follow-up tickets, the ID of the original (closed) ticket this " 122 "one follows up on. `None` when the ticket is not a follow-up." 123 ), 124 ) 125 custom_fields: list[ZendeskCustomField] = Field( 126 default_factory=list, 127 description="Ticket custom fields (`id`/`value` pairs).", 128 ) 129 comments: list[ZendeskComment] = Field( 130 default_factory=list, 131 description="Ticket comments, oldest first. Empty unless requested.", 132 ) 133 134 135class ZendeskInternalNoteResponse(BaseModel): 136 """Response from the `post_zendesk_internal_comment` tool.""" 137 138 success: bool = Field(description="Whether the internal note was posted.") 139 message: str = Field(description="Human-readable status message.") 140 ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.") 141 comment_id: int | None = Field( 142 default=None, 143 description="ID of the created comment, when Zendesk reports it.", 144 ) 145 public: bool = Field( 146 default=False, 147 description="Always `False`: the note is a private, agent-only comment.", 148 ) 149 150 151class ZendeskTagsResponse(BaseModel): 152 """Response from the `add_zendesk_ticket_tags` tool.""" 153 154 success: bool = Field(description="Whether the tags were added.") 155 message: str = Field(description="Human-readable status message.") 156 ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.") 157 tags: list[str] = Field( 158 default_factory=list, 159 description="The ticket's full tag list after the update.", 160 ) 161 162 163def _agent_ticket_url(raw_url: str | None, ticket_id: int | None) -> str | None: 164 """Derive the agent-facing ticket URL from the API `url` field.""" 165 if not raw_url or ticket_id is None: 166 return None 167 # API url looks like https://<subdomain>.zendesk.com/api/v2/tickets/123.json 168 if "/api/v2/" not in raw_url: 169 return None 170 host = raw_url.split("/api/v2/", 1)[0] 171 if not host: 172 return None 173 return f"{host}/agent/tickets/{ticket_id}" 174 175 176def _map_attachments(raw_comment: dict[str, Any]) -> list[ZendeskAttachment]: 177 """Map the `attachments` array of a raw comment into typed models.""" 178 raw_attachments = raw_comment.get("attachments") 179 if not isinstance(raw_attachments, list): 180 return [] 181 return [ 182 ZendeskAttachment( 183 id=a.get("id"), 184 file_name=a.get("file_name"), 185 content_type=a.get("content_type"), 186 content_url=a.get("content_url"), 187 size=a.get("size"), 188 ) 189 for a in raw_attachments 190 if isinstance(a, dict) 191 ] 192 193 194def _map_custom_fields(ticket: dict[str, Any]) -> list[ZendeskCustomField]: 195 """Map the ticket's `custom_fields` array into typed models.""" 196 raw_fields = ticket.get("custom_fields") 197 if not isinstance(raw_fields, list): 198 return [] 199 return [ 200 ZendeskCustomField(id=f.get("id"), value=f.get("value")) 201 for f in raw_fields 202 if isinstance(f, dict) 203 ] 204 205 206def _as_dict(value: Any) -> dict[str, Any]: 207 """Return `value` when it is a dict, otherwise an empty dict.""" 208 return value if isinstance(value, dict) else {} 209 210 211def _follow_up_source_ticket_id(via_source: dict[str, Any]) -> int | None: 212 """Return the original ticket ID when the ticket is a follow-up.""" 213 if via_source.get("rel") != "follow_up": 214 return None 215 ticket_id = _as_dict(via_source.get("from")).get("ticket_id") 216 return ticket_id if isinstance(ticket_id, int) else None 217 218 219@mcp_tool( 220 read_only=True, 221 idempotent=True, 222 open_world=True, 223) 224def get_zendesk_ticket( 225 ticket_id: Annotated[ 226 int, 227 Field(description="The numeric Zendesk ticket ID to retrieve."), 228 ], 229 include_comments: Annotated[ 230 bool, 231 Field( 232 description=( 233 "When `True`, also fetch the ticket's comments (oldest first), " 234 "including any attachment metadata. Only the first page (up to " 235 "100 comments) is returned. Defaults to `False` to keep " 236 "responses small." 237 ) 238 ), 239 ] = False, 240) -> ZendeskTicketResponse: 241 """Retrieve a Zendesk Support ticket by its numeric ID. 242 243 Returns the ticket's subject, status, description, tags, and requester/ 244 organization identifiers. Set `include_comments` to `True` to also pull the 245 ticket's comment thread (public replies and internal notes), oldest first; 246 only the first page (up to 100 comments) is returned. 247 248 Credentials are read from the server environment (`ZENDESK_SUBDOMAIN`, 249 `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool never accepts or logs them. 250 """ 251 try: 252 ticket: dict[str, Any] = get_ticket(ticket_id) 253 except ZendeskAPIError as exc: 254 return ZendeskTicketResponse( 255 success=False, 256 message=str(exc), 257 ticket_id=ticket_id, 258 ) 259 260 comments: list[ZendeskComment] = [] 261 comments_note = "" 262 if include_comments: 263 try: 264 raw_comments = get_ticket_comments(ticket_id) 265 comments = [ 266 ZendeskComment( 267 id=c.get("id"), 268 author_id=c.get("author_id"), 269 public=c.get("public"), 270 body=c.get("plain_body") or c.get("body") or "", 271 created_at=c.get("created_at"), 272 attachments=_map_attachments(c), 273 ) 274 for c in raw_comments 275 if isinstance(c, dict) 276 ] 277 except ZendeskAPIError as exc: 278 comments_note = f" (comments could not be retrieved: {exc})" 279 280 resolved_id = ticket.get("id", ticket_id) 281 via = _as_dict(ticket.get("via")) 282 via_source = _as_dict(via.get("source")) 283 return ZendeskTicketResponse( 284 success=True, 285 message=f"Retrieved Zendesk ticket {resolved_id}.{comments_note}", 286 ticket_id=resolved_id, 287 subject=ticket.get("subject"), 288 status=ticket.get("status"), 289 description=ticket.get("description"), 290 priority=ticket.get("priority"), 291 tags=ticket.get("tags", []) or [], 292 requester_id=ticket.get("requester_id"), 293 organization_id=ticket.get("organization_id"), 294 created_at=ticket.get("created_at"), 295 updated_at=ticket.get("updated_at"), 296 url=_agent_ticket_url(ticket.get("url"), resolved_id), 297 via_channel=via.get("channel"), 298 via_source_rel=via_source.get("rel"), 299 follow_up_source_ticket_id=_follow_up_source_ticket_id(via_source), 300 custom_fields=_map_custom_fields(ticket), 301 comments=comments, 302 ) 303 304 305def _created_comment_id(audit: dict[str, Any]) -> int | None: 306 """Return the created comment's ID from a ticket-update `audit`, if present.""" 307 events = audit.get("events") 308 if not isinstance(events, list): 309 return None 310 for event in events: 311 if not isinstance(event, dict): 312 continue 313 if event.get("type") == "Comment" and isinstance(event.get("id"), int): 314 return event["id"] 315 return None 316 317 318@mcp_tool( 319 read_only=False, 320 idempotent=False, 321 open_world=True, 322) 323def post_zendesk_internal_comment( 324 ticket_id: Annotated[ 325 int, 326 Field(description="The numeric Zendesk ticket ID to comment on."), 327 ], 328 html_body: Annotated[ 329 str, 330 Field( 331 description=( 332 "The internal note as an HTML fragment. Posted as a private " 333 "(non-public) comment visible only to agents \u2014 NOT to the " 334 "ticket requester/end user. Provide HTML: use `<br>`/`<p>` for " 335 "line breaks, `<strong>` for bold, and `<a href>` for links. " 336 "Literal `<`, `>`, and `&` must be HTML-escaped; Zendesk " 337 "sanitizes to a limited HTML subset, so keep markup basic." 338 ) 339 ), 340 ], 341) -> ZendeskInternalNoteResponse: 342 """Post an internal (private) HTML note to a Zendesk ticket by its numeric ID. 343 344 The comment is added with `public=False`, so it is an internal agent note 345 and is **not** visible to the ticket requester/end user. Use it to record 346 triage findings, cross-references, or context for other agents. The note 347 is sent as HTML (`html_body`), so bold text and links render as intended. 348 349 This tool only posts the note; it does not modify tags. To route/tag a 350 ticket, call `add_zendesk_ticket_tags` separately. 351 352 The numeric `ticket_id` must already be known; this tool does not search 353 for tickets. Credentials are read from the server environment 354 (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool 355 never accepts or logs them. 356 """ 357 try: 358 result: dict[str, Any] = add_internal_note(ticket_id, html_body) 359 except ZendeskAPIError as exc: 360 return ZendeskInternalNoteResponse( 361 success=False, 362 message=str(exc), 363 ticket_id=ticket_id, 364 ) 365 366 updated_ticket = _as_dict(result.get("ticket")) 367 resolved_id = updated_ticket.get("id", ticket_id) 368 comment_id = _created_comment_id(_as_dict(result.get("audit"))) 369 return ZendeskInternalNoteResponse( 370 success=True, 371 message=f"Posted internal note to Zendesk ticket {resolved_id}.", 372 ticket_id=resolved_id, 373 comment_id=comment_id, 374 ) 375 376 377@mcp_tool( 378 read_only=False, 379 idempotent=True, 380 open_world=True, 381) 382def add_zendesk_ticket_tags( 383 ticket_id: Annotated[ 384 int, 385 Field(description="The numeric Zendesk ticket ID to tag."), 386 ], 387 tags: Annotated[ 388 list[str], 389 Field( 390 description=( 391 "Tags to add to the ticket. Appended to the ticket's existing " 392 "tags (never clobbered). At least one non-empty tag is required." 393 ) 394 ), 395 ], 396) -> ZendeskTagsResponse: 397 """Add tags to a Zendesk ticket by its numeric ID. 398 399 Tags are Zendesk's label mechanism. This reads the ticket's current tags 400 and writes back the union (existing + supplied) via a ticket update, so 401 the ticket's existing tags — and any tagger-backed custom fields — are 402 preserved and never dropped. Adding a tag that is already present is a 403 no-op. 404 405 The numeric `ticket_id` must already be known; this tool does not search 406 for tickets. Credentials are read from the server environment 407 (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool 408 never accepts or logs them. 409 """ 410 try: 411 result_tags = add_ticket_tags(ticket_id, tags) 412 except ZendeskAPIError as exc: 413 return ZendeskTagsResponse( 414 success=False, 415 message=str(exc), 416 ticket_id=ticket_id, 417 ) 418 419 return ZendeskTagsResponse( 420 success=True, 421 message=f"Added tags to Zendesk ticket {ticket_id}.", 422 ticket_id=ticket_id, 423 tags=result_tags, 424 ) 425 426 427def register_zendesk_ops_tools(app: FastMCP) -> None: 428 """Register zendesk_ops tools with the FastMCP app.""" 429 register_mcp_tools(app, mcp_module=__name__)