mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user