@mindstone-engineering/mcp-server-freshdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Freshdesk authentication and multi-account management module.
3
+ *
4
+ * Supports multiple Freshdesk accounts stored in a file-backed accounts.json.
5
+ * Hot-reloads accounts from disk on every getAccount() call to pick up
6
+ * external changes (e.g. from the host app bridge).
7
+ *
8
+ * Auth format: Basic base64(apiKey:X)
9
+ *
10
+ * File permissions:
11
+ * - accounts.json: 0o600 (read/write owner only)
12
+ * - config directory: 0o700 (rwx owner only)
13
+ */
14
+ import type { AccountsConfig, AccountInfo, FreshdeskAccount } from './types.js';
15
+ /**
16
+ * Returns the config directory path.
17
+ */
18
+ export declare function getConfigPath(): string;
19
+ /**
20
+ * Load accounts from accounts.json.
21
+ * Called on every getAccount() call to support hot-reload.
22
+ */
23
+ export declare function loadAccounts(): void;
24
+ /**
25
+ * Get the current in-memory accounts config (for listing, etc.).
26
+ */
27
+ export declare function getAccountsConfig(): AccountsConfig;
28
+ /**
29
+ * Get an account by domain, with hot-reload support.
30
+ * If no domain specified, returns the default or first account.
31
+ */
32
+ export declare function getAccount(domain?: string): FreshdeskAccount | undefined;
33
+ /**
34
+ * Save accounts.json with secure file permissions.
35
+ */
36
+ export declare function saveAccounts(config: AccountsConfig): void;
37
+ /**
38
+ * Remove an account by domain. Returns true if found and removed.
39
+ */
40
+ export declare function removeAccount(domain: string): boolean;
41
+ /**
42
+ * Add or update an account. Saves to disk with proper permissions.
43
+ */
44
+ export declare function upsertAccount(info: AccountInfo): void;
45
+ //# sourceMappingURL=auth.d.ts.map
package/dist/auth.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Freshdesk authentication and multi-account management module.
3
+ *
4
+ * Supports multiple Freshdesk accounts stored in a file-backed accounts.json.
5
+ * Hot-reloads accounts from disk on every getAccount() call to pick up
6
+ * external changes (e.g. from the host app bridge).
7
+ *
8
+ * Auth format: Basic base64(apiKey:X)
9
+ *
10
+ * File permissions:
11
+ * - accounts.json: 0o600 (read/write owner only)
12
+ * - config directory: 0o700 (rwx owner only)
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import * as os from 'os';
17
+ const CONFIG_PATH = process.env.FRESHDESK_CONFIG_PATH || path.join(os.homedir(), '.mcp', 'freshdesk');
18
+ let accountsConfig = { accounts: [] };
19
+ /**
20
+ * Returns the config directory path.
21
+ */
22
+ export function getConfigPath() {
23
+ return CONFIG_PATH;
24
+ }
25
+ /**
26
+ * Load accounts from accounts.json.
27
+ * Called on every getAccount() call to support hot-reload.
28
+ */
29
+ export function loadAccounts() {
30
+ const accountsPath = path.join(CONFIG_PATH, 'accounts.json');
31
+ try {
32
+ if (fs.existsSync(accountsPath)) {
33
+ const raw = fs.readFileSync(accountsPath, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && Array.isArray(parsed.accounts)) {
36
+ accountsConfig = {
37
+ accounts: parsed.accounts,
38
+ defaultDomain: typeof parsed.defaultDomain === 'string' ? parsed.defaultDomain : undefined,
39
+ };
40
+ }
41
+ else {
42
+ accountsConfig = { accounts: [] };
43
+ }
44
+ }
45
+ }
46
+ catch {
47
+ accountsConfig = { accounts: [] };
48
+ }
49
+ }
50
+ /**
51
+ * Get the current in-memory accounts config (for listing, etc.).
52
+ */
53
+ export function getAccountsConfig() {
54
+ return accountsConfig;
55
+ }
56
+ /**
57
+ * Get an account by domain, with hot-reload support.
58
+ * If no domain specified, returns the default or first account.
59
+ */
60
+ export function getAccount(domain) {
61
+ // Hot-reload: always reload accounts from disk
62
+ loadAccounts();
63
+ if (accountsConfig.accounts.length === 0)
64
+ return undefined;
65
+ let info;
66
+ if (domain) {
67
+ info = accountsConfig.accounts.find((a) => a.domain === domain);
68
+ }
69
+ else {
70
+ const defaultDom = accountsConfig.defaultDomain;
71
+ if (defaultDom) {
72
+ info = accountsConfig.accounts.find((a) => a.domain === defaultDom);
73
+ }
74
+ if (!info) {
75
+ info = accountsConfig.accounts[0];
76
+ }
77
+ }
78
+ if (!info)
79
+ return undefined;
80
+ return {
81
+ domain: info.domain,
82
+ apiKey: info.apiKey,
83
+ agentEmail: info.agentEmail,
84
+ };
85
+ }
86
+ /**
87
+ * Save accounts.json with secure file permissions.
88
+ */
89
+ export function saveAccounts(config) {
90
+ // Ensure config directory exists with 0o700 permissions
91
+ if (!fs.existsSync(CONFIG_PATH)) {
92
+ fs.mkdirSync(CONFIG_PATH, { recursive: true, mode: 0o700 });
93
+ }
94
+ const accountsPath = path.join(CONFIG_PATH, 'accounts.json');
95
+ fs.writeFileSync(accountsPath, JSON.stringify(config, null, 2), { mode: 0o600 });
96
+ // Update in-memory state
97
+ accountsConfig = config;
98
+ }
99
+ /**
100
+ * Remove an account by domain. Returns true if found and removed.
101
+ */
102
+ export function removeAccount(domain) {
103
+ loadAccounts();
104
+ const idx = accountsConfig.accounts.findIndex((a) => a.domain === domain);
105
+ if (idx < 0)
106
+ return false;
107
+ accountsConfig.accounts.splice(idx, 1);
108
+ // Update default if the removed domain was the default
109
+ if (accountsConfig.defaultDomain === domain) {
110
+ accountsConfig.defaultDomain = accountsConfig.accounts[0]?.domain;
111
+ }
112
+ saveAccounts(accountsConfig);
113
+ return true;
114
+ }
115
+ /**
116
+ * Add or update an account. Saves to disk with proper permissions.
117
+ */
118
+ export function upsertAccount(info) {
119
+ loadAccounts();
120
+ const idx = accountsConfig.accounts.findIndex((a) => a.domain === info.domain);
121
+ if (idx >= 0) {
122
+ accountsConfig.accounts[idx] = info;
123
+ }
124
+ else {
125
+ accountsConfig.accounts.push(info);
126
+ }
127
+ // Set default if this is the first account
128
+ if (!accountsConfig.defaultDomain) {
129
+ accountsConfig.defaultDomain = info.domain;
130
+ }
131
+ saveAccounts(accountsConfig);
132
+ }
133
+ // Initialize accounts on startup
134
+ loadAccounts();
135
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Path to bridge state file, supporting both current and legacy env vars.
3
+ */
4
+ export declare const BRIDGE_STATE_PATH: string;
5
+ /**
6
+ * Send a request to the host app bridge.
7
+ *
8
+ * The bridge is an HTTP server running inside the host app (e.g. Rebel)
9
+ * that handles credential management and other cross-process operations.
10
+ */
11
+ export declare const bridgeRequest: (urlPath: string, body: Record<string, unknown>) => Promise<{
12
+ success: boolean;
13
+ warning?: string;
14
+ error?: string;
15
+ }>;
16
+ //# sourceMappingURL=bridge.d.ts.map
package/dist/bridge.js ADDED
@@ -0,0 +1,43 @@
1
+ import * as fs from 'fs';
2
+ import { REQUEST_TIMEOUT_MS } from './types.js';
3
+ /**
4
+ * Path to bridge state file, supporting both current and legacy env vars.
5
+ */
6
+ export const BRIDGE_STATE_PATH = process.env.MCP_HOST_BRIDGE_STATE || process.env.MINDSTONE_REBEL_BRIDGE_STATE || '';
7
+ const loadBridgeState = () => {
8
+ if (!BRIDGE_STATE_PATH)
9
+ return null;
10
+ try {
11
+ const raw = fs.readFileSync(BRIDGE_STATE_PATH, 'utf8');
12
+ return JSON.parse(raw);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ };
18
+ /**
19
+ * Send a request to the host app bridge.
20
+ *
21
+ * The bridge is an HTTP server running inside the host app (e.g. Rebel)
22
+ * that handles credential management and other cross-process operations.
23
+ */
24
+ export const bridgeRequest = async (urlPath, body) => {
25
+ const bridge = loadBridgeState();
26
+ if (!bridge) {
27
+ return { success: false, error: 'Bridge not available' };
28
+ }
29
+ const response = await fetch(`http://127.0.0.1:${bridge.port}${urlPath}`, {
30
+ method: 'POST',
31
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ Authorization: `Bearer ${bridge.token}`,
35
+ },
36
+ body: JSON.stringify(body),
37
+ });
38
+ if (response.status === 401 || response.status === 403) {
39
+ return { success: false, error: `Bridge returned ${response.status}: unauthorized. Check host app authentication.` };
40
+ }
41
+ return response.json();
42
+ };
43
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Freshdesk API HTTP client.
3
+ *
4
+ * Centralises Basic auth header injection, error handling, rate-limit
5
+ * messaging, and domain URL construction for all Freshdesk API calls.
6
+ *
7
+ * Auth: Authorization: Basic base64(apiKey:X)
8
+ * Base URL: https://{domain}.freshdesk.com/api/v2
9
+ */
10
+ export interface FreshdeskFetchOptions extends RequestInit {
11
+ params?: Record<string, string | number | boolean | undefined>;
12
+ }
13
+ /**
14
+ * Make an authenticated request to the Freshdesk API.
15
+ *
16
+ * @param domain Freshdesk subdomain (e.g. "acme")
17
+ * @param apiKey Freshdesk API key
18
+ * @param endpoint Path relative to /api/v2, e.g. '/tickets'
19
+ * @param options Additional fetch options (method, body, headers, params)
20
+ * @returns Parsed JSON response
21
+ */
22
+ export declare function freshdeskFetch<T>(domain: string, apiKey: string, endpoint: string, options?: FreshdeskFetchOptions): Promise<T>;
23
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Freshdesk API HTTP client.
3
+ *
4
+ * Centralises Basic auth header injection, error handling, rate-limit
5
+ * messaging, and domain URL construction for all Freshdesk API calls.
6
+ *
7
+ * Auth: Authorization: Basic base64(apiKey:X)
8
+ * Base URL: https://{domain}.freshdesk.com/api/v2
9
+ */
10
+ import { FreshdeskError, REQUEST_TIMEOUT_MS } from './types.js';
11
+ /**
12
+ * Make an authenticated request to the Freshdesk API.
13
+ *
14
+ * @param domain Freshdesk subdomain (e.g. "acme")
15
+ * @param apiKey Freshdesk API key
16
+ * @param endpoint Path relative to /api/v2, e.g. '/tickets'
17
+ * @param options Additional fetch options (method, body, headers, params)
18
+ * @returns Parsed JSON response
19
+ */
20
+ export async function freshdeskFetch(domain, apiKey, endpoint, options = {}) {
21
+ const { params, ...fetchOptions } = options;
22
+ // Build URL with query params
23
+ let url = `https://${domain}.freshdesk.com/api/v2${endpoint}`;
24
+ if (params) {
25
+ const searchParams = new URLSearchParams();
26
+ for (const [key, value] of Object.entries(params)) {
27
+ if (value !== undefined) {
28
+ searchParams.append(key, String(value));
29
+ }
30
+ }
31
+ const queryString = searchParams.toString();
32
+ if (queryString) {
33
+ url += (url.includes('?') ? '&' : '?') + queryString;
34
+ }
35
+ }
36
+ // Build auth header: Basic base64("{apiKey}:X")
37
+ const authHeader = `Basic ${Buffer.from(`${apiKey}:X`).toString('base64')}`;
38
+ console.error(`[Freshdesk API] ${fetchOptions.method || 'GET'} ${url}`);
39
+ let response;
40
+ try {
41
+ response = await fetch(url, {
42
+ ...fetchOptions,
43
+ signal: fetchOptions.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
44
+ headers: {
45
+ Authorization: authHeader,
46
+ 'Content-Type': 'application/json',
47
+ Accept: 'application/json',
48
+ ...fetchOptions.headers,
49
+ },
50
+ });
51
+ }
52
+ catch (error) {
53
+ if (error instanceof Error && error.name === 'TimeoutError') {
54
+ throw new FreshdeskError('Request to Freshdesk API timed out', 'TIMEOUT', 'The request took too long. Try again or check if the Freshdesk instance is available.');
55
+ }
56
+ throw error;
57
+ }
58
+ // Handle rate limiting
59
+ if (response.status === 429) {
60
+ const retryAfter = response.headers.get('Retry-After');
61
+ const waitTime = retryAfter ? `${retryAfter} seconds` : 'a moment';
62
+ throw new FreshdeskError(`Rate limited. Please wait ${waitTime} before retrying.`, 'RATE_LIMITED', `Wait ${waitTime} and try again. Freshdesk rate limits vary by plan (Blossom: 100/min, Estate: 400/min, Forest: 700/min).`);
63
+ }
64
+ // Handle auth errors
65
+ if (response.status === 401) {
66
+ throw new FreshdeskError('Authentication failed', 'AUTH_FAILED', 'API key is invalid or revoked. Check your Freshdesk API key, or reconnect in Mindstone settings.');
67
+ }
68
+ // Handle forbidden
69
+ if (response.status === 403) {
70
+ throw new FreshdeskError('Access forbidden', 'AUTH_FAILED', 'Your API key does not have permission for this operation. Check your Freshdesk plan and role.');
71
+ }
72
+ // Handle not found
73
+ if (response.status === 404) {
74
+ throw new FreshdeskError('Resource not found', 'NOT_FOUND', 'The requested resource does not exist or you do not have permission to access it.');
75
+ }
76
+ // Handle other errors
77
+ if (!response.ok) {
78
+ const errorText = await response.text().catch(() => '');
79
+ console.error(`Freshdesk API error (${response.status}):`, errorText);
80
+ const statusMessage = response.status === 422
81
+ ? 'Validation error - check request parameters'
82
+ : response.status >= 500
83
+ ? 'Freshdesk server error - try again later'
84
+ : 'Request failed';
85
+ throw new FreshdeskError(`Freshdesk API error (${response.status}): ${statusMessage}`, 'API_ERROR', 'Check the request parameters and try again. If the problem persists, reconnect your Freshdesk account in Mindstone settings.');
86
+ }
87
+ // Handle 204 No Content
88
+ if (response.status === 204) {
89
+ return {};
90
+ }
91
+ return response.json();
92
+ }
93
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Response formatting helpers for Freshdesk ticket data.
3
+ */
4
+ import type { FreshdeskTicket, FreshdeskConversation, FreshdeskTicketField } from './types.js';
5
+ export declare function ticketUrl(domain: string, ticketId: number): string;
6
+ export declare function formatTicketConcise(ticket: FreshdeskTicket, domain: string): string;
7
+ export declare function formatTicketDetailed(ticket: FreshdeskTicket, domain: string): string;
8
+ export declare function formatConversation(conv: FreshdeskConversation): string;
9
+ export declare function formatTicketField(field: FreshdeskTicketField): string;
10
+ //# sourceMappingURL=formatters.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Response formatting helpers for Freshdesk ticket data.
3
+ */
4
+ import { statusToString, priorityToString, sourceToString } from './types.js';
5
+ export function ticketUrl(domain, ticketId) {
6
+ return `https://${domain}.freshdesk.com/a/tickets/${ticketId}`;
7
+ }
8
+ export function formatTicketConcise(ticket, domain) {
9
+ const status = statusToString(ticket.status);
10
+ const priority = priorityToString(ticket.priority);
11
+ return `#${ticket.id}: ${ticket.subject} [${status}] (${priority}) — ${ticketUrl(domain, ticket.id)}`;
12
+ }
13
+ export function formatTicketDetailed(ticket, domain) {
14
+ return [
15
+ `Ticket #${ticket.id}`,
16
+ `URL: ${ticketUrl(domain, ticket.id)}`,
17
+ `Subject: ${ticket.subject}`,
18
+ `Status: ${statusToString(ticket.status)} (${ticket.status})`,
19
+ `Priority: ${priorityToString(ticket.priority)} (${ticket.priority})`,
20
+ `Source: ${sourceToString(ticket.source)}`,
21
+ ticket.type ? `Type: ${ticket.type}` : '',
22
+ `Requester ID: ${ticket.requester_id}`,
23
+ ticket.email ? `Requester Email: ${ticket.email}` : '',
24
+ ticket.responder_id ? `Assignee ID: ${ticket.responder_id}` : 'Assignee: unassigned',
25
+ ticket.group_id ? `Group ID: ${ticket.group_id}` : '',
26
+ `Created: ${ticket.created_at}`,
27
+ `Updated: ${ticket.updated_at}`,
28
+ ticket.due_by ? `Due by: ${ticket.due_by}` : '',
29
+ ticket.tags && ticket.tags.length > 0 ? `Tags: ${ticket.tags.join(', ')}` : '',
30
+ ticket.description_text ? `Description:\n${ticket.description_text}` : '',
31
+ ]
32
+ .filter(Boolean)
33
+ .join('\n');
34
+ }
35
+ export function formatConversation(conv) {
36
+ const type = conv.private ? 'Internal note' : conv.incoming ? 'Customer reply' : 'Agent reply';
37
+ const preview = (conv.body_text || conv.body || '').slice(0, 200);
38
+ const truncated = (conv.body_text || conv.body || '').length > 200 ? '...' : '';
39
+ return `[${conv.created_at}] ${type} (User ${conv.user_id}):\n${preview}${truncated}`;
40
+ }
41
+ export function formatTicketField(field) {
42
+ const required = field.required_for_agents ? ' [required for agents]' : '';
43
+ const closure = field.required_for_closure ? ' [required for closure]' : '';
44
+ return `${field.label} (ID: ${field.id}, name: ${field.name}, type: ${field.type})${required}${closure}`;
45
+ }
46
+ //# sourceMappingURL=formatters.js.map
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Freshdesk MCP Server
4
+ *
5
+ * Provides Freshdesk Support integration via Model Context Protocol.
6
+ * Supports multi-account management with file-backed accounts.json.
7
+ *
8
+ * Environment variables:
9
+ * - FRESHDESK_CONFIG_PATH: Path to config directory containing accounts.json
10
+ * - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
11
+ * - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Freshdesk MCP Server
4
+ *
5
+ * Provides Freshdesk Support integration via Model Context Protocol.
6
+ * Supports multi-account management with file-backed accounts.json.
7
+ *
8
+ * Environment variables:
9
+ * - FRESHDESK_CONFIG_PATH: Path to config directory containing accounts.json
10
+ * - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
11
+ * - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
12
+ */
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { createServer } from './server.js';
15
+ async function main() {
16
+ const server = createServer();
17
+ const transport = new StdioServerTransport();
18
+ await server.connect(transport);
19
+ console.error('Freshdesk MCP server running on stdio');
20
+ }
21
+ main().catch((error) => {
22
+ console.error('Fatal error:', error);
23
+ process.exit(1);
24
+ });
25
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function createServer(): McpServer;
3
+ //# sourceMappingURL=server.d.ts.map
package/dist/server.js ADDED
@@ -0,0 +1,13 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerConfigureTools, registerTicketTools, registerFieldTools } from './tools/index.js';
3
+ export function createServer() {
4
+ const server = new McpServer({
5
+ name: 'freshdesk-mcp-server',
6
+ version: '0.1.0',
7
+ });
8
+ registerConfigureTools(server);
9
+ registerTicketTools(server);
10
+ registerFieldTools(server);
11
+ return server;
12
+ }
13
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerConfigureTools(server: McpServer): void;
3
+ //# sourceMappingURL=configure.d.ts.map
@@ -0,0 +1,114 @@
1
+ import { z } from 'zod';
2
+ import { loadAccounts, getAccountsConfig, removeAccount, upsertAccount } from '../auth.js';
3
+ import { bridgeRequest, BRIDGE_STATE_PATH } from '../bridge.js';
4
+ import { FreshdeskError } from '../types.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ export function registerConfigureTools(server) {
7
+ // ── configure_freshdesk ─────────────────────────────────────────
8
+ server.registerTool('configure_freshdesk', {
9
+ description: 'Connect a Freshdesk account using API key authentication. ' +
10
+ 'Provide the Freshdesk subdomain (e.g., "acme" for acme.freshdesk.com) and API key from Profile Settings. ' +
11
+ 'The account is stored and available for other Freshdesk tools. ' +
12
+ 'HOW TO FIND YOUR API KEY: Log in → Profile picture → Profile Settings → API Key (right side). ' +
13
+ 'COMMON MISTAKES: Providing full URL instead of just the subdomain.',
14
+ inputSchema: z.object({
15
+ domain: z
16
+ .string()
17
+ .min(1)
18
+ .describe('Freshdesk subdomain (e.g. "acme" for acme.freshdesk.com)'),
19
+ api_key: z.string().min(1).describe('Freshdesk API key from Profile Settings'),
20
+ }),
21
+ annotations: { readOnlyHint: false, destructiveHint: false },
22
+ }, withErrorHandling(async (args) => {
23
+ const domain = args.domain.trim();
24
+ const apiKey = args.api_key.trim();
25
+ // If bridge is available, persist via bridge
26
+ if (BRIDGE_STATE_PATH) {
27
+ try {
28
+ const result = await bridgeRequest('/bundled/freshdesk/configure', {
29
+ domain,
30
+ apiKey,
31
+ });
32
+ if (result.success) {
33
+ loadAccounts();
34
+ const message = result.warning
35
+ ? `Freshdesk account connected: ${domain}.freshdesk.com. Note: ${result.warning}`
36
+ : `Freshdesk account connected: ${domain}.freshdesk.com`;
37
+ return JSON.stringify({ ok: true, message, domain });
38
+ }
39
+ // Bridge returned failure — surface as error, do NOT fall through
40
+ throw new FreshdeskError(result.error || 'Bridge configuration failed', 'BRIDGE_ERROR', 'The host app bridge rejected the configuration request. Check the host app logs.');
41
+ }
42
+ catch (error) {
43
+ if (error instanceof FreshdeskError)
44
+ throw error;
45
+ // Bridge request failed (network, timeout, etc.) — surface as error
46
+ throw new FreshdeskError(`Bridge request failed: ${error instanceof Error ? error.message : String(error)}`, 'BRIDGE_ERROR', 'Could not reach the host app bridge. Ensure the host app is running.');
47
+ }
48
+ }
49
+ // No bridge — store locally
50
+ upsertAccount({
51
+ domain,
52
+ apiKey,
53
+ authenticatedAt: new Date().toISOString(),
54
+ });
55
+ return JSON.stringify({
56
+ ok: true,
57
+ message: `Freshdesk account connected: ${domain}.freshdesk.com`,
58
+ domain,
59
+ });
60
+ }));
61
+ // ── list_freshdesk_accounts ─────────────────────────────────────
62
+ server.registerTool('list_freshdesk_accounts', {
63
+ description: 'List connected Freshdesk accounts. Returns all authenticated domains with associated agent emails. ' +
64
+ 'Call this first to check connected accounts. If none, use configure_freshdesk.',
65
+ inputSchema: z.object({}),
66
+ annotations: { readOnlyHint: true },
67
+ }, withErrorHandling(async () => {
68
+ loadAccounts();
69
+ const config = getAccountsConfig();
70
+ if (config.accounts.length === 0) {
71
+ return JSON.stringify({
72
+ ok: true,
73
+ accounts: [],
74
+ message: 'No Freshdesk accounts connected. Use configure_freshdesk or go to Mindstone Settings > Integrations > Freshdesk to connect.',
75
+ });
76
+ }
77
+ const accountList = config.accounts.map((account) => ({
78
+ domain: account.domain,
79
+ agentEmail: account.agentEmail || 'unknown',
80
+ url: `https://${account.domain}.freshdesk.com`,
81
+ authenticatedAt: account.authenticatedAt,
82
+ status: 'active',
83
+ }));
84
+ return JSON.stringify({
85
+ ok: true,
86
+ accounts: accountList,
87
+ defaultDomain: config.defaultDomain,
88
+ });
89
+ }));
90
+ // ── remove_freshdesk_account ────────────────────────────────────
91
+ server.registerTool('remove_freshdesk_account', {
92
+ description: 'Disconnect a Freshdesk account. Removes stored credentials for the specified domain. ' +
93
+ 'Use list_freshdesk_accounts to see available domains.',
94
+ inputSchema: z.object({
95
+ domain: z.string().min(1).describe('Freshdesk domain to disconnect (e.g. "acme")'),
96
+ }),
97
+ annotations: { readOnlyHint: false, destructiveHint: true },
98
+ }, withErrorHandling(async (args) => {
99
+ const domain = args.domain.trim();
100
+ const removed = removeAccount(domain);
101
+ if (!removed) {
102
+ return JSON.stringify({
103
+ ok: false,
104
+ error: `No account found for domain "${domain}".`,
105
+ resolution: 'Use list_freshdesk_accounts to see available domains.',
106
+ });
107
+ }
108
+ return JSON.stringify({
109
+ ok: true,
110
+ message: `Disconnected ${domain}.freshdesk.com`,
111
+ });
112
+ }));
113
+ }
114
+ //# sourceMappingURL=configure.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerFieldTools(server: McpServer): void;
3
+ //# sourceMappingURL=fields.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { getAccount } from '../auth.js';
3
+ import { freshdeskFetch } from '../client.js';
4
+ import { formatTicketField } from '../formatters.js';
5
+ import { withErrorHandling } from '../utils.js';
6
+ export function registerFieldTools(server) {
7
+ server.registerTool('list_freshdesk_ticket_fields', {
8
+ description: 'List all ticket fields including custom fields. Returns field IDs, names, types, and options. ' +
9
+ 'Essential for finding custom field names for create/update operations.',
10
+ inputSchema: z.object({
11
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
12
+ response_format: z
13
+ .enum(['concise', 'detailed'])
14
+ .optional()
15
+ .describe('Response format (default: "concise")'),
16
+ }),
17
+ annotations: { readOnlyHint: true },
18
+ }, withErrorHandling(async (args) => {
19
+ const account = getAccount(args.domain);
20
+ if (!account) {
21
+ return JSON.stringify({
22
+ ok: false,
23
+ error: 'No Freshdesk account connected',
24
+ resolution: 'Use configure_freshdesk or go to Mindstone Settings > Integrations > Freshdesk to connect your account.',
25
+ });
26
+ }
27
+ const fields = await freshdeskFetch(account.domain, account.apiKey, '/admin/ticket_fields');
28
+ const format = args.response_format || 'concise';
29
+ if (format === 'concise') {
30
+ const lines = fields.map(formatTicketField);
31
+ return `Ticket Fields (${fields.length}):\n${lines.join('\n')}`;
32
+ }
33
+ return JSON.stringify({
34
+ ok: true,
35
+ ticket_fields: fields,
36
+ count: fields.length,
37
+ });
38
+ }));
39
+ }
40
+ //# sourceMappingURL=fields.js.map
@@ -0,0 +1,4 @@
1
+ export { registerConfigureTools } from './configure.js';
2
+ export { registerTicketTools } from './tickets.js';
3
+ export { registerFieldTools } from './fields.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { registerConfigureTools } from './configure.js';
2
+ export { registerTicketTools } from './tickets.js';
3
+ export { registerFieldTools } from './fields.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTicketTools(server: McpServer): void;
3
+ //# sourceMappingURL=tickets.d.ts.map
@@ -0,0 +1,344 @@
1
+ import { z } from 'zod';
2
+ import { getAccount } from '../auth.js';
3
+ import { freshdeskFetch } from '../client.js';
4
+ import { parseStatus, parsePriority, } from '../types.js';
5
+ import { ticketUrl, formatTicketConcise, formatTicketDetailed, formatConversation, } from '../formatters.js';
6
+ import { withErrorHandling } from '../utils.js';
7
+ function noAccountError() {
8
+ return JSON.stringify({
9
+ ok: false,
10
+ error: 'No Freshdesk account connected',
11
+ resolution: 'Use configure_freshdesk or go to Mindstone Settings > Integrations > Freshdesk to connect your account.',
12
+ });
13
+ }
14
+ export function registerTicketTools(server) {
15
+ // ── list_freshdesk_tickets ──────────────────────────────────────
16
+ server.registerTool('list_freshdesk_tickets', {
17
+ description: 'List Freshdesk tickets using predefined filters. Default: "new_and_my_open". ' +
18
+ 'For attribute-based search, use search_freshdesk_tickets instead. ' +
19
+ 'FILTERS: new_and_my_open, watching, spam, deleted. Max 30 per page.',
20
+ inputSchema: z.object({
21
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
22
+ filter: z
23
+ .enum(['new_and_my_open', 'watching', 'spam', 'deleted'])
24
+ .optional()
25
+ .describe('Predefined filter (default: "new_and_my_open")'),
26
+ per_page: z.number().optional().describe('Results per page, max 30 (default: 30)'),
27
+ page: z.number().optional().describe('Page number (default: 1)'),
28
+ response_format: z
29
+ .enum(['concise', 'detailed'])
30
+ .optional()
31
+ .describe('Response format (default: "concise")'),
32
+ }),
33
+ annotations: { readOnlyHint: true },
34
+ }, withErrorHandling(async (args) => {
35
+ const account = getAccount(args.domain);
36
+ if (!account)
37
+ return noAccountError();
38
+ const filter = args.filter || 'new_and_my_open';
39
+ const perPage = Math.min(args.per_page || 30, 30);
40
+ const page = args.page || 1;
41
+ const tickets = await freshdeskFetch(account.domain, account.apiKey, '/tickets', { params: { filter, per_page: perPage, page } });
42
+ const format = args.response_format || 'concise';
43
+ if (format === 'concise') {
44
+ if (tickets.length === 0) {
45
+ return `No tickets found for filter "${filter}".`;
46
+ }
47
+ const lines = tickets.map((t) => formatTicketConcise(t, account.domain));
48
+ const moreHint = tickets.length >= perPage
49
+ ? '\n\n(More results may be available — increase page number)'
50
+ : '';
51
+ return `Tickets (${tickets.length}, filter: ${filter}):\n\n${lines.join('\n')}${moreHint}`;
52
+ }
53
+ return JSON.stringify({
54
+ ok: true,
55
+ tickets,
56
+ count: tickets.length,
57
+ filter,
58
+ page,
59
+ hasMore: tickets.length >= perPage,
60
+ });
61
+ }));
62
+ // ── get_freshdesk_ticket ────────────────────────────────────────
63
+ server.registerTool('get_freshdesk_ticket', {
64
+ description: 'Get a single Freshdesk ticket by ID with optional conversations. ' +
65
+ 'Set include_conversations to true to fetch the conversation thread (replies and notes).',
66
+ inputSchema: z.object({
67
+ ticket_id: z.number().describe('Ticket ID'),
68
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
69
+ include_conversations: z
70
+ .boolean()
71
+ .optional()
72
+ .describe('Include ticket conversations (default: false)'),
73
+ response_format: z
74
+ .enum(['concise', 'detailed'])
75
+ .optional()
76
+ .describe('Response format (default: "detailed")'),
77
+ }),
78
+ annotations: { readOnlyHint: true },
79
+ }, withErrorHandling(async (args) => {
80
+ const account = getAccount(args.domain);
81
+ if (!account)
82
+ return noAccountError();
83
+ const includeConversations = args.include_conversations === true;
84
+ const ticketEndpoint = includeConversations
85
+ ? `/tickets/${args.ticket_id}?include=conversations`
86
+ : `/tickets/${args.ticket_id}`;
87
+ const ticket = await freshdeskFetch(account.domain, account.apiKey, ticketEndpoint);
88
+ let conversations;
89
+ if (includeConversations) {
90
+ if (ticket.conversations && Array.isArray(ticket.conversations)) {
91
+ conversations = ticket.conversations;
92
+ }
93
+ else {
94
+ conversations = await freshdeskFetch(account.domain, account.apiKey, `/tickets/${args.ticket_id}/conversations`);
95
+ }
96
+ }
97
+ const format = args.response_format || 'detailed';
98
+ if (format === 'concise') {
99
+ let result = formatTicketConcise(ticket, account.domain);
100
+ if (conversations && conversations.length > 0) {
101
+ result += `\n\nConversations (${conversations.length}):\n`;
102
+ result += conversations.map((c) => formatConversation(c)).join('\n\n');
103
+ }
104
+ return result;
105
+ }
106
+ let result = formatTicketDetailed(ticket, account.domain);
107
+ if (conversations && conversations.length > 0) {
108
+ result += `\n\n--- Conversations (${conversations.length}) ---\n\n`;
109
+ result += conversations.map((c) => formatConversation(c)).join('\n\n');
110
+ }
111
+ return result;
112
+ }));
113
+ // ── search_freshdesk_tickets ────────────────────────────────────
114
+ server.registerTool('search_freshdesk_tickets', {
115
+ description: 'Search Freshdesk tickets using Freshdesk query syntax. ' +
116
+ 'QUERY SYNTAX: "status:2", "priority:4", "tag:\'billing\'", "requester.email:\'john@acme.com\'". ' +
117
+ 'Combine with AND/OR. Auto-wraps query in quotes if needed.',
118
+ inputSchema: z.object({
119
+ query: z.string().min(1).describe('Freshdesk search query (e.g. "status:2 AND priority:4")'),
120
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
121
+ page: z.number().optional().describe('Page number (default: 1)'),
122
+ response_format: z
123
+ .enum(['concise', 'detailed'])
124
+ .optional()
125
+ .describe('Response format (default: "concise")'),
126
+ }),
127
+ annotations: { readOnlyHint: true },
128
+ }, withErrorHandling(async (args) => {
129
+ const account = getAccount(args.domain);
130
+ if (!account)
131
+ return noAccountError();
132
+ let query = args.query.trim();
133
+ // Auto-wrap query in quotes if not already quoted
134
+ if (!query.startsWith('"')) {
135
+ query = `"${query}"`;
136
+ }
137
+ const page = args.page || 1;
138
+ const response = await freshdeskFetch(account.domain, account.apiKey, '/search/tickets', { params: { query, page } });
139
+ const format = args.response_format || 'concise';
140
+ const total = response.total;
141
+ const hasMore = total > page * 30;
142
+ if (format === 'concise') {
143
+ if (response.results.length === 0) {
144
+ return `No tickets found for query: ${query}`;
145
+ }
146
+ const lines = response.results.map((t) => formatTicketConcise(t, account.domain));
147
+ return `Search results (${response.results.length} of ${total})${hasMore ? ' — more available' : ''}:\n\n${lines.join('\n')}`;
148
+ }
149
+ return JSON.stringify({
150
+ ok: true,
151
+ tickets: response.results,
152
+ count: response.results.length,
153
+ total,
154
+ page,
155
+ hasMore,
156
+ });
157
+ }));
158
+ // ── create_freshdesk_ticket ─────────────────────────────────────
159
+ server.registerTool('create_freshdesk_ticket', {
160
+ description: 'Create a new Freshdesk ticket. Required: email, subject, description (HTML body). ' +
161
+ 'PRIORITY: 1=Low, 2=Medium, 3=High, 4=Urgent (or names). ' +
162
+ 'STATUS: 2=Open, 3=Pending, 4=Resolved, 5=Closed (or names).',
163
+ inputSchema: z.object({
164
+ email: z.string().min(1).describe('Requester email address (required)'),
165
+ subject: z.string().min(1).describe('Ticket subject line'),
166
+ description: z.string().min(1).describe('Ticket description (HTML supported)'),
167
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
168
+ priority: z
169
+ .union([z.number(), z.string()])
170
+ .optional()
171
+ .describe('Priority: 1=Low, 2=Medium, 3=High, 4=Urgent (or names)'),
172
+ status: z
173
+ .union([z.number(), z.string()])
174
+ .optional()
175
+ .describe('Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed (or names)'),
176
+ type: z.string().optional().describe('Ticket type (e.g. "Bug", "Question")'),
177
+ tags: z.array(z.string()).optional().describe('Tags to apply'),
178
+ responder_id: z.number().optional().describe('Agent ID to assign ticket to'),
179
+ group_id: z.number().optional().describe('Group ID to assign ticket to'),
180
+ custom_fields: z
181
+ .record(z.unknown())
182
+ .optional()
183
+ .describe('Custom field values as key-value pairs'),
184
+ }),
185
+ annotations: { readOnlyHint: false, destructiveHint: false },
186
+ }, withErrorHandling(async (args) => {
187
+ const account = getAccount(args.domain);
188
+ if (!account)
189
+ return noAccountError();
190
+ const payload = {
191
+ email: args.email,
192
+ subject: args.subject,
193
+ description: args.description,
194
+ };
195
+ if (args.priority !== undefined) {
196
+ const p = parsePriority(args.priority);
197
+ if (p !== undefined)
198
+ payload.priority = p;
199
+ }
200
+ if (args.status !== undefined) {
201
+ const s = parseStatus(args.status);
202
+ if (s !== undefined)
203
+ payload.status = s;
204
+ }
205
+ if (args.type)
206
+ payload.type = args.type;
207
+ if (args.tags)
208
+ payload.tags = args.tags;
209
+ if (args.responder_id)
210
+ payload.responder_id = args.responder_id;
211
+ if (args.group_id)
212
+ payload.group_id = args.group_id;
213
+ if (args.custom_fields)
214
+ payload.custom_fields = args.custom_fields;
215
+ const ticket = await freshdeskFetch(account.domain, account.apiKey, '/tickets', { method: 'POST', body: JSON.stringify(payload) });
216
+ return JSON.stringify({
217
+ ok: true,
218
+ message: `Created ticket #${ticket.id}`,
219
+ ticket: {
220
+ id: ticket.id,
221
+ subject: ticket.subject,
222
+ status: ticket.status,
223
+ priority: ticket.priority,
224
+ url: ticketUrl(account.domain, ticket.id),
225
+ },
226
+ });
227
+ }));
228
+ // ── update_freshdesk_ticket ─────────────────────────────────────
229
+ server.registerTool('update_freshdesk_ticket', {
230
+ description: 'Update an existing Freshdesk ticket. Can update status, priority, assignee, type, tags, custom fields. ' +
231
+ 'For replies use reply_to_freshdesk_ticket; for notes use add_freshdesk_note.',
232
+ inputSchema: z.object({
233
+ ticket_id: z.number().describe('Ticket ID to update'),
234
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
235
+ status: z
236
+ .union([z.number(), z.string()])
237
+ .optional()
238
+ .describe('New status: 2=Open, 3=Pending, 4=Resolved, 5=Closed (or names)'),
239
+ priority: z
240
+ .union([z.number(), z.string()])
241
+ .optional()
242
+ .describe('New priority: 1=Low, 2=Medium, 3=High, 4=Urgent (or names)'),
243
+ type: z.string().optional().describe('New ticket type'),
244
+ responder_id: z.number().optional().describe('New assignee agent ID'),
245
+ group_id: z.number().optional().describe('New group ID'),
246
+ tags: z.array(z.string()).optional().describe('Replace all tags with this list'),
247
+ custom_fields: z
248
+ .record(z.unknown())
249
+ .optional()
250
+ .describe('Custom field updates as key-value pairs'),
251
+ }),
252
+ annotations: { readOnlyHint: false, destructiveHint: false },
253
+ }, withErrorHandling(async (args) => {
254
+ const account = getAccount(args.domain);
255
+ if (!account)
256
+ return noAccountError();
257
+ const payload = {};
258
+ if (args.status !== undefined) {
259
+ const s = parseStatus(args.status);
260
+ if (s !== undefined)
261
+ payload.status = s;
262
+ }
263
+ if (args.priority !== undefined) {
264
+ const p = parsePriority(args.priority);
265
+ if (p !== undefined)
266
+ payload.priority = p;
267
+ }
268
+ if (args.type)
269
+ payload.type = args.type;
270
+ if (args.responder_id)
271
+ payload.responder_id = args.responder_id;
272
+ if (args.group_id)
273
+ payload.group_id = args.group_id;
274
+ if (args.tags)
275
+ payload.tags = args.tags;
276
+ if (args.custom_fields)
277
+ payload.custom_fields = args.custom_fields;
278
+ const ticket = await freshdeskFetch(account.domain, account.apiKey, `/tickets/${args.ticket_id}`, { method: 'PUT', body: JSON.stringify(payload) });
279
+ return JSON.stringify({
280
+ ok: true,
281
+ message: `Updated ticket #${args.ticket_id}`,
282
+ ticket: {
283
+ id: ticket.id,
284
+ subject: ticket.subject,
285
+ status: ticket.status,
286
+ priority: ticket.priority,
287
+ url: ticketUrl(account.domain, ticket.id),
288
+ },
289
+ });
290
+ }));
291
+ // ── reply_to_freshdesk_ticket ───────────────────────────────────
292
+ server.registerTool('reply_to_freshdesk_ticket', {
293
+ description: 'Add a public reply to a Freshdesk ticket. The reply is visible to the customer. ' +
294
+ 'For internal notes use add_freshdesk_note instead.',
295
+ inputSchema: z.object({
296
+ ticket_id: z.number().describe('Ticket ID to reply to'),
297
+ body: z.string().min(1).describe('Reply body (HTML supported)'),
298
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
299
+ }),
300
+ annotations: { readOnlyHint: false, destructiveHint: false },
301
+ }, withErrorHandling(async (args) => {
302
+ const account = getAccount(args.domain);
303
+ if (!account)
304
+ return noAccountError();
305
+ await freshdeskFetch(account.domain, account.apiKey, `/tickets/${args.ticket_id}/reply`, {
306
+ method: 'POST',
307
+ body: JSON.stringify({ body: args.body }),
308
+ });
309
+ return JSON.stringify({
310
+ ok: true,
311
+ message: `Added public reply to ticket #${args.ticket_id}`,
312
+ url: ticketUrl(account.domain, args.ticket_id),
313
+ });
314
+ }));
315
+ // ── add_freshdesk_note ──────────────────────────────────────────
316
+ server.registerTool('add_freshdesk_note', {
317
+ description: 'Add a note to a Freshdesk ticket. Default: private (agents only). ' +
318
+ 'Set private to false for a note visible to the requester. ' +
319
+ 'For public replies use reply_to_freshdesk_ticket.',
320
+ inputSchema: z.object({
321
+ ticket_id: z.number().describe('Ticket ID to add note to'),
322
+ body: z.string().min(1).describe('Note body (HTML supported)'),
323
+ domain: z.string().optional().describe('Freshdesk domain (optional if only one account)'),
324
+ private: z.boolean().optional().describe('Private note (default: true)'),
325
+ }),
326
+ annotations: { readOnlyHint: false, destructiveHint: false },
327
+ }, withErrorHandling(async (args) => {
328
+ const account = getAccount(args.domain);
329
+ if (!account)
330
+ return noAccountError();
331
+ const isPrivate = args.private !== false;
332
+ await freshdeskFetch(account.domain, account.apiKey, `/tickets/${args.ticket_id}/notes`, {
333
+ method: 'POST',
334
+ body: JSON.stringify({ body: args.body, private: isPrivate }),
335
+ });
336
+ const visibility = isPrivate ? 'private note' : 'public note';
337
+ return JSON.stringify({
338
+ ok: true,
339
+ message: `Added ${visibility} to ticket #${args.ticket_id}`,
340
+ url: ticketUrl(account.domain, args.ticket_id),
341
+ });
342
+ }));
343
+ }
344
+ //# sourceMappingURL=tickets.js.map
@@ -0,0 +1,87 @@
1
+ export declare const REQUEST_TIMEOUT_MS = 30000;
2
+ export interface BridgeState {
3
+ port: number;
4
+ token: string;
5
+ }
6
+ export declare class FreshdeskError extends Error {
7
+ readonly code: string;
8
+ readonly resolution: string;
9
+ constructor(message: string, code: string, resolution: string);
10
+ }
11
+ export interface AccountInfo {
12
+ domain: string;
13
+ apiKey: string;
14
+ agentEmail?: string;
15
+ authenticatedAt?: string;
16
+ }
17
+ export interface AccountsConfig {
18
+ accounts: AccountInfo[];
19
+ defaultDomain?: string;
20
+ }
21
+ export interface FreshdeskAccount {
22
+ domain: string;
23
+ apiKey: string;
24
+ agentEmail?: string;
25
+ }
26
+ export interface FreshdeskTicket {
27
+ id: number;
28
+ subject: string;
29
+ description?: string;
30
+ description_text?: string;
31
+ status: number;
32
+ priority: number;
33
+ source: number;
34
+ type?: string;
35
+ requester_id: number;
36
+ responder_id?: number;
37
+ group_id?: number;
38
+ email?: string;
39
+ created_at: string;
40
+ updated_at: string;
41
+ due_by?: string;
42
+ tags?: string[];
43
+ custom_fields?: Record<string, unknown>;
44
+ fr_escalated?: boolean;
45
+ spam?: boolean;
46
+ is_escalated?: boolean;
47
+ }
48
+ export interface FreshdeskConversation {
49
+ id: number;
50
+ body: string;
51
+ body_text?: string;
52
+ incoming: boolean;
53
+ private: boolean;
54
+ user_id: number;
55
+ from_email?: string;
56
+ to_emails?: string[];
57
+ created_at: string;
58
+ updated_at: string;
59
+ source: number;
60
+ }
61
+ export interface FreshdeskTicketField {
62
+ id: number;
63
+ name: string;
64
+ label: string;
65
+ description?: string;
66
+ type: string;
67
+ required_for_closure: boolean;
68
+ required_for_agents: boolean;
69
+ default: boolean;
70
+ position: number;
71
+ choices?: Record<string, unknown> | Array<string | [string, string]>;
72
+ }
73
+ export declare const STATUS_MAP: Record<number, string>;
74
+ export declare const PRIORITY_MAP: Record<number, string>;
75
+ export declare const SOURCE_MAP: Record<number, string>;
76
+ export declare function statusToString(status: number): string;
77
+ export declare function priorityToString(priority: number): string;
78
+ export declare function sourceToString(source: number): string;
79
+ /**
80
+ * Parse a status value that may be a number or a human-readable string.
81
+ */
82
+ export declare function parseStatus(input: unknown): number | undefined;
83
+ /**
84
+ * Parse a priority value that may be a number or a human-readable string.
85
+ */
86
+ export declare function parsePriority(input: unknown): number | undefined;
87
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,80 @@
1
+ export const REQUEST_TIMEOUT_MS = 30_000;
2
+ export class FreshdeskError extends Error {
3
+ code;
4
+ resolution;
5
+ constructor(message, code, resolution) {
6
+ super(message);
7
+ this.code = code;
8
+ this.resolution = resolution;
9
+ this.name = 'FreshdeskError';
10
+ }
11
+ }
12
+ // ---------------------------------------------------------------------------
13
+ // Status / Priority / Source maps
14
+ // ---------------------------------------------------------------------------
15
+ export const STATUS_MAP = {
16
+ 2: 'Open',
17
+ 3: 'Pending',
18
+ 4: 'Resolved',
19
+ 5: 'Closed',
20
+ };
21
+ export const PRIORITY_MAP = {
22
+ 1: 'Low',
23
+ 2: 'Medium',
24
+ 3: 'High',
25
+ 4: 'Urgent',
26
+ };
27
+ export const SOURCE_MAP = {
28
+ 1: 'Email',
29
+ 2: 'Portal',
30
+ 3: 'Phone',
31
+ 7: 'Chat',
32
+ 8: 'Feedback Widget',
33
+ 9: 'Outbound Email',
34
+ };
35
+ export function statusToString(status) {
36
+ return STATUS_MAP[status] || `Custom (${status})`;
37
+ }
38
+ export function priorityToString(priority) {
39
+ return PRIORITY_MAP[priority] || `Unknown (${priority})`;
40
+ }
41
+ export function sourceToString(source) {
42
+ return SOURCE_MAP[source] || `Unknown (${source})`;
43
+ }
44
+ /**
45
+ * Parse a status value that may be a number or a human-readable string.
46
+ */
47
+ export function parseStatus(input) {
48
+ if (typeof input === 'number')
49
+ return input;
50
+ if (typeof input === 'string') {
51
+ const num = parseInt(input, 10);
52
+ if (!isNaN(num))
53
+ return num;
54
+ const lower = input.toLowerCase();
55
+ for (const [key, value] of Object.entries(STATUS_MAP)) {
56
+ if (value.toLowerCase() === lower)
57
+ return parseInt(key, 10);
58
+ }
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * Parse a priority value that may be a number or a human-readable string.
64
+ */
65
+ export function parsePriority(input) {
66
+ if (typeof input === 'number')
67
+ return input;
68
+ if (typeof input === 'string') {
69
+ const num = parseInt(input, 10);
70
+ if (!isNaN(num))
71
+ return num;
72
+ const lower = input.toLowerCase();
73
+ for (const [key, value] of Object.entries(PRIORITY_MAP)) {
74
+ if (value.toLowerCase() === lower)
75
+ return parseInt(key, 10);
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,14 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ type ToolHandler<T> = (args: T, extra: unknown) => Promise<CallToolResult>;
3
+ /**
4
+ * Wraps a tool handler with standard error handling.
5
+ *
6
+ * - On success: returns the string result as a text content block.
7
+ * - On FreshdeskError: returns a structured JSON error with code and resolution.
8
+ * - On unknown error: returns a generic error message.
9
+ *
10
+ * Secrets are never exposed in error messages.
11
+ */
12
+ export declare function withErrorHandling<T>(fn: (args: T, extra: unknown) => Promise<string>): ToolHandler<T>;
13
+ export {};
14
+ //# sourceMappingURL=utils.d.ts.map
package/dist/utils.js ADDED
@@ -0,0 +1,42 @@
1
+ import { FreshdeskError } from './types.js';
2
+ /**
3
+ * Wraps a tool handler with standard error handling.
4
+ *
5
+ * - On success: returns the string result as a text content block.
6
+ * - On FreshdeskError: returns a structured JSON error with code and resolution.
7
+ * - On unknown error: returns a generic error message.
8
+ *
9
+ * Secrets are never exposed in error messages.
10
+ */
11
+ export function withErrorHandling(fn) {
12
+ return async (args, extra) => {
13
+ try {
14
+ const result = await fn(args, extra);
15
+ return { content: [{ type: 'text', text: result }] };
16
+ }
17
+ catch (error) {
18
+ if (error instanceof FreshdeskError) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: 'text',
23
+ text: JSON.stringify({
24
+ ok: false,
25
+ error: error.message,
26
+ code: error.code,
27
+ resolution: error.resolution,
28
+ }),
29
+ },
30
+ ],
31
+ isError: true,
32
+ };
33
+ }
34
+ const errorMessage = error instanceof Error ? error.message : String(error);
35
+ return {
36
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: errorMessage }) }],
37
+ isError: true,
38
+ };
39
+ }
40
+ };
41
+ }
42
+ //# sourceMappingURL=utils.js.map
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@mindstone-engineering/mcp-server-freshdesk",
3
+ "version": "0.1.0",
4
+ "description": "Freshdesk Support MCP server for Model Context Protocol hosts",
5
+ "license": "FSL-1.1-MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mcp-server-freshdesk": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "!dist/**/*.map"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/nspr-io/mcp-servers.git",
17
+ "directory": "connectors/freshdesk"
18
+ },
19
+ "homepage": "https://github.com/nspr-io/mcp-servers/tree/main/connectors/freshdesk",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc && shx chmod +x dist/index.js",
25
+ "prepare": "npm run build",
26
+ "watch": "tsc --watch",
27
+ "start": "node dist/index.js",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "test:coverage": "vitest run --coverage"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.26.0",
34
+ "zod": "^3.23.0"
35
+ },
36
+ "devDependencies": {
37
+ "@mindstone-engineering/mcp-test-harness": "file:../../test-harness",
38
+ "@types/node": "^22",
39
+ "@vitest/coverage-v8": "^4.1.3",
40
+ "msw": "^2.13.2",
41
+ "shx": "^0.3.4",
42
+ "typescript": "^5.8.2",
43
+ "vitest": "^4.1.3"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ }
48
+ }