feat: smart response pagination for large MCP tool results
Intercepts oversized tool responses (>80K chars), caches them, and returns a page index. LLM can fetch specific pages via _resultId/_page params. Supports LLM-generated smart summaries with simple fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
433
src/mcplocal/tests/pagination.test.ts
Normal file
433
src/mcplocal/tests/pagination.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ResponsePaginator, DEFAULT_PAGINATION_CONFIG } from '../src/llm/pagination.js';
|
||||
import type { ProviderRegistry } from '../src/providers/registry.js';
|
||||
import type { LlmProvider } from '../src/providers/types.js';
|
||||
|
||||
function makeProvider(response: string): ProviderRegistry {
|
||||
const provider: LlmProvider = {
|
||||
name: 'test',
|
||||
isAvailable: () => true,
|
||||
complete: vi.fn().mockResolvedValue({ content: response }),
|
||||
};
|
||||
return {
|
||||
getActive: () => provider,
|
||||
register: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||
} as unknown as ProviderRegistry;
|
||||
}
|
||||
|
||||
function makeLargeString(size: number, pattern = 'x'): string {
|
||||
return pattern.repeat(size);
|
||||
}
|
||||
|
||||
function makeLargeStringWithNewlines(size: number, lineLen = 100): string {
|
||||
const lines: string[] = [];
|
||||
let total = 0;
|
||||
let lineNum = 0;
|
||||
while (total < size) {
|
||||
const line = `line-${String(lineNum).padStart(5, '0')} ${'x'.repeat(lineLen - 15)}`;
|
||||
lines.push(line);
|
||||
total += line.length + 1; // +1 for newline
|
||||
lineNum++;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
describe('ResponsePaginator', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- shouldPaginate ---
|
||||
|
||||
describe('shouldPaginate', () => {
|
||||
it('returns false for strings below threshold', () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
expect(paginator.shouldPaginate('short string')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for strings just below threshold', () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
const str = makeLargeString(DEFAULT_PAGINATION_CONFIG.sizeThreshold - 1);
|
||||
expect(paginator.shouldPaginate(str)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for strings at threshold', () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
const str = makeLargeString(DEFAULT_PAGINATION_CONFIG.sizeThreshold);
|
||||
expect(paginator.shouldPaginate(str)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for strings above threshold', () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
const str = makeLargeString(DEFAULT_PAGINATION_CONFIG.sizeThreshold + 1000);
|
||||
expect(paginator.shouldPaginate(str)).toBe(true);
|
||||
});
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100 });
|
||||
expect(paginator.shouldPaginate('x'.repeat(99))).toBe(false);
|
||||
expect(paginator.shouldPaginate('x'.repeat(100))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- paginate (no LLM) ---
|
||||
|
||||
describe('paginate without LLM', () => {
|
||||
it('returns null for small responses', async () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
const result = await paginator.paginate('test/tool', 'small response');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('paginates large responses with simple index', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = makeLargeStringWithNewlines(200);
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.content).toHaveLength(1);
|
||||
expect(result!.content[0]!.type).toBe('text');
|
||||
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).toContain('too large to return directly');
|
||||
expect(text).toContain('_resultId');
|
||||
expect(text).toContain('_page');
|
||||
expect(text).not.toContain('AI-generated summaries');
|
||||
});
|
||||
|
||||
it('includes correct page count in index', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
// 200 chars / 50 per page = 4 pages
|
||||
expect(text).toContain('4 pages');
|
||||
expect(text).toContain('Page 1:');
|
||||
expect(text).toContain('Page 4:');
|
||||
});
|
||||
|
||||
it('caches the result for later page retrieval', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(paginator.cacheSize).toBe(1);
|
||||
});
|
||||
|
||||
it('includes page instructions with _resultId and _page', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).toContain('"_resultId"');
|
||||
expect(text).toContain('"_page"');
|
||||
expect(text).toContain('"all"');
|
||||
});
|
||||
});
|
||||
|
||||
// --- paginate (with LLM) ---
|
||||
|
||||
describe('paginate with LLM', () => {
|
||||
it('generates smart index when provider available', async () => {
|
||||
const summaries = JSON.stringify([
|
||||
{ page: 1, summary: 'Configuration nodes and global settings' },
|
||||
{ page: 2, summary: 'HTTP request nodes and API integrations' },
|
||||
]);
|
||||
const registry = makeProvider(summaries);
|
||||
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 60 });
|
||||
const raw = makeLargeStringWithNewlines(150);
|
||||
const result = await paginator.paginate('node-red/get_flows', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).toContain('AI-generated summaries');
|
||||
expect(text).toContain('Configuration nodes and global settings');
|
||||
expect(text).toContain('HTTP request nodes and API integrations');
|
||||
});
|
||||
|
||||
it('falls back to simple index on LLM failure', async () => {
|
||||
const provider: LlmProvider = {
|
||||
name: 'test',
|
||||
isAvailable: () => true,
|
||||
complete: vi.fn().mockRejectedValue(new Error('LLM unavailable')),
|
||||
};
|
||||
const registry = {
|
||||
getActive: () => provider,
|
||||
register: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||
} as unknown as ProviderRegistry;
|
||||
|
||||
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
// Should NOT contain AI-generated label
|
||||
expect(text).not.toContain('AI-generated summaries');
|
||||
expect(text).toContain('Page 1:');
|
||||
});
|
||||
|
||||
it('sends page previews to LLM, not full content', async () => {
|
||||
const completeFn = vi.fn().mockResolvedValue({
|
||||
content: JSON.stringify([
|
||||
{ page: 1, summary: 'test' },
|
||||
{ page: 2, summary: 'test2' },
|
||||
{ page: 3, summary: 'test3' },
|
||||
]),
|
||||
});
|
||||
const provider: LlmProvider = {
|
||||
name: 'test',
|
||||
isAvailable: () => true,
|
||||
complete: completeFn,
|
||||
};
|
||||
const registry = {
|
||||
getActive: () => provider,
|
||||
register: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||
} as unknown as ProviderRegistry;
|
||||
|
||||
// Use a large enough string (3000 chars, pages of 1000) so previews (500 per page) are smaller than raw
|
||||
const paginator = new ResponsePaginator(registry, { sizeThreshold: 2000, pageSize: 1000 });
|
||||
const raw = makeLargeStringWithNewlines(3000);
|
||||
await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(completeFn).toHaveBeenCalledOnce();
|
||||
const call = completeFn.mock.calls[0]![0]!;
|
||||
const userMsg = call.messages.find((m: { role: string }) => m.role === 'user');
|
||||
// Should contain page preview markers
|
||||
expect(userMsg.content).toContain('Page 1');
|
||||
// The LLM prompt should be significantly smaller than the full content
|
||||
// (each page sends ~500 chars preview, not full 1000 chars)
|
||||
expect(userMsg.content.length).toBeLessThan(raw.length);
|
||||
});
|
||||
|
||||
it('falls back to simple when no active provider', async () => {
|
||||
const registry = {
|
||||
getActive: () => null,
|
||||
register: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
listProviders: () => [],
|
||||
} as unknown as ProviderRegistry;
|
||||
|
||||
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).not.toContain('AI-generated summaries');
|
||||
});
|
||||
});
|
||||
|
||||
// --- getPage ---
|
||||
|
||||
describe('getPage', () => {
|
||||
it('returns specific page content', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'AAAA'.repeat(25) + 'BBBB'.repeat(25); // 200 chars total
|
||||
await paginator.paginate('test/tool', raw);
|
||||
|
||||
// Extract resultId from cache (there should be exactly 1 entry)
|
||||
expect(paginator.cacheSize).toBe(1);
|
||||
|
||||
// We need the resultId — get it from the index response
|
||||
const indexResult = await paginator.paginate('test/tool2', 'C'.repeat(200));
|
||||
const text = indexResult!.content[0]!.text;
|
||||
const match = /"_resultId": "([^"]+)"/.exec(text);
|
||||
expect(match).not.toBeNull();
|
||||
const resultId = match![1]!;
|
||||
|
||||
const page1 = paginator.getPage(resultId, 1);
|
||||
expect(page1).not.toBeNull();
|
||||
expect(page1!.content[0]!.text).toContain('Page 1/');
|
||||
expect(page1!.content[0]!.text).toContain('C');
|
||||
});
|
||||
|
||||
it('returns full content with _page=all', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'D'.repeat(200);
|
||||
const indexResult = await paginator.paginate('test/tool', raw);
|
||||
const match = /"_resultId": "([^"]+)"/.exec(indexResult!.content[0]!.text);
|
||||
const resultId = match![1]!;
|
||||
|
||||
const allPages = paginator.getPage(resultId, 'all');
|
||||
expect(allPages).not.toBeNull();
|
||||
expect(allPages!.content[0]!.text).toBe(raw);
|
||||
});
|
||||
|
||||
it('returns null for unknown resultId (cache miss)', () => {
|
||||
const paginator = new ResponsePaginator(null);
|
||||
const result = paginator.getPage('nonexistent-id', 1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error for out-of-range page', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const indexResult = await paginator.paginate('test/tool', raw);
|
||||
const match = /"_resultId": "([^"]+)"/.exec(indexResult!.content[0]!.text);
|
||||
const resultId = match![1]!;
|
||||
|
||||
const page999 = paginator.getPage(resultId, 999);
|
||||
expect(page999).not.toBeNull();
|
||||
expect(page999!.content[0]!.text).toContain('out of range');
|
||||
});
|
||||
|
||||
it('returns null after TTL expiry', async () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50, ttlMs: 1000 });
|
||||
const raw = 'x'.repeat(200);
|
||||
const indexResult = await paginator.paginate('test/tool', raw);
|
||||
const match = /"_resultId": "([^"]+)"/.exec(indexResult!.content[0]!.text);
|
||||
const resultId = match![1]!;
|
||||
|
||||
// Within TTL — should work
|
||||
expect(paginator.getPage(resultId, 1)).not.toBeNull();
|
||||
|
||||
// Past TTL — should be null
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now + 1001);
|
||||
expect(paginator.getPage(resultId, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- extractPaginationParams ---
|
||||
|
||||
describe('extractPaginationParams', () => {
|
||||
it('returns null when no pagination params', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ query: 'test' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when only _resultId (no _page)', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _resultId: 'abc' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when only _page (no _resultId)', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _page: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts numeric page', () => {
|
||||
const result = ResponsePaginator.extractPaginationParams({ _resultId: 'abc-123', _page: 2 });
|
||||
expect(result).toEqual({ resultId: 'abc-123', page: 2 });
|
||||
});
|
||||
|
||||
it('extracts _page=all', () => {
|
||||
const result = ResponsePaginator.extractPaginationParams({ _resultId: 'abc-123', _page: 'all' });
|
||||
expect(result).toEqual({ resultId: 'abc-123', page: 'all' });
|
||||
});
|
||||
|
||||
it('rejects negative page numbers', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _resultId: 'abc', _page: -1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects zero page number', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _resultId: 'abc', _page: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-integer page numbers', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _resultId: 'abc', _page: 1.5 })).toBeNull();
|
||||
});
|
||||
|
||||
it('requires string resultId', () => {
|
||||
expect(ResponsePaginator.extractPaginationParams({ _resultId: 123, _page: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Cache management ---
|
||||
|
||||
describe('cache management', () => {
|
||||
it('evicts expired entries on paginate', async () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50, ttlMs: 1000 });
|
||||
await paginator.paginate('test/tool1', 'x'.repeat(200));
|
||||
expect(paginator.cacheSize).toBe(1);
|
||||
|
||||
// Advance past TTL and paginate again
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now + 1001);
|
||||
await paginator.paginate('test/tool2', 'y'.repeat(200));
|
||||
// Old entry evicted, new one added
|
||||
expect(paginator.cacheSize).toBe(1);
|
||||
});
|
||||
|
||||
it('evicts LRU at capacity', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50, maxCachedResults: 2 });
|
||||
await paginator.paginate('test/tool1', 'A'.repeat(200));
|
||||
await paginator.paginate('test/tool2', 'B'.repeat(200));
|
||||
expect(paginator.cacheSize).toBe(2);
|
||||
|
||||
// Third entry should evict the first
|
||||
await paginator.paginate('test/tool3', 'C'.repeat(200));
|
||||
expect(paginator.cacheSize).toBe(2);
|
||||
});
|
||||
|
||||
it('clearCache removes all entries', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
await paginator.paginate('test/tool1', 'x'.repeat(200));
|
||||
await paginator.paginate('test/tool2', 'y'.repeat(200));
|
||||
expect(paginator.cacheSize).toBe(2);
|
||||
|
||||
paginator.clearCache();
|
||||
expect(paginator.cacheSize).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Page splitting ---
|
||||
|
||||
describe('page splitting', () => {
|
||||
it('breaks at newline boundaries', async () => {
|
||||
// Create content where a newline falls within the page boundary
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 60 });
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${String(i).padStart(3, '0')} ${'x'.repeat(20)}`);
|
||||
const raw = lines.join('\n');
|
||||
// raw is ~269 chars
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Pages should break at newline boundaries, not mid-line
|
||||
const text = result!.content[0]!.text;
|
||||
const match = /"_resultId": "([^"]+)"/.exec(text);
|
||||
const resultId = match![1]!;
|
||||
|
||||
const page1 = paginator.getPage(resultId, 1);
|
||||
expect(page1).not.toBeNull();
|
||||
// Page content should end at a newline boundary (no partial lines)
|
||||
const pageText = page1!.content[0]!.text;
|
||||
// Remove the header line
|
||||
const contentStart = pageText.indexOf('\n\n') + 2;
|
||||
const pageContent = pageText.slice(contentStart);
|
||||
// Content should contain complete lines
|
||||
expect(pageContent).toMatch(/line\d{3}/);
|
||||
});
|
||||
|
||||
it('handles content without newlines', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
const raw = 'x'.repeat(200); // No newlines at all
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).toContain('4 pages'); // 200/50 = 4
|
||||
});
|
||||
|
||||
it('handles content that fits exactly in one page at threshold', async () => {
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 100 });
|
||||
const raw = 'x'.repeat(100); // Exactly at threshold and page size
|
||||
const result = await paginator.paginate('test/tool', raw);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const text = result!.content[0]!.text;
|
||||
expect(text).toContain('1 pages');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user