freshdesk-mcp-server
# freshdesk-mcp-server
An [MCP](https://modelcontextprotocol.io) server for [Freshdesk](https://freshdesk.com), built around the part every other Freshdesk integration gets wrong: **knowing which tickets actually need an answer.**
```bash
npx -y freshdesk-mcp-server
```
19 tools over the Freshdesk REST API v2 — read tickets with their full thread, move fields, draft internally, reply to customers — with two opinions baked in:
1. **Ticket status does not tell you whose turn it is.** A ticket can sit in "In Progress" and still owe the customer a reply for four days. This server works that out from the reply timestamps instead of guessing from status.
2. **Your account is not the demo account.** Statuses, custom fields and their choices are discovered from your Freshdesk, never hardcoded.
---
## Why another one
There are several Freshdesk MCP servers. This one exists because of four specific things.
**The queue is computed, not guessed.** `GET /tickets` has two defaults that individually look reasonable and together hide exactly the work you care about:
- Without `updated_since` it filters by **creation** date over the last 30 days. A ticket opened two months ago whose customer replied this morning is simply absent — and ordering by `created_at` buries it even when it is present.
- `2=Open, 3=Pending` are the statuses Freshdesk ships. An account with custom statuses keeps its live work elsewhere, so filtering on the defaults returns *zero tickets* while looking perfectly correct.
Both are regression-tested here. `freshdesk_find_unanswered_tickets` asks for `updated_since` + `order_by=updated_at`, and reads `include=stats` to compare `agent_responded_at` against `requester_responded_at` — so classifying a whole page of 100 tickets costs no extra API call.
**Nothing is hardcoded.** The status codes, priorities, ticket types and custom fields come from `GET /ticket_fields`. You can filter and write using **names** (`"Esperando confirmación"`, accents optional) and an invalid value fails locally, listing the valid ones, instead of returning an opaque `400`.
**It respects a shared, small budget.** Freshdesk's rate limit is per **account**, not per key, so every integration you run draws from the same bucket — and per-endpoint ceilings are lower than the account one (ticket listing is capped at 20/min on entry plans). This server throttles client-side, honours `Retry-After`, reports the remaining budget in errors, and asks for `per_page=100` so a result set costs the fewest possible calls.
**It never hides who sees a write.** Every tool says whether the customer receives an email, sees it on the portal, or never knows it happened.
---
## Quickstart
Get your key: **Freshdesk → your avatar → Profile Settings → "Your API Key"**.
### Claude Code
```bash
claude mcp add freshdesk \
--env FRESHDESK_DOMAIN=yourcompany.freshdesk.com \
--env FRESHDESK_API_KEY=your_key \
-- npx -y freshdesk-mcp-server
```
### Claude Desktop, Cursor, or anything else with an `mcpServers` block
```json
{
"mcpServers": {
"freshdesk": {
"command": "npx",
"args": ["-y", "freshdesk-mcp-server"],
"env": {
"FRESHDESK_DOMAIN": "yourcompany.freshdesk.com",
"FRESHDESK_API_KEY": "your_key"
}
}
}
}
```
Then ask for the queue:
> Which tickets are waiting on us? Read the top one and draft a reply as an internal note.
---
## Configuration
| Variable | Required | Default | What it does |
|---|---|---|---|
| `FRESHDESK_DOMAIN` | yes | — | `acme.freshdesk.com`. A full URL or a bare `acme` both work |
| `FRESHDESK_API_KEY` | yes | — | Profile Settings → Your API Key |
| `FRESHDESK_CLOSED_STATUSES` | no | `4,5` | Status codes that mean "nobody is waiting on us". **Read the note below** |
| `FRESHDESK_REQUESTS_PER_MINUTE` | no | `50` | Client-side throttle. Lower it if other integrations share the account |
| `FRESHDESK_MAX_PAGES` | no | `5` | Cap on pages walked per listing. Truncation is always reported, never silent |
| `FRESHDESK_MAX_RETRIES` | no | `3` | Retries on 429 and 5xx |
| `FRESHDESK_TIMEOUT_MS` | no | `30000` | Per-request timeout |
### About `FRESHDESK_CLOSED_STATUSES`
This has to be configuration rather than detection, and it is worth 30 seconds of your attention. `GET /ticket_fields` lists your custom statuses with their labels but **says nothing about which of them are terminal**, and guessing from a label breaks the moment your account is not in English. Resolved (`4`) and Closed (`5`) are the two Freshdesk ships, so they are the default.
Run `freshdesk_get_ticket_fields` once and look at your statuses. If you have your own terminal state — a "Merged" or "Cancelled" — name it:
```
FRESHDESK_CLOSED_STATUSES=4,5,10
```
Get this wrong and tickets nobody is waiting on will show up in the queue. An unknown status is deliberately treated as *open*: surfacing a ticket that needed no answer is cheap, hiding one that did is not.
---
## Tools
### Reading
| Tool | What it does |
|---|---|
| `freshdesk_find_unanswered_tickets` | **Start here.** The queue of tickets awaiting our reply, most recently prodded first |
| `freshdesk_list_tickets` | Recent tickets, filtered by status name or code |
| `freshdesk_get_ticket` | One ticket: fields, requester, company, reply timestamps, original request |
| `freshdesk_get_conversations` | The whole thread, each message labelled by author and visibility |
| `freshdesk_search_tickets` | The filter endpoint, for questions a listing cannot express |
| `freshdesk_get_ticket_fields` | Your account's real statuses, priorities, types and custom fields |
| `freshdesk_get_contact` · `freshdesk_get_company` | Who is asking, and for which account |
| `freshdesk_list_requester_tickets` | That person's history — "have they asked this before?" |
| `freshdesk_get_related_tickets` | Parent, children and trackers, so a recurring incident stays one thing |
| `freshdesk_search_knowledge` · `freshdesk_get_article` | The documented answer, from your solution articles |
| `freshdesk_list_canned_responses` · `freshdesk_get_canned_response` | Wording your team already approved |
### Writing
| Tool | Does the customer see it? | Annotations |
|---|---|---|
| `freshdesk_update_ticket` | No email. Status changes **are** visible on the portal | `destructive`, `idempotent` |
| `freshdesk_add_private_note` | **Never.** Internal only | `destructive` |
| `freshdesk_add_public_note` | Visible on the portal, **no email sent** | `destructive` |
| `freshdesk_reply_to_customer` | **Yes — it emails them.** Cannot be unsent | `destructive`, `openWorld` |
| `freshdesk_log_time` | No | `destructive` |
Bodies accept **markdown or plain text** and are converted to the HTML Freshdesk expects. This matters more than it sounds: a body sent with raw `\n` newlines arrives in the customer's inbox as one unbroken paragraph, because Freshdesk drops the string straight into an HTML email.
### Resources and prompts
- `freshdesk://instance/fields` — your field inventory, loadable once as context instead of a tool call per session.
- `freshdesk://instance/account` — account name and plan (the plan sets your rate limits).
- Prompt **`draft_reply`** — the full workflow: find what is waiting → read the thread → check history → ground it in the KB and approved wording → leave the draft where a human can approve it.
- Prompt **`triage_ticket`** — read a ticket and propose field moves, with a reason for each.
---
## Safety model
Be clear-eyed about this before you point it at a production help desk.
**A Freshdesk API key is account-wide and cannot be scoped.** The same key that reads tickets can close them and email your customers. The only real granularity available is the **Freshdesk role of the agent who owns the key** — so if you want a read-mostly setup, create a limited agent in Freshdesk and use *their* key. This server cannot fence off what the key can do.
**Tool annotations are advisory.** `destructiveHint` asks your MCP client to confirm before calling; a client that does not prompt will let a model act without friction. Do not treat the hints as a control.
**So `freshdesk_reply_to_customer` is two-step.** Called without `confirm`, it returns exactly what would be sent — recipient, subject, rendered HTML — and sends nothing:
```
PREVIEW — nothing was sent.
Ticket: #4821 Invoice export missing March rows
Would email: Dana Okafor <dana@example.com>
Body as Freshdesk would render it:
<p>Hi Dana,</p><p>Thanks for the update.</p>
To send this for real, call freshdesk_reply_to_customer again with confirm: true.
```
A retried or mistaken tool call therefore costs a preview, not an email to somebody's customer.
**When a human should approve the wording, do not send at all.** Freshdesk has no draft concept in its API, but a **private note is exactly that**: the text lands on the ticket for a person to read, edit and send themselves. That is what `draft_reply` uses by default.
Every write is logged to **stderr** with a timestamp, the tool, the ticket and the size — never to stdout, which carries the MCP protocol.
**The API key is never logged.** It is stripped from error text before it leaves the process, because Freshdesk echoes request context into some error bodies and one leak into a client's logs means rotating a credential that opens the whole account.
---
## Troubleshooting
**`401`** — the key is wrong, revoked, or belongs to a different domain than `FRESHDESK_DOMAIN`.
**`403`** — the key is valid but the owning agent's Freshdesk role does not allow the operation. This is a permissions change in Freshdesk, not something to retry.
**`429`** — you hit the account-wide limit. It is shared with every other integration on the account; lower `FRESHDESK_REQUESTS_PER_MINUTE`. Note that each `include` side-load costs extra credit, so a listing is rarely one call's worth.
**A filter returns nothing but the tickets clearly exist** — you are almost certainly filtering on statuses your account does not use. Run `freshdesk_get_ticket_fields`.
**Closed tickets in the queue** — set `FRESHDESK_CLOSED_STATUSES` to include your own terminal statuses.
**Resolving a ticket fails with a `400` naming a field** — your account marks that field `required_for_closure`. `freshdesk_get_ticket_fields` lists which ones, and `freshdesk_update_ticket` warns before the call.
### Why not Freshdesk's own `/mcp` endpoint?
Freshdesk hosts an MCP server at `https://<domain>.freshdesk.com/mcp`, but it is **OAuth-only**: it answers `WWW-Authenticate: Bearer resource_metadata=…` and rejects any static token with a `403`. That is fine for a client that runs an interactive OAuth flow and refreshes tokens, and unusable for anything wiring up a long-lived credential. Freshdesk's REST API v2 authenticates with an API key that does not expire, which is what this server uses.
A useful diagnostic in general: before assuming a `/mcp` endpoint takes a token, call it with no auth and read the `WWW-Authenticate` header.
---
## Development
```bash
npm install
npm run build # tsc; the bin keeps its shebang
npm test # 71 tests, no network
npm run typecheck
npm run lint # biome
npm run inspect # build + @modelcontextprotocol/inspector over stdio
```
Tests stub `fetch` and never touch a real Freshdesk. Three of them are regression tests for bugs that are easy to reintroduce: a custom-status account returning an empty queue, a 40-day-old ticket with a fresh customer reply being ranked below a young quiet one, and a `429` being propagated instead of retried.
One more guards the write surface. `WRITE_TOOL_NAMES` in `src/tools/index.ts` is maintained **by hand**, and a test compares it against the annotations the tools actually declare. Adding a mutating tool without declaring it destructive fails the suite — which is the point. A security-relevant list that is derived from another list is not a list, it is a default.
Built on `@modelcontextprotocol/server` v2 with Zod v4 schemas, over stdio. Node ≥ 20.
### Releasing
Releases go out through **npm trusted publishing** (OIDC): there is no `NPM_TOKEN` in this
repository and no long-lived write token anywhere. `release.yml` exchanges a GitHub Actions
OIDC token for a short-lived credential scoped to this repo and workflow, and npm attaches a
provenance attestation automatically.
That leaves one wrinkle, worth knowing before you look for the setting: **the first version of
a package cannot be published with OIDC.** The trusted-publisher configuration lives on the
package's own settings page, and that page does not exist until the package does. So `0.1.0`
is published by hand, once:
```bash
npm login # browser + 2FA
npm run lint && npm run typecheck && npm test && npm run build
npm publish # no --provenance: attestations need a CI OIDC context
```
Then, on npmjs.com → the package → Settings → Trusted Publisher:
| Field | Value |
|---|---|
| Provider | GitHub Actions |
| Organization or user | `gasconc` |
| Repository | `freshdesk-mcp-server` |
| Workflow filename | `release.yml` — filename only, case-sensitive, no path |
Every release after that is a tag:
```bash
npm version patch # or minor / major; writes package.json and commits
git push --follow-tags
```
`release.yml` refuses to publish if the tag and `package.json` disagree, and asserts
`npm >= 11.5.1` before trying — an older client cannot complete the OIDC handshake, silently
falls back to anonymous, and fails with a `404` that reads like a missing package rather than
a missing permission.
Once trusted publishing works, consider turning on **"require two-factor authentication and
disallow tokens"** for the package. It makes this workflow the only way to publish.
## License
MIT
TDQS
Scored across 19 tools
Each tool targets a distinct resource or action. The ticket-listing tools (find_unanswered, list, search) are explicitly cross-referenced to prevent confusion, and reply/note tools are carefully separated by audience and email behavior.
All tools follow the freshdesk_verb_noun pattern consistently, using snake_case throughout. Verbs like get, list, search, add, update, reply, and log clearly indicate the action, and noun phrases are descriptive and uniform.
Nineteen tools is slightly above the ideal range, but the server covers a broad domain (tickets, contacts, companies, canned responses, knowledge base, time tracking) and each tool serves a distinct purpose without redundancy.
The server lacks a create_ticket tool, which is a core operation for a helpdesk. It also lacks tools to list contacts or companies, only fetching by ID. However, the existing read/search/reply/update flow covers most agent workflows, and these gaps can be partially worked around.