feat: smart response pagination for large MCP tool results #38
@@ -12,6 +12,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { McpRouter } from '../router.js';
|
import { McpRouter } from '../router.js';
|
||||||
|
import { ResponsePaginator } from '../llm/pagination.js';
|
||||||
import { refreshProjectUpstreams } from '../discovery.js';
|
import { refreshProjectUpstreams } from '../discovery.js';
|
||||||
import type { McpdClient } from './mcpd-client.js';
|
import type { McpdClient } from './mcpd-client.js';
|
||||||
import type { JsonRpcRequest } from '../types.js';
|
import type { JsonRpcRequest } from '../types.js';
|
||||||
@@ -44,6 +45,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
const router = existing?.router ?? new McpRouter();
|
const router = existing?.router ?? new McpRouter();
|
||||||
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
|
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
|
// Configure prompt resources with SA-scoped client for RBAC
|
||||||
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
|
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
|
||||||
router.setPromptConfig(saClient, projectName);
|
router.setPromptConfig(saClient, projectName);
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ export { FilterCache, DEFAULT_FILTER_CACHE_CONFIG } from './filter-cache.js';
|
|||||||
export type { FilterCacheConfig } from './filter-cache.js';
|
export type { FilterCacheConfig } from './filter-cache.js';
|
||||||
export { FilterMetrics } from './metrics.js';
|
export { FilterMetrics } from './metrics.js';
|
||||||
export type { FilterMetricsSnapshot } 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';
|
||||||
|
|||||||
354
src/mcplocal/src/llm/pagination.ts
Normal file
354
src/mcplocal/src/llm/pagination.ts
Normal file
@@ -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<string, CachedResult>();
|
||||||
|
private readonly config: PaginationConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private providers: ProviderRegistry | null,
|
||||||
|
config: Partial<PaginationConfig> = {},
|
||||||
|
) {
|
||||||
|
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<PaginatedToolResponse | null> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): { 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<PaginationIndex> {
|
||||||
|
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": <page_number> (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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
||||||
import type { LlmProcessor } from './llm/processor.js';
|
import type { LlmProcessor } from './llm/processor.js';
|
||||||
|
import { ResponsePaginator } from './llm/pagination.js';
|
||||||
import type { McpdClient } from './http/mcpd-client.js';
|
import type { McpdClient } from './http/mcpd-client.js';
|
||||||
|
|
||||||
export interface RouteContext {
|
export interface RouteContext {
|
||||||
@@ -26,6 +27,11 @@ export class McpRouter {
|
|||||||
private mcpdClient: McpdClient | null = null;
|
private mcpdClient: McpdClient | null = null;
|
||||||
private projectName: string | null = null;
|
private projectName: string | null = null;
|
||||||
private mcpctlResourceContents = new Map<string, string>();
|
private mcpctlResourceContents = new Map<string, string>();
|
||||||
|
private paginator: ResponsePaginator | null = null;
|
||||||
|
|
||||||
|
setPaginator(paginator: ResponsePaginator): void {
|
||||||
|
this.paginator = paginator;
|
||||||
|
}
|
||||||
|
|
||||||
setLlmProcessor(processor: LlmProcessor): void {
|
setLlmProcessor(processor: LlmProcessor): void {
|
||||||
this.llmProcessor = processor;
|
this.llmProcessor = processor;
|
||||||
@@ -399,14 +405,36 @@ export class McpRouter {
|
|||||||
return this.handleProposePrompt(request, context);
|
return this.handleProposePrompt(request, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intercept pagination page requests before routing to upstream
|
||||||
|
const toolArgs = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||||
|
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 no processor or tool shouldn't be processed, route directly
|
||||||
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
|
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
|
// Preprocess request params
|
||||||
const toolParams = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
const processed = await this.llmProcessor.preprocessRequest(toolName, toolArgs);
|
||||||
const processed = await this.llmProcessor.preprocessRequest(toolName, toolParams);
|
|
||||||
const processedRequest: JsonRpcRequest = processed.optimized
|
const processedRequest: JsonRpcRequest = processed.optimized
|
||||||
? { ...request, params: { ...params, arguments: processed.params } }
|
? { ...request, params: { ...params, arguments: processed.params } }
|
||||||
: request;
|
: request;
|
||||||
@@ -414,6 +442,10 @@ export class McpRouter {
|
|||||||
// Route to upstream
|
// Route to upstream
|
||||||
const response = await this.routeNamespacedCall(processedRequest, 'name', this.toolToServer);
|
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
|
// Filter response
|
||||||
if (response.error) return response;
|
if (response.error) return response;
|
||||||
const filtered = await this.llmProcessor.filterResponse(toolName, response);
|
const filtered = await this.llmProcessor.filterResponse(toolName, response);
|
||||||
@@ -423,6 +455,21 @@ export class McpRouter {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the response is large enough, paginate it and return the index instead.
|
||||||
|
*/
|
||||||
|
private async maybePaginate(toolName: string | undefined, response: JsonRpcResponse): Promise<JsonRpcResponse> {
|
||||||
|
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<JsonRpcResponse> {
|
private async handleProposePrompt(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||||
if (!this.mcpdClient || !this.projectName) {
|
if (!this.mcpdClient || !this.projectName) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
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