Compare commits
5 Commits
feat/gemin
...
feat/per-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed725b387 | ||
| 17a456d835 | |||
|
|
9481d394a1 | ||
|
|
bc769c4eeb | ||
| 6f534c8ba9 |
@@ -2,10 +2,10 @@ _mcpctl() {
|
|||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="status login logout config get describe delete logs create edit apply backup restore mcp help"
|
local commands="status login logout config get describe delete logs create edit apply backup restore mcp approve help"
|
||||||
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
||||||
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
||||||
local resources="servers instances secrets templates projects users groups rbac"
|
local resources="servers instances secrets templates projects users groups rbac prompts promptrequests"
|
||||||
|
|
||||||
# Check if --project was given
|
# Check if --project was given
|
||||||
local has_project=false
|
local has_project=false
|
||||||
@@ -78,7 +78,7 @@ _mcpctl() {
|
|||||||
case "$subcmd" in
|
case "$subcmd" in
|
||||||
config)
|
config)
|
||||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
|
COMPREPLY=($(compgen -W "view set path reset claude claude-generate setup impersonate help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
status)
|
status)
|
||||||
@@ -114,7 +114,7 @@ _mcpctl() {
|
|||||||
return ;;
|
return ;;
|
||||||
create)
|
create)
|
||||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
|
COMPREPLY=($(compgen -W "server secret project user group rbac prompt promptrequest help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
apply)
|
apply)
|
||||||
@@ -150,6 +150,15 @@ _mcpctl() {
|
|||||||
fi
|
fi
|
||||||
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
approve)
|
||||||
|
if [[ -z "$resource_type" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "promptrequest" -- "$cur"))
|
||||||
|
else
|
||||||
|
local names
|
||||||
|
names=$(_mcpctl_resource_names "$resource_type")
|
||||||
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
|
fi
|
||||||
|
return ;;
|
||||||
help)
|
help)
|
||||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Erase any stale completions from previous versions
|
# Erase any stale completions from previous versions
|
||||||
complete -c mcpctl -e
|
complete -c mcpctl -e
|
||||||
|
|
||||||
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help
|
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp approve help
|
||||||
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
@@ -28,7 +28,7 @@ function __mcpctl_has_project
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Helper: check if a resource type has been selected after get/describe/delete/edit
|
# Helper: check if a resource type has been selected after get/describe/delete/edit
|
||||||
set -l resources servers instances secrets templates projects users groups rbac
|
set -l resources servers instances secrets templates projects users groups rbac prompts promptrequests
|
||||||
|
|
||||||
function __mcpctl_needs_resource_type
|
function __mcpctl_needs_resource_type
|
||||||
set -l tokens (commandline -opc)
|
set -l tokens (commandline -opc)
|
||||||
@@ -36,11 +36,11 @@ function __mcpctl_needs_resource_type
|
|||||||
for tok in $tokens
|
for tok in $tokens
|
||||||
if $found_cmd
|
if $found_cmd
|
||||||
# Check if next token after get/describe/delete/edit is a resource type
|
# Check if next token after get/describe/delete/edit is a resource type
|
||||||
if contains -- $tok servers instances secrets templates projects users groups rbac
|
if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests
|
||||||
return 1 # resource type already present
|
return 1 # resource type already present
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if contains -- $tok get describe delete edit
|
if contains -- $tok get describe delete edit approve
|
||||||
set found_cmd true
|
set found_cmd true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -55,12 +55,12 @@ function __mcpctl_get_resource_type
|
|||||||
set -l found_cmd false
|
set -l found_cmd false
|
||||||
for tok in $tokens
|
for tok in $tokens
|
||||||
if $found_cmd
|
if $found_cmd
|
||||||
if contains -- $tok servers instances secrets templates projects users groups rbac
|
if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests
|
||||||
echo $tok
|
echo $tok
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if contains -- $tok get describe delete edit
|
if contains -- $tok get describe delete edit approve
|
||||||
set found_cmd true
|
set found_cmd true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -139,6 +139,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_
|
|||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a prompt request'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
||||||
|
|
||||||
# Project-scoped commands (with --project)
|
# Project-scoped commands (with --project)
|
||||||
@@ -157,7 +158,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mc
|
|||||||
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
|
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
|
||||||
|
|
||||||
# Resource names — after resource type is selected
|
# Resource names — after resource type is selected
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
|
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
|
||||||
|
|
||||||
# Helper: check if attach-server/detach-server already has a server argument
|
# Helper: check if attach-server/detach-server already has a server argument
|
||||||
function __mcpctl_needs_server_arg
|
function __mcpctl_needs_server_arg
|
||||||
@@ -196,22 +197,25 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email add
|
|||||||
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
|
||||||
|
|
||||||
# config subcommands
|
# config subcommands
|
||||||
set -l config_cmds view set path reset claude claude-generate impersonate
|
set -l config_cmds view set path reset claude claude-generate setup impersonate
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Configure LLM provider'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
|
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
|
||||||
|
|
||||||
# create subcommands
|
# create subcommands
|
||||||
set -l create_cmds server secret project user group rbac
|
set -l create_cmds server secret project user group rbac prompt promptrequest
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding'
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding'
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt'
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request'
|
||||||
|
|
||||||
# logs options
|
# logs options
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
|
||||||
@@ -227,6 +231,9 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s i -l input -d 'In
|
|||||||
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d 'Decryption password' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d 'Decryption password' -x
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail'
|
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail'
|
||||||
|
|
||||||
|
# approve: first arg is resource type (promptrequest only), second is name
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_resource_type" -a 'promptrequest' -d 'Resource type'
|
||||||
|
|
||||||
# apply takes a file
|
# apply takes a file
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF
|
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F
|
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F
|
||||||
|
|||||||
@@ -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('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
|
||||||
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
|
||||||
.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.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
|
||||||
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
if (opts.server.length > 0) body.servers = opts.server;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -379,5 +375,31 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- create promptrequest ---
|
||||||
|
cmd.command('promptrequest')
|
||||||
|
.description('Create a prompt request (pending proposal that needs approval)')
|
||||||
|
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
|
||||||
|
.requiredOption('--project <name>', 'Project name (required)')
|
||||||
|
.option('--content <text>', 'Prompt content text')
|
||||||
|
.option('--content-file <path>', 'Read prompt content from file')
|
||||||
|
.action(async (name: string, opts) => {
|
||||||
|
let content = opts.content as string | undefined;
|
||||||
|
if (opts.contentFile) {
|
||||||
|
const fs = await import('node:fs/promises');
|
||||||
|
content = await fs.readFile(opts.contentFile as string, 'utf-8');
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('--content or --content-file is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = opts.project as string;
|
||||||
|
const pr = await client.post<{ id: string; name: string }>(
|
||||||
|
`/api/v1/projects/${encodeURIComponent(projectName)}/promptrequests`,
|
||||||
|
{ name, content },
|
||||||
|
);
|
||||||
|
log(`prompt request '${pr.name}' created (id: ${pr.id})`);
|
||||||
|
log(` approve with: mcpctl approve promptrequest ${pr.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { loadConfig } from '../config/index.js';
|
import { loadConfig } from '../config/index.js';
|
||||||
import type { ConfigLoaderDeps, LlmConfig } from '../config/index.js';
|
import type { ConfigLoaderDeps } from '../config/index.js';
|
||||||
import { loadCredentials } from '../auth/index.js';
|
import { loadCredentials } from '../auth/index.js';
|
||||||
import type { CredentialsDeps } from '../auth/index.js';
|
import type { CredentialsDeps } from '../auth/index.js';
|
||||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||||
import { APP_VERSION } from '@mcpctl/shared';
|
import { APP_VERSION } from '@mcpctl/shared';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
// ANSI helpers
|
// ANSI helpers
|
||||||
const GREEN = '\x1b[32m';
|
const GREEN = '\x1b[32m';
|
||||||
const RED = '\x1b[31m';
|
const RED = '\x1b[31m';
|
||||||
@@ -24,7 +20,10 @@ export interface StatusCommandDeps {
|
|||||||
log: (...args: string[]) => void;
|
log: (...args: string[]) => void;
|
||||||
write: (text: string) => void;
|
write: (text: string) => void;
|
||||||
checkHealth: (url: string) => Promise<boolean>;
|
checkHealth: (url: string) => Promise<boolean>;
|
||||||
checkLlm: (llm: LlmConfig) => Promise<string>;
|
/** Check LLM health via mcplocal's /llm/health endpoint */
|
||||||
|
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
||||||
|
/** Fetch available models from mcplocal's /llm/models endpoint */
|
||||||
|
fetchModels: (mcplocalUrl: string) => Promise<string[]>;
|
||||||
isTTY: boolean;
|
isTTY: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,34 +42,53 @@ function defaultCheckHealth(url: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quick LLM health check. Returns 'ok', 'binary not found', 'auth error', etc.
|
* Check LLM health by querying mcplocal's /llm/health endpoint.
|
||||||
|
* This tests the actual provider running inside the daemon (uses persistent ACP for gemini, etc.)
|
||||||
*/
|
*/
|
||||||
async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
|
function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
|
||||||
if (llm.provider === 'gemini-cli') {
|
return new Promise((resolve) => {
|
||||||
const bin = llm.binaryPath ?? 'gemini';
|
const req = http.get(`${mcplocalUrl}/llm/health`, { timeout: 30000 }, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string };
|
||||||
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
|
if (body.status === 'ok') {
|
||||||
} catch (err) {
|
resolve('ok');
|
||||||
const msg = (err as Error).message;
|
} else if (body.status === 'not configured') {
|
||||||
if (msg.includes('ENOENT')) return 'binary not found';
|
resolve('not configured');
|
||||||
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
|
} else if (body.error) {
|
||||||
return `error: ${msg.slice(0, 80)}`;
|
resolve(body.error.slice(0, 80));
|
||||||
|
} else {
|
||||||
|
resolve(body.status);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (llm.provider === 'ollama') {
|
|
||||||
const url = llm.url ?? 'http://localhost:11434';
|
|
||||||
try {
|
|
||||||
const ok = await defaultCheckHealth(url);
|
|
||||||
return ok ? 'ok' : 'unreachable';
|
|
||||||
} catch {
|
} catch {
|
||||||
return 'unreachable';
|
resolve('invalid response');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve('mcplocal unreachable'));
|
||||||
|
req.on('timeout', () => { req.destroy(); resolve('timeout'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// For API-key providers, we don't want to make a billable call on every status check
|
function defaultFetchModels(mcplocalUrl: string): Promise<string[]> {
|
||||||
return 'ok (key stored)';
|
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
@@ -82,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')
|
||||||
@@ -104,7 +123,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
||||||
checkHealth(config.mcplocalUrl),
|
checkHealth(config.mcplocalUrl),
|
||||||
checkHealth(config.mcpdUrl),
|
checkHealth(config.mcpdUrl),
|
||||||
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
|
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const llm = llmLabel
|
const llm = llmLabel
|
||||||
@@ -148,8 +167,8 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM check with spinner
|
// LLM check with spinner — queries mcplocal's /llm/health endpoint
|
||||||
const llmPromise = checkLlm(config.llm!);
|
const llmPromise = checkLlm(config.mcplocalUrl);
|
||||||
|
|
||||||
if (isTTY) {
|
if (isTTY) {
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
@@ -175,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',
|
||||||
'--proxy-mode-llm-provider', 'gemini-cli',
|
|
||||||
'--proxy-mode-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'],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,13 +134,23 @@ describe('status command', () => {
|
|||||||
expect(out).toContain('✗ not authenticated');
|
expect(out).toContain('✗ not authenticated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows binary not found error', async () => {
|
it('shows error message from mcplocal', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('✗ binary not found');
|
expect(output.join('\n')).toContain('✗ binary not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queries mcplocal URL for LLM health', async () => {
|
||||||
|
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://custom:9999', llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
|
let queriedUrl = '';
|
||||||
|
const cmd = createStatusCommand(baseDeps({
|
||||||
|
checkLlm: async (url) => { queriedUrl = url; return 'ok'; },
|
||||||
|
}));
|
||||||
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
|
expect(queriedUrl).toBe('http://custom:9999');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses spinner on TTY and writes final result', async () => {
|
it('uses spinner on TTY and writes final result', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand(baseDeps({
|
const cmd = createStatusCommand(baseDeps({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('fish completions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
|
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
|
||||||
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac'];
|
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'];
|
||||||
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|||||||
@@ -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}` });
|
||||||
|
|||||||
@@ -81,6 +81,49 @@ export async function createHttpServer(
|
|||||||
reply.code(200).send({ status: 'ok' });
|
reply.code(200).send({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LLM health check — tests the active provider with a tiny prompt
|
||||||
|
app.get('/llm/health', async (_request, reply) => {
|
||||||
|
const provider = deps.providerRegistry?.getActive() ?? null;
|
||||||
|
if (!provider) {
|
||||||
|
reply.code(200).send({ status: 'not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await provider.complete({
|
||||||
|
messages: [{ role: 'user', content: 'Respond with exactly: ok' }],
|
||||||
|
maxTokens: 10,
|
||||||
|
});
|
||||||
|
const ok = result.content.trim().toLowerCase().includes('ok');
|
||||||
|
reply.code(200).send({
|
||||||
|
status: ok ? 'ok' : 'unexpected response',
|
||||||
|
provider: provider.name,
|
||||||
|
response: result.content.trim().slice(0, 100),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message ?? String(err);
|
||||||
|
reply.code(200).send({
|
||||||
|
status: 'error',
|
||||||
|
provider: provider.name,
|
||||||
|
error: msg.slice(0, 200),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
|||||||
@@ -205,16 +205,20 @@ export class AcpClient {
|
|||||||
|
|
||||||
// Collect text from agent_message_chunk
|
// Collect text from agent_message_chunk
|
||||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||||
const content = update.content as Array<{ type: string; text?: string }> | undefined;
|
const content = update.content;
|
||||||
if (content) {
|
// Gemini ACP sends content as a single object {type, text} or an array [{type, text}]
|
||||||
for (const block of content) {
|
const blocks: Array<{ type: string; text?: string }> = Array.isArray(content)
|
||||||
|
? content as Array<{ type: string; text?: string }>
|
||||||
|
: content && typeof content === 'object'
|
||||||
|
? [content as { type: string; text?: string }]
|
||||||
|
: [];
|
||||||
|
for (const block of blocks) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
this.activePromptChunks.push(block.text);
|
this.activePromptChunks.push(block.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle requests from the agent (e.g., session/request_permission). Reject them all. */
|
/** Handle requests from the agent (e.g., session/request_permission). Reject them all. */
|
||||||
private handleAgentRequest(msg: { id: number; method: string; params?: Record<string, unknown> }): void {
|
private handleAgentRequest(msg: { id: number; method: string; params?: Record<string, unknown> }): void {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -230,6 +230,77 @@ describe('AcpClient', () => {
|
|||||||
expect(result).toBe('Part A Part B');
|
expect(result).toBe('Part A Part B');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles single-object content (real Gemini ACP format)', async () => {
|
||||||
|
createClient();
|
||||||
|
autoHandshake('sess-1');
|
||||||
|
await client.ensureReady();
|
||||||
|
|
||||||
|
mock.stdin.write.mockImplementation((data: string) => {
|
||||||
|
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||||
|
if (msg.method === 'session/prompt') {
|
||||||
|
setImmediate(() => {
|
||||||
|
// Real Gemini ACP sends content as a single object, not an array
|
||||||
|
mock.sendLine({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'session/update',
|
||||||
|
params: {
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
content: { type: 'text', text: 'ok' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.prompt('test');
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores agent_thought_chunk notifications', async () => {
|
||||||
|
createClient();
|
||||||
|
autoHandshake('sess-1');
|
||||||
|
await client.ensureReady();
|
||||||
|
|
||||||
|
mock.stdin.write.mockImplementation((data: string) => {
|
||||||
|
const msg = JSON.parse(data.trim()) as { id: number; method: string };
|
||||||
|
if (msg.method === 'session/prompt') {
|
||||||
|
setImmediate(() => {
|
||||||
|
// Gemini sends thought chunks before message chunks
|
||||||
|
mock.sendLine({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'session/update',
|
||||||
|
params: {
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: { type: 'text', text: 'Thinking about it...' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.sendLine({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'session/update',
|
||||||
|
params: {
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
content: { type: 'text', text: 'ok' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.prompt('test');
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
it('calls ensureReady automatically (lazy init)', async () => {
|
it('calls ensureReady automatically (lazy init)', async () => {
|
||||||
createClient();
|
createClient();
|
||||||
autoHandshake('sess-auto');
|
autoHandshake('sess-auto');
|
||||||
|
|||||||
@@ -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