Skip to main content
Glama
gasconc

freshdesk-mcp-server

by gasconc
README.md
# 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

A3.9/5.0

Scored across 19 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count4/5

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.

Completeness3/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues