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. 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"
}
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=( 148 "Always `False`: this tool can only create a private, agent-only " 149 "note, never a customer-visible reply." 150 ), 151 ) 152 153 154class ZendeskTagsResponse(BaseModel): 155 """Response from the `add_zendesk_ticket_tags` tool.""" 156 157 success: bool = Field(description="Whether the tags were added.") 158 message: str = Field(description="Human-readable status message.") 159 ticket_id: int | None = Field(default=None, description="Zendesk ticket ID.") 160 tags: list[str] = Field( 161 default_factory=list, 162 description="The ticket's full tag list after the update.", 163 ) 164 165 166def _agent_ticket_url(raw_url: str | None, ticket_id: int | None) -> str | None: 167 """Derive the agent-facing ticket URL from the API `url` field.""" 168 if not raw_url or ticket_id is None: 169 return None 170 # API url looks like https://<subdomain>.zendesk.com/api/v2/tickets/123.json 171 if "/api/v2/" not in raw_url: 172 return None 173 host = raw_url.split("/api/v2/", 1)[0] 174 if not host: 175 return None 176 return f"{host}/agent/tickets/{ticket_id}" 177 178 179def _map_attachments(raw_comment: dict[str, Any]) -> list[ZendeskAttachment]: 180 """Map the `attachments` array of a raw comment into typed models.""" 181 raw_attachments = raw_comment.get("attachments") 182 if not isinstance(raw_attachments, list): 183 return [] 184 return [ 185 ZendeskAttachment( 186 id=a.get("id"), 187 file_name=a.get("file_name"), 188 content_type=a.get("content_type"), 189 content_url=a.get("content_url"), 190 size=a.get("size"), 191 ) 192 for a in raw_attachments 193 if isinstance(a, dict) 194 ] 195 196 197def _map_custom_fields(ticket: dict[str, Any]) -> list[ZendeskCustomField]: 198 """Map the ticket's `custom_fields` array into typed models.""" 199 raw_fields = ticket.get("custom_fields") 200 if not isinstance(raw_fields, list): 201 return [] 202 return [ 203 ZendeskCustomField(id=f.get("id"), value=f.get("value")) 204 for f in raw_fields 205 if isinstance(f, dict) 206 ] 207 208 209def _as_dict(value: Any) -> dict[str, Any]: 210 """Return `value` when it is a dict, otherwise an empty dict.""" 211 return value if isinstance(value, dict) else {} 212 213 214def _follow_up_source_ticket_id(via_source: dict[str, Any]) -> int | None: 215 """Return the original ticket ID when the ticket is a follow-up.""" 216 if via_source.get("rel") != "follow_up": 217 return None 218 ticket_id = _as_dict(via_source.get("from")).get("ticket_id") 219 return ticket_id if isinstance(ticket_id, int) else None 220 221 222@mcp_tool( 223 read_only=True, 224 idempotent=True, 225 open_world=True, 226) 227def get_zendesk_ticket( 228 ticket_id: Annotated[ 229 int, 230 Field(description="The numeric Zendesk ticket ID to retrieve."), 231 ], 232 include_comments: Annotated[ 233 bool, 234 Field( 235 description=( 236 "When `True`, also fetch the ticket's comments (oldest first), " 237 "including any attachment metadata. Only the first page (up to " 238 "100 comments) is returned. Defaults to `False` to keep " 239 "responses small." 240 ) 241 ), 242 ] = False, 243) -> ZendeskTicketResponse: 244 """Retrieve a Zendesk Support ticket by its numeric ID. 245 246 Returns the ticket's subject, status, description, tags, and requester/ 247 organization identifiers. Set `include_comments` to `True` to also pull the 248 ticket's comment thread (public replies and internal notes), oldest first; 249 only the first page (up to 100 comments) is returned. 250 251 Credentials are read from the server environment (`ZENDESK_SUBDOMAIN`, 252 `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool never accepts or logs them. 253 """ 254 try: 255 ticket: dict[str, Any] = get_ticket(ticket_id) 256 except ZendeskAPIError as exc: 257 return ZendeskTicketResponse( 258 success=False, 259 message=str(exc), 260 ticket_id=ticket_id, 261 ) 262 263 comments: list[ZendeskComment] = [] 264 comments_note = "" 265 if include_comments: 266 try: 267 raw_comments = get_ticket_comments(ticket_id) 268 comments = [ 269 ZendeskComment( 270 id=c.get("id"), 271 author_id=c.get("author_id"), 272 public=c.get("public"), 273 body=c.get("plain_body") or c.get("body") or "", 274 created_at=c.get("created_at"), 275 attachments=_map_attachments(c), 276 ) 277 for c in raw_comments 278 if isinstance(c, dict) 279 ] 280 except ZendeskAPIError as exc: 281 comments_note = f" (comments could not be retrieved: {exc})" 282 283 resolved_id = ticket.get("id", ticket_id) 284 via = _as_dict(ticket.get("via")) 285 via_source = _as_dict(via.get("source")) 286 return ZendeskTicketResponse( 287 success=True, 288 message=f"Retrieved Zendesk ticket {resolved_id}.{comments_note}", 289 ticket_id=resolved_id, 290 subject=ticket.get("subject"), 291 status=ticket.get("status"), 292 description=ticket.get("description"), 293 priority=ticket.get("priority"), 294 tags=ticket.get("tags", []) or [], 295 requester_id=ticket.get("requester_id"), 296 organization_id=ticket.get("organization_id"), 297 created_at=ticket.get("created_at"), 298 updated_at=ticket.get("updated_at"), 299 url=_agent_ticket_url(ticket.get("url"), resolved_id), 300 via_channel=via.get("channel"), 301 via_source_rel=via_source.get("rel"), 302 follow_up_source_ticket_id=_follow_up_source_ticket_id(via_source), 303 custom_fields=_map_custom_fields(ticket), 304 comments=comments, 305 ) 306 307 308def _created_comment_id(audit: dict[str, Any]) -> int | None: 309 """Return the created comment's ID from a ticket-update `audit`, if present.""" 310 events = audit.get("events") 311 if not isinstance(events, list): 312 return None 313 for event in events: 314 if not isinstance(event, dict): 315 continue 316 if event.get("type") == "Comment" and isinstance(event.get("id"), int): 317 return event["id"] 318 return None 319 320 321@mcp_tool( 322 read_only=False, 323 idempotent=False, 324 open_world=True, 325) 326def post_zendesk_internal_comment( 327 ticket_id: Annotated[ 328 int, 329 Field(description="The numeric Zendesk ticket ID to comment on."), 330 ], 331 html_body: Annotated[ 332 str, 333 Field( 334 description=( 335 "The internal note as an HTML fragment. Posted as a private " 336 "(non-public) comment visible only to agents \u2014 NOT to the " 337 "ticket requester/end user. This server cannot post a public " 338 "customer reply; if asked to reply, write a clearly labelled " 339 "draft for a human agent to send. Provide HTML: use `<br>`/`<p>` for " 340 "line breaks, `<strong>` for bold, and `<a href>` for links. " 341 "Literal `<`, `>`, and `&` must be HTML-escaped; Zendesk " 342 "sanitizes to a limited HTML subset, so keep markup basic." 343 ) 344 ), 345 ], 346) -> ZendeskInternalNoteResponse: 347 """Post an internal (private) HTML note to a Zendesk ticket by its numeric ID. 348 349 The comment is added with `public=False`, so it is an internal agent note 350 and is **not** visible to the ticket requester/end user. Use it to record 351 triage findings, cross-references, or context for other agents. This server 352 has no tool that can post a customer-visible/public reply, so it is 353 impossible to answer the customer through the MCP server. When asked to 354 "reply to the customer", write the proposed reply as an internal note 355 clearly labelled as a draft for a human support agent to send, and tell the 356 requesting human that a support agent still has to post it publicly. The 357 note is sent as HTML (`html_body`), so bold text and links render as intended. 358 359 This tool only posts the note; it does not modify tags. To route/tag a 360 ticket, call `add_zendesk_ticket_tags` separately. 361 362 The numeric `ticket_id` must already be known; this tool does not search 363 for tickets. Credentials are read from the server environment 364 (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool 365 never accepts or logs them. 366 """ 367 try: 368 result: dict[str, Any] = add_internal_note(ticket_id, html_body) 369 except ZendeskAPIError as exc: 370 return ZendeskInternalNoteResponse( 371 success=False, 372 message=str(exc), 373 ticket_id=ticket_id, 374 ) 375 376 updated_ticket = _as_dict(result.get("ticket")) 377 resolved_id = updated_ticket.get("id", ticket_id) 378 comment_id = _created_comment_id(_as_dict(result.get("audit"))) 379 return ZendeskInternalNoteResponse( 380 success=True, 381 message=( 382 f"Posted an internal-only note to Zendesk ticket {resolved_id}; " 383 "it is not visible to the customer." 384 ), 385 ticket_id=resolved_id, 386 comment_id=comment_id, 387 ) 388 389 390@mcp_tool( 391 read_only=False, 392 idempotent=True, 393 open_world=True, 394) 395def add_zendesk_ticket_tags( 396 ticket_id: Annotated[ 397 int, 398 Field(description="The numeric Zendesk ticket ID to tag."), 399 ], 400 tags: Annotated[ 401 list[str], 402 Field( 403 description=( 404 "Tags to add to the ticket. Appended to the ticket's existing " 405 "tags (never clobbered). At least one non-empty tag is required." 406 ) 407 ), 408 ], 409) -> ZendeskTagsResponse: 410 """Add tags to a Zendesk ticket by its numeric ID. 411 412 Tags are Zendesk's label mechanism. This reads the ticket's current tags 413 and writes back the union (existing + supplied) via a ticket update, so 414 the ticket's existing tags — and any tagger-backed custom fields — are 415 preserved and never dropped. Adding a tag that is already present is a 416 no-op. 417 418 The numeric `ticket_id` must already be known; this tool does not search 419 for tickets. Credentials are read from the server environment 420 (`ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`); this tool 421 never accepts or logs them. 422 """ 423 try: 424 result_tags = add_ticket_tags(ticket_id, tags) 425 except ZendeskAPIError as exc: 426 return ZendeskTagsResponse( 427 success=False, 428 message=str(exc), 429 ticket_id=ticket_id, 430 ) 431 432 return ZendeskTagsResponse( 433 success=True, 434 message=f"Added tags to Zendesk ticket {ticket_id}.", 435 ticket_id=ticket_id, 436 tags=result_tags, 437 ) 438 439 440def register_zendesk_ops_tools(app: FastMCP) -> None: 441 """Register zendesk_ops tools with the FastMCP app.""" 442 register_mcp_tools(app, mcp_module=__name__)