Skip to content

Commit 8ff325e

Browse files
asachs01claude
andauthored
feat(attachments): add autotask_create_ticket_attachment tool (#55) (#62)
Adds a new MCP tool and service method for uploading file attachments to Autotask tickets via POST /Tickets/{id}/Attachments. File content is accepted as a base64-encoded string (MCP is JSON-RPC, so binary bytes must be base64-encoded). The service method validates the base64 input and enforces Autotask's 3 MB hard limit on ticket attachments locally, returning a clear error instead of a cryptic 400 from the API. API validation errors are surfaced using the same pattern as #32. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 16614ff commit 8ff325e

5 files changed

Lines changed: 215 additions & 2 deletions

File tree

src/handlers/tool.definitions.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,43 @@ export const TOOL_DEFINITIONS: McpTool[] = [
12741274
required: ['ticketId']
12751275
}
12761276
},
1277+
{
1278+
name: 'autotask_create_ticket_attachment',
1279+
description:
1280+
'Upload a file attachment to an existing ticket. The file content must be passed as a base64-encoded string in the `data` field (MCP is JSON-RPC, so binary bytes must be base64-encoded). Autotask enforces a 3 MB hard limit on ticket attachments; this tool validates the decoded size before calling the API and returns a clear error if the limit is exceeded. Example: { ticketId: 12345, title: "screenshot.png", data: "iVBORw0KGgoAAAANSUhEUgAA..." }',
1281+
inputSchema: {
1282+
type: 'object',
1283+
properties: {
1284+
ticketId: {
1285+
type: 'number',
1286+
description: 'The ticket ID to attach the file to'
1287+
},
1288+
title: {
1289+
type: 'string',
1290+
description: 'Display title for the attachment (typically the filename, e.g. "screenshot.png")'
1291+
},
1292+
data: {
1293+
type: 'string',
1294+
description:
1295+
'Base64-encoded file content. Maximum decoded size: 3 MB (Autotask ticket attachment limit). Example: read a file and pass its base64 representation here.'
1296+
},
1297+
fullPath: {
1298+
type: 'string',
1299+
description: 'Original filename including any path. Defaults to `title` if not provided.'
1300+
},
1301+
contentType: {
1302+
type: 'string',
1303+
description: 'MIME type of the file (e.g. "image/png", "application/pdf"). Optional.'
1304+
},
1305+
publish: {
1306+
type: 'number',
1307+
description: 'Visibility: 1 = All Autotask Users (default), 2 = Internal Users Only',
1308+
default: 1
1309+
}
1310+
},
1311+
required: ['ticketId', 'title', 'data']
1312+
}
1313+
},
12771314

