Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/clients/xero-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class CustomConnectionsXeroClient extends MCPXeroClient {

public async getClientCredentialsToken(): Promise<TokenSet> {
const scope =
"accounting.transactions accounting.contacts accounting.settings accounting.reports.read payroll.settings payroll.employees payroll.timesheets";
"accounting.transactions accounting.contacts accounting.settings accounting.reports.read accounting.attachments payroll.settings payroll.employees payroll.timesheets files";
const credentials = Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64");
Expand Down Expand Up @@ -127,8 +127,37 @@ class CustomConnectionsXeroClient extends MCPXeroClient {
return response.data;
} catch (error) {
const axiosError = error as AxiosError;

// Log detailed error information to help with troubleshooting
console.error("===== Xero OAuth Authentication Failed =====");
console.error("Error Type:", axiosError.name);
console.error("Status Code:", axiosError.response?.status);
console.error("Status Text:", axiosError.response?.statusText);

if (axiosError.response?.data) {
console.error("Error Details:", JSON.stringify(axiosError.response.data, null, 2));
}

// Provide specific guidance based on error type
if (axiosError.response?.status === 401) {
console.error("\nAuthentication Error - Invalid Client Credentials");
console.error(" - Check your XERO_CLIENT_ID and XERO_CLIENT_SECRET");
console.error(" - Verify credentials match your Xero app configuration");
console.error(" - Ensure you've created a new connection after updating credentials");
} else if (axiosError.response?.status === 403) {
console.error("\nForbidden - Insufficient Permissions");
console.error(" - Check that your Xero app has the required scopes:");
console.error(" - " + scope);
} else if (axiosError.code === 'ENOTFOUND' || axiosError.code === 'ECONNREFUSED') {
console.error("\nNetwork Error - Cannot reach Xero API");
console.error(" - Check your internet connection");
console.error(" - Verify firewall settings");
}

console.error("=============================================\n");

throw new Error(
`Failed to get Xero token: ${axiosError.response?.data || axiosError.message}`,
`Failed to get Xero token: ${axiosError.response?.data ? JSON.stringify(axiosError.response.data) : axiosError.message}`,
);
}
}
Expand Down
115 changes: 115 additions & 0 deletions src/handlers/get-attachment.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Attachment } from "xero-node";
import { getClientHeaders } from "../helpers/get-client-headers.js";
import { XeroClientResponse } from "../types/tool-response.js";
import { xeroClient } from "../clients/xero-client.js";
import { AttachmentEntityType } from "./list-attachments.handler.js";

/**
* Result type for downloaded attachments
*/
export interface DownloadedAttachment {
fileName: string;
mimeType: string;
contentBase64: string;
}

/**
* Generic handler to get/download an attachment from any supported entity type
*/
export async function getAttachment(
entityType: AttachmentEntityType,
entityId: string,
attachmentId: string,
): Promise<XeroClientResponse<DownloadedAttachment>> {
try {
await xeroClient.authenticate();

let contentResponse;
let metadataResponse;

switch (entityType) {
case "invoice":
contentResponse =
await xeroClient.accountingApi.getInvoiceAttachmentById(
xeroClient.tenantId,
entityId,
attachmentId,
"application/octet-stream",
getClientHeaders(),
);

metadataResponse =
await xeroClient.accountingApi.getInvoiceAttachments(
xeroClient.tenantId,
entityId,
getClientHeaders(),
);
break;

case "manualJournal":
contentResponse =
await xeroClient.accountingApi.getManualJournalAttachmentById(
xeroClient.tenantId,
entityId,
attachmentId,
"application/octet-stream",
getClientHeaders(),
);

metadataResponse =
await xeroClient.accountingApi.getManualJournalAttachments(
xeroClient.tenantId,
entityId,
getClientHeaders(),
);
break;

default:
return {
result: null,
isError: true,
error: `Unsupported entity type: ${entityType}`,
};
}

if (!contentResponse.body) {
return {
result: null,
isError: true,
error: "No attachment content returned from Xero API",
};
}

const attachment = metadataResponse.body.attachments?.find(
(att: Attachment) => att.attachmentID === attachmentId,
);

if (!attachment) {
return {
result: null,
isError: true,
error: `Attachment ${attachmentId} not found on ${entityType} ${entityId}`,
};
}

const buffer = Buffer.from(contentResponse.body as Buffer);
const contentBase64 = buffer.toString("base64");

return {
result: {
fileName: attachment.fileName || "unknown",
mimeType: attachment.mimeType || "application/octet-stream",
contentBase64,
},
isError: false,
error: null,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
result: null,
isError: true,
error: `Failed to get attachment: ${errorMessage}`,
};
}
}
61 changes: 61 additions & 0 deletions src/handlers/list-attachments.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { xeroClient } from "../clients/xero-client.js";
import { XeroClientResponse } from "../types/tool-response.js";
import { formatError } from "../helpers/format-error.js";
import { Attachment } from "xero-node";
import { getClientHeaders } from "../helpers/get-client-headers.js";

