mcp-quickbase
Version:
Work with Quickbase via Model Context Protocol
261 lines • 10.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.QueryRecordsTool = void 0;
const base_1 = require("../base");
const logger_1 = require("../../utils/logger");
const logger = (0, logger_1.createLogger)('QueryRecordsTool');
/**
* Tool for querying records from a Quickbase table
*/
class QueryRecordsTool extends base_1.BaseTool {
/**
* Constructor
* @param client Quickbase client
*/
constructor(client) {
super(client);
this.name = 'query_records';
this.description = 'Executes a query against a Quickbase table with optional pagination';
/**
* Parameter schema for query_records
*/
this.paramSchema = {
type: 'object',
properties: {
table_id: {
type: 'string',
description: 'The ID of the Quickbase table'
},
where: {
type: 'string',
description: 'Query criteria'
},
select: {
type: 'array',
description: 'Fields to select',
items: {
type: 'string'
}
},
orderBy: {
type: 'array',
description: 'Fields to order results by',
items: {
type: 'object',
properties: {
fieldId: {
type: 'string'
},
order: {
type: 'string',
enum: ['ASC', 'DESC']
}
}
}
},
max_records: {
type: 'number',
description: 'Maximum number of records to return when paginating (default: 1000)'
},
skip: {
type: 'number',
description: 'Number of records to skip'
},
paginate: {
type: 'boolean',
description: 'Whether to automatically handle pagination for large result sets'
},
options: {
type: 'object',
description: 'Query options for filtering, ordering, and pagination'
}
},
required: ['table_id']
};
}
/**
* Run the query_records tool
* @param params Tool parameters
* @returns Queried records
*/
async run(params) {
const { table_id, where, select, orderBy, max_records = 1000, skip = 0, paginate = false, options } = params;
logger.info('Querying records from Quickbase table', {
tableId: table_id,
maxRecords: max_records,
pagination: paginate ? 'enabled' : 'disabled'
});
// Prepare the query body
const body = {
from: table_id
};
// Add where clause if provided
if (where) {
body.where = where;
}
// Add select clause if provided
if (select && select.length > 0) {
body.select = select;
}
// Add sorting if provided
if (orderBy && orderBy.length > 0) {
body.sortBy = orderBy;
}
// Add pagination
const limit = parseInt(max_records.toString(), 10);
body.options = {
skip,
top: Math.min(limit, 1000), // API has a limit of 1000 records per request
...(options || {})
};
// Execute the query
const response = await this.client.request({
method: 'POST',
path: '/records/query',
body
});
if (!response.success || !response.data) {
logger.error('Failed to query records', {
error: response.error,
tableId: table_id
});
throw new Error(response.error?.message || 'Failed to query records');
}
// Safely validate response structure
if (typeof response.data !== 'object' || response.data === null) {
throw new Error('Invalid API response: data is not an object');
}
const data = response.data;
// Validate records array exists
if (!Array.isArray(data.data)) {
logger.error('Query response missing data array', { data });
throw new Error('Query response does not contain records array');
}
const records = data.data;
// Validate and type-cast fields array
const fields = Array.isArray(data.fields)
? data.fields
: undefined;
const metadata = {
fields,
tableId: table_id,
numRecords: records.length,
skip
};
// Handle pagination if enabled and there may be more records
let allRecords = [...records];
let hasMore = records.length === body.options.top;
if (paginate && hasMore && allRecords.length < limit) {
logger.info('Paginating query results', {
recordsFetched: allRecords.length,
limit
});
let currentSkip = skip + records.length;
let iterationCount = 0;
const maxIterations = 100; // Circuit breaker: prevent infinite loops
const startTime = Date.now();
const maxTimeMs = 30000; // 30 second timeout
// Continue fetching until we reach the limit or there are no more records
while (hasMore && allRecords.length < limit) {
// Circuit breaker checks
iterationCount++;
if (iterationCount > maxIterations) {
logger.error('Pagination circuit breaker: too many iterations', {
iterationCount,
maxIterations,
totalRecords: allRecords.length
});
break;
}
if (Date.now() - startTime > maxTimeMs) {
logger.error('Pagination circuit breaker: timeout exceeded', {
timeElapsed: Date.now() - startTime,
maxTimeMs,
totalRecords: allRecords.length
});
break;
}
// Update pagination options
body.options.skip = currentSkip;
body.options.top = Math.min(limit - allRecords.length, 1000);
// Execute next page query
const pageResponse = await this.client.request({
method: 'POST',
path: '/records/query',
body
});
if (!pageResponse.success || !pageResponse.data) {
logger.error('Failed to query additional records', {
error: pageResponse.error,
tableId: table_id,
skip: currentSkip
});
break;
}
// Safely validate pagination response structure
if (typeof pageResponse.data !== 'object' || pageResponse.data === null) {
logger.error('Invalid pagination response: data is not an object');
break;
}
const pageData = pageResponse.data;
// Validate page records array exists
if (!Array.isArray(pageData.data)) {
logger.error('Pagination response missing data array', { pageData });
break;
}
const pageRecords = pageData.data;
// Zero progress detection - prevent infinite loops from bad API responses
if (pageRecords.length === 0) {
logger.debug('No more records returned, stopping pagination');
hasMore = false;
break;
}
// Add the new records to our results
allRecords = [...allRecords, ...pageRecords];
// Validate pagination progress to prevent infinite loops
const previousSkip = currentSkip;
currentSkip += pageRecords.length;
// Anti-bypass check: ensure offset actually advanced
if (currentSkip <= previousSkip) {
logger.error('Pagination offset did not advance - potential infinite loop', {
previousSkip,
currentSkip,
recordsReceived: pageRecords.length
});
break;
}
// Anti-bypass check: ensure we're not receiving the same data
if (pageRecords.length === body.options.top &&
allRecords.length + pageRecords.length > limit) {
logger.debug('Truncating final page to respect limit');
const remainingSlots = limit - allRecords.length;
allRecords = [...allRecords, ...pageRecords.slice(0, remainingSlots)];
hasMore = false;
break;
}
hasMore = pageRecords.length === body.options.top && allRecords.length < limit;
logger.debug('Fetched additional records', {
newRecords: pageRecords.length,
totalRecords: allRecords.length,
limit,
currentSkip,
hasMore
});
}
// Update metadata for the complete result set
metadata.numRecords = allRecords.length;
}
logger.info(`Retrieved ${allRecords.length} records from table`, {
tableId: table_id,
hasMore
});
return {
records: allRecords,
totalRecords: allRecords.length,
hasMore,
metadata
};
}
}
exports.QueryRecordsTool = QueryRecordsTool;
//# sourceMappingURL=query_records.js.map