From bdab377a077cd71828d3c317f77260c762e5f2b6 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 6 Jun 2026 16:37:11 +0200 Subject: [PATCH] fix(mcp): paginate get_api_usage global per-record breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global per-record mode (no userId, no daily) fetched the full mcp-usage record set with no server-side cursor — thousands of rows / ~182KB — overflowing the response cap so output spilled to a file. It was the only mode of the tool without a way to bound the response. Mirror the get_geographic_sessions client-side pager (usage-offset:N cursor) instead of imposing a silent default window, preserving the server's "results are never silently truncated" contract: the caller follows nextLink and every row stays reachable. New paginateUsage helper slices whichever array the payload carries (records / summaries) and echoes all other top-level fields. Adds pageSize (default 200) + continuation params, bumps the cap to adminStream. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/geo-platform-helpers.test.ts | 47 +++++++++++++++++- .../autopilot-monitor-mcp/src/tools/admin.ts | 49 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/geo-platform-helpers.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/geo-platform-helpers.test.ts index a2266457..613d8bec 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/geo-platform-helpers.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/geo-platform-helpers.test.ts @@ -7,7 +7,7 @@ * sessions; the cap and a truncated flag are now surfaced. */ import { describe, it, expect } from 'vitest'; -import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho } from '../tools/admin.js'; +import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage } from '../tools/admin.js'; describe('inferGeoGroupBy', () => { it('treats a single-segment key as country', () => { @@ -74,3 +74,48 @@ describe('platformWindowEcho', () => { expect(echo).toEqual({ windowDays: 7, sessionLimit: 250, truncated: false }); }); }); + +describe('paginateUsage', () => { + const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i })); + + it('pages the global per-record breakdown and emits a nextLink while more remain', () => { + const out = paginateUsage({ tenantId: 't1', records }, 2); + expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim + expect(out.totalCount).toBe(5); + expect(out.count).toBe(2); + expect(out.offset).toBe(0); + expect(out.records).toEqual(records.slice(0, 2)); + expect(out.nextLink).toBe('usage-offset:2'); + }); + + it('follows a continuation cursor to the next slice', () => { + const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2'); + expect(out.offset).toBe(2); + expect(out.records).toEqual(records.slice(2, 4)); + expect(out.nextLink).toBe('usage-offset:4'); + }); + + it('drops nextLink on the final page', () => { + const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4'); + expect(out.count).toBe(1); + expect(out.records).toEqual(records.slice(4)); + expect(out.nextLink).toBeNull(); + }); + + it('pages the daily "summaries" array too, preserving the key', () => { + const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }]; + const out = paginateUsage({ tenantId: 't1', summaries }, 1); + expect(out.summaries).toEqual(summaries.slice(0, 1)); + expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false); + expect(out.nextLink).toBe('usage-offset:1'); + }); + + it('passes an unexpected shape through untouched', () => { + const weird = { tenantId: 't1', somethingElse: 42 }; + expect(paginateUsage(weird, 200)).toEqual(weird); + }); + + it('handles a null/non-object payload defensively', () => { + expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null }); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 1895b87a..8cf364fe 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -95,6 +95,43 @@ export function platformWindowEcho( }; } +/** + * Page client-side over an MCP-usage payload. The mcp-usage endpoints return the + * full set in one shot (no server-side cursor): the global per-record breakdown + * alone can run to thousands of rows and overflow the inline response budget. We + * slice whichever array the payload carries ("records" for the per-user and + * global breakdowns, "summaries" for the daily view) and echo every other + * top-level field (tenantId/userId/usagePlan) verbatim. The "continuation" + * carries an integer offset (usage-offset:N); each page re-reads the same set, + * so raise pageSize to amortize round-trips on a full sweep. Mirrors the + * get_geographic_sessions client-side pager. Exported for unit testing. + */ +export function paginateUsage( + data: unknown, + pageSize: number, + continuation?: string, +): Record { + if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null }; + const obj = data as Record; + const arrayKey = Array.isArray(obj.records) ? 'records' : Array.isArray(obj.summaries) ? 'summaries' : null; + if (!arrayKey) return obj; // unexpected shape — pass through untouched rather than mangle it + const all = obj[arrayKey] as unknown[]; + const m = continuation ? /^usage-offset:(\d+)$/.exec(continuation) : null; + const offset = m ? parseInt(m[1], 10) : 0; + const slice = all.slice(offset, offset + pageSize); + const nextOffset = offset + pageSize; + const nextLink = nextOffset < all.length ? `usage-offset:${nextOffset}` : null; + const { records: _records, summaries: _summaries, ...rest } = obj; + return { + ...rest, + totalCount: all.length, + count: slice.length, + offset, + [arrayKey]: slice, + nextLink, + }; +} + export function registerAdminTools(server: McpServer, ga: boolean): void { // Tool 11: get_api_usage — Global Admin only; not registered for normal users // (the `if (ga)` guards the whole single server.registerTool(...) statement). @@ -107,13 +144,21 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { 'Use to monitor platform usage, identify heavy users, or debug rate limiting. ' + 'Use userId for a specific user, daily for aggregated summaries, or neither for global per-record breakdown. ' + 'The daily and global breakdowns are Global Admin only; the per-user (userId) view also works for a ' + - 'Tenant Admin querying a user within their own tenant.', + 'Tenant Admin querying a user within their own tenant. ' + + 'The global per-record breakdown spans every user/endpoint and can run to thousands of rows, so results ' + + 'are paged: the default pageSize is 200 and the response carries a "nextLink" when more remain — pass that ' + + 'whole string back as "continuation" to get the next slice, and stop when nextLink is absent. Narrow with ' + + 'dateFrom/dateTo (default is the full retention window) or daily=true for a compact summary.', inputSchema: { userId: z.string().optional().describe('Specific user object ID to query usage for'), tenantId: z.string().optional().describe('Filter usage by tenant ID'), dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'), dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'), daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'), + pageSize: z.coerce.number().int().min(1).max(2000).optional().default(200) + .describe('Rows to return per call (1-2000, default 200). Follow nextLink for more; raise it for full sweeps.'), + continuation: z.string().optional() + .describe('Pass the whole nextLink string from the prior response to fetch the next slice.'), }, annotations: READ_ONLY, }, @@ -128,7 +173,7 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { } else { data = await apiFetch(`/api/global/metrics/mcp-usage${buildQuery(params)}`); } - return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + return toolResultText(paginateUsage(data, args.pageSize, args.continuation), MAX_RESULT_SIZE_CHARS.adminStream); } catch (error: unknown) { return toolError('get_api_usage', args, error); }