qbo-mcp-ts
Version:
TypeScript QuickBooks Online MCP Server with enhanced features and dual transport support
334 lines • 13.4 kB
JavaScript
"use strict";
/**
* QuickBooks Online API Client with retry logic and error handling
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.QBOApiClient = void 0;
const axios_1 = __importDefault(require("axios"));
const axios_retry_1 = __importDefault(require("axios-retry"));
const types_1 = require("../types");
const config_1 = require("../utils/config");
const logger_1 = require("../utils/logger");
/**
* QuickBooks Online API Client
*/
class QBOApiClient {
axiosInstance;
tokens;
baseUrl;
authUrl;
qboConfig;
tokenRefreshPromise;
constructor(qboConfig) {
this.qboConfig = qboConfig || config_1.config.getQBOConfig();
const apiConfig = config_1.config.getAPIConfig();
// Set base URLs based on environment
if (this.qboConfig.environment === 'production') {
this.baseUrl = `https://quickbooks.api.intuit.com/v3/company/${this.qboConfig.companyId}`;
this.authUrl = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
}
else {
this.baseUrl = `https://sandbox-quickbooks.api.intuit.com/v3/company/${this.qboConfig.companyId}`;
this.authUrl = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
}
// Initialize tokens
this.tokens = {
accessToken: '',
refreshToken: this.qboConfig.refreshToken,
expiresAt: new Date(0), // Force initial refresh
};
// Create axios instance
this.axiosInstance = axios_1.default.create({
baseURL: this.baseUrl,
timeout: apiConfig.timeout,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
// Add request interceptor for authentication
this.axiosInstance.interceptors.request.use(async (config) => {
// Ensure we have a valid access token
await this.ensureValidToken();
// Add authorization header
config.headers.Authorization = `Bearer ${this.tokens.accessToken}`;
// Log API request
const timer = logger_1.logger.startTimer();
config.metadata = { timer };
logger_1.logger.api('Request', {
method: config.method?.toUpperCase(),
endpoint: config.url,
});
return config;
}, (error) => {
logger_1.logger.error('Request interceptor error', error);
return Promise.reject(error);
});
// Add response interceptor for logging and error handling
this.axiosInstance.interceptors.response.use((response) => {
// Log successful response
const duration = response.config.metadata?.timer?.() || 0;
logger_1.logger.api('Response', {
method: response.config.method?.toUpperCase(),
endpoint: response.config.url,
statusCode: response.status,
duration,
});
return response;
}, async (error) => {
const duration = error.config?.metadata?.timer?.() || 0;
// Log error response
logger_1.logger.api('Error', {
method: error.config?.method?.toUpperCase(),
endpoint: error.config?.url,
statusCode: error.response?.status,
duration,
error: error.message,
});
// Handle specific error types
if (error.response) {
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 401:
// Try to refresh token once
if (!error.config?.retry) {
error.config.retry = true;
await this.refreshAccessToken();
return this.axiosInstance.request(error.config);
}
throw new types_1.AuthenticationError('Authentication failed. Please check your credentials.', data);
case 429:
const retryAfter = error.response.headers['retry-after'];
throw new types_1.RateLimitError('Rate limit exceeded. Please try again later.', retryAfter ? parseInt(retryAfter) : undefined);
case 400:
throw new types_1.QBOError(data?.Fault?.Error?.[0]?.Message || 'Bad request', 'VALIDATION_ERROR', 400, data);
case 403:
throw new types_1.QBOError('Access forbidden. Check your permissions.', 'FORBIDDEN', 403, data);
case 404:
throw new types_1.QBOError('Resource not found', 'NOT_FOUND', 404, data);
case 500:
case 502:
case 503:
case 504:
throw new types_1.NetworkError('QuickBooks service is temporarily unavailable', data);
default:
throw new types_1.QBOError(data?.Fault?.Error?.[0]?.Message || 'An error occurred', 'UNKNOWN_ERROR', status, data);
}
}
else if (error.request) {
throw new types_1.NetworkError('Network error. Please check your connection.');
}
else {
throw new types_1.QBOError(error.message, 'REQUEST_ERROR');
}
});
// Configure retry logic if enabled
if (apiConfig.enableRetry) {
(0, axios_retry_1.default)(this.axiosInstance, {
retries: apiConfig.retryAttempts,
retryDelay: (retryCount) => {
const delay = apiConfig.retryDelay * Math.pow(2, retryCount - 1);
logger_1.logger.info(`Retrying request (attempt ${retryCount}), waiting ${delay}ms`);
return delay;
},
retryCondition: (error) => {
// Retry on network errors and 5xx status codes
return (axios_retry_1.default.isNetworkOrIdempotentRequestError(error) ||
(error.response?.status ? error.response.status >= 500 : false));
},
onRetry: (retryCount, error) => {
logger_1.logger.warn(`Retry attempt ${retryCount} for ${error.config?.url}`, {
error: error.message,
});
},
});
}
}
/**
* Ensure we have a valid access token
*/
async ensureValidToken() {
const now = new Date();
const expiryBuffer = new Date(this.tokens.expiresAt.getTime() - 60000); // 1 minute buffer
if (now >= expiryBuffer) {
// Use existing refresh promise if one is in progress
if (!this.tokenRefreshPromise) {
this.tokenRefreshPromise = this.refreshAccessToken();
}
await this.tokenRefreshPromise;
this.tokenRefreshPromise = undefined;
}
}
/**
* Refresh the OAuth2 access token
*/
async refreshAccessToken() {
logger_1.logger.info('Refreshing QuickBooks access token');
try {
const authHeader = Buffer.from(`${this.qboConfig.clientId}:${this.qboConfig.clientSecret}`).toString('base64');
const response = await axios_1.default.post(this.authUrl, new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.tokens.refreshToken,
}), {
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${authHeader}`,
},
});
const data = response.data;
// Update tokens
this.tokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token || this.tokens.refreshToken,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
};
logger_1.logger.info('Successfully refreshed QuickBooks access token', {
expiresAt: this.tokens.expiresAt.toISOString(),
});
}
catch (error) {
logger_1.logger.error('Failed to refresh access token', error);
throw new types_1.AuthenticationError('Failed to refresh access token. Please check your credentials.');
}
}
/**
* Make a GET request to QuickBooks API
*/
async get(endpoint, params) {
const response = await this.axiosInstance.get(endpoint, { params });
return response.data;
}
/**
* Make a POST request to QuickBooks API
*/
async post(endpoint, data) {
const response = await this.axiosInstance.post(endpoint, data);
return response.data;
}
/**
* Make a PUT request to QuickBooks API
*/
async put(endpoint, data) {
const response = await this.axiosInstance.put(endpoint, data);
return response.data;
}
/**
* Make a DELETE request to QuickBooks API
*/
async delete(endpoint) {
const response = await this.axiosInstance.delete(endpoint);
return response.data;
}
/**
* Execute a query using QuickBooks Query Language
*/
async query(query) {
const response = await this.axiosInstance.get('/query', {
params: { query },
});
return response.data;
}
/**
* Send an email for an entity (invoice, estimate, etc.)
*/
async sendEmail(entityType, entityId, email) {
const endpoint = `/${entityType.toLowerCase()}/${entityId}/send`;
const params = email ? { sendTo: email } : undefined;
const response = await this.axiosInstance.post(endpoint, null, { params });
return response.data;
}
/**
* Download a PDF for an entity
*/
async downloadPDF(entityType, entityId) {
const endpoint = `/${entityType.toLowerCase()}/${entityId}/pdf`;
const response = await this.axiosInstance.get(endpoint, {
responseType: 'arraybuffer',
headers: {
Accept: 'application/pdf',
},
});
return Buffer.from(response.data);
}
/**
* Get company info
*/
async getCompanyInfo() {
return this.get('/companyinfo/1');
}
/**
* Get current user info
*/
async getCurrentUser() {
// const authHeader = Buffer.from(
// `${this.qboConfig.clientId}:${this.qboConfig.clientSecret}`
// ).toString('base64');
const response = await axios_1.default.get('https://accounts.platform.intuit.com/v1/openid_connect/userinfo', {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.tokens.accessToken}`,
},
});
return response.data;
}
/**
* Batch operations
*/
async batch(operations) {
const batchRequest = {
BatchItemRequest: operations.map((op) => {
const item = { bId: op.bId };
switch (op.operation) {
case 'create':
item[op.entity] = op.data;
item.operation = 'create';
break;
case 'update':
item[op.entity] = op.data;
item.operation = 'update';
break;
case 'delete':
item[op.entity] = { Id: op.data.Id };
item.operation = 'delete';
break;
case 'query':
item.Query = op.query;
break;
}
return item;
}),
};
return this.post('/batch', batchRequest);
}
/**
* Get API limits and usage
*/
async getApiLimits() {
// QuickBooks doesn't provide a direct API for this,
// but we can track it from response headers
try {
const response = await this.axiosInstance.get('/companyinfo/1');
const remaining = parseInt(response.headers['x-ratelimit-remaining'] || '1000');
const limit = parseInt(response.headers['x-ratelimit-limit'] || '1000');
const reset = response.headers['x-ratelimit-reset']
? new Date(parseInt(response.headers['x-ratelimit-reset']) * 1000)
: new Date(Date.now() + 3600000);
return { remaining, limit, reset };
}
catch (_error) {
// Return default values if headers are not available
return {
remaining: 1000,
limit: 1000,
reset: new Date(Date.now() + 3600000),
};
}
}
}
exports.QBOApiClient = QBOApiClient;
//# sourceMappingURL=client.js.map