12781315
// Expense Reports tools
12791316
{
@@ -2906,7 +2943,7 @@ export const TOOL_CATEGORIES: Record<string, { description: string; tools: strin
29062943
},
29072944
tickets: {
29082945
description: 'Search, create, update tickets and manage ticket notes, attachments, and charges',
2909-
tools: ['autotask_search_tickets', 'autotask_get_ticket_details', 'autotask_create_ticket', 'autotask_update_ticket', 'autotask_get_ticket_note', 'autotask_search_ticket_notes', 'autotask_create_ticket_note', 'autotask_get_ticket_attachment', 'autotask_search_ticket_attachments', 'autotask_get_ticket_charge', 'autotask_search_ticket_charges', 'autotask_create_ticket_charge', 'autotask_update_ticket_charge', 'autotask_delete_ticket_charge']
2946+
tools: ['autotask_search_tickets', 'autotask_get_ticket_details', 'autotask_create_ticket', 'autotask_update_ticket', 'autotask_get_ticket_note', 'autotask_search_ticket_notes', 'autotask_create_ticket_note', 'autotask_get_ticket_attachment', 'autotask_search_ticket_attachments', 'autotask_create_ticket_attachment', 'autotask_get_ticket_charge', 'autotask_search_ticket_charges', 'autotask_create_ticket_charge', 'autotask_update_ticket_charge', 'autotask_delete_ticket_charge']
29102947
},
29112948
projects: {
29122949
description: 'Search and create projects, tasks, phases, and project notes',

src/handlers/tool.handler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,23 @@ export class AutotaskToolHandler {
10781078
['autotask_search_ticket_attachments', async (a) => {
10791079
const r = await s.searchTicketAttachments(a.ticketId, { pageSize: a.pageSize }); return { result: r, message: `Found ${r.length} ticket attachments` };
10801080
}],
1081+
['autotask_create_ticket_attachment', async (a) => {
1082+
// Never log `data` (base64 file bytes) — can be large / contain PII.
1083+
const decodedBytes = typeof a.data === 'string'
1084+
? Buffer.from(a.data, 'base64').length
1085+
: 0;
1086+
this.logger.info(
1087+
`autotask_create_ticket_attachment invoked: ticketId=${a.ticketId} title="${a.title}" bytes=${decodedBytes}`
1088+
);
1089+
const id = await s.createTicketAttachment(a.ticketId, {
1090+
title: a.title,
1091+
fullPath: a.fullPath || a.title,
1092+
data: a.data,
1093+
contentType: a.contentType,
1094+
publish: a.publish ?? 1
1095+
});
1096+
return { result: id, message: `Successfully created ticket attachment with ID: ${id}` };
1097+
}],
10811098

10821099
// Expense Reports
10831100
['autotask_get_expense_report', async (a) => {

src/services/autotask.service.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
AutotaskCompanyNote,
2121
AutotaskTicketAttachment,
2222
AutotaskTicketChecklistItem,
23+
AutotaskTicketAttachmentCreateRequest,
2324
AutotaskExpenseReport,
2425
AutotaskExpenseItem,
2526
AutotaskQuote,
@@ -1516,6 +1517,82 @@ export class AutotaskService {
15161517
}
15171518
}
15181519

1520+
/**
1521+
* Upload a file attachment to a ticket. `data.data` must be a base64-encoded
1522+
* string of the raw file bytes. Autotask enforces a 3MB hard limit on ticket
1523+
* attachments; we validate locally so callers get a clear error instead of a
1524+
* cryptic 400 from the API.
1525+
*/
1526+
async createTicketAttachment(
1527+
ticketId: number,
1528+
data: AutotaskTicketAttachmentCreateRequest
1529+
): Promise<number> {
1530+
const MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024; // 3 MB
1531+
1532+
if (!data || typeof data.data !== 'string' || data.data.length === 0) {
1533+
throw new Error('createTicketAttachment: `data` (base64-encoded file content) is required');
1534+
}
1535+
if (!data.title) {
1536+
throw new Error('createTicketAttachment: `title` is required');
1537+
}
1538+
1539+
// Validate base64 and decoded size up front.
1540+
let decodedLength: number;
1541+
try {
1542+
const buf = Buffer.from(data.data, 'base64');
1543+
// Buffer.from silently drops invalid chars, so round-trip to detect garbage.
1544+
if (buf.toString('base64').replace(/=+$/, '') !== data.data.replace(/\s+/g, '').replace(/=+$/, '')) {
1545+
throw new Error('invalid base64');
1546+
}
1547+
decodedLength = buf.length;
1548+
} catch {
1549+
throw new Error('createTicketAttachment: `data` is not valid base64-encoded content');
1550+
}
1551+
1552+
if (decodedLength === 0) {
1553+
throw new Error('createTicketAttachment: decoded attachment is empty');
1554+
}
1555+
if (decodedLength > MAX_ATTACHMENT_BYTES) {
1556+
throw new Error(
1557+
`createTicketAttachment: attachment is ${decodedLength} bytes which exceeds the Autotask 3MB (${MAX_ATTACHMENT_BYTES} byte) limit for ticket attachments`
1558+
);
1559+
}
1560+
1561+
const client = await this.ensureClient();
1562+
1563+
const payload = {
1564+
title: data.title,
1565+
fullPath: data.fullPath || data.title,
1566+
data: data.data,
1567+
attachmentType: data.attachmentType || 'FILE_ATTACHMENT',
1568+
contentType: data.contentType,
1569+
publish: data.publish ?? 1,
1570+
parentId: ticketId,
1571+
parentType: 4 // Ticket
1572+
};
1573+
1574+
try {
1575+
this.logger.info(
1576+
`Creating ticket attachment - ticketId=${ticketId} title="${data.title}" bytes=${decodedLength}`
1577+
);
1578+
const axiosInstance = (client as any).axios;
1579+
const response = await axiosInstance.post(`/Tickets/${ticketId}/Attachments`, payload);
1580+
const attachmentId = response.data?.itemId ?? response.data?.id;
1581+
this.logger.info(`Ticket attachment created with ID: ${attachmentId} for ticket ${ticketId}`);
1582+
return attachmentId;
1583+
} catch (error) {
1584+
this.logger.error(`Failed to create ticket attachment for ticket ${ticketId}:`, error);
1585+
// Surface Autotask API validation errors rather than a generic 500.
1586+
const apiErrors: string[] | undefined =
1587+
(error as any)?.originalError?.response?.data?.errors ||
1588+
(error as any)?.response?.data?.errors;
1589+
if (apiErrors && apiErrors.length > 0) {
1590+
throw new Error(`Autotask API error: ${apiErrors.join('; ')}`);
1591+
}
1592+
throw error;
1593+
}
1594+
}
1595+
15191596
// Expense entities
15201597
async getExpenseReport(id: number): Promise<AutotaskExpenseReport | null> {
15211598
const client = await this.ensureClient();

src/types/autotask.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,20 @@ export interface AutotaskTicketAttachment {
261261
[key: string]: any;
262262
}
263263

264+
/**
265+
* Request payload for creating a ticket attachment via
266+
* POST /Tickets/{id}/Attachments. Autotask expects the file bytes
267+
* as a base64-encoded string in `data`.
268+
*/
269+
export interface AutotaskTicketAttachmentCreateRequest {
270+
title: string;
271+
fullPath: string;
272+
data: string; // base64-encoded file bytes
273+
attachmentType?: string; // defaults to 'FILE_ATTACHMENT'
274+
contentType?: string;
275+
publish?: number; // 1 = All Autotask Users, 2 = Internal Users Only
276+
}
277+
264278
export interface AutotaskExpenseReport {
265279
id?: number;
266280
name?: string;

tests/autotask-service.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,79 @@ describe('AutotaskService', () => {
133133

134134
test('should handle attachment methods with proper error messages', async () => {
135135
const service = new AutotaskService(mockConfig, mockLogger);
136-
136+
137137
await expect(service.getTicketAttachment(123, 456)).rejects.toThrow();
138138
await expect(service.searchTicketAttachments(123)).rejects.toThrow();
139139
});
140140

141+
describe('createTicketAttachment', () => {
142+
const validBase64 = Buffer.from('hello world').toString('base64');
143+
144+
test('rejects invalid base64 before any HTTP call', async () => {
145+
const service = new AutotaskService(mockConfig, mockLogger);
146+
// Spy to ensure ensureClient is never reached
147+
const ensureSpy = jest
148+
.spyOn(service as any, 'ensureClient')
149+
.mockResolvedValue({ axios: { post: jest.fn() } });
150+
151+
await expect(
152+
service.createTicketAttachment(123, {
153+
title: 'bad.bin',
154+
fullPath: 'bad.bin',
155+
data: 'not*valid*base64!!!'
156+
})
157+
).rejects.toThrow(/not valid base64/);
158+
159+
expect(ensureSpy).not.toHaveBeenCalled();
160+
});
161+
162+
test('rejects oversized attachments before any HTTP call', async () => {
163+
const service = new AutotaskService(mockConfig, mockLogger);
164+
const ensureSpy = jest
165+
.spyOn(service as any, 'ensureClient')
166+
.mockResolvedValue({ axios: { post: jest.fn() } });
167+
168+
// 4 MB of zero bytes, base64-encoded
169+
const big = Buffer.alloc(4 * 1024 * 1024).toString('base64');
170+
await expect(
171+
service.createTicketAttachment(123, {
172+
title: 'huge.bin',
173+
fullPath: 'huge.bin',
174+
data: big
175+
})
176+
).rejects.toThrow(/exceeds the Autotask 3MB/);
177+
178+
expect(ensureSpy).not.toHaveBeenCalled();
179+
});
180+
181+
test('happy path posts to /Tickets/{id}/Attachments and returns itemId', async () => {
182+
const service = new AutotaskService(mockConfig, mockLogger);
183+
const post = jest.fn().mockResolvedValue({ data: { itemId: 987 } });
184+
jest
185+
.spyOn(service as any, 'ensureClient')
186+
.mockResolvedValue({ axios: { post } });
187+
188+
const id = await service.createTicketAttachment(555, {
189+
title: 'readme.txt',
190+
fullPath: 'readme.txt',
191+
data: validBase64,
192+
contentType: 'text/plain',
193+
publish: 1
194+
});
195+
196+
expect(id).toBe(987);
197+
expect(post).toHaveBeenCalledTimes(1);
198+
const [url, body] = post.mock.calls[0];
199+
expect(url).toBe('/Tickets/555/Attachments');
200+
expect(body.title).toBe('readme.txt');
201+
expect(body.fullPath).toBe('readme.txt');
202+
expect(body.data).toBe(validBase64);
203+
expect(body.attachmentType).toBe('FILE_ATTACHMENT');
204+
expect(body.publish).toBe(1);
205+
expect(body.parentId).toBe(555);
206+
});
207+
});
208+
141209
test('should handle expense methods with proper error messages', async () => {
142210
const service = new AutotaskService(mockConfig, mockLogger);
143211

0 commit comments

Comments
 (0)