forked from spencerm/freshservice-claude
Handle custom ticket statuses: list_ticket_statuses tool + unresolved shortcut
The pesco instance defines 13 ticket statuses, not the 4 defaults, so a (status:2 OR status:3) 'open tickets' filter silently dropped Working, Scheduled, Waiting*, Pending Approval, etc. — undercounting 24 open tickets as 1. Add a status helper that reads the status field from /ticket_form_fields and treats only ids 4/5 (and closed/resolved-labelled) as terminal. Expose list_ticket_statuses() and a list_tickets(unresolved=True) shortcut that builds the full non-terminal status clause automatically. Document the gotcha in the skill reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -120,34 +120,71 @@ def _coerce_priority(v: Any) -> Any:
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# Freshservice instances can define many custom statuses beyond the 4 defaults.
|
||||||
|
# Resolved and Closed are always system ids 4 and 5; everything else (including
|
||||||
|
# custom statuses like "Working" or "Waiting Vendor Support") is "open".
|
||||||
|
_status_cache: dict[str, Any] = {"choices": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _status_choices() -> list[dict[str, Any]]:
|
||||||
|
"""Fetch (and cache) the instance's ticket status choices via the status
|
||||||
|
field on /ticket_form_fields."""
|
||||||
|
if _status_cache["choices"] is None:
|
||||||
|
res = _request("GET", "/ticket_form_fields")
|
||||||
|
choices: list[dict[str, Any]] = []
|
||||||
|
if res.get("ok"):
|
||||||
|
for f in res["data"].get("ticket_fields", []):
|
||||||
|
if f.get("name") == "status" or f.get("field_type") == "default_status":
|
||||||
|
choices = f.get("choices", [])
|
||||||
|
break
|
||||||
|
_status_cache["choices"] = choices
|
||||||
|
return _status_cache["choices"]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_terminal(choice: dict[str, Any]) -> bool:
|
||||||
|
"""A status counts as terminal (resolved/closed) if it's system id 4/5 or its
|
||||||
|
label says so (covers custom 'Closed - …' statuses)."""
|
||||||
|
label = str(choice.get("value", "")).lower()
|
||||||
|
return choice.get("id") in (4, 5) or "resolved" in label or "closed" in label
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
# --- Tickets ---------------------------------------------------------------
|
# --- Tickets ---------------------------------------------------------------
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_tickets(query: str | None = None, updated_since: str | None = None,
|
def list_tickets(query: str | None = None, unresolved: bool = False,
|
||||||
|
updated_since: str | None = None,
|
||||||
order_by: str | None = None, order_type: str | None = None,
|
order_by: str | None = None, order_type: str | None = None,
|
||||||
workspace_id: int | None = None,
|
workspace_id: int | None = None,
|
||||||
page: int = 1, per_page: int = 30) -> str:
|
page: int = 1, per_page: int = 30) -> str:
|
||||||
"""List or filter tickets. With `query`, uses the /tickets/filter endpoint.
|
"""List or filter tickets. With `query` (or `unresolved`), uses /tickets/filter.
|
||||||
|
|
||||||
Status codes: 2=Open 3=Pending 4=Resolved 5=Closed.
|
Default status codes: 2=Open 3=Pending 4=Resolved 5=Closed — but this instance
|
||||||
Priority codes: 1=Low 2=Medium 3=High 4=Urgent.
|
may define CUSTOM statuses too (e.g. "Working", "Waiting Vendor Support"). Call
|
||||||
|
list_ticket_statuses() to see them all. Priority: 1=Low 2=Medium 3=High 4=Urgent.
|
||||||
|
|
||||||
Building a filter `query`:
|
`unresolved=True`: the turnkey "still-open" filter. The server fetches every
|
||||||
* Filter by ASSIGNEE with the numeric `agent_id`, NOT an email. To go from
|
status, drops Resolved/Closed (and any custom closed-like status), and builds
|
||||||
an email to the id, call list_agents(email=...) first and read `id`.
|
the `(status:.. OR ..)` clause for you — so you don't miss custom statuses.
|
||||||
* "Open / unresolved / not closed" has no negation operator — enumerate the
|
Combine it with a `query` to scope further (they are AND-ed), e.g.
|
||||||
statuses instead: `(status:2 OR status:3)`. Wrap an OR group in
|
list_tickets(query="agent_id:21000816864", unresolved=True) = my open tickets.
|
||||||
parentheses when combining with AND.
|
|
||||||
* Quote string values: `tag:'vip'`. Dates: `created_at:>'2026-01-01'`.
|
|
||||||
|
|
||||||
Examples:
|
Building a `query` by hand:
|
||||||
* Tickets assigned to agent 21000816864 that are still open:
|
* Filter by ASSIGNEE with the numeric `agent_id`, NOT an email — resolve via
|
||||||
"agent_id:21000816864 AND (status:2 OR status:3)"
|
list_agents(email=...) first and read `id`.
|
||||||
* Open high-priority: "status:2 AND priority:3"
|
* There is no negation operator; prefer `unresolved=True` over hand-listing
|
||||||
* Unassigned & open: "agent_id:0 AND status:2"
|
statuses. Quote strings: `tag:'vip'`. Dates: `created_at:>'2026-01-01'`.
|
||||||
|
|
||||||
`workspace_id`: accounts with multiple workspaces only search one workspace by
|
`workspace_id`: accounts with multiple workspaces only search one workspace by
|
||||||
default. Pass a workspace id to target another (see an agent's workspace_ids).
|
default. Pass a workspace id to target another (see an agent's workspace_ids).
|
||||||
"""
|
"""
|
||||||
|
if unresolved:
|
||||||
|
ids = _open_status_ids()
|
||||||
|
if ids:
|
||||||
|
clause = "(" + " OR ".join(f"status:{i}" for i in ids) + ")"
|
||||||
|
query = f"({query}) AND {clause}" if query else clause
|
||||||
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)}
|
||||||
@@ -158,6 +195,17 @@ def list_tickets(query: str | None = None, updated_since: str | None = None,
|
|||||||
return _out(_request("GET", "/tickets", params=params))
|
return _out(_request("GET", "/tickets", params=params))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_ticket_statuses() -> str:
|
||||||
|
"""List every ticket status defined in this Freshservice instance (defaults +
|
||||||
|
custom), each flagged `terminal` (Resolved/Closed) or open. Use this to build
|
||||||
|
status filters correctly, or just call list_tickets(unresolved=True)."""
|
||||||
|
statuses = [{"id": c.get("id"), "label": c.get("value"),
|
||||||
|
"terminal": _is_terminal(c)} for c in _status_choices()]
|
||||||
|
return _out({"ok": True, "statuses": statuses,
|
||||||
|
"open_ids": _open_status_ids()})
|
||||||
|
|
||||||
|
|
||||||
@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` e.g. "conversations,requester,stats"."""
|
||||||
|
|||||||
12
reference.md
12
reference.md
@@ -5,8 +5,18 @@ API key as username (handled by `scripts/fs.py`). Full docs: https://api.freshse
|
|||||||
|
|
||||||
## Codes
|
## Codes
|
||||||
|
|
||||||
**Ticket status:** 2 Open · 3 Pending · 4 Resolved · 5 Closed
|
**Ticket status (defaults):** 2 Open · 3 Pending · 4 Resolved · 5 Closed
|
||||||
**Ticket priority:** 1 Low · 2 Medium · 3 High · 4 Urgent
|
**Ticket priority:** 1 Low · 2 Medium · 3 High · 4 Urgent
|
||||||
|
|
||||||
|
> ⚠️ Instances often define **custom statuses** beyond the 4 defaults (e.g.
|
||||||
|
> "Working", "Scheduled", "Waiting Vendor Support"). Resolved and Closed are
|
||||||
|
> always ids **4** and **5**; every other status is "open". To list them all,
|
||||||
|
> read the `status` field choices:
|
||||||
|
> `GET /ticket_form_fields` → field where `name == "status"` → `choices[]`.
|
||||||
|
> So "all my tickets that aren't resolved/closed" must enumerate *every*
|
||||||
|
> non-4/5 status, not just `(status:2 OR status:3)`. Recipe:
|
||||||
|
> `agent_id:<id> AND (status:2 OR status:3 OR status:6 OR status:7 OR ...)`
|
||||||
|
> using all open ids from the status field.
|
||||||
**Ticket source:** 1 Email · 2 Portal · 3 Phone · 4 Chat · 5 Feedback widget ·
|
**Ticket source:** 1 Email · 2 Portal · 3 Phone · 4 Chat · 5 Feedback widget ·
|
||||||
6 Yammer · 7 AWS CloudWatch · 8 PagerDuty · 9 Walkup · 10 Slack
|
6 Yammer · 7 AWS CloudWatch · 8 PagerDuty · 9 Walkup · 10 Slack
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user