diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 9627a79..eae0a91 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -12,6 +12,7 @@ import type { FastifyInstance } from 'fastify'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import { McpRouter } from '../router.js'; +import { ResponsePaginator } from '../llm/pagination.js'; import { refreshProjectUpstreams } from '../discovery.js'; import type { McpdClient } from './mcpd-client.js'; import type { JsonRpcRequest } from '../types.js'; @@ -44,6 +45,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp const router = existing?.router ?? new McpRouter(); await refreshProjectUpstreams(router, mcpdClient, projectName, authToken); + // Wire pagination support (no LLM provider for now — simple index fallback) + router.setPaginator(new ResponsePaginator(null)); + // Configure prompt resources with SA-scoped client for RBAC const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` }); router.setPromptConfig(saClient, projectName); diff --git a/src/mcplocal/src/llm/index.ts b/src/mcplocal/src/llm/index.ts index a9dcebe..79fcdb5 100644 --- a/src/mcplocal/src/llm/index.ts +++ b/src/mcplocal/src/llm/index.ts @@ -6,3 +6,5 @@ export { FilterCache, DEFAULT_FILTER_CACHE_CONFIG } from './filter-cache.js'; export type { FilterCacheConfig } from './filter-cache.js'; export { FilterMetrics } from './metrics.js'; export type { FilterMetricsSnapshot } from './metrics.js'; +export { ResponsePaginator, DEFAULT_PAGINATION_CONFIG, PAGINATION_INDEX_SYSTEM_PROMPT } from './pagination.js'; +export type { PaginationConfig, PaginationIndex, PageSummary, PaginatedToolResponse } from './pagination.js'; diff --git a/src/mcplocal/src/llm/pagination.ts b/src/mcplocal/src/llm/pagination.ts new file mode 100644 index 0000000..8256b3d --- /dev/null +++ b/src/mcplocal/src/llm/pagination.ts @@ -0,0 +1,354 @@ +import { randomUUID } from 'node:crypto'; +import type { ProviderRegistry } from '../providers/registry.js'; +import { estimateTokens } from './token-counter.js'; + +// --- Configuration --- + +export interface PaginationConfig { + /** Character threshold above which responses get paginated (default 80_000) */ + sizeThreshold: number; + /** Characters per page (default 40_000) */ + pageSize: number; + /** Max cached results (LRU eviction) (default 64) */ + maxCachedResults: number; + /** TTL for cached results in ms (default 300_000 = 5 min) */ + ttlMs: number; + /** Max tokens for the LLM index generation call (default 2048) */ + indexMaxTokens: number; +} + +export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = { + sizeThreshold: 80_000, + pageSize: 40_000, + maxCachedResults: 64, + ttlMs: 300_000, + indexMaxTokens: 2048, +}; + +// --- Cache Entry --- + +interface PageInfo { + /** 0-based page index */ + index: number; + /** Start character offset in the raw string */ + startChar: number; + /** End character offset (exclusive) */ + endChar: number; + /** Approximate token count */ + estimatedTokens: number; +} + +interface CachedResult { + resultId: string; + toolName: string; + raw: string; + pages: PageInfo[]; + index: PaginationIndex; + createdAt: number; +} + +// --- Index Types --- + +export interface PageSummary { + page: number; + startChar: number; + endChar: number; + estimatedTokens: number; + summary: string; +} + +export interface PaginationIndex { + resultId: string; + toolName: string; + totalSize: number; + totalTokens: number; + totalPages: number; + pageSummaries: PageSummary[]; + indexType: 'smart' | 'simple'; +} + +// --- The MCP response format --- + +export interface PaginatedToolResponse { + content: Array<{ + type: 'text'; + text: string; + }>; +} + +// --- LLM Prompt --- + +export const PAGINATION_INDEX_SYSTEM_PROMPT = `You are a document indexing assistant. Given a large tool response split into pages, generate a concise summary for each page describing what data it contains. + +Rules: +- For each page, write 1-2 sentences describing the key content +- Be specific: mention entity names, IDs, counts, or key fields visible on that page +- If it's JSON, describe the structure and notable entries +- If it's text, describe the topics covered +- Output valid JSON only: an array of objects with "page" (1-based number) and "summary" (string) +- Example output: [{"page": 1, "summary": "Configuration nodes and global settings (inject, debug, function nodes 1-15)"}, {"page": 2, "summary": "HTTP request nodes and API integrations (nodes 16-40)"}]`; + +/** + * Handles transparent pagination of large MCP tool responses. + * + * When a tool response exceeds the size threshold, it is cached and an + * index is returned instead. The LLM can then request specific pages + * via _page/_resultId parameters on subsequent tool calls. + * + * If an LLM provider is available, the index includes AI-generated + * per-page summaries. Otherwise, simple byte-range descriptions are used. + */ +export class ResponsePaginator { + private cache = new Map(); + private readonly config: PaginationConfig; + + constructor( + private providers: ProviderRegistry | null, + config: Partial = {}, + ) { + this.config = { ...DEFAULT_PAGINATION_CONFIG, ...config }; + } + + /** + * Check if a raw response string should be paginated. + */ + shouldPaginate(raw: string): boolean { + return raw.length >= this.config.sizeThreshold; + } + + /** + * Paginate a large response: cache it and return the index. + * Returns null if the response is below threshold. + */ + async paginate(toolName: string, raw: string): Promise { + if (!this.shouldPaginate(raw)) return null; + + const resultId = randomUUID(); + const pages = this.splitPages(raw); + let index: PaginationIndex; + + try { + index = await this.generateSmartIndex(resultId, toolName, raw, pages); + } catch { + index = this.generateSimpleIndex(resultId, toolName, raw, pages); + } + + // Store in cache + this.evictExpired(); + this.evictLRU(); + this.cache.set(resultId, { + resultId, + toolName, + raw, + pages, + index, + createdAt: Date.now(), + }); + + return this.formatIndexResponse(index); + } + + /** + * Serve a specific page from cache. + * Returns null if the resultId is not found (cache miss / expired). + */ + getPage(resultId: string, page: number | 'all'): PaginatedToolResponse | null { + this.evictExpired(); + const entry = this.cache.get(resultId); + if (!entry) return null; + + if (page === 'all') { + return { + content: [{ type: 'text', text: entry.raw }], + }; + } + + // Pages are 1-based in the API + const pageInfo = entry.pages[page - 1]; + if (!pageInfo) { + return { + content: [{ + type: 'text', + text: `Error: page ${String(page)} is out of range. This result has ${String(entry.pages.length)} pages (1-${String(entry.pages.length)}).`, + }], + }; + } + + const pageContent = entry.raw.slice(pageInfo.startChar, pageInfo.endChar); + return { + content: [{ + type: 'text', + text: `[Page ${String(page)}/${String(entry.pages.length)} of result ${resultId}]\n\n${pageContent}`, + }], + }; + } + + /** + * Check if a tool call has pagination parameters (_page / _resultId). + * Returns the parsed pagination request, or null if not a pagination request. + */ + static extractPaginationParams( + args: Record, + ): { resultId: string; page: number | 'all' } | null { + const resultId = args['_resultId']; + const pageParam = args['_page']; + if (typeof resultId !== 'string' || pageParam === undefined) return null; + + if (pageParam === 'all') return { resultId, page: 'all' }; + + const page = Number(pageParam); + if (!Number.isInteger(page) || page < 1) return null; + + return { resultId, page }; + } + + // --- Private methods --- + + private splitPages(raw: string): PageInfo[] { + const pages: PageInfo[] = []; + let offset = 0; + let pageIndex = 0; + + while (offset < raw.length) { + const end = Math.min(offset + this.config.pageSize, raw.length); + // Try to break at a newline boundary if we're not at the end + let breakAt = end; + if (end < raw.length) { + const lastNewline = raw.lastIndexOf('\n', end); + if (lastNewline > offset) { + breakAt = lastNewline + 1; + } + } + + pages.push({ + index: pageIndex, + startChar: offset, + endChar: breakAt, + estimatedTokens: estimateTokens(raw.slice(offset, breakAt)), + }); + + offset = breakAt; + pageIndex++; + } + + return pages; + } + + private async generateSmartIndex( + resultId: string, + toolName: string, + raw: string, + pages: PageInfo[], + ): Promise { + const provider = this.providers?.getActive(); + if (!provider) { + return this.generateSimpleIndex(resultId, toolName, raw, pages); + } + + // Build a prompt with page previews (first ~500 chars of each page) + const previews = pages.map((p, i) => { + const preview = raw.slice(p.startChar, Math.min(p.startChar + 500, p.endChar)); + const truncated = p.endChar - p.startChar > 500 ? '\n[...]' : ''; + return `--- Page ${String(i + 1)} (chars ${String(p.startChar)}-${String(p.endChar)}, ~${String(p.estimatedTokens)} tokens) ---\n${preview}${truncated}`; + }).join('\n\n'); + + const result = await provider.complete({ + messages: [ + { role: 'system', content: PAGINATION_INDEX_SYSTEM_PROMPT }, + { role: 'user', content: `Tool: ${toolName}\nTotal size: ${String(raw.length)} chars, ${String(pages.length)} pages\n\n${previews}` }, + ], + maxTokens: this.config.indexMaxTokens, + temperature: 0, + }); + + const summaries = JSON.parse(result.content) as Array<{ page: number; summary: string }>; + + return { + resultId, + toolName, + totalSize: raw.length, + totalTokens: estimateTokens(raw), + totalPages: pages.length, + indexType: 'smart', + pageSummaries: pages.map((p, i) => ({ + page: i + 1, + startChar: p.startChar, + endChar: p.endChar, + estimatedTokens: p.estimatedTokens, + summary: summaries.find((s) => s.page === i + 1)?.summary ?? `Page ${String(i + 1)}`, + })), + }; + } + + private generateSimpleIndex( + resultId: string, + toolName: string, + raw: string, + pages: PageInfo[], + ): PaginationIndex { + return { + resultId, + toolName, + totalSize: raw.length, + totalTokens: estimateTokens(raw), + totalPages: pages.length, + indexType: 'simple', + pageSummaries: pages.map((p, i) => ({ + page: i + 1, + startChar: p.startChar, + endChar: p.endChar, + estimatedTokens: p.estimatedTokens, + summary: `Page ${String(i + 1)}: characters ${String(p.startChar)}-${String(p.endChar)} (~${String(p.estimatedTokens)} tokens)`, + })), + }; + } + + private formatIndexResponse(index: PaginationIndex): PaginatedToolResponse { + const lines = [ + `This response is too large to return directly (${String(index.totalSize)} chars, ~${String(index.totalTokens)} tokens).`, + `It has been split into ${String(index.totalPages)} pages.`, + '', + 'To retrieve a specific page, call this same tool again with additional arguments:', + ` "_resultId": "${index.resultId}"`, + ` "_page": (1-${String(index.totalPages)})`, + ' "_page": "all" (returns the full response)', + '', + `--- Page Index${index.indexType === 'smart' ? ' (AI-generated summaries)' : ''} ---`, + ]; + + for (const page of index.pageSummaries) { + lines.push(` Page ${String(page.page)}: ${page.summary}`); + } + + return { + content: [{ type: 'text', text: lines.join('\n') }], + }; + } + + private evictExpired(): void { + const now = Date.now(); + for (const [id, entry] of this.cache) { + if (now - entry.createdAt > this.config.ttlMs) { + this.cache.delete(id); + } + } + } + + private evictLRU(): void { + while (this.cache.size >= this.config.maxCachedResults) { + const oldest = this.cache.keys().next(); + if (oldest.done) break; + this.cache.delete(oldest.value); + } + } + + /** Exposed for testing. */ + get cacheSize(): number { + return this.cache.size; + } + + /** Clear all cached results. */ + clearCache(): void { + this.cache.clear(); + } +} diff --git a/src/mcplocal/src/router.ts b/src/mcplocal/src/router.ts index 1e60d1b..d03c772 100644 --- a/src/mcplocal/src/router.ts +++ b/src/mcplocal/src/router.ts @@ -1,5 +1,6 @@ import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js'; import type { LlmProcessor } from './llm/processor.js'; +import { ResponsePaginator } from './llm/pagination.js'; import type { McpdClient } from './http/mcpd-client.js'; export interface RouteContext { @@ -26,6 +27,11 @@ export class McpRouter { private mcpdClient: McpdClient | null = null; private projectName: string | null = null; private mcpctlResourceContents = new Map(); + private paginator: ResponsePaginator | null = null; + + setPaginator(paginator: ResponsePaginator): void { + this.paginator = paginator; + } setLlmProcessor(processor: LlmProcessor): void { this.llmProcessor = processor; @@ -399,14 +405,36 @@ export class McpRouter { return this.handleProposePrompt(request, context); } + // Intercept pagination page requests before routing to upstream + const toolArgs = (params?.['arguments'] ?? {}) as Record; + if (this.paginator) { + const paginationReq = ResponsePaginator.extractPaginationParams(toolArgs); + if (paginationReq) { + const pageResult = this.paginator.getPage(paginationReq.resultId, paginationReq.page); + if (pageResult) { + return { jsonrpc: '2.0', id: request.id, result: pageResult }; + } + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ + type: 'text', + text: 'Cached result not found (expired or invalid _resultId). Please re-call the tool without _resultId/_page to get a fresh result.', + }], + }, + }; + } + } + // If no processor or tool shouldn't be processed, route directly if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) { - return this.routeNamespacedCall(request, 'name', this.toolToServer); + const response = await this.routeNamespacedCall(request, 'name', this.toolToServer); + return this.maybePaginate(toolName, response); } // Preprocess request params - const toolParams = (params?.['arguments'] ?? {}) as Record; - const processed = await this.llmProcessor.preprocessRequest(toolName, toolParams); + const processed = await this.llmProcessor.preprocessRequest(toolName, toolArgs); const processedRequest: JsonRpcRequest = processed.optimized ? { ...request, params: { ...params, arguments: processed.params } } : request; @@ -414,6 +442,10 @@ export class McpRouter { // Route to upstream const response = await this.routeNamespacedCall(processedRequest, 'name', this.toolToServer); + // Paginate if response is large (skip LLM filtering for paginated responses) + const paginated = await this.maybePaginate(toolName, response); + if (paginated !== response) return paginated; + // Filter response if (response.error) return response; const filtered = await this.llmProcessor.filterResponse(toolName, response); @@ -423,6 +455,21 @@ export class McpRouter { return response; } + /** + * If the response is large enough, paginate it and return the index instead. + */ + private async maybePaginate(toolName: string | undefined, response: JsonRpcResponse): Promise { + if (!this.paginator || !toolName || response.error) return response; + + const raw = JSON.stringify(response.result); + if (!this.paginator.shouldPaginate(raw)) return response; + + const paginated = await this.paginator.paginate(toolName, raw); + if (!paginated) return response; + + return { jsonrpc: '2.0', id: response.id, result: paginated }; + } + private async handleProposePrompt(request: JsonRpcRequest, context?: RouteContext): Promise { if (!this.mcpdClient || !this.projectName) { return { diff --git a/src/mcplocal/tests/pagination.test.ts b/src/mcplocal/tests/pagination.test.ts new file mode 100644 index 0000000..99098f0 --- /dev/null +++ b/src/mcplocal/tests/pagination.test.ts @@ -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'); + }); + }); +});