/**
* Entity types that support attachments
*/
export type AttachmentEntityType = "invoice" | "manualJournal";

/**
* Generic handler to list attachments for any supported entity type
*/
export async function listAttachments(
entityType: AttachmentEntityType,
entityId: string,
): Promise<XeroClientResponse<Attachment[]>> {
try {
await xeroClient.authenticate();

let response;

switch (entityType) {
case "invoice":
response = await xeroClient.accountingApi.getInvoiceAttachments(
xeroClient.tenantId,
entityId,
getClientHeaders(),
);
break;

case "manualJournal":
response = await xeroClient.accountingApi.getManualJournalAttachments(
xeroClient.tenantId,
entityId,
getClientHeaders(),
);
break;

default:
return {
result: null,
isError: true,
error: `Unsupported entity type: ${entityType}`,
};
}

return {
result: response.body.attachments || [],
isError: false,
error: null,
};
} catch (error: unknown) {
return {
result: null,
isError: true,
error: formatError(error),
};
}
}
68 changes: 68 additions & 0 deletions src/helpers/attachment-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Attachment } from "xero-node";

/**
* Format a list of attachments into a human-readable string
*/
export function formatAttachmentList(
attachments: Attachment[],
options: { includeOnline?: boolean } = { includeOnline: true },
): string {
return attachments
.map((att, index) => {
const sizeMB = att.contentLength
? (att.contentLength / (1024 * 1024)).toFixed(2)
: "Unknown";

const lines = [
`${index + 1}. ${att.fileName}`,
` Attachment ID: ${att.attachmentID}`,
` MIME Type: ${att.mimeType}`,
` Size: ${sizeMB} MB`,
];

if (options.includeOnline) {
lines.push(` Include Online: ${att.includeOnline ? "Yes" : "No"}`);
}

return lines.join("\n");
})
.join("\n\n");
}

/**
* Format attachment details for successful operations
*/
export function formatAttachmentDetails(attachment: Attachment): string {
const lines = [
`File Name: ${attachment.fileName}`,
`Attachment ID: ${attachment.attachmentID}`,
`MIME Type: ${attachment.mimeType}`,
];

if (attachment.includeOnline !== undefined) {
lines.push(`Include Online: ${attachment.includeOnline ? "Yes" : "No"}`);
}

return lines.join("\n");
}

/**
* Format file content details for downloaded attachments
*/
export function formatDownloadedAttachment(
fileName: string,
mimeType: string,
contentBase64: string,
): string {
const sizeKB = Math.round((contentBase64.length * 0.75) / 1024);

return [
"Successfully retrieved attachment:",
`File Name: ${fileName}`,
`MIME Type: ${mimeType}`,
`Content Size: ${sizeKB} KB (approx)`,
"",
"The file content is provided as base64-encoded data below.",
"You can decode this content to access the file data.",
].join("\n");
}
68 changes: 68 additions & 0 deletions src/tools/attach/get-invoice-attachment.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { z } from "zod";
import { getAttachment } from "../../handlers/get-attachment.handler.js";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { formatDownloadedAttachment } from "../../helpers/attachment-formatter.js";

const GetInvoiceAttachmentTool = CreateXeroTool(
"get-invoice-attachment",
`Download a file attachment from an invoice or bill in Xero.
Returns the file content as base64-encoded data along with filename and MIME type.
Use list-invoice-attachments first to get the attachment ID.
The agent can then process the base64 content (e.g., read PDF text, analyze images).`,
{
invoiceId: z.string().describe(
"The ID of the invoice or bill containing the attachment. Can be obtained from create-invoice or list-invoices tools.",
),
attachmentId: z.string().describe(
"The ID of the attachment to download. Can be obtained from list-invoice-attachments tool.",
),
},
async ({ invoiceId, attachmentId }) => {
const result = await getAttachment("invoice", invoiceId, attachmentId);

if (result.isError) {
return {
content: [
{
type: "text" as const,
text: `Error getting attachment: ${result.error}`,
},
],
};
}

const attachment = result.result;

if (!attachment) {
return {
content: [
{
type: "text" as const,
text: `Attachment ${attachmentId} not found.`,
},
],
};
}

const formattedDetails = formatDownloadedAttachment(
attachment.fileName,
attachment.mimeType,
attachment.contentBase64,
);

return {
content: [
{
type: "text" as const,
text: formattedDetails,
},
{
type: "text" as const,
text: attachment.contentBase64,
},
],
};
},
);

export default GetInvoiceAttachmentTool;
Loading