// Autotask Tool Handler
// Handles MCP tool calls for Autotask operations (search, create, update)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { AutotaskService } from '../services/autotask.service.js';
import { PicklistCache, PicklistValue } from '../services/picklist.cache.js';
import { Logger } from '../utils/logger.js';
import { formatCompactResponse, detectEntityType, COMPACT_SEARCH_TOOLS } from '../utils/response.formatter.js';
import { MappingService } from '../utils/mapping.service.js';
import { TOOL_DEFINITIONS } from './tool.definitions.js';
export interface McpTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, any>;
required?: string[];
};
}
export interface McpToolResult {
content: Array<{
type: string;
text: string;
}>;
isError?: boolean;
}
export class AutotaskToolHandler {
protected autotaskService: AutotaskService;
protected logger: Logger;
protected picklistCache: PicklistCache;
protected mcpServer: Server | null = null;
private mappingService: MappingService | null = null;
constructor(autotaskService: AutotaskService, logger: Logger) {
this.autotaskService = autotaskService;
this.logger = logger;
this.picklistCache = new PicklistCache(
logger,
(entityType) => this.autotaskService.getFieldInfo(entityType)
);
}
private async getMappingService(): Promise<MappingService> {
if (!this.mappingService) {
this.mappingService = await MappingService.getInstance(this.autotaskService, this.logger);
}
return this.mappingService;
}
/**
* Enhance items by inlining company/resource names from IDs
*/
private async enhanceItems(items: any[]): Promise<any[]> {
try {
const mappingService = await this.getMappingService();
const enhanced = await Promise.allSettled(
items.map(async (item) => {
const result = { ...item };
if (item.companyID != null && typeof item.companyID === 'number') {
try {
const name = await mappingService.getCompanyName(item.companyID);
if (name) result.company = name;
} catch { /* skip */ }
}
if (item.assignedResourceID != null && typeof item.assignedResourceID === 'number') {
try {
const name = await mappingService.getResourceName(item.assignedResourceID);
if (name) result.assignedTo = name;
} catch { /* skip */ }
}
if (item.projectLeadResourceID != null && typeof item.projectLeadResourceID === 'number') {
try {
const name = await mappingService.getResourceName(item.projectLeadResourceID);
if (name) result.lead = name;
} catch { /* skip */ }
}
return result;
})
);
return enhanced
.filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled')
.map(r => r.value);
} catch (error) {
this.logger.debug('Enhancement failed, returning original items:', error);
return items;
}
}
/**
* Set the MCP server reference for elicitation support
*/
setServer(server: Server): void {
this.mcpServer = server;
}
/**
* Elicit user input for a selection from picklist values.
* Falls back to returning null if elicitation is not supported by the client.
*/
protected async elicitSelection(
message: string,
fieldName: string,
options: PicklistValue[]
): Promise<string | null> {
if (!this.mcpServer) return null;
try {
const result = await this.mcpServer.elicitInput({
message,
requestedSchema: {
type: 'object' as const,
properties: {
[fieldName]: {
type: 'string' as const,
title: fieldName,
description: `Select a ${fieldName}`,
enum: options.map(o => o.value),
enumNames: options.map(o => o.label),
}
},
required: [fieldName],
}
});
if (result.action === 'accept' && result.content) {
return result.content[fieldName] as string;
}
return null;
} catch (error) {
// Client likely doesn't support elicitation — not an error
this.logger.debug(`Elicitation not available: ${error instanceof Error ? error.message : 'unknown'}`);
return null;
}
}
/**
* Elicit a date range filter when no filters are provided for ticket search.
* Returns date filter params or null if elicitation is not available/dismissed.
* Times out after 5 seconds to avoid blocking in non-interactive environments.
*/
protected async elicitDateRange(): Promise<Record<string, string> | null> {
if (!this.mcpServer) return null;
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('elicitation timeout')), 5000)
);
const result = await Promise.race([this.mcpServer.elicitInput({
message: 'No filters specified. What date range would you like to search?',
requestedSchema: {
type: 'object' as const,
properties: {
dateRange: {
type: 'string' as const,
title: 'Date Range',
description: 'How far back to search',
enum: ['today', 'past_week', 'past_month', 'past_quarter', 'all'],
enumNames: ['Today', 'Past Week', 'Past Month', 'Past Quarter', 'All Time'],
}
},
required: ['dateRange'],
}
}), timeoutPromise]);
if (result.action === 'accept' && result.content) {
const range = result.content.dateRange as string;
const now = new Date();
let createdAfter: string | undefined;
switch (range) {
case 'today':
createdAfter = now.toISOString().split('T')[0];
break;
case 'past_week':
now.setDate(now.getDate() - 7);
createdAfter = now.toISOString().split('T')[0];
break;
case 'past_month':
now.setMonth(now.getMonth() - 1);
createdAfter = now.toISOString().split('T')[0];
break;
case 'past_quarter':
now.setMonth(now.getMonth() - 3);
createdAfter = now.toISOString().split('T')[0];
break;
case 'all':
default:
return null; // No date filter
}
if (createdAfter) {
return { createdAfter };
}
}
return null;
} catch (error) {
this.logger.debug(`Date range elicitation not available: ${error instanceof Error ? error.message : 'unknown'}`);
return null;
}
}
/**
* List all available tools
*/
async listTools(): Promise<McpTool[]> {
this.logger.debug(`Listed ${TOOL_DEFINITIONS.length} available tools`);
return TOOL_DEFINITIONS;
}
/**
* Dispatch table: maps tool names to handler functions
*/
private getDispatchTable(): Map<string, (args: any) => Promise<{ result: any; message: string }>> {
const s = this.autotaskService;
type H = (args: any) => Promise<{ result: any; message: string }>;
return new Map<string, H>([
// Connection
['autotask_test_connection', async () => {
const ok = await s.testConnection();
return { result: { success: ok }, message: ok ? 'Successfully connected to Autotask API' : 'Connection failed' };
}],
// Companies
['autotask_search_companies', async (a) => {
const r = await s.searchCompanies(a); return { result: r, message: `Found ${r.length} companies` };
}],
['autotask_create_company', async (a) => {
const id = await s.createCompany(a); return { result: id, message: `Successfully created company with ID: ${id}` };
}],
['autotask_update_company', async (a) => {
await s.updateCompany(a.id, a); return { result: undefined, message: `Successfully updated company ID: ${a.id}` };
}],
// Contacts
['autotask_search_contacts', async (a) => {
const r = await s.searchContacts(a); return { result: r, message: `Found ${r.length} contacts` };
}],
['autotask_create_contact', async (a) => {
const id = await s.createContact(a); return { result: id, message: `Successfully created contact with ID: ${id}` };
}],
// Tickets
['autotask_search_tickets', async (a) => {
// Elicitation for zero-filter ticket searches
const hasFilters = a.searchTerm || a.companyID || a.status !== undefined ||
a.assignedResourceID || a.unassigned || a.createdAfter || a.createdBefore || a.lastActivityAfter;
if (!hasFilters && this.mcpServer) {
const dateChoice = await this.elicitDateRange();
if (dateChoice) a = { ...a, ...dateChoice };
}
const { companyID, ...rest } = a;
const opts = { ...rest, ...(companyID !== undefined && { companyId: companyID }) };
const r = await s.searchTickets(opts);
return { result: r, message: `Found ${r.length} tickets` };
}],
['autotask_get_ticket_details', async (a) => {
const r = await s.getTicket(a.ticketID, a.fullDetails); return { result: r, message: 'Ticket details retrieved successfully' };
}],
['autotask_create_ticket', async (a) => {
const id = await s.createTicket(a); return { result: id, message: `Successfully created ticket with ID: ${id}` };
}],
// Time entries
['autotask_create_time_entry', async (a) => {
const id = await s.createTimeEntry(a); return { result: id, message: `Successfully created time entry with ID: ${id}` };
}],
// Projects
['autotask_search_projects', async (a) => {
const r = await s.searchProjects(a); return { result: r, message: `Found ${r.length} projects` };
}],
['autotask_create_project', async (a) => {
const id = await s.createProject(a); return { result: id, message: `Successfully created project with ID: ${id}` };
}],
// Resources
['autotask_search_resources', async (a) => {
const r = await s.searchResources(a); return { result: r, message: `Found ${r.length} resources` };
}],
// Configuration Items
['autotask_search_configuration_items', async (a) => {
const r = await s.searchConfigurationItems(a); return { result: r, message: `Found ${r.length} configuration items` };
}],
// Contracts
['autotask_search_contracts', async (a) => {
const r = await s.searchContracts(a); return { result: r, message: `Found ${r.length} contracts` };
}],
// Invoices
['autotask_search_invoices', async (a) => {
const r = await s.searchInvoices(a); return { result: r, message: `Found ${r.length} invoices` };
}],
// Tasks
['autotask_search_tasks', async (a) => {
const r = await s.searchTasks(a); return { result: r, message: `Found ${r.length} tasks` };
}],
['autotask_create_task', async (a) => {
const id = await s.createTask(a); return { result: id, message: `Successfully created task with ID: ${id}` };
}],
// Notes (ticket/project/company)
['autotask_get_ticket_note', async (a) => {
const r = await s.getTicketNote(a.ticketId, a.noteId); return { result: r, message: 'Ticket note retrieved successfully' };
}],
['autotask_search_ticket_notes', async (a) => {
const r = await s.searchTicketNotes(a.ticketId, { pageSize: a.pageSize }); return { result: r, message: `Found ${r.length} ticket notes` };
}],
['autotask_create_ticket_note', async (a) => {
const id = await s.createTicketNote(a.ticketId, { title: a.title, description: a.description, noteType: a.noteType, publish: a.publish });
return { result: id, message: `Successfully created ticket note with ID: ${id}` };
}],
['autotask_get_project_note', async (a) => {
const r = await s.getProjectNote(a.projectId, a.noteId); return { result: r, message: 'Project note retrieved successfully' };
}],
['autotask_search_project_notes', async (a) => {
const r = await s.searchProjectNotes(a.projectId, { pageSize: a.pageSize }); return { result: r, message: `Found ${r.length} project notes` };
}],
['autotask_create_project_note', async (a) => {
const id = await s.createProjectNote(a.projectId, { title: a.title, description: a.description, noteType: a.noteType });
return { result: id, message: `Successfully created project note with ID: ${id}` };
}],
['autotask_get_company_note', async (a) => {
const r = await s.getCompanyNote(a.companyId, a.noteId); return { result: r, message: 'Company note retrieved successfully' };
}],
['autotask_search_company_notes', async (a) => {
const r = await s.searchCompanyNotes(a.companyId, { pageSize: a.pageSize }); return { result: r, message: `Found ${r.length} company notes` };
}],
['autotask_create_company_note', async (a) => {
const id = await s.createCompanyNote(a.companyId, { title: a.title, description: a.description, actionType: a.actionType });
return { result: id, message: `Successfully created company note with ID: ${id}` };
}],
// Attachments
['autotask_get_ticket_attachment', async (a) => {
const r = await s.getTicketAttachment(a.ticketId, a.attachmentId, a.includeData); return { result: r, message: 'Ticket attachment retrieved successfully' };
}],
['autotask_search_ticket_attachments', async (a) => {
const r = await s.searchTicketAttachments(a.ticketId, { pageSize: a.pageSize }); return { result: r, message: `Found ${r.length} ticket attachments` };
}],
// Expense Reports
['autotask_get_expense_report', async (a) => {
const r = await s.getExpenseReport(a.reportId); return { result: r, message: 'Expense report retrieved successfully' };
}],
['autotask_search_expense_reports', async (a) => {
const r = await s.searchExpenseReports({ submitterId: a.submitterId, status: a.status, pageSize: a.pageSize });
return { result: r, message: `Found ${r.length} expense reports` };
}],
['autotask_create_expense_report', async (a) => {
const id = await s.createExpenseReport({ name: a.name, description: a.description, submitterID: a.submitterId, weekEndingDate: a.weekEndingDate });
return { result: id, message: `Successfully created expense report with ID: ${id}` };
}],
// Quotes
['autotask_get_quote', async (a) => {
const r = await s.getQuote(a.quoteId); return { result: r, message: 'Quote retrieved successfully' };
}],
['autotask_search_quotes', async (a) => {
const r = await s.searchQuotes({ companyId: a.companyId, contactId: a.contactId, opportunityId: a.opportunityId, searchTerm: a.searchTerm, pageSize: a.pageSize });
return { result: r, message: `Found ${r.length} quotes` };
}],
['autotask_create_quote', async (a) => {
const id = await s.createQuote({ name: a.name, description: a.description, companyID: a.companyId, contactID: a.contactId, opportunityID: a.opportunityId, effectiveDate: a.effectiveDate, expirationDate: a.expirationDate });
return { result: id, message: `Successfully created quote with ID: ${id}` };
}],
// Picklist tools
['autotask_list_queues', async () => {
const queues = await this.picklistCache.getQueues();
return { result: queues.map(q => ({ id: q.value, name: q.label, isActive: q.isActive })), message: `Found ${queues.length} queues` };
}],
['autotask_list_ticket_statuses', async () => {
const statuses = await this.picklistCache.getTicketStatuses();
return { result: statuses.map(s => ({ id: s.value, name: s.label, isActive: s.isActive })), message: `Found ${statuses.length} ticket statuses` };
}],
['autotask_list_ticket_priorities', async () => {
const priorities = await this.picklistCache.getTicketPriorities();
return { result: priorities.map(p => ({ id: p.value, name: p.label, isActive: p.isActive })), message: `Found ${priorities.length} ticket priorities` };
}],
['autotask_get_field_info', async (a) => {
const fields = await this.picklistCache.getFields(a.entityType);
if (a.fieldName) {
const field = fields.find(f => f.name.toLowerCase() === a.fieldName.toLowerCase());
return { result: field || null, message: field ? `Field info for ${a.entityType}.${a.fieldName}` : `Field '${a.fieldName}' not found on ${a.entityType}` };
}
const summary = fields.map(f => ({ name: f.name, dataType: f.dataType, isRequired: f.isRequired, isPickList: f.isPickList, isQueryable: f.isQueryable, picklistValueCount: f.picklistValues?.length || 0 }));
return { result: summary, message: `Found ${fields.length} fields for ${a.entityType}` };
}],
]);
}
/**
* Call a tool with the given arguments
*/
async callTool(name: string, args: Record<string, any>): Promise<McpToolResult> {
this.logger.debug(`Calling tool: ${name}`, args);
try {
const handler = this.getDispatchTable().get(name);
if (!handler) throw new Error(`Unknown tool: ${name}`);
const { result, message } = await handler(args);
// Format and enhance response
let responseText: string;
if (COMPACT_SEARCH_TOOLS.has(name) && Array.isArray(result)) {
const entityType = detectEntityType(name);
if (entityType) {
const compact = formatCompactResponse(result, entityType, {
page: args.page,
pageSize: args.pageSize,
});
compact.items = await this.enhanceItems(compact.items);
responseText = JSON.stringify(compact);
} else {
const enhanced = await this.enhanceItems(result);
responseText = JSON.stringify({ message, data: enhanced });
}
} else if (Array.isArray(result)) {
const enhanced = await this.enhanceItems(result);
responseText = JSON.stringify({ message, data: enhanced });
} else if (result && typeof result === 'object' && !Array.isArray(result)) {
const enhanced = await this.enhanceItems([result]);
responseText = JSON.stringify({ message, data: enhanced[0] || result });
} else {
responseText = JSON.stringify({ message, data: result });
}
this.logger.debug(`Successfully executed tool: ${name}`);
return { content: [{ type: 'text', text: responseText }] };
} catch (error) {
this.logger.error(`Tool execution failed for ${name}:`, error);
return {
content: [{ type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error', tool: name }) }],
isError: true
};
}
}
}