UNPKG

mcp-quickbase

Version:

Work with Quickbase via Model Context Protocol

265 lines 10.7 kB
"use strict"; 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; } // Capture length before appending to check limit correctly const prevAllLen = allRecords.length; // Check if adding all pageRecords would exceed limit - truncate if needed if (prevAllLen + pageRecords.length > limit) { logger.debug("Truncating final page to respect limit"); const remainingSlots = limit - prevAllLen; allRecords = [...allRecords, ...pageRecords.slice(0, remainingSlots)]; // If we had to truncate, there are definitely more records available hasMore = true; 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; } 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