diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index a499b9a..a889844 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,11 +5,11 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate rotate" + local commands="status login logout config get describe delete logs create edit apply chat patch backup approve console cache test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" - local resource_aliases="servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" + local resources="servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" + local resource_aliases="servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" # Check if --project/-p was given local has_project=false @@ -175,7 +175,7 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret llm secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -187,6 +187,9 @@ _mcpctl() { llm) COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur")) ;; + agent) + COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --force -h --help" -- "$cur")) + ;; secretbackend) COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --wizard --setup-token --policy-name --token-role --no-promote-default --force -h --help" -- "$cur")) ;; @@ -232,6 +235,15 @@ _mcpctl() { apply) COMPREPLY=($(compgen -f -W "-f --file --dry-run -h --help" -- "$cur")) return ;; + chat) + if [[ $((cword - subcmd_pos)) -eq 1 ]]; then + local names + names=$(_mcpctl_resource_names "agents") + COMPREPLY=($(compgen -W "$names -m --message --thread --system --system-file --system-append --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + else + COMPREPLY=($(compgen -W "-m --message --thread --system --system-file --system-append --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + fi + return ;; patch) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "$resources -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 35d0995..ce00dbc 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate rotate +set -l commands status login logout config get describe delete logs create edit apply chat patch backup approve console cache test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all +set -l resources servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -61,6 +61,7 @@ function __mcpctl_resolve_resource case secret sec secrets; echo secrets case secretbackend sb secretbackends; echo secretbackends case llm llms; echo llms + case agent agents; echo agents case template tpl templates; echo templates case project proj projects; echo projects case user users; echo users @@ -76,7 +77,7 @@ function __mcpctl_resolve_resource end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -225,9 +226,10 @@ 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 describe -d 'Show detailed information about a resource' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent (REPL or one-shot).' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' @@ -242,7 +244,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' @@ -285,10 +287,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret llm secretbackend project user group rbac mcptoken prompt serverattachment promptrequest +set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt serverattachment promptrequest complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' 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 llm -d 'Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a agent -d 'Create an Agent (LLM persona pinned to an Llm, optionally attached to a Project)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secretbackend -d 'Create a secret backend (plaintext, openbao)' 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' @@ -329,6 +332,23 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l api-key-ref -d 'API complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l extra -d 'Extra config key=value (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l force -d 'Update if already exists' +# create agent options +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l llm -d 'Pinned Llm (see `mcpctl get llms`)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l project -d 'Attach to this Project (optional)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l description -d 'Description (shown in MCP tools/list)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l system-prompt -d 'System prompt (persona)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l system-prompt-file -d 'Read system prompt from a file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l proxy-model -d 'Optional proxyModel name override (informational)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-temperature -d 'Default sampling temperature' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-top-p -d 'Default top_p' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-top-k -d 'Default top_k' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-max-tokens -d 'Default max_tokens' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-seed -d 'Default seed' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-stop -d 'Default stop sequence (repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-extra -d 'Default provider-specific knob k=v (repeat)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-params-file -d 'Read defaultParams from a JSON file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l force -d 'Update if already exists' + # create secretbackend options complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l type -d 'Backend type (plaintext, openbao)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l description -d 'Description' -x @@ -471,6 +491,22 @@ complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s i -l instance -d 'In complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path to config file (alternative to positional arg)' -rF complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' +# chat options +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s m -l message -d 'One-shot: send a single message and exit (no REPL)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an existing thread' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system -d 'Replace agent.systemPrompt for this session' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-file -d 'Read --system text from a file' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-append -d 'Append to the agent system block for this session' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l temperature -d 'Sampling temperature (0..2)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-p -d 'Nucleus sampling cutoff (0..1)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-k -d 'Top-K sampling (Anthropic; OpenAI ignores)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l max-tokens -d 'Maximum tokens in the assistant reply' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l seed -d 'Reproducibility seed (provider-dependent)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l stop -d 'Stop sequence (repeatable)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l allow-tool -d 'Restrict to this tool only (repeatable)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l extra -d 'Provider-specific knob k=v (repeatable)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l no-stream -d 'Disable SSE streaming (single JSON response)' + # console options complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd' diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 4f24357..761b0bd 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -184,7 +184,7 @@ async function extractTree(): Promise { // ============================================================ const CANONICAL_RESOURCES = [ - 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'templates', 'projects', + 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'all', ]; @@ -195,6 +195,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['secret', 'secrets'], ['sec', 'secrets'], ['secretbackend', 'secretbackends'], ['sb', 'secretbackends'], ['llm', 'llms'], ['llms', 'llms'], + ['agent', 'agents'], ['agents', 'agents'], ['template', 'templates'], ['tpl', 'templates'], ['project', 'projects'], ['proj', 'projects'], ['user', 'users'], @@ -904,6 +905,20 @@ function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): v return; } + // chat: first arg is agent name + if (name === 'chat') { + emit(` ${name})`); + emit(' if [[ $((cword - subcmd_pos)) -eq 1 ]]; then'); + emit(' local names'); + emit(' names=$(_mcpctl_resource_names "agents")'); + emit(` COMPREPLY=($(compgen -W "$names ${optFlags}" -- "$cur"))`); + emit(' else'); + emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`); + emit(' fi'); + emit(' return ;;'); + return; + } + // console: first arg is project name if (name === 'console') { emit(` ${name})`); diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index a24ce44..4eac13a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -63,6 +63,42 @@ const LlmSpecSchema = z.object({ extraConfig: z.record(z.unknown()).default({}), }); +const AgentChatParamsAppliedSchema = z.object({ + temperature: z.number().optional(), + top_p: z.number().optional(), + top_k: z.number().int().optional(), + max_tokens: z.number().int().optional(), + stop: z.union([z.string(), z.array(z.string())]).optional(), + presence_penalty: z.number().optional(), + frequency_penalty: z.number().optional(), + seed: z.number().int().optional(), + response_format: z.record(z.unknown()).optional(), + tool_choice: z.unknown().optional(), + tools_allowlist: z.array(z.string()).optional(), + systemOverride: z.string().optional(), + systemAppend: z.string().optional(), + extra: z.record(z.unknown()).optional(), +}).strict(); + +const AgentSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + description: z.string().max(500).default(''), + systemPrompt: z.string().default(''), + llm: z.union([ + z.object({ name: z.string().min(1) }), + z.object({ id: z.string().min(1) }), + // Allow string shorthand: `llm: qwen3-thinking` → `{ name: 'qwen3-thinking' }` + z.string().min(1).transform((name) => ({ name })), + ]), + project: z.union([ + z.object({ name: z.string().min(1) }), + z.string().min(1).transform((name) => ({ name })), + ]).optional(), + proxyModelName: z.string().optional(), + defaultParams: AgentChatParamsAppliedSchema.default({}), + extras: z.record(z.unknown()).default({}), +}); + const TemplateEnvEntrySchema = z.object({ name: z.string().min(1), description: z.string().optional(), @@ -172,6 +208,7 @@ const ApplyConfigSchema = z.object({ secretbackends: z.array(SecretBackendSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), llms: z.array(LlmSpecSchema).default([]), + agents: z.array(AgentSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]), users: z.array(UserSpecSchema).default([]), groups: z.array(GroupSpecSchema).default([]), @@ -215,6 +252,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`); if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`); if (config.llms.length > 0) log(` ${config.llms.length} llm(s)`); + if (config.agents.length > 0) log(` ${config.agents.length} agent(s)`); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.users.length > 0) log(` ${config.users.length} user(s)`); if (config.groups.length > 0) log(` ${config.groups.length} group(s)`); @@ -262,6 +300,7 @@ const KIND_TO_RESOURCE: Record = { mcptoken: 'mcptokens', secretbackend: 'secretbackends', llm: 'llms', + agent: 'agents', }; /** @@ -434,6 +473,24 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } + // Apply agents (after llms — agent.llm references an existing Llm by name) + for (const agent of config.agents) { + try { + const existing = await cachedFindByName('agents', agent.name); + if (existing) { + const { name: _n, ...updateBody } = agent; + await withRetry(() => client.put(`/api/v1/agents/${existing.id}`, updateBody)); + log(`Updated agent: ${agent.name}`); + } else { + await withRetry(() => client.post('/api/v1/agents', agent)); + invalidateCache('agents'); + log(`Created agent: ${agent.name}`); + } + } catch (err) { + log(`Error applying agent '${agent.name}': ${err instanceof Error ? err.message : err}`); + } + } + // Apply users (matched by email) for (const user of config.users) { try { diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts new file mode 100644 index 0000000..88b0901 --- /dev/null +++ b/src/cli/src/commands/chat.ts @@ -0,0 +1,409 @@ +/** + * `mcpctl chat ` — interactive REPL + one-shot mode. + * + * Streams the agent's response over SSE so the user sees text appear as it's + * generated. Tool calls and tool results print to stderr in dim style so the + * REPL output stays clean. LiteLLM-style flags (--temperature, --max-tokens, + * --system, etc.) override the agent's defaultParams for this session only; + * use the in-REPL `/save` slash-command to persist them back to the agent. + * + * Modes: + * mcpctl chat # REPL, new thread + * mcpctl chat --thread # REPL, resume thread + * mcpctl chat -m "hi" # one-shot, prints reply, no REPL + * + * Slash-commands inside the REPL: + * /set KEY VALUE # adjust an override (temperature 0.2) + * /system # set systemAppend for this turn onward + * /tools # list tools the agent can call + * /clear # start a fresh thread (same agent) + * /save # PATCH agent.defaultParams = current overrides + * /quit # exit + */ +import { Command } from 'commander'; +import http from 'node:http'; +import https from 'node:https'; +import readline from 'node:readline'; +import { promises as fs } from 'node:fs'; +import type { ApiClient } from '../api-client.js'; + +const STREAM_TIMEOUT_MS = 600_000; // 10 minutes — agent turns can include long tool calls + +export interface ChatCommandDeps { + client: ApiClient; + baseUrl: string; + token?: string | undefined; + log: (...args: unknown[]) => void; +} + +export function createChatCommand(deps: ChatCommandDeps): Command { + return new Command('chat') + .description('Open an interactive chat session with an agent (REPL or one-shot).') + .argument('', 'Agent name (see `mcpctl get agents`)') + .option('-m, --message ', 'One-shot: send a single message and exit (no REPL)') + .option('--thread ', 'Resume an existing thread') + .option('--system ', 'Replace agent.systemPrompt for this session') + .option('--system-file ', 'Read --system text from a file') + .option('--system-append ', 'Append to the agent system block for this session') + .option('--temperature ', 'Sampling temperature (0..2)', parseFloat) + .option('--top-p ', 'Nucleus sampling cutoff (0..1)', parseFloat) + .option('--top-k ', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt) + .option('--max-tokens ', 'Maximum tokens in the assistant reply', parseFloatInt) + .option('--seed ', 'Reproducibility seed (provider-dependent)', parseFloatInt) + .option('--stop ', 'Stop sequence (repeatable)', collect, []) + .option('--allow-tool ', 'Restrict to this tool only (repeatable)', collect, []) + .option('--extra ', 'Provider-specific knob k=v (repeatable)', collect, []) + .option('--no-stream', 'Disable SSE streaming (single JSON response)') + .action(async (agent: string, opts: ChatOpts) => { + const overrides = await buildInitialOverrides(opts); + + if (opts.message !== undefined) { + await runOneShot(deps, agent, opts.message, opts.thread, overrides, opts.stream); + return; + } + await runRepl(deps, agent, opts.thread, overrides, opts.stream); + }); +} + +interface ChatOpts { + message?: string; + thread?: string; + system?: string; + systemFile?: string; + systemAppend?: string; + temperature?: number; + topP?: number; + topK?: number; + maxTokens?: number; + seed?: number; + stop?: string[]; + allowTool?: string[]; + extra?: string[]; + stream?: boolean; +} + +interface Overrides { + systemOverride?: string; + systemAppend?: string; + temperature?: number; + top_p?: number; + top_k?: number; + max_tokens?: number; + seed?: number; + stop?: string[]; + tools_allowlist?: string[]; + extra?: Record; +} + +async function buildInitialOverrides(opts: ChatOpts): Promise { + const out: Overrides = {}; + let system = opts.system; + if (system === undefined && opts.systemFile !== undefined) { + system = (await fs.readFile(opts.systemFile, 'utf-8')).trim(); + } + if (system !== undefined) out.systemOverride = system; + if (opts.systemAppend !== undefined) out.systemAppend = opts.systemAppend; + if (opts.temperature !== undefined) out.temperature = opts.temperature; + if (opts.topP !== undefined) out.top_p = opts.topP; + if (opts.topK !== undefined) out.top_k = opts.topK; + if (opts.maxTokens !== undefined) out.max_tokens = opts.maxTokens; + if (opts.seed !== undefined) out.seed = opts.seed; + if (opts.stop !== undefined && opts.stop.length > 0) out.stop = opts.stop; + if (opts.allowTool !== undefined && opts.allowTool.length > 0) out.tools_allowlist = opts.allowTool; + if (opts.extra !== undefined && opts.extra.length > 0) { + const extra: Record = {}; + for (const kv of opts.extra) { + const eq = kv.indexOf('='); + if (eq < 1) throw new Error(`--extra '${kv}' must be key=value`); + extra[kv.slice(0, eq)] = parseExtraValue(kv.slice(eq + 1)); + } + out.extra = extra; + } + return out; +} + +function parseExtraValue(raw: string): unknown { + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw === 'null') return null; + if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); + return raw; +} + +async function runOneShot( + deps: ChatCommandDeps, + agent: string, + message: string, + threadId: string | undefined, + overrides: Overrides, + stream: boolean | undefined, +): Promise { + if (stream === false) { + const body: Record = { message, ...overrides }; + if (threadId !== undefined) body.threadId = threadId; + const res = await deps.client.post<{ assistant: string; threadId: string; turnIndex: number }>( + `/api/v1/agents/${encodeURIComponent(agent)}/chat`, + body, + ); + process.stdout.write(`${res.assistant}\n`); + process.stderr.write(`(thread: ${res.threadId})\n`); + return; + } + const finalThread = await streamOnce(deps, agent, message, threadId, overrides); + process.stderr.write(`\n(thread: ${finalThread})\n`); +} + +async function runRepl( + deps: ChatCommandDeps, + agent: string, + initialThread: string | undefined, + initialOverrides: Overrides, + stream: boolean | undefined, +): Promise { + const overrides: Overrides = { ...initialOverrides }; + let threadId = initialThread; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + process.stderr.write(`Chat with agent '${agent}'. Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`); + if (threadId !== undefined) { + process.stderr.write(`(resuming thread ${threadId})\n`); + } + + while (true) { + let line: string; + try { + line = await ask('> '); + } catch { + break; + } + if (line === '') continue; + if (line.startsWith('/')) { + const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; }); + if (handled === 'quit') break; + continue; + } + + try { + if (stream === false) { + const body: Record = { message: line, ...overrides }; + if (threadId !== undefined) body.threadId = threadId; + const res = await deps.client.post<{ assistant: string; threadId: string }>( + `/api/v1/agents/${encodeURIComponent(agent)}/chat`, + body, + ); + threadId = res.threadId; + process.stdout.write(`${res.assistant}\n`); + } else { + threadId = await streamOnce(deps, agent, line, threadId, overrides); + process.stdout.write('\n'); + } + } catch (err) { + process.stderr.write(`error: ${(err as Error).message}\n`); + } + } + rl.close(); +} + +async function handleSlash( + raw: string, + deps: ChatCommandDeps, + agent: string, + overrides: Overrides, + resetThread: () => void, +): Promise<'quit' | 'continue'> { + const [cmd, ...rest] = raw.slice(1).split(/\s+/); + switch (cmd) { + case 'quit': + case 'exit': + return 'quit'; + case 'clear': + resetThread(); + process.stderr.write('(new thread\n)'); + return 'continue'; + case 'system': { + const text = rest.join(' '); + if (text === '') { + delete overrides.systemAppend; + process.stderr.write('(systemAppend cleared)\n'); + } else { + overrides.systemAppend = text; + process.stderr.write('(systemAppend set)\n'); + } + return 'continue'; + } + case 'set': { + const [key, ...vparts] = rest; + if (key === undefined || vparts.length === 0) { + process.stderr.write('usage: /set KEY VALUE\n'); + return 'continue'; + } + applySetCommand(overrides, key, vparts.join(' ')); + process.stderr.write(`(${key}=${vparts.join(' ')})\n`); + return 'continue'; + } + case 'tools': { + try { + const a = await deps.client.get<{ project: { name: string } | null }>( + `/api/v1/agents/${encodeURIComponent(agent)}`, + ); + if (a.project === null) { + process.stderr.write('(agent has no project — no tools available)\n'); + return 'continue'; + } + const servers = await deps.client.get>( + `/api/v1/projects/${encodeURIComponent(a.project.name)}/servers`, + ); + if (servers.length === 0) { + process.stderr.write('(project has no MCP servers attached)\n'); + } else { + for (const s of servers) { + process.stderr.write(` ${s.server.name}\n`); + } + } + } catch (err) { + process.stderr.write(`error listing tools: ${(err as Error).message}\n`); + } + return 'continue'; + } + case 'save': { + try { + await deps.client.put(`/api/v1/agents/${encodeURIComponent(agent)}`, { + defaultParams: stripSession(overrides), + }); + process.stderr.write('(saved current overrides as agent.defaultParams)\n'); + } catch (err) { + process.stderr.write(`error saving: ${(err as Error).message}\n`); + } + return 'continue'; + } + default: + process.stderr.write(`unknown command: /${cmd ?? ''}\n`); + return 'continue'; + } +} + +function stripSession(o: Overrides): Record { + // /save persists sampling defaults but not the per-session systemOverride / systemAppend. + const out: Record = { ...o }; + delete out.systemOverride; + delete out.systemAppend; + return out; +} + +function applySetCommand(o: Overrides, key: string, valueRaw: string): void { + const num = Number(valueRaw); + switch (key) { + case 'temperature': o.temperature = num; break; + case 'top_p': case 'top-p': o.top_p = num; break; + case 'top_k': case 'top-k': o.top_k = Math.trunc(num); break; + case 'max_tokens': case 'max-tokens': o.max_tokens = Math.trunc(num); break; + case 'seed': o.seed = Math.trunc(num); break; + case 'stop': o.stop = [valueRaw]; break; + default: + // Anything unknown drops into `extra` so the user can still pass it. + o.extra = { ...(o.extra ?? {}), [key]: parseExtraValue(valueRaw) }; + } +} + +/** Stream a single chat call. Returns the resolved threadId. */ +async function streamOnce( + deps: ChatCommandDeps, + agent: string, + message: string, + threadId: string | undefined, + overrides: Overrides, +): Promise { + const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`); + const body = JSON.stringify({ message, threadId, stream: true, ...overrides }); + + return new Promise((resolve, reject) => { + const driver = url.protocol === 'https:' ? https : http; + const req = driver.request({ + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'POST', + timeout: STREAM_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + ...(deps.token !== undefined ? { Authorization: `Bearer ${deps.token}` } : {}), + }, + }, (res) => { + const status = res.statusCode ?? 0; + if (status >= 400) { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => reject(new Error(`HTTP ${String(status)}: ${Buffer.concat(chunks).toString('utf-8')}`))); + return; + } + let buf = ''; + let resolvedThread = threadId ?? ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, nl); + buf = buf.slice(nl + 2); + for (const line of frame.split('\n')) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6); + if (data === '[DONE]') continue; + try { + const evt = JSON.parse(data) as ChatStreamFrame; + switch (evt.type) { + case 'text': + if (typeof evt.delta === 'string') process.stdout.write(evt.delta); + break; + case 'tool_call': + process.stderr.write(`\n[tool_call: ${evt.toolName ?? ''}]\n`); + break; + case 'tool_result': + process.stderr.write(`[tool_result: ${evt.toolName ?? ''} ${evt.ok === false ? 'FAIL' : 'ok'}]\n`); + break; + case 'final': + if (evt.threadId !== undefined) resolvedThread = evt.threadId; + break; + case 'error': + process.stderr.write(`\n[error: ${evt.message ?? ''}]\n`); + break; + } + } catch { + // ignore malformed frames + } + } + } + }); + res.on('end', () => resolve(resolvedThread)); + res.on('error', reject); + }); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('chat stream timed out')); + }); + req.write(body); + req.end(); + }); +} + +interface ChatStreamFrame { + type: 'text' | 'tool_call' | 'tool_result' | 'final' | 'error'; + delta?: string; + toolName?: string; + ok?: boolean; + threadId?: string; + turnIndex?: number; + message?: string; +} + +function collect(value: string, prev: string[]): string[] { + return [...prev, value]; +} + +function parseFloatInt(value: string): number { + const n = Number(value); + if (!Number.isInteger(n)) throw new Error(`expected integer, got '${value}'`); + return n; +} diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 9bec976..2a92112 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)'); + .description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)'); // --- create server --- cmd.command('server') @@ -307,6 +307,81 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create agent --- + cmd.command('agent') + .description('Create an Agent (LLM persona pinned to an Llm, optionally attached to a Project)') + .argument('', 'Agent name (lowercase alphanumeric with hyphens)') + .requiredOption('--llm ', 'Pinned Llm (see `mcpctl get llms`)') + .option('--project ', 'Attach to this Project (optional)') + .option('--description ', 'Description (shown in MCP tools/list)') + .option('--system-prompt ', 'System prompt (persona)') + .option('--system-prompt-file ', 'Read system prompt from a file') + .option('--proxy-model ', 'Optional proxyModel name override (informational)') + .option('--default-temperature ', 'Default sampling temperature', parseFloat) + .option('--default-top-p ', 'Default top_p', parseFloat) + .option('--default-top-k ', 'Default top_k', (s: string) => parseInt(s, 10)) + .option('--default-max-tokens ', 'Default max_tokens', (s: string) => parseInt(s, 10)) + .option('--default-seed ', 'Default seed', (s: string) => parseInt(s, 10)) + .option('--default-stop ', 'Default stop sequence (repeat for multiple)', collect, []) + .option('--default-extra ', 'Default provider-specific knob k=v (repeat)', collect, []) + .option('--default-params-file ', 'Read defaultParams from a JSON file') + .option('--force', 'Update if already exists') + .action(async (name: string, opts) => { + const body: Record = { + name, + llm: { name: opts.llm }, + }; + if (opts.project) body.project = { name: opts.project }; + if (opts.description !== undefined) body.description = opts.description; + + let systemPrompt = opts.systemPrompt as string | undefined; + if (systemPrompt === undefined && opts.systemPromptFile !== undefined) { + const fs = await import('node:fs/promises'); + systemPrompt = (await fs.readFile(opts.systemPromptFile as string, 'utf-8')).trim(); + } + if (systemPrompt !== undefined) body.systemPrompt = systemPrompt; + if (opts.proxyModel !== undefined) body.proxyModelName = opts.proxyModel; + + let defaults: Record = {}; + if (opts.defaultParamsFile !== undefined) { + const fs = await import('node:fs/promises'); + defaults = JSON.parse(await fs.readFile(opts.defaultParamsFile as string, 'utf-8')) as Record; + } + if (opts.defaultTemperature !== undefined) defaults.temperature = opts.defaultTemperature; + if (opts.defaultTopP !== undefined) defaults.top_p = opts.defaultTopP; + if (opts.defaultTopK !== undefined) defaults.top_k = opts.defaultTopK; + if (opts.defaultMaxTokens !== undefined) defaults.max_tokens = opts.defaultMaxTokens; + if (opts.defaultSeed !== undefined) defaults.seed = opts.defaultSeed; + if (opts.defaultStop && (opts.defaultStop as string[]).length > 0) { + defaults.stop = (opts.defaultStop as string[]).length === 1 ? (opts.defaultStop as string[])[0] : opts.defaultStop; + } + if (opts.defaultExtra && (opts.defaultExtra as string[]).length > 0) { + const extra: Record = (defaults.extra as Record | undefined) ?? {}; + for (const kv of opts.defaultExtra as string[]) { + const eq = (kv as string).indexOf('='); + if (eq < 1) throw new Error(`--default-extra '${kv}' must be key=value`); + extra[(kv as string).slice(0, eq)] = (kv as string).slice(eq + 1); + } + defaults.extra = extra; + } + if (Object.keys(defaults).length > 0) body.defaultParams = defaults; + + try { + const row = await client.post<{ id: string; name: string }>('/api/v1/agents', body); + log(`agent '${row.name}' created (id: ${row.id})`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/agents')).find((a) => a.name === name); + if (!existing) throw err; + const { name: _n, ...updateBody } = body; + await client.put(`/api/v1/agents/${existing.id}`, updateBody); + log(`agent '${name}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + // --- create secretbackend --- cmd.command('secretbackend') .alias('sb') diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index b79c97b..82fd128 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -143,6 +143,27 @@ const llmColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface AgentRow { + id: string; + name: string; + description: string; + llm: { id: string; name: string }; + project: { id: string; name: string } | null; +} + +const agentColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'LLM', key: (r) => r.llm.name, width: 24 }, + { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 20 }, + { header: 'DESCRIPTION', key: (r) => truncate(r.description, 50) || '-', width: 50 }, + { header: 'ID', key: 'id' }, +]; + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max - 1) + '…'; +} + interface SecretBackendRow { id: string; name: string; @@ -322,6 +343,8 @@ function getColumnsForResource(resource: string): Column return secretBackendColumns as unknown as Column>[]; case 'llms': return llmColumns as unknown as Column>[]; + case 'agents': + return agentColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, @@ -346,6 +369,7 @@ const RESOURCE_KIND: Record = { mcptokens: 'mcptoken', secretbackends: 'secretbackend', llms: 'llm', + agents: 'agent', }; /** diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index af7929e..fcfcafa 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -36,6 +36,10 @@ export const RESOURCE_ALIASES: Record = { sb: 'secretbackends', llm: 'llms', llms: 'llms', + agent: 'agents', + agents: 'agents', + thread: 'threads', + threads: 'threads', all: 'all', }; diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 28fa193..a01ac82 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js'; import { createPatchCommand } from './commands/patch.js'; import { createConsoleCommand } from './commands/console/index.js'; import { createCacheCommand } from './commands/cache.js'; +import { createChatCommand } from './commands/chat.js'; import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; import { ApiClient, ApiError } from './api-client.js'; @@ -216,6 +217,13 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createChatCommand({ + client, + baseUrl, + ...(creds?.token !== undefined ? { token: creds.token } : {}), + log: (...args) => console.log(...args), + })); + program.addCommand(createPatchCommand({ client, log: (...args) => console.log(...args), diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index a4197db..084e68b 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -183,3 +183,51 @@ describe('bash completions', () => { expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/); }); }); + +describe('agent + chat completions', () => { + it('fish lists agents as a resource type', () => { + expect(fishFile).toMatch(/set -l resources [^\n]*\bagents\b/); + }); + + it('fish accepts both `agent` and `agents` aliases', () => { + const aliasLine = fishFile.split('\n').find((l) => l.startsWith(' set -l resource_aliases')); + expect(aliasLine).toMatch(/\bagent\b/); + expect(aliasLine).toMatch(/\bagents\b/); + }); + + it('fish offers `chat` as a top-level command', () => { + expect(fishFile).toMatch(/set -l commands [^\n]*\bchat\b/); + }); + + it('fish offers `agent` under `mcpctl create`', () => { + expect(fishFile).toMatch(/-a agent\b[^\n]*Create an Agent/); + }); + + it('fish wires --llm flag for create agent', () => { + expect(fishFile).toMatch(/__mcpctl_subcmd_active create agent[^\n]*-l llm\b/); + }); + + it('bash lists agents in resources and resource_aliases', () => { + expect(bashFile).toMatch(/local resources="[^"]*\bagents\b[^"]*"/); + expect(bashFile).toMatch(/local resource_aliases="[^"]*\bagent\b[^"]*"/); + }); + + it('bash includes `chat` in the commands list', () => { + expect(bashFile).toMatch(/local commands="[^"]*\bchat\b[^"]*"/); + }); + + it('bash dispatches a `chat)` case that completes with agent names + LiteLLM-style flags', () => { + const chatBlock = bashFile.match(/chat\)[\s\S]*?return ;;/)?.[0] ?? ''; + expect(chatBlock, 'chat must call _mcpctl_resource_names with "agents"').toContain('"agents"'); + expect(chatBlock, 'chat must offer --temperature').toContain('--temperature'); + expect(chatBlock, 'chat must offer --thread').toContain('--thread'); + expect(chatBlock, 'chat must offer --no-stream').toContain('--no-stream'); + }); + + it('bash dispatches `create agent` with the correct flags', () => { + const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? ''; + expect(createBlock).toContain('--llm'); + expect(createBlock).toContain('--system-prompt'); + expect(createBlock).toContain('--default-temperature'); + }); +});