fix(mcp): paginate get_api_usage global per-record breakdown

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-06 16:37:11 +02:00
parent 9855860f1c
commit bdab377a07
2 changed files with 93 additions and 3 deletions
@@ -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 });
});
});
@@ -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<string, unknown> {
if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null };
const obj = data as Record<string, unknown>;
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);
}