@verygoodplugins/mcp-freescout 1.4.0 → 2.0.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/index.js CHANGED
@@ -1,71 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
4
  import { config } from 'dotenv';
6
- import { execSync } from 'child_process';
5
+ import { z } from 'zod';
7
6
  import { FreeScoutAPI } from './freescout-api.js';
8
7
  import { TicketAnalyzer } from './ticket-analyzer.js';
8
+ import { ConversationSchema, TicketAnalysisSchema, SearchFiltersSchema } from './types.js';
9
9
  // Load environment variables
10
10
  config();
11
11
  // Validate required environment variables
12
12
  const FREESCOUT_URL = process.env.FREESCOUT_URL;
13
13
  const FREESCOUT_API_KEY = process.env.FREESCOUT_API_KEY;
14
14
  const DEFAULT_USER_ID = parseInt(process.env.FREESCOUT_DEFAULT_USER_ID || '1');
15
- // WORKING_DIRECTORY defaults to current working directory if not specified
16
- // This allows the server to work with the current project context automatically
17
- const WORKING_DIRECTORY = process.env.WORKING_DIRECTORY || process.cwd();
18
- // Helper function to check if GitHub CLI is available
19
- function isGhAvailable() {
20
- try {
21
- execSync('gh --version', {
22
- stdio: 'pipe',
23
- timeout: 5000
24
- });
25
- return true;
26
- }
27
- catch (error) {
28
- return false;
29
- }
30
- }
31
- // Helper function to get GitHub repo using gh CLI
32
- function getGitHubRepo() {
33
- if (process.env.GITHUB_REPO) {
34
- return process.env.GITHUB_REPO;
35
- }
36
- if (!isGhAvailable()) {
37
- return undefined;
38
- }
39
- try {
40
- // Use gh to get repo info - this is more reliable than parsing git remotes
41
- const repoInfo = execSync('gh repo view --json nameWithOwner', {
42
- cwd: WORKING_DIRECTORY,
43
- encoding: 'utf-8',
44
- stdio: 'pipe'
45
- }).trim();
46
- const parsed = JSON.parse(repoInfo);
47
- return parsed.nameWithOwner;
48
- }
49
- catch (error) {
50
- // gh command failed - might not be in a GitHub repo or not authenticated
51
- return undefined;
52
- }
53
- }
54
- // Helper function to check if Git operations are available
55
- function isGitAvailable() {
56
- try {
57
- execSync('git --version', {
58
- stdio: 'pipe',
59
- timeout: 5000
60
- });
61
- return true;
62
- }
63
- catch (error) {
64
- return false;
65
- }
66
- }
67
- // Get GitHub configuration - no token needed, gh CLI handles auth
68
- const GITHUB_REPO = getGitHubRepo();
69
15
  if (!FREESCOUT_URL || !FREESCOUT_API_KEY) {
70
16
  console.error('Missing required environment variables: FREESCOUT_URL and FREESCOUT_API_KEY');
71
17
  process.exit(1);
@@ -73,751 +19,264 @@ if (!FREESCOUT_URL || !FREESCOUT_API_KEY) {
73
19
  // Initialize API and analyzer
74
20
  const api = new FreeScoutAPI(FREESCOUT_URL, FREESCOUT_API_KEY);
75
21
  const analyzer = new TicketAnalyzer();
76
- // Create MCP server
77
- const server = new Server({
22
+ // Create MCP server with new McpServer class
23
+ const server = new McpServer({
78
24
  name: 'mcp-freescout',
79
- version: '1.0.0',
80
- }, {
81
- capabilities: {
82
- tools: {},
83
- },
25
+ version: '2.0.0',
84
26
  });
85
- // Define tool schemas
86
- const tools = [
87
- {
88
- name: 'freescout_get_ticket',
89
- description: 'Fetch and analyze a FreeScout ticket by ID or URL',
90
- inputSchema: {
91
- type: 'object',
92
- properties: {
93
- ticket: {
94
- type: 'string',
95
- description: 'Ticket ID, ticket number, or FreeScout URL',
96
- },
97
- includeThreads: {
98
- type: 'boolean',
99
- description: 'Include all conversation threads (default: true)',
100
- default: true,
101
- },
102
- },
103
- required: ['ticket'],
104
- },
27
+ // Tool 1: Get Ticket
28
+ server.registerTool('freescout_get_ticket', {
29
+ title: 'Get FreeScout Ticket',
30
+ description: 'Fetch and analyze a FreeScout ticket by ID or URL',
31
+ inputSchema: {
32
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
33
+ includeThreads: z
34
+ .boolean()
35
+ .optional()
36
+ .default(true)
37
+ .describe('Include all conversation threads'),
105
38
  },
106
- {
107
- name: 'freescout_analyze_ticket',
108
- description: 'Analyze a FreeScout ticket to determine issue type, root cause, and suggested solution',
109
- inputSchema: {
110
- type: 'object',
111
- properties: {
112
- ticket: {
113
- type: 'string',
114
- description: 'Ticket ID, ticket number, or FreeScout URL',
115
- },
116
- },
117
- required: ['ticket'],
118
- },
39
+ outputSchema: ConversationSchema,
40
+ }, async ({ ticket, includeThreads }) => {
41
+ const ticketId = api.parseTicketInput(ticket);
42
+ const conversation = await api.getConversation(ticketId, includeThreads ?? true);
43
+ return {
44
+ content: [{ type: 'text', text: JSON.stringify(conversation, null, 2) }],
45
+ structuredContent: conversation,
46
+ };
47
+ });
48
+ // Tool 2: Analyze Ticket
49
+ server.registerTool('freescout_analyze_ticket', {
50
+ title: 'Analyze FreeScout Ticket',
51
+ description: 'Analyze a FreeScout ticket to determine issue type, root cause, and suggested solution',
52
+ inputSchema: {
53
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
119
54
  },
120
- {
121
- name: 'freescout_add_note',
122
- description: 'Add an internal note to a FreeScout ticket',
123
- inputSchema: {
124
- type: 'object',
125
- properties: {
126
- ticket: {
127
- type: 'string',
128
- description: 'Ticket ID, ticket number, or FreeScout URL',
129
- },
130
- note: {
131
- type: 'string',
132
- description: 'The note content to add',
133
- },
134
- userId: {
135
- type: 'number',
136
- description: 'User ID for the note (default: from env)',
137
- },
138
- },
139
- required: ['ticket', 'note'],
140
- },
55
+ outputSchema: TicketAnalysisSchema,
56
+ }, async ({ ticket }) => {
57
+ const ticketId = api.parseTicketInput(ticket);
58
+ const conversation = await api.getConversation(ticketId, true);
59
+ const analysis = analyzer.analyzeConversation(conversation);
60
+ return {
61
+ content: [{ type: 'text', text: JSON.stringify(analysis, null, 2) }],
62
+ structuredContent: analysis,
63
+ };
64
+ });
65
+ // Tool 3: Add Note
66
+ server.registerTool('freescout_add_note', {
67
+ title: 'Add Note to Ticket',
68
+ description: 'Add an internal note to a FreeScout ticket',
69
+ inputSchema: {
70
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
71
+ note: z.string().describe('The note content to add'),
72
+ userId: z.number().optional().describe('User ID for the note (default: from env)'),
141
73
  },
142
- {
143
- name: 'freescout_update_ticket',
144
- description: 'Update ticket status and/or assignment',
145
- inputSchema: {
146
- type: 'object',
147
- properties: {
148
- ticket: {
149
- type: 'string',
150
- description: 'Ticket ID, ticket number, or FreeScout URL',
151
- },
152
- status: {
153
- type: 'string',
154
- enum: ['active', 'pending', 'closed', 'spam'],
155
- description: 'New ticket status',
156
- },
157
- assignTo: {
158
- type: 'number',
159
- description: 'User ID to assign the ticket to',
160
- },
161
- },
162
- required: ['ticket'],
163
- },
74
+ outputSchema: {
75
+ success: z.boolean(),
76
+ message: z.string(),
77
+ ticketId: z.string(),
164
78
  },
165
- {
166
- name: 'freescout_create_draft_reply',
167
- description: 'Create a draft reply in FreeScout that can be edited before sending',
168
- inputSchema: {
169
- type: 'object',
170
- properties: {
171
- ticket: {
172
- type: 'string',
173
- description: 'Ticket ID, ticket number, or FreeScout URL',
174
- },
175
- replyText: {
176
- type: 'string',
177
- description: 'The draft reply content (generated by the LLM)',
178
- },
179
- userId: {
180
- type: 'number',
181
- description: 'User ID creating the draft (defaults to env setting)',
182
- },
183
- },
184
- required: ['ticket', 'replyText'],
185
- },
79
+ }, async ({ ticket, note, userId }) => {
80
+ const ticketId = api.parseTicketInput(ticket);
81
+ const actualUserId = userId ?? DEFAULT_USER_ID;
82
+ await api.addThread(ticketId, 'note', note, actualUserId);
83
+ const output = {
84
+ success: true,
85
+ message: `Note added to ticket #${ticketId}`,
86
+ ticketId,
87
+ };
88
+ return {
89
+ content: [{ type: 'text', text: output.message }],
90
+ structuredContent: output,
91
+ };
92
+ });
93
+ // Tool 4: Update Ticket
94
+ server.registerTool('freescout_update_ticket', {
95
+ title: 'Update Ticket Status/Assignment',
96
+ description: 'Update ticket status and/or assignment',
97
+ inputSchema: {
98
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
99
+ status: z
100
+ .enum(['active', 'pending', 'closed', 'spam'])
101
+ .optional()
102
+ .describe('New ticket status'),
103
+ assignTo: z.number().optional().describe('User ID to assign the ticket to'),
186
104
  },
187
- {
188
- name: 'freescout_get_ticket_context',
189
- description: 'Get ticket context and customer info to help draft personalized replies',
190
- inputSchema: {
191
- type: 'object',
192
- properties: {
193
- ticket: {
194
- type: 'string',
195
- description: 'Ticket ID, ticket number, or FreeScout URL',
196
- },
197
- },
198
- required: ['ticket'],
199
- },
105
+ outputSchema: {
106
+ success: z.boolean(),
107
+ message: z.string(),
108
+ ticketId: z.string(),
200
109
  },
201
- {
202
- name: 'freescout_search_tickets',
203
- description: 'Search for FreeScout tickets',
204
- inputSchema: {
205
- type: 'object',
206
- properties: {
207
- query: {
208
- type: 'string',
209
- description: 'Search query',
210
- },
211
- status: {
212
- type: 'string',
213
- enum: ['active', 'pending', 'closed', 'spam', 'all'],
214
- description: 'Filter by status (default: all)',
215
- },
216
- state: {
217
- type: 'string',
218
- enum: ['published', 'deleted'],
219
- description: 'Filter by state (default: published to exclude deleted tickets)',
220
- },
221
- mailboxId: {
222
- type: 'number',
223
- description: 'Filter by mailbox ID (optional, searches all mailboxes if not specified)',
224
- },
225
- },
226
- required: ['query'],
227
- },
110
+ }, async ({ ticket, status, assignTo }) => {
111
+ const ticketId = api.parseTicketInput(ticket);
112
+ const updates = { byUser: DEFAULT_USER_ID };
113
+ if (status)
114
+ updates.status = status;
115
+ if (assignTo)
116
+ updates.assignTo = assignTo;
117
+ await api.updateConversation(ticketId, updates);
118
+ const output = {
119
+ success: true,
120
+ message: `Ticket #${ticketId} updated successfully`,
121
+ ticketId,
122
+ };
123
+ return {
124
+ content: [{ type: 'text', text: output.message }],
125
+ structuredContent: output,
126
+ };
127
+ });
128
+ // Tool 5: Create Draft Reply
129
+ server.registerTool('freescout_create_draft_reply', {
130
+ title: 'Create Draft Reply',
131
+ description: 'Create a draft reply in FreeScout that can be edited before sending',
132
+ inputSchema: {
133
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
134
+ replyText: z.string().describe('The draft reply content (generated by the LLM)'),
135
+ userId: z
136
+ .number()
137
+ .optional()
138
+ .describe('User ID creating the draft (defaults to env setting)'),
228
139
  },
229
- {
230
- name: 'freescout_get_mailboxes',
231
- description: 'Get list of available mailboxes',
232
- inputSchema: {
233
- type: 'object',
234
- properties: {},
235
- required: [],
236
- },
140
+ outputSchema: {
141
+ success: z.boolean(),
142
+ message: z.string(),
143
+ ticketId: z.string(),
144
+ draftId: z.number(),
237
145
  },
238
- {
239
- name: 'git_create_worktree',
240
- description: 'Create a Git worktree for working on a ticket',
241
- inputSchema: {
242
- type: 'object',
243
- properties: {
244
- ticketId: {
245
- type: 'string',
246
- description: 'Ticket ID for the worktree',
247
- },
248
- branchName: {
249
- type: 'string',
250
- description: 'Branch name (default: fix/freescout-{ticketId})',
251
- },
252
- baseBranch: {
253
- type: 'string',
254
- description: 'Base branch to create from (default: master)',
255
- default: 'master',
256
- },
146
+ }, async ({ ticket, replyText, userId }) => {
147
+ const ticketId = api.parseTicketInput(ticket);
148
+ const actualUserId = userId ?? DEFAULT_USER_ID;
149
+ const draftThread = await api.createDraftReply(ticketId, replyText, actualUserId);
150
+ const output = {
151
+ success: true,
152
+ message: `Draft reply created successfully in FreeScout ticket #${ticketId}`,
153
+ ticketId,
154
+ draftId: draftThread.id,
155
+ };
156
+ return {
157
+ content: [
158
+ {
159
+ type: 'text',
160
+ text: `✅ ${output.message}\n\nDraft ID: ${draftThread.id}\n\nThe draft reply is now saved in FreeScout and can be reviewed, edited, and sent from the FreeScout interface.`,
257
161
  },
258
- required: ['ticketId'],
259
- },
162
+ ],
163
+ structuredContent: output,
164
+ };
165
+ });
166
+ // Tool 6: Get Ticket Context
167
+ server.registerTool('freescout_get_ticket_context', {
168
+ title: 'Get Ticket Context',
169
+ description: 'Get ticket context and customer info to help draft personalized replies',
170
+ inputSchema: {
171
+ ticket: z.string().describe('Ticket ID, ticket number, or FreeScout URL'),
260
172
  },
261
- {
262
- name: 'git_remove_worktree',
263
- description: 'Remove a Git worktree after work is complete',
264
- inputSchema: {
265
- type: 'object',
266
- properties: {
267
- ticketId: {
268
- type: 'string',
269
- description: 'Ticket ID of the worktree to remove',
270
- },
271
- },
272
- required: ['ticketId'],
273
- },
173
+ outputSchema: {
174
+ ticketId: z.string(),
175
+ customer: z.object({
176
+ name: z.string(),
177
+ email: z.string(),
178
+ }),
179
+ subject: z.string(),
180
+ status: z.string(),
181
+ issueDescription: z.string(),
182
+ customerMessages: z.array(z.object({
183
+ date: z.string(),
184
+ content: z.string(),
185
+ })),
186
+ teamMessages: z.array(z.object({
187
+ date: z.string(),
188
+ content: z.string(),
189
+ })),
190
+ analysis: z.object({
191
+ isBug: z.boolean(),
192
+ isThirdPartyIssue: z.boolean(),
193
+ testedByTeam: z.boolean(),
194
+ rootCause: z.string().optional(),
195
+ }),
274
196
  },
275
- {
276
- name: 'github_create_pr',
277
- description: 'Create a GitHub pull request for the current branch',
278
- inputSchema: {
279
- type: 'object',
280
- properties: {
281
- title: {
282
- type: 'string',
283
- description: 'PR title',
284
- },
285
- body: {
286
- type: 'string',
287
- description: 'PR description/body',
288
- },
289
- ticketId: {
290
- type: 'string',
291
- description: 'FreeScout ticket ID for reference',
292
- },
293
- branch: {
294
- type: 'string',
295
- description: 'Branch name (defaults to current branch)',
296
- },
297
- baseBranch: {
298
- type: 'string',
299
- description: 'Base branch (default: master)',
300
- default: 'master',
301
- },
302
- draft: {
303
- type: 'boolean',
304
- description: 'Create as draft PR (default: false)',
305
- default: false,
306
- },
307
- },
308
- required: ['title', 'body'],
197
+ }, async ({ ticket }) => {
198
+ const ticketId = api.parseTicketInput(ticket);
199
+ const conversation = await api.getConversation(ticketId, true);
200
+ const analysis = analyzer.analyzeConversation(conversation);
201
+ const threads = conversation._embedded?.threads || [];
202
+ const customerMessages = threads.filter((t) => t.type === 'customer');
203
+ const teamMessages = threads.filter((t) => t.type === 'message' || t.type === 'note');
204
+ const context = {
205
+ ticketId,
206
+ customer: {
207
+ name: analysis.customerName,
208
+ email: analysis.customerEmail,
309
209
  },
310
- },
311
- {
312
- name: 'freescout_implement_ticket',
313
- description: 'Full workflow: analyze ticket, create worktree, and prepare implementation plan',
314
- inputSchema: {
315
- type: 'object',
316
- properties: {
317
- ticket: {
318
- type: 'string',
319
- description: 'Ticket ID, ticket number, or FreeScout URL',
320
- },
321
- additionalContext: {
322
- type: 'string',
323
- description: 'Additional context or suggestions for implementation',
324
- },
325
- autoCreateWorktree: {
326
- type: 'boolean',
327
- description: 'Automatically create Git worktree (default: true)',
328
- default: true,
329
- },
330
- },
331
- required: ['ticket'],
210
+ subject: conversation.subject,
211
+ status: conversation.status,
212
+ issueDescription: analysis.issueDescription,
213
+ customerMessages: customerMessages.map((m) => ({
214
+ date: m.created_at,
215
+ content: analyzer.stripHtml(m.body).substring(0, 500) +
216
+ (analyzer.stripHtml(m.body).length > 500 ? '...' : ''),
217
+ })),
218
+ teamMessages: teamMessages.slice(-3).map((m) => ({
219
+ date: m.created_at,
220
+ content: analyzer.stripHtml(m.body).substring(0, 300) +
221
+ (analyzer.stripHtml(m.body).length > 300 ? '...' : ''),
222
+ })),
223
+ analysis: {
224
+ isBug: analysis.isBug,
225
+ isThirdPartyIssue: analysis.isThirdPartyIssue,
226
+ testedByTeam: analysis.testedByTeam,
227
+ rootCause: analysis.rootCause,
332
228
  },
229
+ };
230
+ return {
231
+ content: [{ type: 'text', text: JSON.stringify(context, null, 2) }],
232
+ structuredContent: context,
233
+ };
234
+ });
235
+ // Tool 7: Search Tickets
236
+ server.registerTool('freescout_search_tickets', {
237
+ title: 'Search FreeScout Tickets',
238
+ description: 'Search for FreeScout tickets with explicit filter parameters. Use assignee: "unassigned" for unassigned tickets, or assignee: number for specific user. Supports relative time filters like "7d", "24h".',
239
+ inputSchema: SearchFiltersSchema,
240
+ outputSchema: {
241
+ conversations: z.array(ConversationSchema),
242
+ totalCount: z.number(),
243
+ page: z.number().optional(),
244
+ totalPages: z.number().optional(),
333
245
  },
334
- ];
335
- // Handle list tools request
336
- server.setRequestHandler(ListToolsRequestSchema, async () => {
246
+ }, async (filters) => {
247
+ const results = await api.searchConversations(filters);
248
+ const output = {
249
+ conversations: results._embedded?.conversations || [],
250
+ totalCount: results.page?.total_elements || 0,
251
+ page: results.page?.number,
252
+ totalPages: results.page?.total_pages,
253
+ };
337
254
  return {
338
- tools,
255
+ content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
256
+ structuredContent: output,
339
257
  };
340
258
  });
341
- // Handle tool execution
342
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
343
- const { name, arguments: args } = request.params || {};
344
- if (!args) {
345
- throw new Error('Arguments are missing');
346
- }
347
- try {
348
- switch (name) {
349
- case 'freescout_get_ticket': {
350
- const ticketId = api.parseTicketInput(args.ticket);
351
- const conversation = await api.getConversation(ticketId, args.includeThreads !== false);
352
- return {
353
- content: [
354
- {
355
- type: 'text',
356
- text: JSON.stringify(conversation, null, 2),
357
- },
358
- ],
359
- };
360
- }
361
- case 'freescout_analyze_ticket': {
362
- const ticketId = api.parseTicketInput(args.ticket);
363
- const conversation = await api.getConversation(ticketId, true);
364
- const analysis = analyzer.analyzeConversation(conversation);
365
- return {
366
- content: [
367
- {
368
- type: 'text',
369
- text: JSON.stringify(analysis, null, 2),
370
- },
371
- ],
372
- };
373
- }
374
- case 'freescout_add_note': {
375
- const ticketId = api.parseTicketInput(args.ticket);
376
- const userId = args.userId || DEFAULT_USER_ID;
377
- const thread = await api.addThread(ticketId, 'note', args.note, userId);
378
- return {
379
- content: [
380
- {
381
- type: 'text',
382
- text: `Note added to ticket #${ticketId}`,
383
- },
384
- ],
385
- };
386
- }
387
- case 'freescout_update_ticket': {
388
- const ticketId = api.parseTicketInput(args.ticket);
389
- const updates = {
390
- byUser: DEFAULT_USER_ID,
391
- };
392
- if (args.status) {
393
- updates.status = args.status;
394
- }
395
- if (args.assignTo) {
396
- updates.assignTo = args.assignTo;
397
- }
398
- const updated = await api.updateConversation(ticketId, updates);
399
- return {
400
- content: [
401
- {
402
- type: 'text',
403
- text: `Ticket #${ticketId} updated successfully`,
404
- },
405
- ],
406
- };
407
- }
408
- case 'freescout_create_draft_reply': {
409
- const ticketId = api.parseTicketInput(args.ticket);
410
- const replyText = args.replyText;
411
- const userId = args.userId || DEFAULT_USER_ID;
412
- try {
413
- const draftThread = await api.createDraftReply(ticketId, replyText, userId);
414
- return {
415
- content: [
416
- {
417
- type: 'text',
418
- text: `✅ Draft reply created successfully in FreeScout ticket #${ticketId}\n\nDraft ID: ${draftThread.id}\n\nThe draft reply is now saved in FreeScout and can be reviewed, edited, and sent from the FreeScout interface.`,
419
- },
420
- ],
421
- };
422
- }
423
- catch (error) {
424
- throw new Error(`Failed to create draft reply: ${error.message}`);
425
- }
426
- }
427
- case 'freescout_get_ticket_context': {
428
- const ticketId = api.parseTicketInput(args.ticket);
429
- const conversation = await api.getConversation(ticketId, true);
430
- const analysis = analyzer.analyzeConversation(conversation);
431
- const threads = conversation._embedded?.threads || [];
432
- const customerMessages = threads.filter(t => t.type === 'customer');
433
- const teamMessages = threads.filter(t => t.type === 'message' || t.type === 'note');
434
- const context = {
435
- ticketId,
436
- customer: {
437
- name: analysis.customerName,
438
- email: analysis.customerEmail,
439
- },
440
- subject: conversation.subject,
441
- status: conversation.status,
442
- issueDescription: analysis.issueDescription,
443
- customerMessages: customerMessages.map(m => ({
444
- date: m.created_at,
445
- content: analyzer.stripHtml(m.body).substring(0, 500) + (analyzer.stripHtml(m.body).length > 500 ? '...' : ''),
446
- })),
447
- teamMessages: teamMessages.slice(-3).map(m => ({
448
- date: m.created_at,
449
- content: analyzer.stripHtml(m.body).substring(0, 300) + (analyzer.stripHtml(m.body).length > 300 ? '...' : ''),
450
- })),
451
- analysis: {
452
- isBug: analysis.isBug,
453
- isThirdPartyIssue: analysis.isThirdPartyIssue,
454
- testedByTeam: analysis.testedByTeam,
455
- rootCause: analysis.rootCause,
456
- },
457
- };
458
- return {
459
- content: [
460
- {
461
- type: 'text',
462
- text: JSON.stringify(context, null, 2),
463
- },
464
- ],
465
- };
466
- }
467
- case 'freescout_search_tickets': {
468
- // Always default to 'published' state unless user explicitly requests 'deleted'
469
- const state = args.state || 'published';
470
- const query = args.query;
471
- let results;
472
- // If searching for unassigned tickets, use the list endpoint which properly supports filtering
473
- if (query.includes('assignee:null') || query.includes('unassigned')) {
474
- results = await api.listConversations(args.status, state, null // null assignee for unassigned tickets
475
- );
476
- }
477
- else {
478
- // Use search for other queries
479
- results = await api.searchConversations(query, args.status, state, args.mailboxId);
480
- /**
481
- * LIMITATION: The FreeScout searchConversations API endpoint does not respect
482
- * the 'state' parameter (published/draft). Unlike the list endpoint, which
483
- * correctly filters by state, the search endpoint ignores this parameter and
484
- * returns all conversations regardless of state.
485
- *
486
- * WORKAROUND: We apply client-side filtering below when state='published'.
487
- *
488
- * PERFORMANCE IMPLICATIONS:
489
- * - Client-side filtering may be expensive for large result sets
490
- * - Pagination counts may be inaccurate (total_elements reflects filtered count)
491
- * - Results may be incomplete if the API returns paginated data
492
- *
493
- * ACTION ITEM: Verify with FreeScout maintainers whether the searchConversations
494
- * endpoint should accept the 'state' parameter or if API documentation only
495
- * applies to the list endpoint. Consider filing an issue or feature request.
496
- */
497
- if (state === 'published' && results._embedded?.conversations) {
498
- const originalCount = results._embedded.conversations.length;
499
- results._embedded.conversations = results._embedded.conversations.filter((conversation) => conversation.state === 'published');
500
- const filteredCount = results._embedded.conversations.length;
501
- console.error(`[WARNING] searchConversations endpoint does not respect 'state' parameter. ` +
502
- `Applied client-side filtering for state='${state}'. ` +
503
- `Original count: ${originalCount}, Filtered count: ${filteredCount}. ` +
504
- `This may be expensive for large result sets and incomplete with pagination.`);
505
- // Update the total count to reflect filtered results
506
- if (results.page) {
507
- results.page.total_elements = results._embedded.conversations.length;
508
- }
509
- }
510
- }
511
- return {
512
- content: [
513
- {
514
- type: 'text',
515
- text: JSON.stringify(results, null, 2),
516
- },
517
- ],
518
- };
519
- }
520
- case 'freescout_get_mailboxes': {
521
- const mailboxes = await api.getMailboxes();
522
- return {
523
- content: [
524
- {
525
- type: 'text',
526
- text: JSON.stringify(mailboxes, null, 2),
527
- },
528
- ],
529
- };
530
- }
531
- case 'git_create_worktree': {
532
- if (!isGitAvailable()) {
533
- return {
534
- content: [
535
- {
536
- type: 'text',
537
- text: '⚠️ Git is not available in this environment. Please create the worktree manually:\n\n' +
538
- `git worktree add worktrees/ticket-${args.ticketId} -b ${args.branchName || `fix/freescout-${args.ticketId}`} ${args.baseBranch || 'master'}`,
539
- },
540
- ],
541
- };
542
- }
543
- const ticketId = args.ticketId;
544
- const branchName = args.branchName || `fix/freescout-${ticketId}`;
545
- const baseBranch = args.baseBranch || 'master';
546
- const worktreeDir = `${WORKING_DIRECTORY}/worktrees/ticket-${ticketId}`;
547
- try {
548
- // Create worktrees directory if it doesn't exist
549
- execSync(`mkdir -p "${WORKING_DIRECTORY}/worktrees"`, {
550
- cwd: WORKING_DIRECTORY,
551
- stdio: 'pipe'
552
- });
553
- // Create worktree
554
- execSync(`git worktree add "${worktreeDir}" -b "${branchName}" ${baseBranch}`, {
555
- cwd: WORKING_DIRECTORY,
556
- stdio: 'pipe'
557
- });
558
- // Add to .gitignore if needed
559
- try {
560
- const gitignore = execSync(`cat "${WORKING_DIRECTORY}/.gitignore"`, {
561
- encoding: 'utf-8',
562
- stdio: 'pipe'
563
- });
564
- if (!gitignore.includes('worktrees/')) {
565
- execSync(`echo "worktrees/" >> "${WORKING_DIRECTORY}/.gitignore"`, {
566
- cwd: WORKING_DIRECTORY,
567
- stdio: 'pipe'
568
- });
569
- }
570
- }
571
- catch {
572
- // .gitignore might not exist
573
- }
574
- return {
575
- content: [
576
- {
577
- type: 'text',
578
- text: `✅ Created worktree at: ${worktreeDir}\n✅ Working on branch: ${branchName}\n✅ Ready for implementation`,
579
- },
580
- ],
581
- };
582
- }
583
- catch (error) {
584
- return {
585
- content: [
586
- {
587
- type: 'text',
588
- text: `⚠️ Could not create worktree automatically: ${error.message}\n\nPlease create manually:\ngit worktree add worktrees/ticket-${ticketId} -b ${branchName} ${baseBranch}`,
589
- },
590
- ],
591
- };
592
- }
593
- }
594
- case 'git_remove_worktree': {
595
- if (!isGitAvailable()) {
596
- return {
597
- content: [
598
- {
599
- type: 'text',
600
- text: `⚠️ Git is not available in this environment. Please remove the worktree manually:\n\ngit worktree remove worktrees/ticket-${args.ticketId}`,
601
- },
602
- ],
603
- };
604
- }
605
- const ticketId = args.ticketId;
606
- const worktreeDir = `${WORKING_DIRECTORY}/worktrees/ticket-${ticketId}`;
607
- try {
608
- execSync(`git worktree remove "${worktreeDir}"`, {
609
- cwd: WORKING_DIRECTORY,
610
- stdio: 'pipe'
611
- });
612
- return {
613
- content: [
614
- {
615
- type: 'text',
616
- text: `✅ Worktree removed for ticket #${ticketId}`,
617
- },
618
- ],
619
- };
620
- }
621
- catch (error) {
622
- return {
623
- content: [
624
- {
625
- type: 'text',
626
- text: `⚠️ Could not remove worktree automatically: ${error.message}\n\nPlease remove manually:\ngit worktree remove worktrees/ticket-${ticketId}`,
627
- },
628
- ],
629
- };
630
- }
631
- }
632
- case 'github_create_pr': {
633
- if (!isGhAvailable()) {
634
- return {
635
- content: [
636
- {
637
- type: 'text',
638
- text: '⚠️ GitHub CLI (gh) is not installed or available. Please install it from https://cli.github.com/ and ensure you are authenticated with `gh auth login`.',
639
- },
640
- ],
641
- };
642
- }
643
- if (!GITHUB_REPO) {
644
- return {
645
- content: [
646
- {
647
- type: 'text',
648
- text: '⚠️ Could not detect GitHub repository. Please ensure you are in a Git repository connected to GitHub, or set GITHUB_REPO environment variable.',
649
- },
650
- ],
651
- };
652
- }
653
- const title = args.title;
654
- const body = args.body;
655
- const ticketId = args.ticketId;
656
- const branch = args.branch || '';
657
- const baseBranch = args.baseBranch || 'master';
658
- const draft = args.draft || false;
659
- try {
660
- // If ticketId is provided, add FreeScout link to the body
661
- let enhancedBody = body;
662
- if (ticketId) {
663
- enhancedBody = `${body}\n\n---\n\nFreeScout Ticket: ${FREESCOUT_URL}/conversation/${ticketId}`;
664
- }
665
- // Create the PR using GitHub CLI (no token needed - gh handles auth)
666
- const args_array = ['pr', 'create'];
667
- if (GITHUB_REPO) {
668
- args_array.push('--repo', GITHUB_REPO);
669
- }
670
- args_array.push('--title', title);
671
- args_array.push('--body', enhancedBody);
672
- args_array.push('--base', baseBranch);
673
- if (draft) {
674
- args_array.push('--draft');
675
- }
676
- if (branch) {
677
- args_array.push('--head', branch);
678
- }
679
- const result = execSync(`gh ${args_array.map(arg => `"${arg.replace(/"/g, '\\"')}"`).join(' ')}`, {
680
- cwd: WORKING_DIRECTORY,
681
- encoding: 'utf-8',
682
- stdio: 'pipe'
683
- }).trim();
684
- return {
685
- content: [
686
- {
687
- type: 'text',
688
- text: `✅ Pull request created successfully!\n\n${result}`,
689
- },
690
- ],
691
- };
692
- }
693
- catch (error) {
694
- // Handle authentication errors
695
- if (error.message.includes('not authenticated') || error.message.includes('authentication')) {
696
- return {
697
- content: [
698
- {
699
- type: 'text',
700
- text: '⚠️ GitHub CLI is not authenticated. Please run `gh auth login` to authenticate with GitHub.',
701
- },
702
- ],
703
- };
704
- }
705
- return {
706
- content: [
707
- {
708
- type: 'text',
709
- text: `⚠️ Failed to create PR: ${error.message}\n\nPlease ensure:\n1. You are authenticated: \`gh auth login\`\n2. You are in a GitHub repository\n3. Your branch is pushed to GitHub`,
710
- },
711
- ],
712
- };
713
- }
714
- }
715
- case 'freescout_implement_ticket': {
716
- const ticketId = api.parseTicketInput(args.ticket);
717
- const conversation = await api.getConversation(ticketId, true);
718
- const analysis = analyzer.analyzeConversation(conversation);
719
- let worktreeInfo = '';
720
- if (args.autoCreateWorktree !== false) {
721
- if (!isGitAvailable()) {
722
- const branchName = `fix/freescout-${ticketId}`;
723
- worktreeInfo = `\n\n## Git Worktree (Manual Setup Required)\n⚠️ Git is not available in this environment. Please create manually:\n\`\`\`bash\ngit worktree add worktrees/ticket-${ticketId} -b ${branchName} master\n\`\`\``;
724
- }
725
- else {
726
- try {
727
- const branchName = `fix/freescout-${ticketId}`;
728
- const worktreeDir = `${WORKING_DIRECTORY}/worktrees/ticket-${ticketId}`;
729
- execSync(`mkdir -p "${WORKING_DIRECTORY}/worktrees"`, {
730
- cwd: WORKING_DIRECTORY,
731
- stdio: 'pipe'
732
- });
733
- execSync(`git worktree add "${worktreeDir}" -b "${branchName}" master`, {
734
- cwd: WORKING_DIRECTORY,
735
- stdio: 'pipe'
736
- });
737
- worktreeInfo = `\n\n## Git Worktree Created\n- Branch: ${branchName}\n- Location: ${worktreeDir}`;
738
- }
739
- catch (error) {
740
- const branchName = `fix/freescout-${ticketId}`;
741
- worktreeInfo = `\n\n## Git Worktree (Manual Setup Required)\n⚠️ Could not create automatically: ${error.message}\n\nPlease create manually:\n\`\`\`bash\ngit worktree add worktrees/ticket-${ticketId} -b ${branchName} master\n\`\`\``;
742
- }
743
- }
744
- }
745
- const plan = {
746
- issue: analysis.issueDescription,
747
- rootCause: analysis.rootCause || 'To be determined',
748
- solution: analysis.suggestedSolution || 'To be implemented',
749
- filesToModify: [],
750
- alternativeApproaches: [],
751
- hasBreakingChanges: false,
752
- };
753
- const output = `# FreeScout Ticket #${ticketId} Implementation Plan
754
-
755
- ## Customer Information
756
- - Name: ${analysis.customerName}
757
- - Email: ${analysis.customerEmail}
758
-
759
- ## Issue Analysis
760
- - **Is Bug**: ${analysis.isBug ? 'Yes' : 'No'}
761
- - **Is Third-Party Issue**: ${analysis.isThirdPartyIssue ? 'Yes' : 'No'}
762
- - **Tested by Team**: ${analysis.testedByTeam ? 'Yes' : 'No'}
763
- - **Reproducible**: ${analysis.isReproducible ? 'Yes' : 'No'}
764
-
765
- ## Issue Description
766
- ${analysis.issueDescription}
767
-
768
- ## Root Cause
769
- ${plan.rootCause}
770
-
771
- ## Proposed Solution
772
- ${plan.solution}
773
-
774
- ${analysis.codeSnippets.length > 0 ? `## Code Snippets from Ticket\n${analysis.codeSnippets.join('\n\n')}` : ''}
775
-
776
- ${analysis.errorMessages.length > 0 ? `## Error Messages\n${analysis.errorMessages.join('\n')}` : ''}
777
-
778
- ${analysis.hasAttachments ? `## Attachments\n${analysis.attachments.join('\n')}` : ''}
779
-
780
- ${args.additionalContext ? `## Additional Context\n${args.additionalContext}` : ''}
781
-
782
- ${worktreeInfo}
783
-
784
- ${GITHUB_REPO ? `## GitHub Repository\n- Repository: ${GITHUB_REPO}\n- Ready for PR creation with \`github_create_pr\` tool\n- Requires: GitHub CLI (\`gh\`) installed and authenticated` : '## GitHub Repository\n- ⚠️ No GitHub repository detected\n- Install GitHub CLI: \`gh\` and run \`gh auth login\`\n- Or set GITHUB_REPO environment variable'}
785
-
786
- ## Next Steps
787
- 1. Review the analysis above
788
- 2. ${analysis.isBug ? 'Implement the fix in the worktree' : 'Draft an explanatory reply'}
789
- 3. Test the changes
790
- 4. Create a pull request${GITHUB_REPO ? ' using `github_create_pr` tool' : ''}
791
- 5. Update the FreeScout ticket`;
792
- return {
793
- content: [
794
- {
795
- type: 'text',
796
- text: output,
797
- },
798
- ],
799
- };
800
- }
801
- default:
802
- throw new Error(`Unknown tool: ${name}`);
803
- }
804
- }
805
- catch (error) {
806
- return {
807
- content: [
808
- {
809
- type: 'text',
810
- text: `Error: ${error.message}`,
811
- },
812
- ],
813
- };
814
- }
259
+ // Tool 8: Get Mailboxes
260
+ server.registerTool('freescout_get_mailboxes', {
261
+ title: 'Get Mailboxes',
262
+ description: 'Get list of available mailboxes',
263
+ inputSchema: {},
264
+ outputSchema: {
265
+ mailboxes: z.array(z.any()),
266
+ },
267
+ }, async () => {
268
+ const mailboxes = await api.getMailboxes();
269
+ const output = { mailboxes };
270
+ return {
271
+ content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
272
+ structuredContent: output,
273
+ };
815
274
  });
816
275
  // Start the server
817
276
  async function main() {
818
277
  const transport = new StdioServerTransport();
819
278
  await server.connect(transport);
820
- console.error('FreeScout MCP Server running...');
279
+ console.error('FreeScout MCP Server v2.0.0 running...');
821
280
  }
822
281
  main().catch((error) => {
823
282
  console.error('Server error:', error);