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:
Michal
2026-02-24 21:40:33 +00:00
parent c6fab132aa
commit b7d54a4af6
5 changed files with 843 additions and 3 deletions

View 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');
});
});
});