freshdesk-mcp-server
An MCP server for Freshdesk that identifies which tickets actually need a reply, and provides tools to read, draft, and respond to tickets while respecting your account's custom statuses.
README
freshdesk-mcp-server
An MCP server for Freshdesk, built around the part every other Freshdesk integration gets wrong: knowing which tickets actually need an answer.
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:
- 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.
- 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_sinceit 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 bycreated_atburies it even when it is present. 2=Open, 3=Pendingare 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
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
{
"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
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:
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:
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
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。