Merge pull request 'feat: per-project LLM models, ACP session pool, smart pagination tests' (#42) from feat/per-project-llm-pagination-tests into main
This commit was merged in pull request #42.
This commit is contained in:
@@ -196,8 +196,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.argument('<name>', 'Project name')
|
.argument('<name>', 'Project name')
|
||||||
.option('-d, --description <text>', 'Project description', '')
|
.option('-d, --description <text>', 'Project description', '')
|
||||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||||
.option('--llm-provider <name>', 'LLM provider name')
|
|
||||||
.option('--llm-model <name>', 'LLM model name')
|
|
||||||
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
||||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||||
.option('--force', 'Update if already exists')
|
.option('--force', 'Update if already exists')
|
||||||
@@ -208,8 +206,6 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
proxyMode: opts.proxyMode ?? 'direct',
|
proxyMode: opts.proxyMode ?? 'direct',
|
||||||
};
|
};
|
||||||
if (opts.prompt) body.prompt = opts.prompt;
|
if (opts.prompt) body.prompt = opts.prompt;
|
||||||
if (opts.llmProvider) body.llmProvider = opts.llmProvider;
|
|
||||||
if (opts.llmModel) body.llmModel = opts.llmModel;
|
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
if (opts.server.length > 0) body.servers = opts.server;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface StatusCommandDeps {
|
|||||||
checkHealth: (url: string) => Promise<boolean>;
|
checkHealth: (url: string) => Promise<boolean>;
|
||||||
/** Check LLM health via mcplocal's /llm/health endpoint */
|
/** Check LLM health via mcplocal's /llm/health endpoint */
|
||||||
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
||||||
|
/** Fetch available models from mcplocal's /llm/models endpoint */
|
||||||
|
fetchModels: (mcplocalUrl: string) => Promise<string[]>;
|
||||||
isTTY: boolean;
|
isTTY: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,25 @@ function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultFetchModels(mcplocalUrl: string): Promise<string[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.get(`${mcplocalUrl}/llm/models`, { timeout: 5000 }, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { models?: string[] };
|
||||||
|
resolve(body.models ?? []);
|
||||||
|
} catch {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve([]));
|
||||||
|
req.on('timeout', () => { req.destroy(); resolve([]); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
const defaultDeps: StatusCommandDeps = {
|
const defaultDeps: StatusCommandDeps = {
|
||||||
@@ -79,11 +100,12 @@ const defaultDeps: StatusCommandDeps = {
|
|||||||
write: (text) => process.stdout.write(text),
|
write: (text) => process.stdout.write(text),
|
||||||
checkHealth: defaultCheckHealth,
|
checkHealth: defaultCheckHealth,
|
||||||
checkLlm: defaultCheckLlm,
|
checkLlm: defaultCheckLlm,
|
||||||
|
fetchModels: defaultFetchModels,
|
||||||
isTTY: process.stdout.isTTY ?? false,
|
isTTY: process.stdout.isTTY ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
||||||
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, isTTY } = { ...defaultDeps, ...deps };
|
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, isTTY } = { ...defaultDeps, ...deps };
|
||||||
|
|
||||||
return new Command('status')
|
return new Command('status')
|
||||||
.description('Show mcpctl status and connectivity')
|
.description('Show mcpctl status and connectivity')
|
||||||
@@ -172,5 +194,11 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
log(`LLM: ${llmLabel} ✗ ${llmStatus}`);
|
log(`LLM: ${llmLabel} ✗ ${llmStatus}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show available models (non-blocking, best effort)
|
||||||
|
const models = await fetchModels(config.mcplocalUrl);
|
||||||
|
if (models.length > 0) {
|
||||||
|
log(`${DIM} Available: ${models.join(', ')}${RESET}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ describe('project with new fields', () => {
|
|||||||
'project', 'smart-home',
|
'project', 'smart-home',
|
||||||
'-d', 'Smart home project',
|
'-d', 'Smart home project',
|
||||||
'--proxy-mode', 'filtered',
|
'--proxy-mode', 'filtered',
|
||||||
'--llm-provider', 'gemini-cli',
|
|
||||||
'--llm-model', 'gemini-2.0-flash',
|
|
||||||
'--server', 'my-grafana',
|
'--server', 'my-grafana',
|
||||||
'--server', 'my-ha',
|
'--server', 'my-ha',
|
||||||
], { from: 'user' });
|
], { from: 'user' });
|
||||||
@@ -40,8 +38,6 @@ describe('project with new fields', () => {
|
|||||||
name: 'smart-home',
|
name: 'smart-home',
|
||||||
description: 'Smart home project',
|
description: 'Smart home project',
|
||||||
proxyMode: 'filtered',
|
proxyMode: 'filtered',
|
||||||
llmProvider: 'gemini-cli',
|
|
||||||
llmModel: 'gemini-2.0-flash',
|
|
||||||
servers: ['my-grafana', 'my-ha'],
|
servers: ['my-grafana', 'my-ha'],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,33 @@ export async function refreshProjectUpstreams(
|
|||||||
return syncUpstreams(router, mcpdClient, servers);
|
return syncUpstreams(router, mcpdClient, servers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a project's LLM config (llmProvider, llmModel) from mcpd.
|
||||||
|
* These are the project-level "recommendations" — local overrides take priority.
|
||||||
|
*/
|
||||||
|
export interface ProjectLlmConfig {
|
||||||
|
llmProvider?: string;
|
||||||
|
llmModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectLlmConfig(
|
||||||
|
mcpdClient: McpdClient,
|
||||||
|
projectName: string,
|
||||||
|
): Promise<ProjectLlmConfig> {
|
||||||
|
try {
|
||||||
|
const project = await mcpdClient.get<{
|
||||||
|
llmProvider?: string;
|
||||||
|
llmModel?: string;
|
||||||
|
}>(`/api/v1/projects/${encodeURIComponent(projectName)}`);
|
||||||
|
const config: ProjectLlmConfig = {};
|
||||||
|
if (project.llmProvider) config.llmProvider = project.llmProvider;
|
||||||
|
if (project.llmModel) config.llmModel = project.llmModel;
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared sync logic: reconcile a router's upstreams with a server list. */
|
/** Shared sync logic: reconcile a router's upstreams with a server list. */
|
||||||
function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdServer[]): string[] {
|
function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdServer[]): string[] {
|
||||||
const registered: string[] = [];
|
const registered: string[] = [];
|
||||||
|
|||||||
@@ -44,21 +44,54 @@ export interface LlmFileConfig {
|
|||||||
binaryPath?: string;
|
binaryPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLlmOverride {
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpctlConfig {
|
||||||
|
llm?: LlmFileConfig;
|
||||||
|
projects?: Record<string, { llm?: ProjectLlmOverride }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached config for the process lifetime (reloaded on SIGHUP if needed). */
|
||||||
|
let cachedConfig: McpctlConfig | null = null;
|
||||||
|
|
||||||
|
function loadFullConfig(): McpctlConfig {
|
||||||
|
if (cachedConfig) return cachedConfig;
|
||||||
|
try {
|
||||||
|
const configPath = join(homedir(), '.mcpctl', 'config.json');
|
||||||
|
if (!existsSync(configPath)) return {};
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
cachedConfig = JSON.parse(raw) as McpctlConfig;
|
||||||
|
return cachedConfig;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load LLM configuration from ~/.mcpctl/config.json.
|
* Load LLM configuration from ~/.mcpctl/config.json.
|
||||||
* Returns undefined if no LLM section is configured.
|
* Returns undefined if no LLM section is configured.
|
||||||
*/
|
*/
|
||||||
export function loadLlmConfig(): LlmFileConfig | undefined {
|
export function loadLlmConfig(): LlmFileConfig | undefined {
|
||||||
try {
|
const config = loadFullConfig();
|
||||||
const configPath = join(homedir(), '.mcpctl', 'config.json');
|
if (!config.llm?.provider || config.llm.provider === 'none') return undefined;
|
||||||
if (!existsSync(configPath)) return undefined;
|
return config.llm;
|
||||||
const raw = readFileSync(configPath, 'utf-8');
|
}
|
||||||
const parsed = JSON.parse(raw) as { llm?: LlmFileConfig };
|
|
||||||
if (!parsed.llm?.provider || parsed.llm.provider === 'none') return undefined;
|
/**
|
||||||
return parsed.llm;
|
* Load per-project LLM override from ~/.mcpctl/config.json.
|
||||||
} catch {
|
* Returns the project-specific model/provider override, or undefined.
|
||||||
return undefined;
|
*/
|
||||||
}
|
export function loadProjectLlmOverride(projectName: string): ProjectLlmOverride | undefined {
|
||||||
|
const config = loadFullConfig();
|
||||||
|
return config.projects?.[projectName]?.llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset cached config (for testing). */
|
||||||
|
export function resetConfigCache(): void {
|
||||||
|
cachedConfig = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHttpConfig(env: Record<string, string | undefined> = process.env): HttpConfig {
|
export function loadHttpConfig(env: Record<string, string | undefined> = process.env): HttpConfig {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|||||||
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 { ResponsePaginator } from '../llm/pagination.js';
|
||||||
import { refreshProjectUpstreams } from '../discovery.js';
|
import { refreshProjectUpstreams, fetchProjectLlmConfig } from '../discovery.js';
|
||||||
|
import { loadProjectLlmOverride } from './config.js';
|
||||||
import type { McpdClient } from './mcpd-client.js';
|
import type { McpdClient } from './mcpd-client.js';
|
||||||
import type { ProviderRegistry } from '../providers/registry.js';
|
import type { ProviderRegistry } from '../providers/registry.js';
|
||||||
import type { JsonRpcRequest } from '../types.js';
|
import type { JsonRpcRequest } from '../types.js';
|
||||||
@@ -46,8 +47,13 @@ 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 with LLM provider if configured
|
// Resolve project LLM model: local override → mcpd recommendation → global default
|
||||||
router.setPaginator(new ResponsePaginator(providerRegistry ?? null));
|
const localOverride = loadProjectLlmOverride(projectName);
|
||||||
|
const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName);
|
||||||
|
const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined;
|
||||||
|
|
||||||
|
// Wire pagination support with LLM provider and project model override
|
||||||
|
router.setPaginator(new ResponsePaginator(providerRegistry ?? null, {}, resolvedModel));
|
||||||
|
|
||||||
// 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}` });
|
||||||
|
|||||||
@@ -109,6 +109,21 @@ export async function createHttpServer(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LLM models — list available models from the active provider
|
||||||
|
app.get('/llm/models', async (_request, reply) => {
|
||||||
|
const provider = deps.providerRegistry?.getActive() ?? null;
|
||||||
|
if (!provider) {
|
||||||
|
reply.code(200).send({ models: [], provider: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const models = await provider.listModels();
|
||||||
|
reply.code(200).send({ models, provider: provider.name });
|
||||||
|
} catch {
|
||||||
|
reply.code(200).send({ models: [], provider: provider.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Proxy management routes to mcpd
|
// Proxy management routes to mcpd
|
||||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||||
registerProxyRoutes(app, mcpdClient);
|
registerProxyRoutes(app, mcpdClient);
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export class ResponsePaginator {
|
|||||||
constructor(
|
constructor(
|
||||||
private providers: ProviderRegistry | null,
|
private providers: ProviderRegistry | null,
|
||||||
config: Partial<PaginationConfig> = {},
|
config: Partial<PaginationConfig> = {},
|
||||||
|
private modelOverride?: string,
|
||||||
) {
|
) {
|
||||||
this.config = { ...DEFAULT_PAGINATION_CONFIG, ...config };
|
this.config = { ...DEFAULT_PAGINATION_CONFIG, ...config };
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,8 @@ export class ResponsePaginator {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
index = await this.generateSmartIndex(resultId, toolName, raw, pages);
|
index = await this.generateSmartIndex(resultId, toolName, raw, pages);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error(`[pagination] Smart index failed for ${toolName}, falling back to simple:`, err instanceof Error ? err.message : String(err));
|
||||||
index = this.generateSimpleIndex(resultId, toolName, raw, pages);
|
index = this.generateSimpleIndex(resultId, toolName, raw, pages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +261,12 @@ export class ResponsePaginator {
|
|||||||
],
|
],
|
||||||
maxTokens: this.config.indexMaxTokens,
|
maxTokens: this.config.indexMaxTokens,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
|
...(this.modelOverride ? { model: this.modelOverride } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const summaries = JSON.parse(result.content) as Array<{ page: number; summary: string }>;
|
// LLMs often wrap JSON in ```json ... ``` fences — strip them
|
||||||
|
const cleaned = result.content.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
|
||||||
|
const summaries = JSON.parse(cleaned) as Array<{ page: number; summary: string }>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resultId,
|
resultId,
|
||||||
|
|||||||
@@ -11,39 +11,56 @@ export interface GeminiAcpConfig {
|
|||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
initTimeoutMs?: number;
|
initTimeoutMs?: number;
|
||||||
|
/** Idle TTL for pooled sessions in ms (default: 8 hours) */
|
||||||
|
idleTtlMs?: number;
|
||||||
/** Override for testing — passed through to AcpClient */
|
/** Override for testing — passed through to AcpClient */
|
||||||
spawn?: AcpClientConfig['spawn'];
|
spawn?: AcpClientConfig['spawn'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PoolEntry {
|
||||||
|
client: AcpClient;
|
||||||
|
lastUsed: number;
|
||||||
|
queue: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini CLI provider using ACP (Agent Client Protocol) mode.
|
* Gemini CLI provider using ACP (Agent Client Protocol) mode.
|
||||||
* Keeps the gemini process alive as a persistent subprocess, eliminating
|
*
|
||||||
* the ~10s cold-start per call. Auto-restarts on crash or timeout.
|
* Maintains a pool of persistent subprocesses keyed by model name.
|
||||||
|
* Each model gets its own `gemini --experimental-acp` subprocess with
|
||||||
|
* a serial request queue. Idle sessions are evicted after 8 hours.
|
||||||
|
*
|
||||||
|
* NOTE: Gemini ACP currently doesn't support per-session model selection,
|
||||||
|
* so all sessions use the same model. The pool infrastructure is ready for
|
||||||
|
* when vLLM/OpenAI providers are added (they support per-request model).
|
||||||
*/
|
*/
|
||||||
export class GeminiAcpProvider implements LlmProvider {
|
export class GeminiAcpProvider implements LlmProvider {
|
||||||
readonly name = 'gemini-cli';
|
readonly name = 'gemini-cli';
|
||||||
private client: AcpClient;
|
private pool = new Map<string, PoolEntry>();
|
||||||
private binaryPath: string;
|
private binaryPath: string;
|
||||||
private defaultModel: string;
|
private defaultModel: string;
|
||||||
private queue: Promise<void> = Promise.resolve();
|
private readonly requestTimeoutMs: number;
|
||||||
|
private readonly initTimeoutMs: number;
|
||||||
|
private readonly idleTtlMs: number;
|
||||||
|
private readonly spawnOverride?: AcpClientConfig['spawn'];
|
||||||
|
|
||||||
constructor(config?: GeminiAcpConfig) {
|
constructor(config?: GeminiAcpConfig) {
|
||||||
this.binaryPath = config?.binaryPath ?? 'gemini';
|
this.binaryPath = config?.binaryPath ?? 'gemini';
|
||||||
this.defaultModel = config?.defaultModel ?? 'gemini-2.5-flash';
|
this.defaultModel = config?.defaultModel ?? 'gemini-2.5-flash';
|
||||||
|
this.requestTimeoutMs = config?.requestTimeoutMs ?? 60_000;
|
||||||
const acpConfig: AcpClientConfig = {
|
this.initTimeoutMs = config?.initTimeoutMs ?? 30_000;
|
||||||
binaryPath: this.binaryPath,
|
this.idleTtlMs = config?.idleTtlMs ?? 8 * 60 * 60 * 1000; // 8 hours
|
||||||
model: this.defaultModel,
|
if (config?.spawn) this.spawnOverride = config.spawn;
|
||||||
requestTimeoutMs: config?.requestTimeoutMs ?? 60_000,
|
|
||||||
initTimeoutMs: config?.initTimeoutMs ?? 30_000,
|
|
||||||
};
|
|
||||||
if (config?.spawn) acpConfig.spawn = config.spawn;
|
|
||||||
|
|
||||||
this.client = new AcpClient(acpConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(options: CompletionOptions): Promise<CompletionResult> {
|
async complete(options: CompletionOptions): Promise<CompletionResult> {
|
||||||
return this.enqueue(() => this.doComplete(options));
|
const model = options.model ?? this.defaultModel;
|
||||||
|
const entry = this.getOrCreateEntry(model);
|
||||||
|
entry.lastUsed = Date.now();
|
||||||
|
|
||||||
|
this.evictIdle();
|
||||||
|
|
||||||
|
return this.enqueue(entry, () => this.doComplete(entry.client, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(): Promise<string[]> {
|
async listModels(): Promise<string[]> {
|
||||||
@@ -60,12 +77,51 @@ export class GeminiAcpProvider implements LlmProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.client.dispose();
|
for (const entry of this.pool.values()) {
|
||||||
|
entry.client.dispose();
|
||||||
|
}
|
||||||
|
this.pool.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of active pool entries (for testing). */
|
||||||
|
get poolSize(): number {
|
||||||
|
return this.pool.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Private ---
|
// --- Private ---
|
||||||
|
|
||||||
private async doComplete(options: CompletionOptions): Promise<CompletionResult> {
|
private getOrCreateEntry(model: string): PoolEntry {
|
||||||
|
const existing = this.pool.get(model);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const acpConfig: AcpClientConfig = {
|
||||||
|
binaryPath: this.binaryPath,
|
||||||
|
model,
|
||||||
|
requestTimeoutMs: this.requestTimeoutMs,
|
||||||
|
initTimeoutMs: this.initTimeoutMs,
|
||||||
|
};
|
||||||
|
if (this.spawnOverride) acpConfig.spawn = this.spawnOverride;
|
||||||
|
|
||||||
|
const entry: PoolEntry = {
|
||||||
|
client: new AcpClient(acpConfig),
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
queue: Promise.resolve(),
|
||||||
|
};
|
||||||
|
this.pool.set(model, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictIdle(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [model, entry] of this.pool) {
|
||||||
|
if (now - entry.lastUsed > this.idleTtlMs) {
|
||||||
|
entry.client.dispose();
|
||||||
|
this.pool.delete(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doComplete(client: AcpClient, options: CompletionOptions): Promise<CompletionResult> {
|
||||||
const prompt = options.messages
|
const prompt = options.messages
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
if (m.role === 'system') return `System: ${m.content}`;
|
if (m.role === 'system') return `System: ${m.content}`;
|
||||||
@@ -75,7 +131,7 @@ export class GeminiAcpProvider implements LlmProvider {
|
|||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
const content = await this.client.prompt(prompt);
|
const content = await client.prompt(prompt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
@@ -85,9 +141,9 @@ export class GeminiAcpProvider implements LlmProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
private enqueue<T>(entry: PoolEntry, fn: () => Promise<T>): Promise<T> {
|
||||||
const result = new Promise<T>((resolve, reject) => {
|
const result = new Promise<T>((resolve, reject) => {
|
||||||
this.queue = this.queue.then(
|
entry.queue = entry.queue.then(
|
||||||
() => fn().then(resolve, reject),
|
() => fn().then(resolve, reject),
|
||||||
() => fn().then(resolve, reject),
|
() => fn().then(resolve, reject),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('GeminiAcpProvider', () => {
|
|||||||
expect(result.content).toBe('padded response');
|
expect(result.content).toBe('padded response');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes concurrent calls', async () => {
|
it('serializes concurrent calls to same model', async () => {
|
||||||
const callOrder: number[] = [];
|
const callOrder: number[] = [];
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
|
||||||
@@ -110,6 +110,70 @@ describe('GeminiAcpProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session pool', () => {
|
||||||
|
it('creates separate pool entries for different models', async () => {
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'gemini-2.5-flash' });
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'gemini-2.5-pro' });
|
||||||
|
|
||||||
|
expect(provider.poolSize).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses existing pool entry for same model', async () => {
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'gemini-2.5-flash' });
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'gemini-2.5-flash' });
|
||||||
|
|
||||||
|
expect(provider.poolSize).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses defaultModel when no model specified', async () => {
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'a' }] });
|
||||||
|
|
||||||
|
expect(provider.poolSize).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts idle sessions', async () => {
|
||||||
|
// Use a very short TTL for testing
|
||||||
|
const shortTtl = new GeminiAcpProvider({
|
||||||
|
binaryPath: '/usr/bin/gemini',
|
||||||
|
defaultModel: 'gemini-2.5-flash',
|
||||||
|
idleTtlMs: 1, // 1ms TTL
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
await shortTtl.complete({ messages: [{ role: 'user', content: 'a' }], model: 'model-a' });
|
||||||
|
expect(shortTtl.poolSize).toBe(1);
|
||||||
|
|
||||||
|
// Wait for TTL to expire
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
// Next complete call triggers eviction of old entry and creates new one
|
||||||
|
await shortTtl.complete({ messages: [{ role: 'user', content: 'b' }], model: 'model-b' });
|
||||||
|
// model-a should have been evicted, only model-b remains
|
||||||
|
expect(shortTtl.poolSize).toBe(1);
|
||||||
|
expect(mockDispose).toHaveBeenCalled();
|
||||||
|
|
||||||
|
shortTtl.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose kills all pooled clients', async () => {
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'model-a' });
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'model-b' });
|
||||||
|
expect(provider.poolSize).toBe(2);
|
||||||
|
|
||||||
|
provider.dispose();
|
||||||
|
expect(provider.poolSize).toBe(0);
|
||||||
|
expect(mockDispose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('listModels', () => {
|
describe('listModels', () => {
|
||||||
it('returns static model list', async () => {
|
it('returns static model list', async () => {
|
||||||
const models = await provider.listModels();
|
const models = await provider.listModels();
|
||||||
@@ -120,7 +184,9 @@ describe('GeminiAcpProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('dispose', () => {
|
describe('dispose', () => {
|
||||||
it('delegates to AcpClient', () => {
|
it('delegates to all pooled AcpClients', async () => {
|
||||||
|
mockPrompt.mockResolvedValue('ok');
|
||||||
|
await provider.complete({ messages: [{ role: 'user', content: 'test' }] });
|
||||||
provider.dispose();
|
provider.dispose();
|
||||||
expect(mockDispose).toHaveBeenCalled();
|
expect(mockDispose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { loadLlmConfig } from '../../src/http/config.js';
|
import { loadLlmConfig, resetConfigCache } from '../../src/http/config.js';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
vi.mock('node:fs', async () => {
|
vi.mock('node:fs', async () => {
|
||||||
@@ -11,6 +11,10 @@ vi.mock('node:fs', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
* (node:http) and a mock LLM provider. No Docker or external services needed.
|
* (node:http) and a mock LLM provider. No Docker or external services needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
|
||||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
|
||||||
import { McpRouter } from '../../src/router.js';
|
import { McpRouter } from '../../src/router.js';
|
||||||
import { McpdUpstream } from '../../src/upstream/mcpd.js';
|
import { McpdUpstream } from '../../src/upstream/mcpd.js';
|
||||||
import { McpdClient } from '../../src/http/mcpd-client.js';
|
import { McpdClient } from '../../src/http/mcpd-client.js';
|
||||||
import { LlmProcessor, DEFAULT_PROCESSOR_CONFIG } from '../../src/llm/processor.js';
|
import { LlmProcessor, DEFAULT_PROCESSOR_CONFIG } from '../../src/llm/processor.js';
|
||||||
|
import { ResponsePaginator } from '../../src/llm/pagination.js';
|
||||||
import { ProviderRegistry } from '../../src/providers/registry.js';
|
import { ProviderRegistry } from '../../src/providers/registry.js';
|
||||||
import { TieredHealthMonitor } from '../../src/health/tiered.js';
|
import { TieredHealthMonitor } from '../../src/health/tiered.js';
|
||||||
import { refreshUpstreams } from '../../src/discovery.js';
|
import { refreshUpstreams } from '../../src/discovery.js';
|
||||||
@@ -1096,4 +1097,429 @@ describe('End-to-end integration: 3-tier architecture', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 8. Smart pagination through the full pipeline
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('Smart pagination', () => {
|
||||||
|
// Helper: generate a large JSON response (~100KB)
|
||||||
|
function makeLargeToolResult(): { flows: Array<{ id: string; type: string; label: string; wires: string[] }> } {
|
||||||
|
return {
|
||||||
|
flows: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
id: `flow-${String(i).padStart(4, '0')}`,
|
||||||
|
type: i % 3 === 0 ? 'function' : i % 3 === 1 ? 'http request' : 'inject',
|
||||||
|
label: `Node ${String(i)}: ${i % 3 === 0 ? 'Data transform' : i % 3 === 1 ? 'API call' : 'Timer trigger'}`,
|
||||||
|
wires: [`flow-${String(i + 1).padStart(4, '0')}`],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('paginates large tool response with smart AI summaries through router', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
// Set up paginator with LLM provider for smart summaries
|
||||||
|
const registry = new ProviderRegistry();
|
||||||
|
const completeFn = vi.fn().mockImplementation(() => ({
|
||||||
|
content: JSON.stringify([
|
||||||
|
{ page: 1, summary: 'Function nodes and data transforms (flow-0000 through flow-0050)' },
|
||||||
|
{ page: 2, summary: 'HTTP request nodes and API integrations (flow-0051 through flow-0100)' },
|
||||||
|
{ page: 3, summary: 'Inject/timer nodes and triggers (flow-0101 through flow-0150)' },
|
||||||
|
{ page: 4, summary: 'Remaining nodes and wire connections (flow-0151 through flow-0199)' },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
const mockProvider: LlmProvider = {
|
||||||
|
name: 'test-paginator',
|
||||||
|
isAvailable: () => true,
|
||||||
|
complete: completeFn,
|
||||||
|
};
|
||||||
|
registry.register(mockProvider);
|
||||||
|
|
||||||
|
// Low threshold so our response triggers pagination
|
||||||
|
const paginator = new ResponsePaginator(registry, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 8000,
|
||||||
|
});
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
// Call the tool — should get pagination index, not raw data
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'paginate-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const result = response.result as { content: Array<{ type: string; text: string }> };
|
||||||
|
expect(result.content).toHaveLength(1);
|
||||||
|
const indexText = result.content[0]!.text;
|
||||||
|
|
||||||
|
// Verify smart index with AI summaries
|
||||||
|
expect(indexText).toContain('AI-generated summaries');
|
||||||
|
expect(indexText).toContain('Function nodes and data transforms');
|
||||||
|
expect(indexText).toContain('HTTP request nodes');
|
||||||
|
expect(indexText).toContain('_resultId');
|
||||||
|
expect(indexText).toContain('_page');
|
||||||
|
|
||||||
|
// LLM was called to generate summaries
|
||||||
|
expect(completeFn).toHaveBeenCalledOnce();
|
||||||
|
const llmCall = completeFn.mock.calls[0]![0]!;
|
||||||
|
expect(llmCall.messages[0].role).toBe('system');
|
||||||
|
expect(llmCall.messages[1].content).toContain('node-red/get_flows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrieves specific pages after pagination via _resultId/_page', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
// Simple paginator (no LLM) for predictable behavior
|
||||||
|
const paginator = new ResponsePaginator(null, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 8000,
|
||||||
|
});
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
// First call — get the pagination index
|
||||||
|
const indexResponse = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'idx-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(indexResponse.error).toBeUndefined();
|
||||||
|
const indexResult = indexResponse.result as { content: Array<{ text: string }> };
|
||||||
|
const indexText = indexResult.content[0]!.text;
|
||||||
|
const resultIdMatch = /"_resultId": "([^"]+)"/.exec(indexText);
|
||||||
|
expect(resultIdMatch).not.toBeNull();
|
||||||
|
const resultId = resultIdMatch![1]!;
|
||||||
|
|
||||||
|
// Second call — retrieve page 1 via _resultId/_page
|
||||||
|
const page1Response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'page-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'node-red/get_flows',
|
||||||
|
arguments: { _resultId: resultId, _page: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page1Response.error).toBeUndefined();
|
||||||
|
const page1Result = page1Response.result as { content: Array<{ text: string }> };
|
||||||
|
expect(page1Result.content[0]!.text).toContain('Page 1/');
|
||||||
|
// Page content should contain flow data
|
||||||
|
expect(page1Result.content[0]!.text).toContain('flow-');
|
||||||
|
|
||||||
|
// Third call — retrieve page 2
|
||||||
|
const page2Response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'page-2',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'node-red/get_flows',
|
||||||
|
arguments: { _resultId: resultId, _page: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page2Response.error).toBeUndefined();
|
||||||
|
const page2Result = page2Response.result as { content: Array<{ text: string }> };
|
||||||
|
expect(page2Result.content[0]!.text).toContain('Page 2/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrieves full content with _page=all', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
const paginator = new ResponsePaginator(null, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 8000,
|
||||||
|
});
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
// Get index
|
||||||
|
const indexResponse = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'all-idx',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
const indexText = (indexResponse.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||||
|
const resultId = /"_resultId": "([^"]+)"/.exec(indexText)![1]!;
|
||||||
|
|
||||||
|
// Request all pages
|
||||||
|
const allResponse = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'all-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'node-red/get_flows',
|
||||||
|
arguments: { _resultId: resultId, _page: 'all' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allResponse.error).toBeUndefined();
|
||||||
|
const allResult = allResponse.result as { content: Array<{ text: string }> };
|
||||||
|
// Full response should contain the original JSON
|
||||||
|
const fullText = allResult.content[0]!.text;
|
||||||
|
expect(fullText).toContain('flow-0000');
|
||||||
|
expect(fullText).toContain('flow-0199');
|
||||||
|
// Should be the full serialized result
|
||||||
|
expect(JSON.parse(fullText)).toEqual(largeResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to simple index when LLM fails', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
// Set up paginator with a failing LLM
|
||||||
|
const registry = new ProviderRegistry();
|
||||||
|
registry.register(createFailingLlmProvider('broken-llm'));
|
||||||
|
const paginator = new ResponsePaginator(registry, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 8000,
|
||||||
|
});
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'fallback-idx',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||||
|
// Should still paginate, just without AI summaries
|
||||||
|
expect(text).toContain('_resultId');
|
||||||
|
expect(text).not.toContain('AI-generated summaries');
|
||||||
|
expect(text).toContain('Page 1:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns expired cache message for stale _resultId', async () => {
|
||||||
|
router = new McpRouter();
|
||||||
|
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
// Try to retrieve a page with an unknown resultId
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'stale-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'anything/tool',
|
||||||
|
arguments: { _resultId: 'nonexistent-id', _page: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||||
|
expect(text).toContain('expired');
|
||||||
|
expect(text).toContain('re-call');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pagination for small responses', async () => {
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-small', name: 'smallserver', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-small:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_status', description: 'Get status' }] },
|
||||||
|
}],
|
||||||
|
['srv-small:tools/call', {
|
||||||
|
result: { status: 'ok', uptime: 12345 },
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
const paginator = new ResponsePaginator(null, { sizeThreshold: 80000, pageSize: 40000 });
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'small-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'smallserver/get_status', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
// Should return the raw result directly, not a pagination index
|
||||||
|
expect(response.result).toEqual({ status: 'ok', uptime: 12345 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles markdown-fenced LLM responses (Gemini quirk)', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
// Simulate Gemini wrapping JSON in ```json fences
|
||||||
|
const registry = new ProviderRegistry();
|
||||||
|
const mockProvider: LlmProvider = {
|
||||||
|
name: 'gemini-mock',
|
||||||
|
isAvailable: () => true,
|
||||||
|
complete: vi.fn().mockResolvedValue({
|
||||||
|
content: '```json\n' + JSON.stringify([
|
||||||
|
{ page: 1, summary: 'Climate automation flows' },
|
||||||
|
{ page: 2, summary: 'Lighting control flows' },
|
||||||
|
]) + '\n```',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
registry.register(mockProvider);
|
||||||
|
|
||||||
|
const paginator = new ResponsePaginator(registry, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 8000,
|
||||||
|
});
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'fence-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||||
|
// Fences were stripped — smart summaries should appear
|
||||||
|
expect(text).toContain('AI-generated summaries');
|
||||||
|
expect(text).toContain('Climate automation flows');
|
||||||
|
expect(text).toContain('Lighting control flows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes model override to LLM when project has custom model', async () => {
|
||||||
|
const largeResult = makeLargeToolResult();
|
||||||
|
|
||||||
|
mockMcpd = await startMockMcpd({
|
||||||
|
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||||
|
proxyResponses: new Map([
|
||||||
|
['srv-nodered:tools/list', {
|
||||||
|
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||||
|
}],
|
||||||
|
['srv-nodered:tools/call', {
|
||||||
|
result: largeResult,
|
||||||
|
}],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||||
|
router = new McpRouter();
|
||||||
|
await refreshUpstreams(router, client);
|
||||||
|
await router.discoverTools();
|
||||||
|
|
||||||
|
const registry = new ProviderRegistry();
|
||||||
|
const completeFn = vi.fn().mockResolvedValue({
|
||||||
|
content: JSON.stringify([{ page: 1, summary: 'test' }]),
|
||||||
|
});
|
||||||
|
const mockProvider: LlmProvider = {
|
||||||
|
name: 'test-model-override',
|
||||||
|
isAvailable: () => true,
|
||||||
|
complete: completeFn,
|
||||||
|
};
|
||||||
|
registry.register(mockProvider);
|
||||||
|
|
||||||
|
// Paginator with per-project model override
|
||||||
|
const paginator = new ResponsePaginator(registry, {
|
||||||
|
sizeThreshold: 1000,
|
||||||
|
pageSize: 80000, // One big page so we get exactly 1 summary
|
||||||
|
}, 'gemini-2.5-pro');
|
||||||
|
router.setPaginator(paginator);
|
||||||
|
|
||||||
|
await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 'model-1',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'node-red/get_flows', arguments: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the model was passed through to the LLM call
|
||||||
|
expect(completeFn).toHaveBeenCalledOnce();
|
||||||
|
const llmOpts = completeFn.mock.calls[0]![0]!;
|
||||||
|
expect(llmOpts.model).toBe('gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,25 @@ describe('ResponsePaginator', () => {
|
|||||||
expect(text).toContain('HTTP request nodes and API integrations');
|
expect(text).toContain('HTTP request nodes and API integrations');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips markdown code fences from LLM JSON response', async () => {
|
||||||
|
const summaries = [
|
||||||
|
{ page: 1, summary: 'Config section' },
|
||||||
|
{ page: 2, summary: 'Data section' },
|
||||||
|
];
|
||||||
|
// Gemini often wraps JSON in ```json ... ``` fences
|
||||||
|
const fenced = '```json\n' + JSON.stringify(summaries) + '\n```';
|
||||||
|
const registry = makeProvider(fenced);
|
||||||
|
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 60 });
|
||||||
|
const raw = makeLargeStringWithNewlines(150);
|
||||||
|
const result = await paginator.paginate('test/tool', raw);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
const text = result!.content[0]!.text;
|
||||||
|
expect(text).toContain('AI-generated summaries');
|
||||||
|
expect(text).toContain('Config section');
|
||||||
|
expect(text).toContain('Data section');
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to simple index on LLM failure', async () => {
|
it('falls back to simple index on LLM failure', async () => {
|
||||||
const provider: LlmProvider = {
|
const provider: LlmProvider = {
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -225,6 +244,56 @@ describe('ResponsePaginator', () => {
|
|||||||
const text = result!.content[0]!.text;
|
const text = result!.content[0]!.text;
|
||||||
expect(text).not.toContain('AI-generated summaries');
|
expect(text).not.toContain('AI-generated summaries');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes modelOverride to provider.complete()', async () => {
|
||||||
|
const completeFn = vi.fn().mockResolvedValue({
|
||||||
|
content: JSON.stringify([{ page: 1, summary: 'test' }, { page: 2, summary: 'test2' }]),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
|
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 60 }, 'gemini-2.5-pro');
|
||||||
|
const raw = makeLargeStringWithNewlines(150);
|
||||||
|
await paginator.paginate('test/tool', raw);
|
||||||
|
|
||||||
|
expect(completeFn).toHaveBeenCalledOnce();
|
||||||
|
const call = completeFn.mock.calls[0]![0]!;
|
||||||
|
expect(call.model).toBe('gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits model when no modelOverride set', async () => {
|
||||||
|
const completeFn = vi.fn().mockResolvedValue({
|
||||||
|
content: JSON.stringify([{ page: 1, summary: 'test' }, { page: 2, summary: 'test2' }]),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
|
const paginator = new ResponsePaginator(registry, { sizeThreshold: 100, pageSize: 60 });
|
||||||
|
const raw = makeLargeStringWithNewlines(150);
|
||||||
|
await paginator.paginate('test/tool', raw);
|
||||||
|
|
||||||
|
expect(completeFn).toHaveBeenCalledOnce();
|
||||||
|
const call = completeFn.mock.calls[0]![0]!;
|
||||||
|
expect(call.model).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- getPage ---
|
// --- getPage ---
|
||||||
|
|||||||
@@ -6,8 +6,18 @@ import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js'
|
|||||||
// Mock discovery module — we don't want real HTTP calls
|
// Mock discovery module — we don't want real HTTP calls
|
||||||
vi.mock('../src/discovery.js', () => ({
|
vi.mock('../src/discovery.js', () => ({
|
||||||
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
||||||
|
fetchProjectLlmConfig: vi.fn(async () => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock config module — don't read real config files
|
||||||
|
vi.mock('../src/http/config.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadProjectLlmOverride: vi.fn(() => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||||
|
|
||||||
function mockMcpdClient() {
|
function mockMcpdClient() {
|
||||||
|
|||||||
Reference in New Issue
Block a user