Compare commits

3 Commits
main ... main

Author SHA1 Message Date
5ded947b73 Document include=assets as the way to see a ticket's associated assets
The /tickets/{id}/associated-assets path 404s on this instance; assets come
back embedded via GET /tickets/{id}?include=assets. Spell out the full include
list on get_ticket and in the skill reference so consumers don't hunt for a
nonexistent endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:37:16 -06:00
829dbe1a3f Label ticket enums server-side so clients never guess the scale
A webhook consumer read priority 4 off a ticket and called it 'Low (4)' in a
posted note — inverting Freshservice's scale (4=Urgent). Add *_label companions
(priority, status, impact, urgency) to every ticket returned by get_ticket and
list_tickets; status labels resolve through the live instance status list, so
custom statuses label correctly too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:06:48 -06:00
e0bf450e08 Pin mcp SDK below 2.0
A fresh image build pulled mcp 2.0.0, which removed mcp.server.fastmcp and
crash-looped the container (ModuleNotFoundError). Pin to the 1.x line the
server is written against; the 2.x migration is a deliberate change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:32:20 -06:00
3 changed files with 58 additions and 8 deletions

View File

@@ -1 +1,4 @@
mcp>=1.2.0 # Pinned below 2.0: mcp 2.0.0 removed mcp.server.fastmcp (and the 1.x
# lowlevel/transport_security layout this server imports). Migrating to the
# 2.x API is a deliberate change, not a rebuild side effect.
mcp>=1.2.0,<2

View File

@@ -204,6 +204,33 @@ def _open_status_ids() -> list[int]:
return [c["id"] for c in _status_choices() if c.get("id") is not None and not _is_terminal(c)] return [c["id"] for c in _status_choices() if c.get("id") is not None and not _is_terminal(c)]
# Impact/urgency are fixed 1-3 scales in Freshservice (priority is fixed 1-4).
IMPACT_URGENCY = {1: "Low", 2: "Medium", 3: "High"}
def _label_ticket(t: Any) -> Any:
"""Add *_label fields next to a ticket's numeric enums (priority, status,
impact, urgency) so no consumer ever guesses the scale. A weak model once
read priority 4 and called it "Low (4)" — Urgent — in a customer-visible
note; labels in the data remove that entire error class."""
if not isinstance(t, dict):
return t
p = t.get("priority")
if p in TICKET_PRIORITY:
t["priority_label"] = TICKET_PRIORITY[p]
for field in ("impact", "urgency"):
v = t.get(field)
if v in IMPACT_URGENCY:
t[f"{field}_label"] = IMPACT_URGENCY[v]
s = t.get("status")
if isinstance(s, int) and "status_name" not in t:
label = next((c.get("value") for c in _status_choices() if c.get("id") == s),
None) or TICKET_STATUS.get(s)
if label:
t["status_label"] = label
return t
# --- Tickets --------------------------------------------------------------- # --- Tickets ---------------------------------------------------------------
@mcp.tool() @mcp.tool()
def list_tickets(query: str | None = None, unresolved: bool = False, def list_tickets(query: str | None = None, unresolved: bool = False,
@@ -247,11 +274,16 @@ def list_tickets(query: str | None = None, unresolved: bool = False,
if query: if query:
params = {"query": f'"{query}"', "workspace_id": workspace_id, params = {"query": f'"{query}"', "workspace_id": workspace_id,
"page": page, "per_page": min(per_page, 100)} "page": page, "per_page": min(per_page, 100)}
return _out(_request("GET", "/tickets/filter", params=params)) res = _request("GET", "/tickets/filter", params=params)
params = {"updated_since": updated_since, "order_by": order_by, else:
"order_type": order_type, "workspace_id": workspace_id, params = {"updated_since": updated_since, "order_by": order_by,
"page": page, "per_page": min(per_page, 100)} "order_type": order_type, "workspace_id": workspace_id,
return _out(_request("GET", "/tickets", params=params)) "page": page, "per_page": min(per_page, 100)}
res = _request("GET", "/tickets", params=params)
if res.get("ok"):
for t in res["data"].get("tickets", []):
_label_ticket(t)
return _out(res)
@mcp.tool() @mcp.tool()
@@ -271,9 +303,19 @@ def list_ticket_statuses() -> str:
@mcp.tool() @mcp.tool()
def get_ticket(ticket_id: int, include: str | None = None) -> str: def get_ticket(ticket_id: int, include: str | None = None) -> str:
"""Get a ticket by id. `include` e.g. "conversations,requester,stats".""" """Get a ticket by id. `include` accepts a comma-separated subset of:
conversations, requester, requested_for, stats, problem, assets, tags,
related_tickets. NOTE: include=assets is the ONLY way to see the ticket's
associated CMDB assets (a /tickets/{id}/associated-assets path does not
exist — it 404s); the assets come back embedded in the ticket object.
Numeric enums come back with companion *_label fields (priority_label,
status_label, impact_label, urgency_label) — use those names, never guess
the scale (priority 4 is Urgent, not low)."""
params = {"include": include} if include else None params = {"include": include} if include else None
return _out(_request("GET", f"/tickets/{ticket_id}", params=params)) res = _request("GET", f"/tickets/{ticket_id}", params=params)
if res.get("ok"):
_label_ticket(res["data"].get("ticket"))
return _out(res)
@mcp.tool() @mcp.tool()

View File

@@ -86,6 +86,11 @@ lists which ones they're in).
- `GET /tickets` supports `filter` presets (`new_and_my_open`, `watching`, - `GET /tickets` supports `filter` presets (`new_and_my_open`, `watching`,
`spam`, `deleted`), `updated_since`, `order_by`, `order_type`, `page`, `spam`, `deleted`), `updated_since`, `order_by`, `order_type`, `page`,
`per_page` (max 100), and `include` (`conversations`, `requester`, `stats`). `per_page` (max 100), and `include` (`conversations`, `requester`, `stats`).
- `GET /tickets/{id}?include=assets` is the **only** way to see a ticket's
associated CMDB assets — they come back embedded in the ticket object. There
is no `/tickets/{id}/associated-assets` endpoint (it 404s). Other
single-ticket includes: `conversations`, `requester`, `requested_for`,
`stats`, `problem`, `tags`, `related_tickets`.
- `GET /tickets/filter?query="..."` is for ad-hoc field queries (also 100/page). - `GET /tickets/filter?query="..."` is for ad-hoc field queries (also 100/page).
## Pagination & rate limits ## Pagination & rate limits