From 582f6f185bd16b4c73c4f24a9ed6d2deb965660b Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 09:34:15 +0100 Subject: [PATCH 01/77] =?UTF-8?q?feat(cli):=20add=20`mcpctl=20config=20pri?= =?UTF-8?q?me-agent`=20=E2=80=94=20proxy=20MCP=20+=20skills=20sync=20for?= =?UTF-8?q?=20prime-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl proxy MCP gateway over HTTP instead of stdio): `mcpctl config prime-agent --project X`: - registers the proxy MCP gateway in ~/.prime/agent/settings.json as mcpServers.X = { type: "http", url: /projects/X/mcp }, merging with any existing servers (e.g. the bundled `sre` project) and preserving all other settings - writes a .mcpctl-project marker so later syncs resolve the project - syncs the project's skills into ~/.prime/agent/skills// as markdown skills (prime-agent auto-discovers them at session start) New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later. - src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers - src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses installSkillAtomic + skills-state; skips Claude-only hooks/postInstall) - completes config.ts/skills.ts wiring; regenerated shell completions - tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts --- README.md | 31 ++ completions/mcpctl.bash | 10 +- completions/mcpctl.fish | 23 +- src/cli/src/commands/config.ts | 94 ++++++ src/cli/src/commands/skills.ts | 24 +- src/cli/src/config/prime-agent.ts | 97 ++++++ src/cli/src/utils/prime-agent-skills.ts | 287 ++++++++++++++++++ src/cli/tests/commands/prime-agent.test.ts | 147 +++++++++ src/cli/tests/completions.test.ts | 2 +- .../tests/utils/prime-agent-skills.test.ts | 124 ++++++++ 10 files changed, 831 insertions(+), 8 deletions(-) create mode 100644 src/cli/src/config/prime-agent.ts create mode 100644 src/cli/src/utils/prime-agent-skills.ts create mode 100644 src/cli/tests/commands/prime-agent.test.ts create mode 100644 src/cli/tests/utils/prime-agent-skills.test.ts diff --git a/README.md b/README.md index e12537e..db6d3ec 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,37 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` +### Connect prime-agent + +Prime-agent (Claude's open-source counterpart) talks to the same proxy MCP +gateway over HTTP rather than stdio. Register a project and sync its skills +into `~/.prime/agent/`: + +```bash +mcpctl config prime-agent --project monitoring +``` + +This: + +1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as + `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` + (merging with any existing servers — the bundled `sre` project is preserved). +2. Writes a `.mcpctl-project` marker so later syncs resolve the project. +3. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills (prime-agent auto-discovers them at session start). + +Re-sync later with: + +```bash +mcpctl skills sync --agent prime-agent --project monitoring +``` + +Preview the change without writing anything: + +```bash +mcpctl config prime-agent --project monitoring --dry-run +``` + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..9c6d22b 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -124,6 +124,12 @@ _mcpctl() { claude-generate) COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; + prime-agent) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; + prime-agent-generate) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; @@ -378,7 +384,7 @@ _mcpctl() { else case "$skills_sub" in sync) - COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --agent --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) ;; *) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ff85934..972cf97 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -239,7 +239,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 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)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,13 +267,15 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration 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 + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -294,6 +296,20 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +# config prime-agent options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print the settings.json change without writing or syncing' + +# config prime-agent-generate options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print the settings.json change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -495,10 +511,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk under ~/.claude/skills/' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default) or prime-agent' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by SessionStart hook)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 44330ee..f3bc0cb 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -12,6 +12,12 @@ import type { ApiClient } from '../api-client.js'; import { writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; import { runSkillsSync } from './skills.js'; +import { + registerPrimeAgentMcp, + primeAgentSettingsPath, + DEFAULT_MCPCTL_GATEWAY_URL, +} from '../config/prime-agent.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; interface McpConfig { mcpServers: Record }>; @@ -193,9 +199,97 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } } + // prime-agent: register our proxy MCP gateway in prime-agent's settings.json + // + sync the project's skills into prime-agent's skills tree. Mirror of the + // claude command above, but targeting ~/.prime/agent/ instead of .mcp.json. + function registerPrimeAgentCommand(name: string, hidden: boolean): void { + const cmd = config + .command(name) + .description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)') + .option('-p, --project ', 'Project name') + .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--skip-skills', 'Skip the skills sync step') + .option('--dry-run', 'Print the settings.json change without writing or syncing') + .action(async (opts: { + project?: string; + output?: string; + gatewayUrl: string; + skipSkills?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + primeAgent: { + settingsPath, + mcpServers: { + [opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` }, + }, + }, + action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + }, null, 2); + log(dry); + return; + } + + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // Write the project marker in cwd so later `skills sync` calls resolve scope. + try { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // Sync skills into prime-agent's skills tree (skippable). + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runPrimeAgentSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } + } catch (err: unknown) { + log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); + } + } + }); + if (hidden) { + void cmd; + } + } + registerClaudeCommand('claude', false); registerClaudeCommand('claude-generate', true); // backward compat + registerPrimeAgentCommand('prime-agent', false); + registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + config.addCommand(createConfigSetupCommand({ configDeps })); if (apiDeps) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 627799d..edc4dbb 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -31,6 +31,7 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -441,11 +442,12 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { console.error(...(args as Parameters)); }; - const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .description('Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--agent ', 'Sync target: claude (default) or prime-agent', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)') @@ -453,12 +455,30 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') .action(async (opts: { project?: string; + agent?: string; dryRun?: boolean; force?: boolean; quiet?: boolean; skipPostinstall?: boolean; keepOrphans?: boolean; }) => { + if (opts.agent === 'prime-agent') { + const result = await runPrimeAgentSkillsSync( + { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), + ...(opts.force !== undefined ? { force: opts.force } : {}), + ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), + ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + }, + { client, log, warn }, + ); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } + return; + } + const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts new file mode 100644 index 0000000..a4bff7f --- /dev/null +++ b/src/cli/src/config/prime-agent.ts @@ -0,0 +1,97 @@ +/** + * Read/merge/write helpers for prime-agent's own configuration files, used + * by `mcpctl config prime-agent`. + * + * prime-agent keeps two user-editable files under `~/.prime/agent/`: + * - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus + * model/provider preferences. `mcpctl config prime-agent` registers our + * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. + * - `auth.json` — per-server bearer tokens keyed as `mcp:`. + * + * We only ever merge the `mcpServers` map, preserving every other key and any + * servers the user has already configured (including non-mcpctl gateways like + * the bundled `sre` project). + */ +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** Base URL of the deployed mcpctl HTTP MCP gateway. */ +export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; + +/** Resolve the prime-agent settings.json path. */ +export function primeAgentSettingsPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'settings.json'); +} + +/** Proxy MCP URL for a given project on the gateway. */ +export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { + const base = gatewayUrl.replace(/\/+$/, ''); + return `${base}/projects/${encodeURIComponent(project)}/mcp`; +} + +interface PrimeAgentSettings { + mcpServers?: Record; + [key: string]: unknown; +} + +/** Load prime-agent settings; return an empty object if absent/invalid. */ +export async function loadPrimeAgentSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as PrimeAgentSettings; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export interface RegisterMcpResult { + settingsPath: string; + created: boolean; // true if the settings file did not previously exist + addedServer: string; + newServer: boolean; // true if the project's MCP entry was not already present + url: string; + totalServers: number; +} + +/** + * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the + * prime-agent settings file, preserving all other fields and servers. + * Returns a summary of what changed. + */ +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project); + settings.mcpServers[project] = { type: 'http', url }; + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { + settingsPath, + created: !existed, + addedServer: project, + newServer: isNewServer, + url, + totalServers, + }; +} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts new file mode 100644 index 0000000..9663bb4 --- /dev/null +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -0,0 +1,287 @@ +/** + * Prime-agent skill sync for `mcpctl config prime-agent`. + * + * Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`) + * but materialises server-side skills as *markdown* skills for prime-agent + * under `~/.prime/agent/skills//`. + * + * Why a separate module instead of parameterising `runSkillsSync`: + * - prime-agent skills carry no `hooks` (there is no SessionStart hook on + * the prime-agent side) and must never touch `~/.claude/settings.json`, + * so the hooks side-effect in `runSkillsSync` would be wrong here. + * - prime-agent skills have no `postInstall` scripts (server scripts assume + * a Claude-esque shell), so we skip that machinery too. + * + * The on-disk format is deliberately the same as what prime-agent already + * ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary + * `files`). prime-agent auto-discovers these at session start, so once the + * config command has pointed prime-agent at the proxy MCP and synced the + * project's skills, later `mcpctl skills sync --agent prime-agent` calls (or + * the config command itself) keep the tree up to date. + */ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { ApiClient } from '../api-client.js'; +import { ApiError } from '../api-client.js'; +import { findProjectMarker } from './project-marker.js'; +import { + loadState, + saveState, + detectModifiedFiles, + type SkillState, +} from './skills-state.js'; +import { + installSkillAtomic, + removeSkillAtomic, +} from './skills-disk.js'; + +/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ +export function primeAgentSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'skills'); +} + +/** State bookkeeping lives separately from the Claude skills state. */ +export function primeAgentStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); +} + +/** Shape of a server-side visible skill (subset we act on). */ +interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; +} + +/** Full skill body fetched from /api/v1/skills/:id (subset we install). */ +interface FullSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + content: string; + files: Record; +} + +export interface PrimeAgentSyncOpts { + /** Project name; otherwise resolved from the .mcpctl-project marker. */ + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + keepOrphans?: boolean; + /** For tests: override cwd for the marker walk-up. */ + cwd?: string; + /** For tests: override the prime-agent skills root. */ + installRoot?: string; + /** For tests: override the state file path. */ + statePath?: string; + /** For tests: override $HOME used for default paths. */ + homeDir?: string; +} + +export interface PrimeAgentSyncResult { + installed: string[]; + updated: string[]; + skipped: string[]; + removed: string[]; + preserved: string[]; + errors: Array<{ skill: string; error: string }>; + exitCode: 0 | 1 | 2; +} + +export interface PrimeAgentSyncDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +/** + * Sync the active project's skills into prime-agent's markdown skills tree. + * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 + * disk/state error. + */ +export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise { + const { client, log, warn } = deps; + const result: PrimeAgentSyncResult = { + installed: [], + updated: [], + skipped: [], + removed: [], + preserved: [], + errors: [], + exitCode: 0, + }; + + // 1. Resolve project scope (explicit flag beats the marker walk-up). + let projectName = opts.project; + if (projectName === undefined || projectName === '') { + const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir()); + if (marker) projectName = marker.project; + } + + // 2. Fetch the visible skill list. + let visible: VisibleSkill[]; + try { + if (projectName !== undefined) { + visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); + } else { + visible = await client.get('/api/v1/skills?scope=global'); + } + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { + warn('mcpctl: auth failed — run `mcpctl login`'); + result.exitCode = 1; + return result; + } + if (opts.quiet === true) { + // Fail-open in quiet mode so a hung mcpd never blocks agent startup. + warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 0; + return result; + } + throw err; + } + + // Agent-scoped skills aren't surfaced to a user's prime-agent session. + visible = visible.filter((s) => s.scope !== 'agent'); + + // 3. Load state + resolve install root. + const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir()); + const state = await loadState(statePath); + const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir()); + + // 4. Diff against last sync. + const visibleByName = new Map(visible.map((s) => [s.name, s])); + const stateNames = Object.keys(state.skills); + + const toFetch: VisibleSkill[] = []; + for (const v of visible) { + const prior = state.skills[v.name]; + if (!prior) { + toFetch.push(v); + continue; + } + if (prior.contentHash === v.contentHash) { + result.skipped.push(v.name); + continue; + } + toFetch.push(v); + } + + // 5. Apply install/update (concurrency limit 5). + const concurrency = 5; + for (let i = 0; i < toFetch.length; i += concurrency) { + const batch = toFetch.slice(i, i + concurrency); + await Promise.all(batch.map((v) => applyOne(v))); + } + + // 6. Orphan removal. + if (opts.keepOrphans !== true) { + for (const name of stateNames) { + if (visibleByName.has(name)) continue; + const prior = state.skills[name]; + if (!prior) continue; + try { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0 && opts.force !== true) { + warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); + result.preserved.push(name); + continue; + } + if (opts.dryRun === true) { + result.removed.push(name); + continue; + } + await removeSkillAtomic(prior.installDir); + delete state.skills[name]; + result.removed.push(name); + } catch (err: unknown) { + result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); + } + } + } + + // 7. Persist state. + state.lastSync = new Date().toISOString(); + if (projectName !== undefined) state.lastSyncProject = projectName; + if (opts.dryRun !== true) { + try { + await saveState(state, statePath); + } catch (err: unknown) { + warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 2; + } + } + + // 8. Summary. + const anythingHappened = + result.errors.length > 0 || + result.installed.length > 0 || + result.updated.length > 0 || + result.removed.length > 0; + if (opts.quiet !== true || anythingHappened) { + const parts: string[] = []; + if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); + if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); + if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); + if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); + if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); + if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); + if (parts.length === 0) parts.push('no changes'); + if (opts.quiet !== true) { + log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); + } else { + warn(`mcpctl: ${parts.join(', ')}`); + } + } + + return result; + + async function applyOne(v: VisibleSkill): Promise { + try { + const prior = state.skills[v.name]; + const targetDir = prior?.installDir ?? join(installRoot, v.name); + if (prior !== undefined && opts.force !== true) { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0) { + warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); + result.preserved.push(v.name); + return; + } + } + if (opts.dryRun === true) { + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + return; + } + + const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); + const files = await installSkillAtomic(targetDir, { + content: full.content, + ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), + }); + + const newState: SkillState = { + id: v.id, + semver: v.semver, + contentHash: v.contentHash, + scope: v.scope, + installDir: targetDir, + files, + postInstallHash: null, + lastSyncedAt: new Date().toISOString(), + }; + state.skills[v.name] = newState; + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + } catch (err: unknown) { + result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); + } + } +} diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts new file mode 100644 index 0000000..947b87e --- /dev/null +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; +import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config prime-agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + let prevCwd: string; + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-')); + // config prime-agent writes the .mcpctl-project marker into cwd, so run + // every test from an isolated temp dir to avoid polluting the repo. + prevCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + process.exitCode = 0; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('requires --project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(process.exitCode).toBe(1); + }); + + it('writes proxy MCP entry into prime-agent settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['homeautomation']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + }); + expect(output.join('\n')).toContain('homeautomation'); + }); + + it('merges with existing servers and preserves other settings', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + defaultProvider: 'itaz', + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, + }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.defaultProvider).toBe('itaz'); // untouched + expect(written.mcpServers['sre']).toBeDefined(); // preserved + expect(written.mcpServers['proj-1']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + }); + }); + + it('writes a project marker for later skills sync', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const markerPath = join(tmpDir, '.mcpctl-project'); + expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre'); + }); + + it('--dry-run prints the change without writing', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' }); + + expect(output.join('\n')).toContain('proj-2'); + // No file should have been created. + expect(exceptionSafeRead(settingsPath)).toBeNull(); + }); + + it('does not call the API when --skip-skills is set', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + expect(client.get).not.toHaveBeenCalled(); + }); + + it('backward compat: prime-agent-generate still works', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['proj-1']).toBeDefined(); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index 2e303ab..cfbfbf6 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -234,7 +234,7 @@ describe('agent + chat completions', () => { }); it('bash dispatches `create agent` with the correct flags', () => { - const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? ''; + const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? ''; expect(createBlock).toContain('--llm'); expect(createBlock).toContain('--system-prompt'); expect(createBlock).toContain('--default-temperature'); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts new file mode 100644 index 0000000..d696724 --- /dev/null +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; +import { loadState } from '../../src/utils/skills-state.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(overrides: Record = {}): ApiClient { + return { + get: vi.fn(async (url: string) => { + if (url.includes('/skills/visible')) { + return overrides['visible'] ?? []; + } + if (url.startsWith('/api/v1/skills/')) { + const id = url.split('/').pop() as string; + const full = (overrides['full'] as Record)?.[id]; + if (!full) throw new Error(`no full skill for ${id}`); + return full; + } + if (url.endsWith('/skills?scope=global')) { + return overrides['visible'] ?? []; + } + throw new Error(`unexpected get: ${url}`); + }), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +const SKILL_MD = `--- +name: sample-skill +description: A test skill synced into prime-agent. +--- + +# Sample Skill + +Body text. +`; + +describe('runPrimeAgentSkillsSync', () => { + let tmpDir: string; + let installRoot: string; + let statePath: string; + const log = (..._a: unknown[]) => {}; + const warn = (..._a: unknown[]) => {}; + + function deps(client: ApiClient) { + return { client, log, warn }; + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-pa-sync-')); + installRoot = join(tmpDir, 'skills'); + statePath = join(tmpDir, 'skills-state.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs a new markdown skill into the prime-agent skills root', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync( + { project: 'proj', installRoot, statePath }, + deps(client), + ); + + expect(result.installed).toEqual(['sample-skill']); + expect(result.errors).toEqual([]); + + const skillDir = join(installRoot, 'sample-skill'); + expect(existsSync(skillDir)).toBe(true); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD); + + // State persisted so a re-sync is a no-op. + const state = await loadState(statePath); + expect(state.skills['sample-skill'].contentHash).toBe('sha256:h1'); + }); + + it('skips unchanged skills on re-sync', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.skipped).toEqual(['sample-skill']); + expect(result.installed).toEqual([]); + }); + + it('syncs the global set when no project is provided', async () => { + const visible = [ + { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', metadata: {}, scope: 'global' }, + ]; + const full = { + 'skill-2': { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', content: '# global\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + // Isolate cwd so no stray .mcpctl-project marker is discovered. + const empty = join(tmpDir, 'empty'); + mkdirSync(empty, { recursive: true }); + + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client)); + + expect(result.installed).toEqual(['global-skill']); + const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); + expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); + }); +}); From eb1642ab1a494e2ad615c5ca5dfb55d32ab232f4 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:22:46 +0100 Subject: [PATCH 02/77] fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation). --- README.md | 24 +- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 10 +- src/cli/src/commands/config.ts | 97 +++++- src/cli/src/commands/skills.ts | 122 +++++--- src/cli/src/config/prime-agent-extension.ts | 10 + src/cli/src/config/prime-agent.ts | 138 ++++++--- src/cli/src/utils/prime-agent-skills.ts | 293 ++---------------- src/cli/src/utils/skills-state.ts | 7 + src/cli/tests/commands/prime-agent.test.ts | 127 +++++++- src/cli/tests/commands/skills.test.ts | 56 ++++ .../tests/utils/prime-agent-skills.test.ts | 64 +++- 12 files changed, 584 insertions(+), 368 deletions(-) create mode 100644 src/cli/src/config/prime-agent-extension.ts create mode 100644 src/cli/tests/commands/skills.test.ts diff --git a/README.md b/README.md index db6d3ec..8d781b9 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,18 @@ This: 1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` - (merging with any existing servers — the bundled `sre` project is preserved). -2. Writes a `.mcpctl-project` marker so later syncs resolve the project. -3. Syncs the project's skills into `~/.prime/agent/skills//` as markdown - skills (prime-agent auto-discovers them at session start). + (merging with any existing servers and preserving all other settings). +2. Provisions the project's bearer credential in `~/.prime/agent/auth.json` + (`mcp:monitoring`) — either from `--token `, an existing entry, or an + auto-minted project token. +3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never + from `$HOME`) so later syncs resolve the project. +4. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills. The shared tree is ownership-tracked per project: it never deletes + another project's skills or an untracked hand-authored skill. +5. Installs a `/mcpctl` project-switcher extension into + `~/.prime/agent/extensions/` so you can switch mcpctl projects from inside + the prime-agent UI (skip with `--skip-extension`). Re-sync later with: @@ -138,6 +146,14 @@ Re-sync later with: mcpctl skills sync --agent prime-agent --project monitoring ``` +Skip individual steps as needed: + +```bash +mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills +mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher +``` + Preview the change without writing anything: ```bash diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 9c6d22b..0bbaf18 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -125,10 +125,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; prime-agent) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) ;; prime-agent-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 972cf97..31867cb 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -274,7 +274,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration 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 + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' -complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -300,15 +300,19 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' -complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print the settings.json change without writing or syncing' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' -complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print the settings.json change without writing or syncing' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index f3bc0cb..586d888 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; @@ -9,14 +9,17 @@ import { saveCredentials, loadCredentials } from '../auth/index.js'; import { createConfigSetupCommand } from './config-setup.js'; import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; -import { writeProjectMarker } from '../utils/project-marker.js'; +import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; import { runSkillsSync } from './skills.js'; import { registerPrimeAgentMcp, primeAgentSettingsPath, DEFAULT_MCPCTL_GATEWAY_URL, + writePrimeAgentAuth, + hasPrimeAgentAuth, } from '../config/prime-agent.js'; +import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; interface McpConfig { @@ -205,17 +208,21 @@ export function createConfigCommand(deps?: Partial, apiDeps?: function registerPrimeAgentCommand(name: string, hidden: boolean): void { const cmd = config .command(name) - .description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)') + .description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)') .option('-p, --project ', 'Project name') .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to store in auth.json (skips auto-minting)') .option('--skip-skills', 'Skip the skills sync step') - .option('--dry-run', 'Print the settings.json change without writing or syncing') + .option('--skip-extension', 'Do not install the /mcpctl project-switcher extension') + .option('--dry-run', 'Print what would change without writing or syncing') .action(async (opts: { project?: string; output?: string; gatewayUrl: string; + token?: string; skipSkills?: boolean; + skipExtension?: boolean; dryRun?: boolean; }) => { if (opts.project === undefined || opts.project === '') { @@ -225,21 +232,27 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + const agentDir = dirname(settingsPath); + const authPath = join(agentDir, 'auth.json'); + const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`; if (opts.dryRun === true) { const dry = JSON.stringify({ primeAgent: { settingsPath, - mcpServers: { - [opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` }, - }, + authPath, + mcpServers: { [opts.project]: { type: 'http', url } }, + extension: opts.skipExtension === true ? '' : extPath, }, - action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', }, null, 2); log(dry); return; } + // 1. Register the proxy MCP gateway (merge; never destroy settings). try { const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); log(reg.created @@ -251,15 +264,55 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - // Write the project marker in cwd so later `skills sync` calls resolve scope. + // 2. Provision the bearer credential prime-agent needs for this project. + // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must + // store an mcp: token in auth.json. Use --token if given, + // keep an existing one, otherwise mint it via the API. try { - const markerPath = await writeProjectMarker(process.cwd(), opts.project); - log(`Wrote ${markerPath}`); + if (opts.token !== undefined && opts.token !== '') { + await writePrimeAgentAuth(opts.project, opts.token, authPath); + log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + } else if (await hasPrimeAgentAuth(opts.project, authPath)) { + log(`Bearer credential for '${opts.project}' already present in ${authPath}`); + } else if (skillsClient) { + const tokenName = `prime-agent-${Date.now()}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: tokenName, + projectName: opts.project, + ttl: 'never', + description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, + }); + if (typeof minted?.token === 'string' && minted.token.length > 0) { + await writePrimeAgentAuth(opts.project, minted.token, authPath); + log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + } else { + log(`Warning: no token returned minting for '${opts.project}'; pass --token to supply one`); + } + } else { + log('Warning: no API client available to mint a project token — pass --token to provision auth.json'); + } + } catch (err: unknown) { + log(`Warning: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + + // 3. Write the .mcpctl-project marker so later `skills sync` calls can + // resolve the project. Never clobber an existing marker found by + // walk-up, and never scope $HOME itself. + try { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null) { + log(`Project already scoped by existing marker ${existing.markerPath} ('${existing.project}'); not overwriting`); + } else if (process.cwd() !== homedir()) { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(`Wrote ${markerPath}`); + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } } catch (err: unknown) { log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); } - // Sync skills into prime-agent's skills tree (skippable). + // 4. Sync skills into prime-agent's skills tree (skippable). if (opts.skipSkills !== true) { if (skillsClient) { try { @@ -268,16 +321,32 @@ export function createConfigCommand(deps?: Partial, apiDeps?: { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, ); const total = result.installed.length + result.updated.length + result.removed.length; - if (total > 0) { - log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + if (total > 0 || result.errors.length > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); + } + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + log(`Warning: prime-agent skills sync exited with code ${String(result.exitCode)}`); } } catch (err: unknown) { log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; } } else { log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); } } + + // 5. Install the /mcpctl project-switcher extension (skippable). + if (opts.skipExtension !== true) { + try { + mkdirSync(dirname(extPath), { recursive: true }); + writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8'); + log(`Installed /mcpctl switcher extension: ${extPath}`); + } catch (err: unknown) { + log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { void cmd; diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index edc4dbb..48afdf1 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -10,6 +10,7 @@ import { detectModifiedFiles, type SkillState, defaultStatePath, + pathExists, } from '../utils/skills-state.js'; import { installSkillAtomic, @@ -31,7 +32,6 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; -import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -88,10 +88,22 @@ export interface SyncOpts { keepOrphans?: boolean; /** For tests: override cwd start for the marker walk-up. */ cwd?: string; - /** For tests: override skills install root (default: ~/.claude/skills). */ + /** For tests: override skills install root (default depends on target). */ installRoot?: string; - /** For tests: override state file path. */ + /** For tests: override state file path (default depends on target). */ statePath?: string; + /** Override $HOME used for default paths (tests). */ + homeDir?: string; + /** + * Which agent's skill tree to sync into: + * 'claude' (default) — ~/.claude/skills, with hooks + postInstall. + * 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall, + * shared flat tree with per-project ownership so + * configuring a second project never deletes the + * first project's skills, and pre-existing + * (untracked) skill dirs are preserved. + */ + target?: 'claude' | 'prime-agent'; } export interface SyncResult { @@ -121,6 +133,8 @@ export interface SyncDeps { */ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { const { client, log, warn } = deps; + const target = opts.target ?? 'claude'; + const homeDir = opts.homeDir ?? homedir(); const result: SyncResult = { installed: [], updated: [], @@ -174,10 +188,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise s.scope !== 'agent'); - // 3. Load state. - const statePath = opts.statePath ?? defaultStatePath(); + // 3. Load state. Defaults depend on the sync target: Claude Code gets + // ~/.claude/skills + the shared state file; prime-agent gets its own + // tree + separate state file so the two never collide. + const isPrimeAgent = target === 'prime-agent'; + const statePath = opts.statePath ?? (isPrimeAgent + ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') + : defaultStatePath()); const state = await loadState(statePath); - const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + const installRoot = opts.installRoot ?? (isPrimeAgent + ? join(homeDir, '.prime', 'agent', 'skills') + : join(homeDir, '.claude', 'skills')); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -206,12 +227,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise applyOne(v))); } - // 6. Orphan removal: skills in state but not in server's visible set. + // 6. Orphan removal: skills in state but not in the server's visible set. if (!opts.keepOrphans) { for (const name of stateNames) { if (visibleByName.has(name)) continue; const prior = state.skills[name]; if (!prior) continue; + // prime-agent shares one flat skill tree across projects while + // settings.json accumulates one MCP server per project. Never delete a + // skill that belongs to a *different* project (or the user would lose + // their first project just by configuring a second one). Only remove + // skills this project (or globals) previously installed and that have + // since left the visible set. + if (isPrimeAgent && prior.project !== projectName) continue; try { // Preserve user-modified skills — warn + skip. const modified = await detectModifiedFiles(prior.installDir, prior.files); @@ -225,8 +253,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise so each skill's hooks - // can be cleanly added/updated/removed without trampling other - // skills or user-added hooks. No-op when the field is absent or - // empty. const meta = (full.metadata ?? {}) as SyncedSkillMetadata; - if (meta.hooks && typeof meta.hooks === 'object') { - try { - const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); - if (hookRes.updated) result.hooksApplied.push(v.name); - } catch (err: unknown) { - warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + + // ── hooks (Claude only) ── + // prime-agent has no SessionStart-hook equivalent and must never touch + // ~/.claude/settings.json. Tagged with _mcpctl_source: so + // each skill's hooks can be cleanly added/updated/removed without + // trampling other skills or user-added hooks. No-op when absent. + if (!isPrimeAgent) { + if (meta.hooks && typeof meta.hooks === 'object') { + try { + const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); + if (hookRes.updated) result.hooksApplied.push(v.name); + } catch (err: unknown) { + warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + } + } else if (prior !== undefined) { + // Skill no longer declares hooks but used to — clean up. + try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } - } else if (prior !== undefined) { - // Skill no longer declares hooks but used to — clean up. - try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } // ── mcpServers: auto-attach declared deps to the active project ── @@ -352,7 +400,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 @@ -419,6 +470,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { - if (opts.agent === 'prime-agent') { - const result = await runPrimeAgentSkillsSync( - { - ...(opts.project !== undefined ? { project: opts.project } : {}), - ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), - ...(opts.force !== undefined ? { force: opts.force } : {}), - ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), - ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - }, - { client, log, warn }, - ); - if (result.exitCode !== 0) { - process.exitCode = result.exitCode; - } + // Validate --agent so an unknown value fails loudly instead of silently + // running the default (Claude) sync. + const agent = opts.agent ?? 'claude'; + if (agent !== 'claude' && agent !== 'prime-agent') { + warn(`mcpctl: unknown sync target '${agent}' (expected 'claude' or 'prime-agent')`); + process.exitCode = 1; return; } - const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), @@ -487,6 +530,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + target: agent as 'claude' | 'prime-agent', }, { client, log, warn }, ); diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts new file mode 100644 index 0000000..6dd48c8 --- /dev/null +++ b/src/cli/src/config/prime-agent-extension.ts @@ -0,0 +1,10 @@ +/** + * The source of the `/mcpctl` project-switcher extension, exported as a string + * so `mcpctl config prime-agent` can install it into prime-agent's auto- + * discovered extensions directory (`~/.prime/agent/extensions/`). + * + * The installed file is this exact source (verbatim), so the extension shipped + * by the CLI is always the one that runs. + */ +export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record };\n if (!settings.mcpServers) return null;\n for (const name of Object.keys(settings.mcpServers)) {\n const url = settings.mcpServers[name]?.url ?? '';\n const m = url.match(/\\/projects\\/([^/]+)\\/mcp$/);\n if (m && m[1] === name) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index a4bff7f..5632323 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -8,9 +8,14 @@ * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. * - `auth.json` — per-server bearer tokens keyed as `mcp:`. * - * We only ever merge the `mcpServers` map, preserving every other key and any - * servers the user has already configured (including non-mcpctl gateways like - * the bundled `sre` project). + * Safety invariants: + * - We only ever *merge* the `mcpServers` map, preserving every other key + * and any servers the user already configured. + * - If `settings.json` exists but is corrupt, we fail loudly instead of + * swallowing the parse error and rewriting (which would destroy every + * non-mcpServers setting). Untouched corrupt files are never overwritten. + * - A project's existing `mcpServers` entry is merged (user-added fields are + * kept), never replaced wholesale. */ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { join, dirname } from 'node:path'; @@ -24,25 +29,46 @@ export function primeAgentSettingsPath(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'settings.json'); } +/** Resolve the prime-agent auth.json path. */ +export function primeAgentAuthPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'auth.json'); +} + +/** Resolve the prime-agent extensions directory (auto-discovered by the app). */ +export function primeAgentExtensionsDir(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'extensions'); +} + /** Proxy MCP URL for a given project on the gateway. */ export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { const base = gatewayUrl.replace(/\/+$/, ''); return `${base}/projects/${encodeURIComponent(project)}/mcp`; } -interface PrimeAgentSettings { - mcpServers?: Record; +export interface PrimeAgentSettings { + mcpServers?: Record>; [key: string]: unknown; } -/** Load prime-agent settings; return an empty object if absent/invalid. */ +/** + * Load prime-agent settings. + * - Missing file → returns `{}` (a brand-new file about to be created). + * - Unreadable/corrupt → throws, so the caller refuses to overwrite it. + */ export async function loadPrimeAgentSettings(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; try { - const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as PrimeAgentSettings; return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; + } catch (err: unknown) { + throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); } } @@ -57,9 +83,72 @@ export interface RegisterMcpResult { /** * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the - * prime-agent settings file, preserving all other fields and servers. - * Returns a summary of what changed. + * prime-agent settings file. Preserves all other fields and servers, and + * merges into an existing `mcpServers[project]` entry (keeping any user-added + * keys like `headers`) rather than replacing it wholesale. */ +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + if (settings.mcpServers !== undefined && (typeof settings.mcpServers !== 'object' || settings.mcpServers === null)) { + throw new Error(`invalid mcpServers block in ${settingsPath} — refusing to overwrite it`); + } + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const existing = settings.mcpServers[project]; + const newServer = existing === undefined; + // Merge: keep any user-added fields on the project's entry (e.g. headers). + settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url }; + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers }; +} + +/** + * Ensure `mcp:` carries `{ type: "api_key", key }` in + * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` + * provider credential, other `mcp:*` servers, etc). + */ +export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { + const current = await loadPrimeAgentAuth(authPath); + current[`mcp:${project}`] = { type: 'api_key', key }; + await mkdir(dirname(authPath), { recursive: true }); + // auth.json is 0600 normally; preserve an existing mode if present. + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', 'utf-8'); +} + +/** Load auth.json; missing/corrupt (non-JSON) treated as a fresh file. */ +async function loadPrimeAgentAuth(path: string): Promise> { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + const parsed = JSON.parse(raw) as Record; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** Does the project already have a credential in auth.json? */ +export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { + try { + const raw = await readFile(authPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + const entry = parsed?.[`mcp:${project}`]; + return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); + } catch { + return false; + } +} + async function pathExists(p: string): Promise { try { await stat(p); @@ -68,30 +157,3 @@ async function pathExists(p: string): Promise { return false; } } - -export async function registerPrimeAgentMcp( - project: string, - settingsPath: string, - gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, -): Promise { - const existed = await pathExists(settingsPath); - const settings = await loadPrimeAgentSettings(settingsPath); - - settings.mcpServers = settings.mcpServers ?? {}; - const url = projectMcpUrl(project, gatewayUrl); - const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project); - settings.mcpServers[project] = { type: 'http', url }; - const totalServers = Object.keys(settings.mcpServers).length; - - await mkdir(dirname(settingsPath), { recursive: true }); - await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - - return { - settingsPath, - created: !existed, - addedServer: project, - newServer: isNewServer, - url, - totalServers, - }; -} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts index 9663bb4..4ff7cc0 100644 --- a/src/cli/src/utils/prime-agent-skills.ts +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -1,287 +1,54 @@ /** - * Prime-agent skill sync for `mcpctl config prime-agent`. + * Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`. * - * Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`) - * but materialises server-side skills as *markdown* skills for prime-agent - * under `~/.prime/agent/skills//`. + * This is a thin convenience wrapper around the shared [`runSkillsSync`] + * implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`. + * All diffing, atomic install, preservation and orphan logic lives there; this + * module only: + * - resolves the prime-agent install root and state file paths, and + * - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified + * sync so callers and tests keep a stable, intent-revealing name. * - * Why a separate module instead of parameterising `runSkillsSync`: - * - prime-agent skills carry no `hooks` (there is no SessionStart hook on - * the prime-agent side) and must never touch `~/.claude/settings.json`, - * so the hooks side-effect in `runSkillsSync` would be wrong here. - * - prime-agent skills have no `postInstall` scripts (server scripts assume - * a Claude-esque shell), so we skip that machinery too. - * - * The on-disk format is deliberately the same as what prime-agent already - * ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary - * `files`). prime-agent auto-discovers these at session start, so once the - * config command has pointed prime-agent at the proxy MCP and synced the - * project's skills, later `mcpctl skills sync --agent prime-agent` calls (or - * the config command itself) keep the tree up to date. + * Target-specific behaviour (handled by the shared implementation): + * - installs markdown skills under `~/.prime/agent/skills//` + * - never touches `~/.claude/settings.json` (no hooks / postInstall) + * - still auto-attaches skill-declared `mcpServers` deps to the project + * - records per-project ownership and preserves untracked / cross-project + * skill dirs so a shared, hand-editable tree is never silently wiped */ import { join } from 'node:path'; import { homedir } from 'node:os'; -import type { ApiClient } from '../api-client.js'; -import { ApiError } from '../api-client.js'; -import { findProjectMarker } from './project-marker.js'; -import { - loadState, - saveState, - detectModifiedFiles, - type SkillState, -} from './skills-state.js'; -import { - installSkillAtomic, - removeSkillAtomic, -} from './skills-disk.js'; +import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js'; /** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ export function primeAgentSkillsRoot(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'skills'); } -/** State bookkeeping lives separately from the Claude skills state. */ +/** prime-agent keeps its own state file so it never collides with Claude's. */ export function primeAgentStatePath(homeDir: string = homedir()): string { return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); } -/** Shape of a server-side visible skill (subset we act on). */ -interface VisibleSkill { - id: string; - name: string; - description: string; - semver: string; - contentHash: string; - metadata: unknown; - scope: 'project' | 'global' | 'agent'; -} - -/** Full skill body fetched from /api/v1/skills/:id (subset we install). */ -interface FullSkill { - id: string; - name: string; - description: string; - semver: string; - contentHash: string; - content: string; - files: Record; -} - -export interface PrimeAgentSyncOpts { - /** Project name; otherwise resolved from the .mcpctl-project marker. */ - project?: string; - dryRun?: boolean; - force?: boolean; - quiet?: boolean; - keepOrphans?: boolean; - /** For tests: override cwd for the marker walk-up. */ - cwd?: string; - /** For tests: override the prime-agent skills root. */ - installRoot?: string; - /** For tests: override the state file path. */ - statePath?: string; - /** For tests: override $HOME used for default paths. */ - homeDir?: string; -} - -export interface PrimeAgentSyncResult { - installed: string[]; - updated: string[]; - skipped: string[]; - removed: string[]; - preserved: string[]; - errors: Array<{ skill: string; error: string }>; - exitCode: 0 | 1 | 2; -} - -export interface PrimeAgentSyncDeps { - client: ApiClient; - log: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; -} +export type PrimeAgentSyncOpts = Pick< + SyncOpts, + 'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir' +>; +export type PrimeAgentSyncResult = SyncResult; +export type PrimeAgentSyncDeps = SyncDeps; /** * Sync the active project's skills into prime-agent's markdown skills tree. * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 * disk/state error. */ -export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise { - const { client, log, warn } = deps; - const result: PrimeAgentSyncResult = { - installed: [], - updated: [], - skipped: [], - removed: [], - preserved: [], - errors: [], - exitCode: 0, - }; - - // 1. Resolve project scope (explicit flag beats the marker walk-up). - let projectName = opts.project; - if (projectName === undefined || projectName === '') { - const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir()); - if (marker) projectName = marker.project; - } - - // 2. Fetch the visible skill list. - let visible: VisibleSkill[]; - try { - if (projectName !== undefined) { - visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); - } else { - visible = await client.get('/api/v1/skills?scope=global'); - } - } catch (err: unknown) { - if (err instanceof ApiError && err.status === 401) { - warn('mcpctl: auth failed — run `mcpctl login`'); - result.exitCode = 1; - return result; - } - if (opts.quiet === true) { - // Fail-open in quiet mode so a hung mcpd never blocks agent startup. - warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); - result.exitCode = 0; - return result; - } - throw err; - } - - // Agent-scoped skills aren't surfaced to a user's prime-agent session. - visible = visible.filter((s) => s.scope !== 'agent'); - - // 3. Load state + resolve install root. - const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir()); - const state = await loadState(statePath); - const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir()); - - // 4. Diff against last sync. - const visibleByName = new Map(visible.map((s) => [s.name, s])); - const stateNames = Object.keys(state.skills); - - const toFetch: VisibleSkill[] = []; - for (const v of visible) { - const prior = state.skills[v.name]; - if (!prior) { - toFetch.push(v); - continue; - } - if (prior.contentHash === v.contentHash) { - result.skipped.push(v.name); - continue; - } - toFetch.push(v); - } - - // 5. Apply install/update (concurrency limit 5). - const concurrency = 5; - for (let i = 0; i < toFetch.length; i += concurrency) { - const batch = toFetch.slice(i, i + concurrency); - await Promise.all(batch.map((v) => applyOne(v))); - } - - // 6. Orphan removal. - if (opts.keepOrphans !== true) { - for (const name of stateNames) { - if (visibleByName.has(name)) continue; - const prior = state.skills[name]; - if (!prior) continue; - try { - const modified = await detectModifiedFiles(prior.installDir, prior.files); - if (modified.length > 0 && opts.force !== true) { - warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); - result.preserved.push(name); - continue; - } - if (opts.dryRun === true) { - result.removed.push(name); - continue; - } - await removeSkillAtomic(prior.installDir); - delete state.skills[name]; - result.removed.push(name); - } catch (err: unknown) { - result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); - } - } - } - - // 7. Persist state. - state.lastSync = new Date().toISOString(); - if (projectName !== undefined) state.lastSyncProject = projectName; - if (opts.dryRun !== true) { - try { - await saveState(state, statePath); - } catch (err: unknown) { - warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`); - result.exitCode = 2; - } - } - - // 8. Summary. - const anythingHappened = - result.errors.length > 0 || - result.installed.length > 0 || - result.updated.length > 0 || - result.removed.length > 0; - if (opts.quiet !== true || anythingHappened) { - const parts: string[] = []; - if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); - if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); - if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); - if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); - if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); - if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); - if (parts.length === 0) parts.push('no changes'); - if (opts.quiet !== true) { - log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); - } else { - warn(`mcpctl: ${parts.join(', ')}`); - } - } - - return result; - - async function applyOne(v: VisibleSkill): Promise { - try { - const prior = state.skills[v.name]; - const targetDir = prior?.installDir ?? join(installRoot, v.name); - if (prior !== undefined && opts.force !== true) { - const modified = await detectModifiedFiles(prior.installDir, prior.files); - if (modified.length > 0) { - warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); - result.preserved.push(v.name); - return; - } - } - if (opts.dryRun === true) { - if (prior) result.updated.push(v.name); - else result.installed.push(v.name); - return; - } - - const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); - const files = await installSkillAtomic(targetDir, { - content: full.content, - ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), - }); - - const newState: SkillState = { - id: v.id, - semver: v.semver, - contentHash: v.contentHash, - scope: v.scope, - installDir: targetDir, - files, - postInstallHash: null, - lastSyncedAt: new Date().toISOString(), - }; - state.skills[v.name] = newState; - if (prior) result.updated.push(v.name); - else result.installed.push(v.name); - } catch (err: unknown) { - result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); - } - } +export async function runPrimeAgentSkillsSync( + opts: PrimeAgentSyncOpts, + deps: PrimeAgentSyncDeps, +): Promise { + return runSkillsSync({ ...opts, target: 'prime-agent' }, deps); } + +// Re-export for callers that prefer to use the shared function directly. +export { runSkillsSync }; diff --git a/src/cli/src/utils/skills-state.ts b/src/cli/src/utils/skills-state.ts index 8f653cd..fe64d5d 100644 --- a/src/cli/src/utils/skills-state.ts +++ b/src/cli/src/utils/skills-state.ts @@ -30,6 +30,13 @@ export interface SkillState { /** sha256 of the postInstall script if any; null if none. */ postInstallHash: string | null; lastSyncedAt: string; + /** + * Owning project name, used by the prime-agent sync to avoid cross-project + * orphan deletion in the shared ~/.prime/agent/skills tree. Globals record + * null; project-scoped skills record the project that installed them. + * Unset for the Claude Code path. + */ + project?: string | null; } export interface SkillsStateFile { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 947b87e..1f9c7d5 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; @@ -114,15 +114,16 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(settingsPath)).toBeNull(); }); - it('does not call the API when --skip-skills is set', async () => { + it('does not call the API when --skip-skills and --token are given', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); - await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' }); expect(client.get).not.toHaveBeenCalled(); + expect(client.post).not.toHaveBeenCalled(); }); it('backward compat: prime-agent-generate still works', async () => { @@ -136,6 +137,124 @@ describe('config prime-agent', () => { const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['proj-1']).toBeDefined(); }); + + it('provisions auth.json by minting a project token', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' })); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' }); + }); + + it('uses --token without calling the API', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' }); + }); + + it('keeps an existing credential and does not re-mint', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl'].key).toBe('existing'); + }); + + it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts'); + expect(existsSync(extPath)).toBe(true); + expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'"); + + output.length = 0; + const cmd2 = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + expect(output.join('\n')).not.toContain('switcher extension'); + }); + + it('does not write a .mcpctl-project marker when run from $HOME', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const prevCwd = process.cwd(); + process.chdir(homedir()); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('Skipped .mcpctl-project marker'); + expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); + }); + + it('refuses to overwrite a corrupt settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, '{ this is not valid json !!!'); + const prevCwd = process.cwd(); + process.chdir(tmpDir); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('refusing to overwrite'); + // The corrupt file is untouched. + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); + }); + + it('merges a re-configured project entry, preserving user-added fields', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } }, + }, + })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['ha']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, + headers: { Authorization: 'Bearer u' }, // user-added field preserved + }); + }); }); function exceptionSafeRead(path: string): string | null { diff --git a/src/cli/tests/commands/skills.test.ts b/src/cli/tests/commands/skills.test.ts new file mode 100644 index 0000000..1556667 --- /dev/null +++ b/src/cli/tests/commands/skills.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createSkillsCommand } from '../../src/commands/skills.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('skills sync --agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-skills-agent-')); + process.exitCode = 0; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + process.exitCode = 0; + }); + + it('defaults to claude and runs the normal sync', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--skip-postinstall'], { from: 'user' }); + // claude path calls the project visible endpoint. + expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible'); + }); + + it('routes --agent prime-agent to the prime-agent target', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'prime-agent'], { from: 'user' }); + // prime-agent path also hits the project visible endpoint, and the summary + // line should mention the target. + expect(output.join('\n')).toContain('prime-agent'); + }); + + it('rejects an unknown --agent value with a non-zero exit', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(client.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index d696724..a6b56e5 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; @@ -121,4 +121,66 @@ describe('runPrimeAgentSkillsSync', () => { const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); }); + + it('preserves an untracked pre-existing skill dir on first sync (no rm -rf)', async () => { + const existing = join(installRoot, 'sample-skill'); + mkdirSync(existing, { recursive: true }); + writeFileSync(join(existing, 'SKILL.md'), '# hand-authored\n', 'utf-8'); + + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: '# server content\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.preserved).toContain('sample-skill'); + expect(result.installed).toEqual([]); + // The hand-authored content is untouched. + expect(readFileSync(join(existing, 'SKILL.md'), 'utf-8')).toBe('# hand-authored\n'); + }); + + it('does not delete another project\'s skills when configuring a second project', async () => { + // Project A installs a skill. + const av = [ + { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + + // Project B syncs with a totally different skill set. + const bv = [ + { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + // B should neither remove A\'s skill nor claim it was removed. + expect(resultB.removed).toEqual([]); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + expect(existsSync(join(installRoot, 'b-skill'))).toBe(true); + }); + + it('removes an orphaned skill that belongs to the same project', async () => { + const v = [ + { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', metadata: {}, scope: 'project' }, + ]; + const f = { 'x-1': { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', content: '# old\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(true); + + // Next sync for the same project: the skill is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client2)); + + expect(result2.removed).toContain('old-skill'); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(false); + }); }); From fa7055ac5e860d6b852f83156653a12da8b09f5b Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:28:15 +0100 Subject: [PATCH 03/77] fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second round of `config prime-agent` review (10 findings). auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin: - loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and- rewrite, so one syntax error can no longer destroy the provider API key and every other project's credential. hasPrimeAgentAuth shares that guarantee. - writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead of the default umask — bearer tokens are no longer world-readable on first creation. state ownership (commands/skills.ts) — the ownership model edge cases: - orphan-removal guard now normalises a canonical scope (project name, or null for globals; legacy undefined adopted to current scope) instead of comparing null against undefined, so global-only syncs and pre-PR state can no longer leave stale skills on disk forever. - a same-named skill *tracked* to a different project is preserved (with a warning) rather than silently overwritten in the shared flat tree. - mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning (prime-agent's HTTP gateway must not mutate shared mcpd project attachments); this also makes the earlier dropped-attach concern explicit rather than silent. single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts): - registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes other mcpctl-managed entries, so prime-agent has one *active* mcpctl project while preserving untagged servers (hand-configured sre, websearch, etc). - the /mcpctl extension now reads that tag as the single source of truth for the active project, fixing the false short-circuit / no-op switch. config.ts command: - an explicit -p now updates a differing up-tree .mcpctl-project marker (scope no longer silently reverts on the next sync), no-ops when it matches, and still never scopes $HOME. - a project left with no usable credential now exits non-zero (the /mcpctl extension checks child exit status, so it no longer reports a successful switch after provisioning failed). - skills sync is treated as best-effort: settings+auth determine switch success, so a skills error no longer falsely fails the switch. - token minting now revokes prior active `prime-agent` tokens before creating a fresh one (no more never-expiring token litter / lost-credential duplication). Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit code, single-active dedup preserving untagged sre, cross-project overwrite preservation, and global-orphan removal on global-only sync. --- src/cli/src/commands/config.ts | 62 +++++++++++----- src/cli/src/commands/skills.ts | 38 +++++++++- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/src/config/prime-agent.ts | 72 ++++++++++++++----- src/cli/tests/commands/prime-agent.test.ts | 65 ++++++++++++++++- .../tests/utils/prime-agent-skills.test.ts | 42 +++++++++++ 6 files changed, 238 insertions(+), 43 deletions(-) diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 586d888..864ed35 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -267,17 +267,33 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // 2. Provision the bearer credential prime-agent needs for this project. // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must // store an mcp: token in auth.json. Use --token if given, - // keep an existing one, otherwise mint it via the API. + // keep an existing one, otherwise mint it via the API. A switch with + // no usable credential is a FAILURE (exit != 0) so the /mcpctl + // extension does not report success after leaving a project bare. + let provisioned = false; try { if (opts.token !== undefined && opts.token !== '') { await writePrimeAgentAuth(opts.project, opts.token, authPath); log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + provisioned = true; } else if (await hasPrimeAgentAuth(opts.project, authPath)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); + provisioned = true; } else if (skillsClient) { - const tokenName = `prime-agent-${Date.now()}-${Math.floor(Math.random() * 1e6).toString(36)}`; + // Revoke any prior active `prime-agent` token for this project + // first (tokens are immutable + shown once), so we never litter + // never-expiring tokens on repeated reprovisioning. + const list = await skillsClient + .get | unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`) + .catch(() => []); + const existing = Array.isArray(list) ? list : []; + for (const t of existing) { + if (t.name === 'prime-agent' && t.status === 'active') { + try { await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); } catch { /* best-effort */ } + } + } const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: tokenName, + name: 'prime-agent', projectName: opts.project, ttl: 'never', description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, @@ -285,26 +301,36 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (typeof minted?.token === 'string' && minted.token.length > 0) { await writePrimeAgentAuth(opts.project, minted.token, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + provisioned = true; } else { - log(`Warning: no token returned minting for '${opts.project}'; pass --token to supply one`); + log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } } else { - log('Warning: no API client available to mint a project token — pass --token to provision auth.json'); + log('Error: no API client available to mint a project token — pass --token to provision auth.json'); } } catch (err: unknown) { - log(`Warning: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (!provisioned) { + process.exitCode = 1; } // 3. Write the .mcpctl-project marker so later `skills sync` calls can - // resolve the project. Never clobber an existing marker found by - // walk-up, and never scope $HOME itself. + // resolve the project. An explicit -p is authoritative: it updates a + // differing up-tree marker (so the scope doesn't silently revert on + // the next sync), is a no-op when it already matches, and never + // scopes $HOME itself. try { - const existing = await findProjectMarker(process.cwd(), homedir()); - if (existing !== null) { - log(`Project already scoped by existing marker ${existing.markerPath} ('${existing.project}'); not overwriting`); - } else if (process.cwd() !== homedir()) { - const markerPath = await writeProjectMarker(process.cwd(), opts.project); - log(`Wrote ${markerPath}`); + if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')` + : `Wrote ${markerPath}`); + } } else { log('Skipped .mcpctl-project marker (running from $HOME)'); } @@ -313,6 +339,9 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } // 4. Sync skills into prime-agent's skills tree (skippable). + // Best-effort: settings + auth (steps 1–2) determine whether the + // switch succeeded. A skills error is reported but must not flip the + // /mcpctl switch to "failed" when MCP access is already provisioned. if (opts.skipSkills !== true) { if (skillsClient) { try { @@ -324,13 +353,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (total > 0 || result.errors.length > 0) { log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); } - if (result.exitCode !== 0) { - process.exitCode = result.exitCode; - log(`Warning: prime-agent skills sync exited with code ${String(result.exitCode)}`); - } } catch (err: unknown) { log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; } } else { log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 48afdf1..f5ac265 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -192,6 +192,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 && projectName) { + if (isPrimeAgent) { + // prime-agent talks to the gateway over HTTP; auto-attaching a + // skill's declared server deps would mutate the *shared* mcpd project + // attachments (and a /mcpctl switch would re-trigger it). Deliberately + // never attach for prime-agent, but say so instead of being silent. + if (mcpServerDeps.length > 0) { + warn(`mcpctl: skill '${v.name}' declares mcpServers but prime-agent sync does not attach project servers; skipping attach`); + } + } else if (mcpServerDeps.length > 0 && projectName) { try { const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn); for (const srv of att.attached) { @@ -470,7 +502,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record };\n if (!settings.mcpServers) return null;\n for (const name of Object.keys(settings.mcpServers)) {\n const url = settings.mcpServers[name]?.url ?? '';\n const m = url.match(/\\/projects\\/([^/]+)\\/mcp$/);\n if (m && m[1] === name) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/**\n * The single *active* mcpctl project is the mcpServers entry that carries the\n * `mcpctlManaged: true` tag (written by `config prime-agent`). Untagged entries\n * (e.g. a hand-configured `sre`, websearch) are never treated as the active\n * mcpctl project, avoiding false short-circuits.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n for (const name of Object.keys(settings.mcpServers)) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index 5632323..1889cb3 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -79,6 +79,8 @@ export interface RegisterMcpResult { newServer: boolean; // true if the project's MCP entry was not already present url: string; totalServers: number; + /** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */ + removed: string[]; } /** @@ -102,51 +104,83 @@ export async function registerPrimeAgentMcp( const url = projectMcpUrl(project, gatewayUrl); const existing = settings.mcpServers[project]; const newServer = existing === undefined; - // Merge: keep any user-added fields on the project's entry (e.g. headers). - settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url }; + // Merge: keep any user-added fields on the project's entry (e.g. headers), + // and tag it so the /mcpctl switcher can find the single *active* project. + settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url, mcpctlManaged: true }; + + // prime-agent loads every mcpServers entry, so only ONE mcpctl project should + // be active at a time. Remove any *other* mcpctl-managed project entries we + // previously installed, but preserve untagged servers (e.g. a hand-configured + // `sre`, websearch, etc) so switching never nukes unrelated integrations. + const removed: string[] = []; + for (const k of Object.keys(settings.mcpServers)) { + if (k === project) continue; + const entry = settings.mcpServers[k]; + if (entry && typeof entry === 'object' && (entry as Record)['mcpctlManaged'] === true) { + delete settings.mcpServers[k]; + removed.push(k); + } + } const totalServers = Object.keys(settings.mcpServers).length; await mkdir(dirname(settingsPath), { recursive: true }); await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers }; + return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers, removed }; } /** * Ensure `mcp:` carries `{ type: "api_key", key }` in * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` * provider credential, other `mcp:*` servers, etc). + * + * auth.json holds bearer tokens, so it is written 0600 (preserving an existing + * file's mode if present) — never the default umask. */ export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { const current = await loadPrimeAgentAuth(authPath); current[`mcp:${project}`] = { type: 'api_key', key }; await mkdir(dirname(authPath), { recursive: true }); - // auth.json is 0600 normally; preserve an existing mode if present. - await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', 'utf-8'); + // Preserve an existing 0600 mode; always 0600 on first creation. + let mode: number | undefined; + try { + const s = await stat(authPath); + mode = s.mode; + } catch { + mode = 0o600; + } + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode }); } -/** Load auth.json; missing/corrupt (non-JSON) treated as a fresh file. */ +/** + * Load auth.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it (the same + * guarantee as settings.json — one syntax error must not destroy every + * credential, including the provider API key). + */ async function loadPrimeAgentAuth(path: string): Promise> { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; try { - const raw = await readFile(path, 'utf-8'); - if (raw.trim().length === 0) return {}; const parsed = JSON.parse(raw) as Record; return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; + } catch (err: unknown) { + throw new Error(`auth file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); } } -/** Does the project already have a credential in auth.json? */ +/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { - try { - const raw = await readFile(authPath, 'utf-8'); - const parsed = JSON.parse(raw) as Record; - const entry = parsed?.[`mcp:${project}`]; - return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); - } catch { - return false; - } + const parsed = await loadPrimeAgentAuth(authPath) as Record; + const entry = parsed[`mcp:${project}`]; + return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); } async function pathExists(p: string): Promise { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 1f9c7d5..ed2d993 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -61,6 +61,7 @@ describe('config prime-agent', () => { expect(written.mcpServers['homeautomation']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + mcpctlManaged: true, }); expect(output.join('\n')).toContain('homeautomation'); }); @@ -86,6 +87,7 @@ describe('config prime-agent', () => { expect(written.mcpServers['proj-1']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + mcpctlManaged: true, }); }); @@ -216,6 +218,45 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); }); + it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(output.join('\n')).toContain('refusing to overwrite'); + expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json'); + expect(process.exitCode).toBe(1); + }); + + it('exits non-zero when a credential cannot be provisioned', async () => { + // mockClient post returns { token: ... } by default; override to no token. + const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client; + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + expect(process.exitCode).toBe(1); + // body of provisioning error surfaced + expect(output.join('\n')).toContain('no token returned'); + }); + + it('writes auth.json with mode 0600', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token + const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777; + expect(mode).toBe(0o600); + }); + it('refuses to overwrite a corrupt settings.json', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, '{ this is not valid json !!!'); @@ -235,6 +276,27 @@ describe('config prime-agent', () => { expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); }); + it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + })); + // Active project is homeautomation (tagged). Switch to labctl. + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active + expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed + expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved + }); + it('merges a re-configured project entry, preserving user-added fields', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ @@ -253,6 +315,7 @@ describe('config prime-agent', () => { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, headers: { Authorization: 'Bearer u' }, // user-added field preserved + mcpctlManaged: true, }); }); }); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index a6b56e5..1fcf24b 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -183,4 +183,46 @@ describe('runPrimeAgentSkillsSync', () => { expect(result2.removed).toContain('old-skill'); expect(existsSync(join(installRoot, 'old-skill'))).toBe(false); }); + + it('does not overwrite a same-named skill owned by a different project', async () => { + // Project A installs skill X. + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + + // Project B also has a skill named X with different content. + const bv = [ + { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# version-b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.preserved).toContain('x-skill'); + // A's version is untouched (not clobbered by B's). + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + }); + + it('removes global orphans on a global-only sync', async () => { + // First sync a global skill. + const v = [ + { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const f = { 'g-1': { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# g\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + const empty = join(tmpDir, 'empty2'); + mkdirSync(empty, { recursive: true }); + await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(true); + + // Next global-only sync: the global is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client2)); + expect(result2.removed).toContain('gone-global'); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(false); + }); }); From 170dc06496d5d3843ffcf1f03bfe479f9acb9d5e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 12:01:52 +0100 Subject: [PATCH 04/77] =?UTF-8?q?fix(cli):=20close=20third=20review=20?= =?UTF-8?q?=E2=80=94=20token=20collision,=20migration,=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- README.md | 22 +- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 2 + src/cli/src/commands/config.ts | 178 +++++++++++--- src/cli/src/commands/skills.ts | 62 +++-- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/src/config/prime-agent.ts | 118 +++++++-- src/cli/tests/commands/prime-agent.test.ts | 227 +++++++++++++++++- .../tests/utils/prime-agent-skills.test.ts | 95 ++++++++ 9 files changed, 629 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 8d781b9..28d99d1 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,19 @@ mcpctl config prime-agent --project monitoring This: -1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as +1. Provisions the project's bearer credential in `~/.prime/agent/auth.json` + (`mcp:monitoring`, written 0600) — either from `--token `, an existing + entry that is still active server-side, or a freshly minted project token. + This happens first: if no credential can be provisioned the command stops + here with a non-zero exit and leaves `settings.json` alone, so the project + you are currently on keeps working. +2. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` - (merging with any existing servers and preserving all other settings). -2. Provisions the project's bearer credential in `~/.prime/agent/auth.json` - (`mcp:monitoring`) — either from `--token `, an existing entry, or an - auto-minted project token. + (merging with any existing servers and preserving all other settings), and + unmounts the previously active mcpctl project so exactly one is live. + Servers you configured by hand are never touched. 3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never - from `$HOME`) so later syncs resolve the project. + from `$HOME`) so later syncs resolve the project. Skip with `--skip-marker`. 4. Syncs the project's skills into `~/.prime/agent/skills//` as markdown skills. The shared tree is ownership-tracked per project: it never deletes another project's skills or an untracked hand-authored skill. @@ -152,8 +157,13 @@ Skip individual steps as needed: mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher +mcpctl config prime-agent --project monitoring --skip-marker # don't touch .mcpctl-project here ``` +The `/mcpctl` switcher runs with `--skip-extension --skip-marker`, so switching +projects from inside prime-agent never re-scopes whichever repository +prime-agent happened to be started in. + Preview the change without writing anything: ```bash diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0bbaf18..0e2be01 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -125,10 +125,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; prime-agent) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; prime-agent-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 31867cb..325719f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -303,6 +303,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent-generate options @@ -312,6 +313,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l ga complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' # config impersonate options diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 864ed35..140803f 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -17,11 +17,21 @@ import { primeAgentSettingsPath, DEFAULT_MCPCTL_GATEWAY_URL, writePrimeAgentAuth, - hasPrimeAgentAuth, + readPrimeAgentAuthKey, + mcpTokenPrefixOf, + isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; +/** + * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint + * gets a unique `-` name because `McpToken` is unique on + * (name, projectId) and revoke is a soft delete — a fixed name could only ever + * be minted once per project. + */ +const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; + interface McpConfig { mcpServers: Record }>; } @@ -215,6 +225,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--token ', 'mcpctl project bearer token to store in auth.json (skips auto-minting)') .option('--skip-skills', 'Skip the skills sync step') .option('--skip-extension', 'Do not install the /mcpctl project-switcher extension') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') .option('--dry-run', 'Print what would change without writing or syncing') .action(async (opts: { project?: string; @@ -223,6 +234,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: token?: string; skipSkills?: boolean; skipExtension?: boolean; + skipMarker?: boolean; dryRun?: boolean; }) => { if (opts.project === undefined || opts.project === '') { @@ -245,55 +257,49 @@ export function createConfigCommand(deps?: Partial, apiDeps?: authPath, mcpServers: { [opts.project]: { type: 'http', url } }, extension: opts.skipExtension === true ? '' : extPath, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), }, - action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', }, null, 2); log(dry); return; } - // 1. Register the proxy MCP gateway (merge; never destroy settings). - try { - const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); - log(reg.created - ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` - : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); - } catch (err: unknown) { - log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; - return; - } - - // 2. Provision the bearer credential prime-agent needs for this project. + // 1. Provision the bearer credential prime-agent needs for this project. // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must // store an mcp: token in auth.json. Use --token if given, - // keep an existing one, otherwise mint it via the API. A switch with - // no usable credential is a FAILURE (exit != 0) so the /mcpctl - // extension does not report success after leaving a project bare. + // keep a still-valid existing one, otherwise mint it via the API. + // + // This runs BEFORE settings.json is touched: registering the new + // project unmounts the previously active one, so a mint failure must + // not be able to leave prime-agent with no working project at all. + // A switch with no usable credential is a FAILURE (exit != 0) so the + // /mcpctl extension does not report success over a bare project. let provisioned = false; try { + // Whatever this auth.json held before we touched it — the only token + // this run is entitled to retire once it has a replacement. + const staleKey = await readPrimeAgentAuthKey(opts.project, authPath); if (opts.token !== undefined && opts.token !== '') { await writePrimeAgentAuth(opts.project, opts.token, authPath); log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - } else if (await hasPrimeAgentAuth(opts.project, authPath)) { + // Only when we actually replaced something: `--token` with a fresh + // auth.json must stay entirely offline, as documented. + if (staleKey !== null) { + await retireSupersededToken(opts.project, staleKey, opts.token); + } + } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); provisioned = true; } else if (skillsClient) { - // Revoke any prior active `prime-agent` token for this project - // first (tokens are immutable + shown once), so we never litter - // never-expiring tokens on repeated reprovisioning. - const list = await skillsClient - .get | unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`) - .catch(() => []); - const existing = Array.isArray(list) ? list : []; - for (const t of existing) { - if (t.name === 'prime-agent' && t.status === 'active') { - try { await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); } catch { /* best-effort */ } - } - } + // Mint under a fresh, unique name. `McpToken` is unique on + // (name, projectId) and revoke is a soft delete, so reusing a fixed + // name would collide with the revoked row forever. Retire the old + // tokens only *after* the replacement is safely on disk. + const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: 'prime-agent', + name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, projectName: opts.project, ttl: 'never', description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, @@ -302,6 +308,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: await writePrimeAgentAuth(opts.project, minted.token, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; + await retireSupersededToken(opts.project, staleKey, minted.token); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -313,15 +320,37 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } if (!provisioned) { process.exitCode = 1; + log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Register the proxy MCP gateway (merge; never destroy settings). + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath }); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + if (reg.removed.length > 0) { + log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`); + } + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; } // 3. Write the .mcpctl-project marker so later `skills sync` calls can // resolve the project. An explicit -p is authoritative: it updates a // differing up-tree marker (so the scope doesn't silently revert on // the next sync), is a no-op when it already matches, and never - // scopes $HOME itself. + // scopes $HOME itself. `--skip-marker` opts out entirely: the + // /mcpctl switcher runs this command from whatever directory + // prime-agent happens to be started in, and must not silently + // re-scope an unrelated repo that Claude Code's own sync reads. try { - if (process.cwd() !== homedir()) { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { const existing = await findProjectMarker(process.cwd(), homedir()); if (existing !== null && existing.project === opts.project) { log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); @@ -375,6 +404,87 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (hidden) { void cmd; } + + /** + * Is the credential already in auth.json still usable? + * + * A key being *present* proves nothing — a revoked or expired token would + * short-circuit provisioning and leave prime-agent silently unable to reach + * the gateway while the command reported success. mcptokens are only ever + * shown once, so we compare the stored token's 16-char `tokenPrefix` + * against the project's *active* tokens instead of sending the secret. + * + * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some + * other kind), or an unreachable API all mean "keep what's there" rather + * than minting a duplicate on every run. + */ + async function hasUsableCredential(project: string, key: string | null): Promise { + if (key === null) return false; + if (!skillsClient || !isMcpctlToken(key)) return true; + const tokens = await listProjectTokens(project); + if (tokens === null) return true; // can't check → don't churn credentials + const prefix = mcpTokenPrefixOf(key); + const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); + if (!live) { + log(`Stored credential for '${project}' is no longer active — minting a replacement`); + } + return live; + } + + /** + * Retire the token this auth.json used to hold, now that `keepToken` has + * replaced it on disk. + * + * Scoped to that one credential on purpose. Sweeping every `prime-agent` + * token for the project would revoke the one a *different* auth.json is + * using — another machine, or this machine when the run targeted a custom + * `--output`. Anything else that looks orphaned is reported, not deleted: + * an unnecessary token costs nothing, a revoked one costs a broken install. + * Best-effort throughout, and only ever called once the replacement is + * safely stored. + */ + async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise { + if (!skillsClient) return; + const keepPrefix = mcpTokenPrefixOf(keepToken); + const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; + if (stalePrefix === keepPrefix) return; + const tokens = await listProjectTokens(project); + if (tokens === null) return; + + const orphans: string[] = []; + for (const t of tokens) { + if (typeof t.id !== 'string' || t.status !== 'active') continue; + if (t.tokenPrefix === keepPrefix) continue; + // Only ever consider tokens minted for this purpose. + const name = t.name ?? ''; + if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue; + if (t.tokenPrefix === stalePrefix) { + try { + await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); + log(`Revoked the superseded '${name}' token for '${project}'`); + } catch { /* best-effort */ } + } else { + orphans.push(name); + } + } + if (orphans.length > 0) { + log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. ` + + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); + } + } + + interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } + + /** The project's tokens, or null when the API can't be consulted. */ + async function listProjectTokens(project: string): Promise { + if (!skillsClient) return null; + try { + const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); + return Array.isArray(list) ? list as ProjectToken[] : null; + } catch { + return null; + } + } } registerClaudeCommand('claude', false); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index f5ac265..f0f9770 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -192,14 +192,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); const statePath = opts.statePath ?? (isPrimeAgent ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') : defaultStatePath()); const state = await loadState(statePath); + // Which project last wrote this state file, captured before step 7 overwrites + // it. Skills tracked by a CLI that predates ownership recording carry no + // `project` field; this is the only evidence of who installed them. + const priorSyncProject = state.lastSyncProject; const installRoot = opts.installRoot ?? (isPrimeAgent ? join(homeDir, '.prime', 'agent', 'skills') : join(homeDir, '.claude', 'skills')); @@ -243,13 +248,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { try { // If on-disk files were locally modified, preserve unless --force. @@ -346,15 +366,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/**\n * The single *active* mcpctl project is the mcpServers entry that carries the\n * `mcpctlManaged: true` tag (written by `config prime-agent`). Untagged entries\n * (e.g. a hand-configured `sre`, websearch) are never treated as the active\n * mcpctl project, avoiding false short-circuits.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n for (const name of Object.keys(settings.mcpServers)) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index 1889cb3..2e4c60e 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -17,13 +17,16 @@ * - A project's existing `mcpServers` entry is merged (user-added fields are * kept), never replaced wholesale. */ -import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; /** Base URL of the deployed mcpctl HTTP MCP gateway. */ export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; +/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */ +const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_'; + /** Resolve the prime-agent settings.json path. */ export function primeAgentSettingsPath(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'settings.json'); @@ -72,6 +75,61 @@ export async function loadPrimeAgentSettings(path: string): Promise/mcp` + * proxy URL **and** auth.json holds an `mcp:` mcpctl PAT. Older CLIs + * wrote the entry + credential pair but no tag; without adopting them a + * project switch would leave two gateways mounted at once and the `/mcpctl` + * switcher would report no active project. + * + * A hand-configured server never has an mcpctl PAT stored under `mcp:`, + * so it is never adopted — that pairing is what makes the legacy match safe. + */ +export function isMcpctlManagedEntry( + name: string, + entry: unknown, + authKeys: ReadonlySet, +): boolean { + if (entry === null || typeof entry !== 'object') return false; + const rec = entry as Record; + if (rec['mcpctlManaged'] === true) return true; + const url = rec['url']; + if (typeof url !== 'string') return false; + // Host-agnostic: adopt regardless of which gateway the old entry pointed at. + const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`); + return canonical.test(url) && authKeys.has(name); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Names of the projects auth.json holds an mcpctl PAT for (`mcp:`). + * Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt + * auth.json yields an empty set — adoption then simply doesn't happen. + */ +export async function primeAgentAuthProjects(authPath: string): Promise> { + let parsed: Record; + try { + parsed = await loadPrimeAgentAuth(authPath); + } catch { + return new Set(); + } + const out = new Set(); + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = (v as { key?: unknown } | null)?.key; + if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4)); + } + return out; +} + export interface RegisterMcpResult { settingsPath: string; created: boolean; // true if the settings file did not previously exist @@ -93,6 +151,7 @@ export async function registerPrimeAgentMcp( project: string, settingsPath: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, + opts: { authPath?: string } = {}, ): Promise { const existed = await pathExists(settingsPath); const settings = await loadPrimeAgentSettings(settingsPath); @@ -110,13 +169,16 @@ export async function registerPrimeAgentMcp( // prime-agent loads every mcpServers entry, so only ONE mcpctl project should // be active at a time. Remove any *other* mcpctl-managed project entries we - // previously installed, but preserve untagged servers (e.g. a hand-configured + // previously installed — including the untagged ones older CLIs wrote (see + // isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke // `sre`, websearch, etc) so switching never nukes unrelated integrations. + const authKeys = opts.authPath !== undefined + ? await primeAgentAuthProjects(opts.authPath) + : new Set(); const removed: string[] = []; for (const k of Object.keys(settings.mcpServers)) { if (k === project) continue; - const entry = settings.mcpServers[k]; - if (entry && typeof entry === 'object' && (entry as Record)['mcpctlManaged'] === true) { + if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) { delete settings.mcpServers[k]; removed.push(k); } @@ -134,22 +196,19 @@ export async function registerPrimeAgentMcp( * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` * provider credential, other `mcp:*` servers, etc). * - * auth.json holds bearer tokens, so it is written 0600 (preserving an existing - * file's mode if present) — never the default umask. + * auth.json holds never-expiring bearer tokens, so it always ends up 0600 — + * never the default umask. `writeFile`'s `mode` only applies when the file is + * created, and prime-agent itself creates auth.json 0644, so we chmod after + * writing rather than trusting the open flags. */ export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { const current = await loadPrimeAgentAuth(authPath); current[`mcp:${project}`] = { type: 'api_key', key }; await mkdir(dirname(authPath), { recursive: true }); - // Preserve an existing 0600 mode; always 0600 on first creation. - let mode: number | undefined; + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 }); try { - const s = await stat(authPath); - mode = s.mode; - } catch { - mode = 0o600; - } - await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode }); + await chmod(authPath, 0o600); + } catch { /* best-effort: a credential written is better than one refused */ } } /** @@ -176,11 +235,36 @@ async function loadPrimeAgentAuth(path: string): Promise } } -/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ -export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { +/** + * The credential currently stored for `project`, or null if there is none. + * Throws on corrupt JSON (the caller must refuse to overwrite the file). + */ +export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise { const parsed = await loadPrimeAgentAuth(authPath) as Record; const entry = parsed[`mcp:${project}`]; - return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); + if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) { + return entry.key; + } + return null; +} + +/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ +export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { + return (await readPrimeAgentAuthKey(project, authPath)) !== null; +} + +/** + * The displayable prefix mcpd records for a raw token (`tokenPrefix` on + * McpToken): the first 16 characters. Lets us match a stored credential against + * the server's token list without ever sending the secret. + */ +export function mcpTokenPrefixOf(raw: string): string { + return raw.slice(0, 16); +} + +/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */ +export function isMcpctlToken(raw: string): boolean { + return raw.startsWith(MCPCTL_TOKEN_PREFIX); } async function pathExists(p: string): Promise { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index ed2d993..6688d4e 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -297,6 +297,231 @@ describe('config prime-agent', () => { expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved }); + it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => { + // Written by a CLI that predates `mcpctlManaged`: an untagged entry whose + // URL is canonical AND a matching mcp: PAT in auth.json. + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` }, + websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential + }, + })); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + itaz: { type: 'api_key', key: 'sk-provider' }, + 'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted + expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved + expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); + }); + + it('mints each credential under a unique name (never a fixed one)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string }; + // A fixed name can only ever be minted once: McpToken is unique on + // (name, projectId) and revoke is a soft delete. + expect(body.name).not.toBe('prime-agent'); + expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/); + }); + + it('revokes the token it replaced, only after the replacement is stored', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' }, + })); + const order: string[] = []; + const api = { + get: vi.fn(async (url: string) => { + order.push(`get ${url}`); + return [ + { id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' }, + { id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' }, + ]; + }), + post: vi.fn(async (url: string) => { + order.push(`post ${url}`); + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + // Explicitly replace the stored credential. + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' }); + + // The new credential landed on disk... + expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000'); + // ...before the token it replaced was revoked — never the other way round. + const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke'); + expect(revokeAt).toBeGreaterThanOrEqual(0); + expect(statSync(authPath).mtimeMs).toBeGreaterThan(0); + // Tokens this auth.json never held are reported, never revoked. + expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke'); + }); + + it('never revokes a token this auth.json did not hold', async () => { + // A run against a custom --output (or a second machine) must not touch the + // credential the real install is using. + const settingsPath = join(tmpDir, 'settings.json'); + const api = { + get: vi.fn(async () => [ + { id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' }, + ]), + post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke')); + expect(revokes).toEqual([]); + // ...but the user is told about it rather than left guessing. + expect(output.join('\n')).toContain('prime-agent-abc'); + }); + + it('leaves settings.json untouched when the credential cannot be provisioned', async () => { + // The active project must keep working when a switch fails: registering the + // new project unmounts the old one, so it may not run before the mint. + const settingsPath = join(tmpDir, 'settings.json'); + const before = JSON.stringify({ + mcpServers: { + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + }); + writeFileSync(settingsPath, before); + const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(readFileSync(settingsPath, 'utf-8')).toBe(before); + }); + + it('re-mints when the stored credential is no longer active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' }, + })); + const api = { + get: vi.fn(async () => [ + { id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' }, + ]), + post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000'); + expect(process.exitCode).toBe(0); + }); + + it('keeps a stored credential that is still active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' }, + })); + const api = { + get: vi.fn(async () => [ + // mcpd records the first 16 chars of the raw token as tokenPrefix. + { id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' }, + ]), + post: vi.fn(async () => ({ token: 'should-not-be-minted' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(api.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa'); + }); + + it('tightens a pre-existing 0644 auth.json to 0600', async () => { + // prime-agent creates auth.json itself with the default umask; writeFile's + // `mode` is ignored for an existing file, so the write must chmod. + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 }); + chmodSync(authPath, 0o644); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(statSync(authPath).mode & 0o777).toBe(0o600); + // The provider credential is still there. + expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x'); + }); + + it('--skip-marker leaves the current directory alone', async () => { + // The /mcpctl switcher runs from whatever directory prime-agent started in. + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull(); + }); + + it('the installed switcher extension passes --skip-marker', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + expect(ext).toContain("'--skip-extension', '--skip-marker'"); + }); + it('merges a re-configured project entry, preserving user-added fields', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index 1fcf24b..284c346 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -207,6 +207,101 @@ describe('runPrimeAgentSkillsSync', () => { expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); }); + it('does not delete legacy, ownership-less state belonging to another project', async () => { + // State written by a CLI that predates the `project` field: the skill has + // no recorded owner and the file records projA as the last syncing project. + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + // note: no `project` field + }, + }, + }), 'utf-8'); + + // First sync after upgrading, for a *different* project. + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client)); + + expect(result.removed).toEqual([]); + expect(existsSync(legacyDir)).toBe(true); + }); + + it('cleans up legacy state once the owning project syncs again', async () => { + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), 'utf-8'); + + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client)); + + expect(result.removed).toContain('legacy-skill'); + expect(existsSync(legacyDir)).toBe(false); + }); + + it('keeps global skills updatable after switching projects', async () => { + // A global installed while projA was active must not be pinned to projA — + // globals are visible from every project. + const gv = (hash: string) => [ + { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' }, + ]; + const gf = (hash: string, body: string) => ({ + 'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} }, + }); + + const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull(); + + // Switch to projB; the global has been updated server-side. + const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.updated).toContain('shared-global'); + expect(resultB.preserved).toEqual([]); + expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toBe('# v2\n'); + }); + + it('does not let a global-only sync clobber a project-owned skill', async () => { + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af }))); + + // A global of the same name shows up on a global-only sync. + const gv = [ + { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } }; + const empty = join(tmpDir, 'empty3'); + mkdirSync(empty, { recursive: true }); + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf }))); + + expect(result.preserved).toContain('x-skill'); + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + }); + it('removes global orphans on a global-only sync', async () => { // First sync a global skill. const v = [ From ce7df10e06decf00720f932a808204b50ccece82 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 12:18:21 +0100 Subject: [PATCH 05/77] feat(cli): show the active mcpctl project in prime-agent's footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/mcpctl` could switch projects but there was no way to see which one was active without running a command. prime-agent exposes the same status-bar channel the model name uses (`ctx.ui.setStatus`), so the switcher now publishes `mcpctl:` there. Wired to `session_start`, which fires on startup *and* on every reload — including the reload the switch itself triggers — so the footer tracks settings.json without extra bookkeeping. Cleared when no project is mounted. Also fixes `notify(..., 'success')`: the API only accepts info|warning|error. Not a live bug (prime-agent falls through to the same showStatus path as 'info') but it fails a typecheck of the extension against the real ExtensionAPI, which is how it was found. The extension ships as a JSON-escaped string and is never compiled by our build, so it was typechecked out-of-tree against @earendil-works/pi-coding-agent's published types. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 7bc2510..6509cbd 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our footer entry is stored under (see ctx.ui.setStatus). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Publish the active project into prime-agent's footer, alongside the model\n * name — so the current mcpctl project is always visible rather than something\n * you have to run a command to discover. Cleared when no project is mounted.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n ctx.ui.setStatus(STATUS_KEY, active ? `mcpctl:${active}` : undefined);\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 6688d4e..9d8a627 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -510,6 +510,24 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull(); }); + it('the installed switcher extension publishes the active project to the footer', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Footer status, refreshed on startup and on every reload (which is what + // the switch itself triggers) — the mcpctl equivalent of the model name. + expect(ext).toContain("pi.on('session_start'"); + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + expect(ext).toContain('`mcpctl:${active}`'); + // notify() only accepts info|warning|error — 'success' is not a valid type. + expect(ext).not.toContain("'success'"); + }); + it('the installed switcher extension passes --skip-marker', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( From 28f1a411fd584fe6b56494f6adaf9b287baf6cf4 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 16:23:02 +0100 Subject: [PATCH 06/77] =?UTF-8?q?feat(pi):=20add=20native=20pi=20integrati?= =?UTF-8?q?on=20=E2=80=94=20extension,=20config=20pi,=20skills=20--agent?= =?UTF-8?q?=20pi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored mcp-http client) that talks JSON-RPC directly to mcplocal and registers project MCP tools as native pi tools. No MCP client, no ~/.claude. - Persistent per-project session so gated projects ungate on begin_session. - /mcpctl command: status, switch project (GUI), refresh tools, sync skills. - mcpctl config pi: installs extension, wires pi settings, persists active project, syncs skills into ~/.pi/agent/skills. - skills sync: add --agent pi (target install root). - docs + tests. --- README.md | 24 ++ docs/pi-extension.md | 97 ++++++ src/cli/src/commands/config.ts | 103 ++++++ src/cli/src/commands/skills.ts | 29 +- src/cli/src/utils/pi-settings.ts | 147 ++++++++ src/cli/tests/commands/config-pi.test.ts | 83 +++++ src/cli/tests/utils/pi-settings.test.ts | 92 +++++ src/pi-ext/mcp-http.ts | 237 +++++++++++++ src/pi-ext/mcpctl-pi.ts | 413 +++++++++++++++++++++++ 9 files changed, 1222 insertions(+), 3 deletions(-) create mode 100644 docs/pi-extension.md create mode 100644 src/cli/src/utils/pi-settings.ts create mode 100644 src/cli/tests/commands/config-pi.test.ts create mode 100644 src/cli/tests/utils/pi-settings.test.ts create mode 100644 src/pi-ext/mcp-http.ts create mode 100644 src/pi-ext/mcpctl-pi.ts diff --git a/README.md b/README.md index e12537e..6a0ae84 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,30 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` +### 7. Use mcpctl with pi (no MCP client, no Claude) + +[pi](https://github.com/earendil-works/pi) does not support MCP, but it supports +**extensions** and **skills**. mcpctl ships a native pi integration that talks +JSON-RPC directly to mcplocal over HTTP — no MCP library, no `~/.claude` +dependency, so it keeps working even if you drop Claude entirely. + +```bash +mcpctl config pi --project monitoring +``` + +This installs the `mcpctl` pi extension into `~/.pi/agent/extensions/mcpctl/`, +registers it (plus a pi skills dir) in `~/.pi/agent/settings.json`, persists the +active project in `~/.mcpctl/pi-state.json`, and runs an initial skills sync into +`~/.pi/agent/skills/`. Then start pi and the project's MCP tools are registered as +native pi tools (`mc__`). + +Inside pi: + +- `/mcpctl` — status, **switch project** (from the GUI), refresh tools, sync skills +- gated projects auto-ungate: call `begin_session` once and the full tool set opens + +See [docs/pi-extension.md](docs/pi-extension.md) for full details. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/docs/pi-extension.md b/docs/pi-extension.md new file mode 100644 index 0000000..3ca45b3 --- /dev/null +++ b/docs/pi-extension.md @@ -0,0 +1,97 @@ +# mcpctl × pi — native integration (no MCP, no Claude) + +## Motivation + +With Claude Code, `mcpctl config claude --project X` wires a project's MCP +servers into the agent two ways: + +1. a `.mcp.json` entry running `mcpctl mcp -p X` (an MCP **stdio bridge**), and +2. a `SessionStart` hook + `mcpctl skills sync` that materialises server-side + **skills** under `~/.claude/skills/`. + +Claude Code supports MCP natively, so tools travel over MCP. **pi does not +support MCP.** But pi has two native mechanisms that cover the same ground: + +- **Extensions** — TypeScript modules that can register tools, commands, and + custom UI, and can talk HTTP directly. +- **Skills** — pi implements the [Agent Skills standard](https://agentskills.io/specification) + and loads `SKILL.md` trees (the exact format mcpctl already syncs). + +This addon exploits both so mcpctl works with **pi only**, with **no MCP** and +**no dependency on `~/.claude/`** (so it keeps working if you drop Claude). + +## How it works + +The MCP layer is not magic. `mcpctl mcp` is just a JSON-RPC ↔ stdio bridge that +forwards to mcplocal's Streamable-HTTP endpoint: + +``` +POST {mcplocalUrl}/projects//mcp +Accepts: application/json, text/event-stream +Authorization: Bearer +``` + +The protocol is JSON-RPC over HTTP: +- `initialize` → returns an `mcp-session-id` header +- `notifications/initialized` +- `tools/list` → `{ tools: [{ name, description, inputSchema }] }` +- `tools/call` → `{ name, arguments }` → `{ content, isError }` + +The pi extension is a **direct JSON-RPC client** to that same endpoint. It +replaces the MCP transport with a plain function call, so pi never needs an MCP +client library. + +### Gating + +Some projects are *gated*: until `begin_session` is called, `tools/list` returns +only `begin_session`. After it is called the full tool set appears. + +**Critical subtlety:** mcplocal's gate state is bound to a `mcp-session-id`. A +fresh session is gated again even after another session was ungated — so the +extension keeps **one persistent `McpHttpSession` per active project** and +routes every `tools/call` through it. After each call it re-runs `tools/list` +and reconciles the active pi tool set, so calling `begin_session` automatically +opens the gate and reveals the rest — no push listener required (mcplocal's HTTP +responses are request/response, not a persistent push stream). + +## Deliverables + +| Artifact | Purpose | +|----------|---------| +| `src/pi-ext/mcpctl-pi.ts` | Self-contained pi extension (load via `pi -e` or settings `extensions`) | +| `mcpctl config pi` | CLI wiring: registers the extension with pi + initial skill sync | +| `docs/pi-extension.md` | This document | + +## Project switching + +The active project is persisted in `~/.mcpctl/pi-state.json` (settable, immune to +your shell's cwd) and is also inferred from a `.mcpctl-project` marker walk-up. +`/mcpctl` opens an interactive menu; **switch project** lists projects via +`ctx.ui.select()` and reloads the tool set for the new project, namespaced so +projects never collide. + +### Tool naming + +To allow multiple projects and avoid schema collisions, registered pi tools are +namespaced: `mc__`. Only the **current project's** +tools are in the active set at any time. + +## Skills + +Skills already sync in the Agent Skills `SKILL.md` format. `mcpctl config pi` +(when given a project) writes them under pi's own directory +`~/.pi/agent/skills/` and registers that directory in `~/.pi/agent/settings.json` +— no Claude involvement. `mcpctl pi sync-skills` (a thin wrapper) re-runs the +existing `skills` code path against the pi target directory. + +## Layout + +``` +src/pi-ext/ + mcpctl-pi.ts # the extension (self-contained) + mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) +``` + +The extension imports only from pi-bundled packages +(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it +loads standalone. diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 44330ee..ca826b2 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -11,6 +11,17 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; import { writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; +import { + installExtensionFiles, + registerWithPi, + piSkillsDir, + piSettingsPath, + piExtensionDir, + piExtensionMainPath, + piStatePath, + withPiAgentDir, + writePiProjectState, +} from '../utils/pi-settings.js'; import { runSkillsSync } from './skills.js'; interface McpConfig { @@ -196,6 +207,98 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerClaudeCommand('claude', false); registerClaudeCommand('claude-generate', true); // backward compat + // ── pi: wire the mcpctl pi extension (no MCP, no Claude) ── + config + .command('pi') + .description('Install the pi extension + sync skills (native, no MCP client, no Claude)') + .option('-p, --project ', 'Project name to make active') + .option('--extension-dir ', 'Path to the src/pi-ext sources to install (default: this checkout)') + .option('--skip-skills', 'Skip the initial skills sync') + .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') + .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') + .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => { + if (!opts.project) { + log('Error: --project is required for mcpctl config pi'); + process.exitCode = 1; + return; + } + + // Resolve target paths, honouring an optional --pi-dir override. + const piHome = opts.piDir ? withPiAgentDir(opts.piDir) : undefined; + const settingsPath = opts.settings ?? piHome?.settingsPath() ?? piSettingsPath(); + const extDest = piHome?.extensionDir() ?? piExtensionDir(); + const skillsInstall = piHome?.skillsDir() ?? piSkillsDir(); + // Isolate the persisted active-project state under the pi home when a + // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). + const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); + + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/ + let srcDir: string = opts.extensionDir!; + if (!srcDir) { + // best-effort: locate this checkout's src/pi-ext from the running + // script (dist compile) or from cwd (tsx/dev). + const candidates = [ + resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', 'src', 'pi-ext'), + resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', '..', 'src', 'pi-ext'), + resolve(process.cwd(), 'src', 'pi-ext'), + ]; + const { existsSync } = await import('node:fs'); + srcDir = candidates.find((c) => existsSync(join(c, 'mcpctl-pi.ts'))) ?? candidates[0]!; + } + try { + const written = await installExtensionFiles(srcDir, extDest); + log(`Installed extension files:`); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install extension from '${srcDir}': ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // 2. Register with pi settings (extensions + skills dir). + try { + const extMain = piHome?.extensionMainPath() ?? piExtensionMainPath(); + const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extMain, skillsInstall); + log(`Updated ${settingsPath}`); + log(` extensions: ${addedExtensions.length ? addedExtensions.join(', ') : 'already registered'}`); + log(` skills: ${addedSkills.length ? addedSkills.join(', ') : 'already registered'}`); + } catch (err: unknown) { + log(`Warning: could not update pi settings: ${err instanceof Error ? err.message : String(err)}`); + } + + // 3. Persist the active project (survives across sessions/cwd). + try { + const p = await writePiProjectState(opts.project, statePath); + log(`Wrote active project to ${p}`); + } catch (err: unknown) { + log(`Warning: could not write pi state: ${err instanceof Error ? err.message : String(err)}`); + } + + // 4. Optional initial skills sync into pi's skills dir. + if (!opts.skipSkills && skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project, installRoot: skillsInstall }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } else { + log('Skills: no changes (already up to date)'); + } + } catch (err: unknown) { + log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else if (!opts.skipSkills) { + log('Warning: skipping initial skills sync (no API client provided)'); + } + + log(''); + log('Next: restart pi (or start a new session) — the mcpctl tools for the active'); + log(`project will be registered automatically. Use /mcpctl to switch projects.`); + }); + config.addCommand(createConfigSetupCommand({ configDeps })); if (apiDeps) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 627799d..b507154 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -435,30 +435,52 @@ export interface SkillsCommandDeps { log: (...args: unknown[]) => void; } +/** + * Map an agent target to its on-disk skills install root. + * "claude" → ~/.claude/skills + * "prime-agent" → ~/.prime/agent/skills + * "pi" → ~/.pi/agent/skills + * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + */ +export function agentInstallRoot(agent: string | undefined): string { + switch (agent) { + case 'prime-agent': + return join(homedir(), '.prime', 'agent', 'skills'); + case 'pi': + return join(homedir(), '.pi', 'agent', 'skills'); + case 'claude': + default: + return join(homedir(), '.claude', 'skills'); + } +} + export function createSkillsCommand(deps: SkillsCommandDeps): Command { const { client, log } = deps; const warn = (...args: unknown[]): void => { console.error(...(args as Parameters)); }; - const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + const cmd = new Command('skills').description('Manage Agent-skill bundles synced from mcpd'); cmd.command('sync') - .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .description('Sync skills from mcpd onto disk') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--agent ', 'Sync target install root: claude (default), prime-agent, or pi', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') - .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)') + .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') .option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)') .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') .action(async (opts: { project?: string; + agent?: string; dryRun?: boolean; force?: boolean; quiet?: boolean; skipPostinstall?: boolean; keepOrphans?: boolean; }) => { + const installRoot = agentInstallRoot(opts.agent); const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), @@ -467,6 +489,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + installRoot, }, { client, log, warn }, ); diff --git a/src/cli/src/utils/pi-settings.ts b/src/cli/src/utils/pi-settings.ts new file mode 100644 index 0000000..4739d17 --- /dev/null +++ b/src/cli/src/utils/pi-settings.ts @@ -0,0 +1,147 @@ +/** + * Wiring helpers for the pi extension. + * + * `mcpctl config pi --project X`: + * 1. copies `src/pi-ext/` (extension + http client) into + * `~/.pi/agent/extensions/mcpctl/`, + * 2. adds the extension path and the pi skills dir to + * `~/.pi/agent/settings.json` (`extensions`, `skills` arrays), + * 3. writes `~/.mcpctl/pi-state.json` so the active project survives across + * sessions regardless of the shell's cwd. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`. + */ +import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises'; +import { dirname, join, basename, resolve } from 'node:path'; +import { homedir } from 'node:os'; + +export function piAgentDir(): string { + return join(homedir(), '.pi', 'agent'); +} + +/** Override the pi agent home (for tests / custom installs). */ +export function withPiAgentDir(base: string): { + settingsPath: () => string; + extensionDir: () => string; + skillsDir: () => string; + extensionMainPath: () => string; +} { + return { + settingsPath: () => join(base, 'settings.json'), + extensionDir: () => join(base, 'extensions', 'mcpctl'), + skillsDir: () => join(base, 'skills'), + extensionMainPath: () => join(base, 'extensions', 'mcpctl', 'mcpctl-pi.ts'), + }; +} + +export function piSettingsPath(): string { + return join(piAgentDir(), 'settings.json'); +} + +export function piExtensionDir(): string { + return join(piAgentDir(), 'extensions', 'mcpctl'); +} + +export function piSkillsDir(): string { + return join(piAgentDir(), 'skills'); +} + +export function piExtensionMainPath(): string { + return join(piExtensionDir(), 'mcpctl-pi.ts'); +} + +export function piStatePath(): string { + return join(homedir(), '.mcpctl', 'pi-state.json'); +} + +/** + * Copy the bundled extension sources (relative to this source tree's + * `src/pi-ext/`) into the pi extensions dir. The extension imports its http + * client via a sibling `./mcp-http.js`, so both files must land together. + */ +export async function installExtensionFiles( + srcPiExtDir: string, + dest: string = piExtensionDir(), +): Promise { + await mkdir(dest, { recursive: true }); + const files = [ + ['mcpctl-pi.ts', 'mcpctl-pi.ts'], + ['mcp-http.ts', 'mcp-http.ts'], + ] as const; + const written: string[] = []; + for (const [from, to] of files) { + const src = resolve(srcPiExtDir, from); + const dst = join(dest, to); + await copyFile(src, dst); + written.push(dst); + } + return written; +} + +interface PiSettings { + extensions?: string[]; + skills?: string[]; + [k: string]: unknown; +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + return JSON.parse(raw) as PiSettings; + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') return {}; + // settings.json may contain line comments; strip them defensively. + const stripped = (await readFile(path, 'utf-8')).replace(/^\s*\/\/.*$/gm, ''); + return JSON.parse(stripped) as PiSettings; + } +} + +async function writeSettings(path: string, settings: PiSettings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Merge our extension path + skills dir into `~/.pi/agent/settings.json`. + * Idempotent — re-running doesn't duplicate entries. + * Returns the paths that were added. + */ +export async function registerWithPi( + settingsPath = piSettingsPath(), + extensionPath = piExtensionMainPath(), + skillsDir = piSkillsDir(), +): Promise<{ addedExtensions: string[]; addedSkills: string[] }> { + const settings = await readSettings(settingsPath); + if (!Array.isArray(settings.extensions)) settings.extensions = []; + if (!Array.isArray(settings.skills)) settings.skills = []; + + const addedExtensions: string[] = []; + if (!settings.extensions.includes(extensionPath)) { + settings.extensions.push(extensionPath); + addedExtensions.push(extensionPath); + } + const addedSkills: string[] = []; + if (!settings.skills.includes(skillsDir)) { + settings.skills.push(skillsDir); + addedSkills.push(skillsDir); + } + + await writeSettings(settingsPath, settings); + return { addedExtensions, addedSkills }; +} + +/** Write the persisted active-project state. */ +export async function writePiProjectState(project: string, path: string = piStatePath()): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8'); + return path; +} + +/** Debug helper: the on-disk file name of an extension path. */ +export function _base(path: string): string { + return basename(path); +} diff --git a/src/cli/tests/commands/config-pi.test.ts b/src/cli/tests/commands/config-pi.test.ts new file mode 100644 index 0000000..fe0d673 --- /dev/null +++ b/src/cli/tests/commands/config-pi.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(visibleSkills: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.endsWith('/skills/visible')) return visibleSkills; + if (path.startsWith('/api/v1/skills/')) return { + id: 's1', name: 'demo', description: 'd', semver: '1.0.0', + content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null, + }; + return {}; + }), + post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config pi', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs the extension + registers with a custom pi dir (skips skills)', async () => { + // Stage a src/pi-ext source tree to install from. + const srcPiExt = join(tmpDir, 'src-pi-ext'); + mkdirSync(srcPiExt, { recursive: true }); + writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n'); + writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n'); + + const piDir = join(tmpDir, 'pi-agent'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + + await cmd.parseAsync( + ['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'], + { from: 'user' }, + ); + + // Extension files landed in the pi dir. + expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true); + expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true); + + // settings.json updated. + const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8')); + expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts')); + expect(settings.skills).toContain(join(piDir, 'skills')); + + // Active project persisted under the pi dir (fully isolated). + const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8')); + expect(state.project).toBe('docmost'); + + // Output mentions the write. + expect(output.join('\n')).toContain('Wrote active project'); + }); + + it('requires a project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + }); +}); diff --git a/src/cli/tests/utils/pi-settings.test.ts b/src/cli/tests/utils/pi-settings.test.ts new file mode 100644 index 0000000..2a9b127 --- /dev/null +++ b/src/cli/tests/utils/pi-settings.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { agentInstallRoot } from '../../src/commands/skills.js'; +import { + registerWithPi, + installExtensionFiles, +} from '../../src/utils/pi-settings.js'; + +describe('skills agentInstallRoot', () => { + it('maps claude (default) to ~/.claude/skills', () => { + expect(agentInstallRoot(undefined)).toBe(join(homedir(), '.claude', 'skills')); + expect(agentInstallRoot('claude')).toBe(join(homedir(), '.claude', 'skills')); + }); + it('maps pi to ~/.pi/agent/skills', () => { + expect(agentInstallRoot('pi')).toBe(join(homedir(), '.pi', 'agent', 'skills')); + }); + it('maps prime-agent to ~/.prime/agent/skills', () => { + expect(agentInstallRoot('prime-agent')).toBe(join(homedir(), '.prime', 'agent', 'skills')); + }); +}); + +describe('pi-settings util', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'mcpctl-pi-settings-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('registers extension + skills dir into an empty settings file', async () => { + const settingsPath = join(tmp, 'settings.json'); + const extPath = join(tmp, 'ext', 'mcpctl-pi.ts'); + const skillsDir = join(tmp, 'skills'); + + const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extPath, skillsDir); + + expect(addedExtensions).toEqual([extPath]); + expect(addedSkills).toEqual([skillsDir]); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.extensions).toEqual([extPath]); + expect(written.skills).toEqual([skillsDir]); + }); + + it('is idempotent — running twice does not duplicate entries', async () => { + const settingsPath = join(tmp, 'settings.json'); + const extPath = join(tmp, 'ext', 'mcpctl-pi.ts'); + const skillsDir = join(tmp, 'skills'); + + await registerWithPi(settingsPath, extPath, skillsDir); + const second = await registerWithPi(settingsPath, extPath, skillsDir); + + expect(second.addedExtensions).toEqual([]); + expect(second.addedSkills).toEqual([]); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.extensions).toEqual([extPath]); + expect(written.skills).toEqual([skillsDir]); + }); + + it('preserves existing settings keys', async () => { + const settingsPath = join(tmp, 'settings.json'); + mkdirSync(tmp, { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ theme: 'dark', packages: ['git:foo/bar'] }, null, 2)); + + await registerWithPi(settingsPath, '/abs/ext.ts', '/abs/skills'); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.theme).toBe('dark'); + expect(written.packages).toEqual(['git:foo/bar']); + expect(written.extensions).toEqual(['/abs/ext.ts']); + expect(written.skills).toEqual(['/abs/skills']); + }); + + it('copies the extension files into the pi extension dir', async () => { + const srcDir = join(tmp, 'src-pi-ext'); + const destDir = join(tmp, 'dest-ext'); + mkdirSync(srcDir, { recursive: true }); + writeFileSync(join(srcDir, 'mcpctl-pi.ts'), '// extension\n'); + writeFileSync(join(srcDir, 'mcp-http.ts'), '// client\n'); + + const written = await installExtensionFiles(srcDir, destDir); + + expect(written).toHaveLength(2); + expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n'); + expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n'); + }); +}); diff --git a/src/pi-ext/mcp-http.ts b/src/pi-ext/mcp-http.ts new file mode 100644 index 0000000..c6df53d --- /dev/null +++ b/src/pi-ext/mcp-http.ts @@ -0,0 +1,237 @@ +/** + * Self-contained Streamable-HTTP JSON-RPC client for the pi extension. + * + * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone + * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to + * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to. + * + * Only Node builtins are imported. + */ +import http from 'node:http'; +import https from 'node:https'; + +export interface McpHttpOptions { + bearer?: string; + headers?: Record; + timeoutMs?: number; +} + +export interface ToolInfo { + name: string; + description?: string; + inputSchema?: unknown; +} + +export interface ToolCallResult { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +} + +interface HttpArgs { + url: string; + method: string; + headers?: Record; + body?: string; + timeoutMs?: number; +} + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function rawHttpRequest(opts: HttpArgs): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(opts.url); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: opts.method, + headers: opts.headers, + timeout: opts.timeoutMs ?? 30_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }); + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('MCP request timed out')); + }); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +function parseSse(body: string): unknown[] { + const messages: unknown[] = []; + for (const line of body.split('\n')) { + if (line.startsWith('data: ')) { + try { + messages.push(JSON.parse(line.slice(6))); + } catch { + // skip malformed SSE line + } + } + } + return messages; +} + +export class McpProtocolError extends Error { + constructor(public readonly code: number, message: string) { + super(`MCP error ${code}: ${message}`); + this.name = 'McpProtocolError'; + } +} + +export class McpTransportError extends Error { + constructor(public readonly status: number, public readonly body: string, message?: string) { + super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`); + this.name = 'McpTransportError'; + } +} + +export interface McpResponse { + result?: unknown; + error?: { code: number; message: string }; + notifications: unknown[]; +} + +export class McpHttpSession { + private sessionId: string | undefined; + private nextId = 1; + + constructor( + public readonly url: string, + private readonly options: McpHttpOptions = {}, + ) {} + + private buildHeaders(extra: Record = {}): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + ...(this.options.headers ?? {}), + ...extra, + }; + if (this.sessionId) headers['mcp-session-id'] = this.sessionId; + if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`; + return headers; + } + + /** + * Send a JSON-RPC request, return the matched result AND any notifications + * that were multiplexed into the same body (e.g. tools/list_changed). + */ + async sendFull(method: string, params: Record = {}): Promise { + const id = this.nextId++; + const request = { jsonrpc: '2.0', id, method, params }; + const result = await rawHttpRequest({ + url: this.url, + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(request), + timeoutMs: this.options.timeoutMs, + }); + + if (!this.sessionId) { + const sid = result.headers['mcp-session-id']; + if (typeof sid === 'string') this.sessionId = sid; + } + + if (result.status >= 400) { + let message = `HTTP ${result.status}`; + try { + const body = JSON.parse(result.body) as { error?: string | { message?: string } }; + const errField = body.error; + if (typeof errField === 'string') message = errField; + else if (errField && typeof errField === 'object' && typeof errField.message === 'string') { + message = errField.message; + } + } catch { + message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`; + } + throw new McpTransportError(result.status, result.body, message); + } + + const messages = result.headers['content-type']?.includes('text/event-stream') + ? parseSse(result.body) + : [JSON.parse(result.body)]; + + const notifications: unknown[] = []; + const matched = messages.find((m) => { + const msg = m as { id?: unknown; method?: unknown }; + if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) { + notifications.push(msg); + return false; + } + return msg.id === id; + }) as { result?: unknown; error?: { code: number; message: string } } | undefined; + + const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined); + if (!parsed) throw new Error(`No response for ${method}`); + if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message); + return { result: parsed.result, error: undefined, notifications }; + } + + /** Convenience: return just the result, throwing on error. */ + async send(method: string, params: Record = {}): Promise { + const res = await this.sendFull(method, params); + return res.result; + } + + async sendNotification(method: string, params: Record = {}): Promise { + const notification = { jsonrpc: '2.0', method, params }; + await rawHttpRequest({ + url: this.url, + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(notification), + timeoutMs: this.options.timeoutMs, + }).catch(() => { /* best-effort */ }); + } + + async initialize(): Promise { + return this.send('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'mcpctl-pi', version: '1.0.0' }, + }); + } + + /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */ + async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> { + const raw = await this.send('tools/list'); + const tools = (raw as { tools?: ToolInfo[] }).tools ?? []; + return { tools, raw }; + } + + /** Call a tool. */ + async callTool(name: string, args: Record = {}): Promise { + return this.send('tools/call', { name, arguments: args }) as ToolCallResult; + } + + /** Close the session (DELETE). Safe when no session has been negotiated. */ + async close(): Promise { + if (this.sessionId === undefined) return; + await rawHttpRequest({ + url: this.url, + method: 'DELETE', + headers: this.buildHeaders(), + timeoutMs: 5_000, + }).catch(() => { /* best-effort */ }); + this.sessionId = undefined; + } +} diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts new file mode 100644 index 0000000..2eb726d --- /dev/null +++ b/src/pi-ext/mcpctl-pi.ts @@ -0,0 +1,413 @@ +/** + * mcpctl pi extension — native integration (no MCP client, no Claude). + * + * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers + * each project tool as a native pi tool. + * + * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a + * `mcp-session-id`. A gated project only exposes `begin_session` until it is + * called, after which the full tool set becomes callable — but only within + * THAT session. So we keep ONE persistent `McpHttpSession` per active project + * and route every tools/call through it, re-listing after each call to pick up + * the post-begin_session tool additions. + * + * Features: + * - project gating auto-ungates when begin_session is called + * - switching the active project from the pi TUI (/mcpctl) + * - a status line + * + * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts + * or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"] + * + * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude. + */ +import { Type, type TSchema } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + McpHttpSession, + type ToolInfo, + type McpHttpOptions, +} from "./mcp-http.js"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +// ── config / state ─────────────────────────────────────────────────────────── +const McpctlDir = join(homedir(), ".mcpctl"); +const ConfigPath = join(McpctlDir, "config.json"); +const CredPath = join(McpctlDir, "credentials"); +const PiStatePath = join(McpctlDir, "pi-state.json"); +const ProjectMarker = ".mcpctl-project"; + +interface PiState { + project?: string; +} + +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf-8")) as T; + } catch { + return null; + } +} + +async function readConfig(): Promise<{ mcplocalUrl: string }> { + const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath); + return { mcplocalUrl: cfg?.mcplocalUrl ?? "http://localhost:3200" }; +} + +async function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> { + try { + const raw = await readFile(CredPath, "utf-8"); + const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string }; + if (!parsed.token) return null; + return parsed; + } catch { + return null; + } +} + +async function readPiState(): Promise { + return (await readJson(PiStatePath)) ?? {}; +} + +async function writePiState(state: PiState): Promise { + await mkdir(McpctlDir, { recursive: true }); + await writeFile(PiStatePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); +} + +/** Walk up directories looking for a `.mcpctl-project` marker. */ +async function findProjectMarker(start = process.cwd()): Promise { + const home = homedir(); + let dir = start; + for (let i = 0; i < 50; i++) { + try { + const raw = await readFile(join(dir, ProjectMarker), "utf-8"); + const project = raw.split("\n")[0]?.trim() ?? ""; + if (project) return project; + } catch { + // continue walking + } + if (dir === home || dir === "/") break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** Request the list of projects from mcpd (via mcplocal API passthrough). */ +async function listProjects(mcplocalUrl: string, token?: string): Promise { + const url = `${mcplocalUrl}/api/v1/projects`; + const headers: Record = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`); + const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> }; + const arr = Array.isArray(body) ? body : (body.data ?? []); + return arr.map((p) => p.name); +} + +// ── JSON Schema → TypeBox ──────────────────────────────────────────────────── +function convertSchema(inputSchema: unknown): TSchema { + if (!inputSchema || typeof inputSchema !== "object") { + return Type.Object({}); + } + const s = inputSchema as { + type?: string; + properties?: Record; + required?: string[]; + items?: unknown; + enum?: unknown[]; + }; + + const required = new Set(Array.isArray(s.required) ? s.required : []); + const props = s.properties ?? {}; + const fields: Record = {}; + + for (const [name, raw] of Object.entries(props)) { + const schema = convertProp(raw); + fields[name] = required.has(name) ? schema : Type.Optional(schema); + } + return Type.Object(fields); +} + +function convertProp(raw: unknown): TSchema { + if (!raw || typeof raw !== "object") return Type.Unknown(); + const s = raw as { + type?: string | string[]; + enum?: unknown[]; + description?: string; + items?: unknown; + properties?: Record; + }; + + const desc = typeof s.description === "string" ? s.description : undefined; + const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined; + + if (enumVals && enumVals.every((v) => typeof v === "string")) { + return StringEnum(enumVals as string[]); + } + if (enumVals && enumVals.every((v) => typeof v === "number")) { + const literals = enumVals.map((v) => Type.Literal(v)); + return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals); + } + + let type = s.type; + if (Array.isArray(type)) type = type.find((t) => t !== "null"); + type = type ?? (s.properties ? "object" : s.items ? "array" : "string"); + if (type === "integer") type = "number"; + + switch (type) { + case "string": + return desc ? Type.String({ description: desc }) : Type.String(); + case "number": + return desc ? Type.Number({ description: desc }) : Type.Number(); + case "boolean": + return desc ? Type.Boolean({ description: desc }) : Type.Boolean(); + case "array": { + const inner = s.items ? convertProp(s.items) : Type.Unknown(); + return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner); + } + case "object": + return convertSchema(s as unknown); + default: + return desc ? Type.Unknown({ description: desc }) : Type.Unknown(); + } +} + +/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */ +function safeSegment(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x"; +} + +function slugify(name: string): string { + return safeSegment(name); +} + +/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */ +function piToolName(project: string, mcpTool: string): string { + const proj = slugify(project); + const tool = mcpTool.replace(/^[a-z0-9_-]+\//i, (m) => slugify(m.slice(0, -1)) + "_") || mcpTool; + return `mc_${proj}_${safeSegment(tool)}`; +} + +// ── the extension ──────────────────────────────────────────────────────────── +export default function (pi: ExtensionAPI) { + let mcplocalUrl = "http://localhost:3200"; + let token: string | undefined; + let activeProject: string | null = null; + /** Persistent session for the active project — MUST be reused to keep gate state. */ + let session: McpHttpSession | null = null; + /** Pi tool names currently registered for the active project. */ + const projectToolNames = new Set(); + let activeCtx: ExtensionContext | null = null; + + function endpointUrl(): string { + return `${mcplocalUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(activeProject ?? "")}/mcp`; + } + + /** Build (or reuse) the persistent session for the active project. */ + async function ensureSession(): Promise { + if (session) return session; + const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 }; + const sess = new McpHttpSession(endpointUrl(), opts); + await sess.initialize(); + await sess.sendNotification("notifications/initialized"); + session = sess; + return sess; + } + + async function closeSession(): Promise { + const s = session; + session = null; + if (s) await s.close(); + } + + /** + * (Re)discover the active project's current tool set and register any new pi + * tools. Called on load and after every tool call (to pick up ungating). + */ + async function reconcileTools(project: string): Promise<{ tools: string[] }> { + const sess = await ensureSession(); + const { tools } = await sess.listTools(); + const names: string[] = []; + for (const t of tools) { + const piName = piToolName(project, t.name); + if (!projectToolNames.has(piName)) { + pi.registerTool(createTool(piName, t, project)); + projectToolNames.add(piName); + } + names.push(piName); + } + // Activate built-ins/other extensions + all tools discovered in this project. + const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_")); + pi.setActiveTools([...new Set([...active, ...names])]); + return { tools: names }; + } + + function createTool(piName: string, t: ToolInfo, project: string) { + const mcpName = t.name; + return { + name: piName, + label: `${project} :: ${mcpName}`, + description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`, + parameters: convertSchema(t.inputSchema), + async execute(_toolCallId: string, params: Record) { + const sess = await ensureSession(); + const res = await sess.callTool(mcpName, params ?? {}); + // Re-list after every call — picks up post-begin_session tool additions. + try { + await reconcileTools(project); + } catch { + // non-fatal + } + const text = (res.content ?? []) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("\n"); + if (res.isError) { + throw new Error(text || `${mcpName} returned an error`); + } + return { + content: [{ type: "text", text: text || "(no text content)" }], + details: { mcpTool: mcpName, project, raw: res }, + }; + }, + }; + } + + function setStatus(msg?: string): void { + try { + activeCtx?.ui?.setStatus?.("mcpctl", msg ?? (activeProject ? `project: ${activeProject}` : "no project")); + } catch { + /* non-fatal */ + } + } + + // ── /mcpctl command ── + pi.registerCommand("mcpctl", { + description: "mcpctl integration: status, switch project, refresh tools", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + return `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`; + } + const choice = await ctx.ui.select("mcpctl", [ + { value: "status", label: "Status" }, + { value: "switch", label: "Switch project" }, + { value: "refresh", label: "Refresh tools" }, + { value: "sync skills", label: "Sync skills (mcpctl skills sync)" }, + { value: "close", label: "Close" }, + ]); + if (!choice) return; + + if (choice === "status") { + const cs = await readConfig(); + const creds = await readCreds(); + const info = + `mcplocal: ${cs.mcplocalUrl}\n` + + `user: ${creds?.mcpdUrl ?? "not logged in"}\n` + + `active project: ${activeProject ?? "none"}\n` + + `tools registered: ${projectToolNames.size}\n` + + ` ${[...projectToolNames].join("\n ")}\n`; + await ctx.ui.editor("mcpctl status", info); + return; + } + + if (choice === "switch") { + await switchProject(ctx); + return; + } + + if (choice === "refresh") { + if (!activeProject) { + ctx.ui.notify("No active project — switch project first", "warning"); + return; + } + try { + const r = await reconcileTools(activeProject); + ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, "info"); + setStatus(); + } catch (e) { + ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, "error"); + } + return; + } + + if (choice === "sync skills") { + try { + const { execSync } = await import("node:child_process"); + execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : ""} --quiet`, { + stdio: "inherit", + cwd: process.cwd(), + }); + ctx.ui.notify("mcpctl skills synced (into ~/.claude/skills)", "info"); + } catch (e) { + ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error"); + } + } + }, + }); + + async function switchProject(ctx: ExtensionContext): Promise { + let projects: string[] = []; + try { + projects = await listProjects(mcplocalUrl, token); + } catch (e) { + ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, "error"); + return; + } + if (projects.length === 0) { + ctx.ui.notify("No projects returned by mcpd", "warning"); + return; + } + const picked = await ctx.ui.select("Switch to project", projects); + if (!picked) return; + + // Tear down old project's session + active tools. + await closeSession(); + const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_")); + pi.setActiveTools([...active]); + projectToolNames.clear(); + + activeProject = picked; + await writePiState({ project: picked }); + try { + const r = await reconcileTools(picked); + ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, "info"); + setStatus(); + } catch (e) { + ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error"); + } + } + + // ── session lifecycle ── + pi.on("session_start", async (_event, ctx) => { + activeCtx = ctx; + try { ctx.ui?.setStatus?.("mcpctl", "connecting..."); } catch { /* ignore */ } + const cfg = await readConfig(); + mcplocalUrl = cfg.mcplocalUrl; + const creds = await readCreds(); + token = creds?.token ?? undefined; + + const piState = await readPiState(); + const project = piState.project ?? (await findProjectMarker()); + if (!project) { + try { ctx.ui?.setStatus?.("mcpctl", "no project (use /mcpctl)"); } catch { /* ignore */ } + return; + } + activeProject = project; + try { + const r = await reconcileTools(project); + if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, "info"); + setStatus(); + } catch (e) { + if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, "error"); + } + }); + + pi.on("session_shutdown", () => { + void closeSession(); + }); +} From f1d84b0952e0b12a4d16ed7cbad01b4973afc65e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 17:03:03 +0100 Subject: [PATCH 07/77] fix(config pi): embed extension sources so installed binary is self-contained - add scripts/generate-pi-extension.ts to embed src/pi-ext sources as string constants (mirrors prime-agent's embedded switcher pattern). - config pi now writes the embedded sources by default, so it works from an /usr/bin install with no source tree (--extension-dir overrides for dev). Verified installed binary writes files byte-identical to source. - add installEmbeddedExtension test. --- scripts/generate-pi-extension.ts | 48 +++++++++++++++++++++++++ src/cli/src/commands/config.ts | 26 ++++++-------- src/cli/src/config/pi-extension.ts | 15 ++++++++ src/cli/src/utils/pi-settings.ts | 30 +++++++++++++--- src/cli/tests/utils/pi-settings.test.ts | 17 +++++++++ 5 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 scripts/generate-pi-extension.ts create mode 100644 src/cli/src/config/pi-extension.ts diff --git a/scripts/generate-pi-extension.ts b/scripts/generate-pi-extension.ts new file mode 100644 index 0000000..1cd3e19 --- /dev/null +++ b/scripts/generate-pi-extension.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/pi-extension.ts` which embeds the pi extension + * sources (`mcpctl-pi.ts` + `mcp-http.ts`) as string constants, mirroring how + * prime-agent's `/mcpctl` switcher is embedded (`prime-agent-extension.ts`). + * + * Embedding matters: `mcpctl config pi` must work from an installed binary + * that has no access to the source tree. The installed extension files are this + * exact embedded source, so what the CLI ships is always what pi runs. + * + * Regenerate after editing the extension sources: + * npx tsx scripts/generate-pi-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const piExtDir = join(root, 'src', 'pi-ext'); + +const MAIN = readFileSync(join(piExtDir, 'mcpctl-pi.ts'), 'utf-8'); +const HTTP = readFileSync(join(piExtDir, 'mcp-http.ts'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-pi-extension.ts\` from + * \`src/pi-ext/mcpctl-pi.ts\` and \`src/pi-ext/mcp-http.ts\`. + * + * \`mcpctl config pi\` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; + +export const PI_EXTENSION_FILES: Record = { + 'mcpctl-pi.ts': ${embed(MAIN)}, + 'mcp-http.ts': ${embed(HTTP)}, +}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'pi-extension.ts'), out); +console.log('wrote src/cli/src/config/pi-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 9f0c263..676a5e7 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -13,6 +13,7 @@ import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.j import { installManagedSessionHook } from '../utils/sessionhook.js'; import { installExtensionFiles, + installEmbeddedExtension, registerWithPi, piSkillsDir, piSettingsPath, @@ -506,7 +507,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command('pi') .description('Install the pi extension + sync skills (native, no MCP client, no Claude)') .option('-p, --project ', 'Project name to make active') - .option('--extension-dir ', 'Path to the src/pi-ext sources to install (default: this checkout)') + .option('--extension-dir ', 'Source-tree src/pi-ext dir (dev only; default: embedded sources)') .option('--skip-skills', 'Skip the initial skills sync') .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') @@ -526,25 +527,18 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); - // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/ - let srcDir: string = opts.extensionDir!; - if (!srcDir) { - // best-effort: locate this checkout's src/pi-ext from the running - // script (dist compile) or from cwd (tsx/dev). - const candidates = [ - resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', 'src', 'pi-ext'), - resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', '..', 'src', 'pi-ext'), - resolve(process.cwd(), 'src', 'pi-ext'), - ]; - const { existsSync } = await import('node:fs'); - srcDir = candidates.find((c) => existsSync(join(c, 'mcpctl-pi.ts'))) ?? candidates[0]!; - } + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/. + // Default: write the embedded sources (works from an installed binary + // with no source tree). --extension-dir overrides with a source-tree + // checkout for dev iteration. try { - const written = await installExtensionFiles(srcDir, extDest); + const written = opts.extensionDir + ? await installExtensionFiles(opts.extensionDir, extDest) + : await installEmbeddedExtension(extDest); log(`Installed extension files:`); for (const w of written) log(` ${w}`); } catch (err: unknown) { - log(`Error: could not install extension from '${srcDir}': ${err instanceof Error ? err.message : String(err)}`); + log(`Error: could not install extension: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; return; } diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts new file mode 100644 index 0000000..271e2e6 --- /dev/null +++ b/src/cli/src/config/pi-extension.ts @@ -0,0 +1,15 @@ +/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-pi-extension.ts` from + * `src/pi-ext/mcpctl-pi.ts` and `src/pi-ext/mcp-http.ts`. + * + * `mcpctl config pi` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; + +export const PI_EXTENSION_FILES: Record = { + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Pi tool names currently registered for the active project. */\n const projectToolNames = new Set();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = piToolName(project, t.name);\n if (!projectToolNames.has(piName)) {\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.add(piName);\n }\n names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n content: [{ type: \"text\", text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n return `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`;\n }\n const choice = await ctx.ui.select(\"mcpctl\", [\n { value: \"status\", label: \"Status\" },\n { value: \"switch\", label: \"Switch project\" },\n { value: \"refresh\", label: \"Refresh tools\" },\n { value: \"sync skills\", label: \"Sync skills (mcpctl skills sync)\" },\n { value: \"close\", label: \"Close\" },\n ]);\n if (!choice) return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync skills\") {\n try {\n const { execSync } = await import(\"node:child_process\");\n execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : \"\"} --quiet`, {\n stdio: \"inherit\",\n cwd: process.cwd(),\n });\n ctx.ui.notify(\"mcpctl skills synced (into ~/.claude/skills)\", \"info\");\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await ctx.ui.select(\"Switch to project\", projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n return this.send('tools/call', { name, arguments: args }) as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", +}; diff --git a/src/cli/src/utils/pi-settings.ts b/src/cli/src/utils/pi-settings.ts index 4739d17..db611a4 100644 --- a/src/cli/src/utils/pi-settings.ts +++ b/src/cli/src/utils/pi-settings.ts @@ -2,18 +2,23 @@ * Wiring helpers for the pi extension. * * `mcpctl config pi --project X`: - * 1. copies `src/pi-ext/` (extension + http client) into - * `~/.pi/agent/extensions/mcpctl/`, + * 1. writes the embedded extension sources (`mcpctl-pi.ts` + `mcp-http.ts`) + * into `~/.pi/agent/extensions/mcpctl/`, * 2. adds the extension path and the pi skills dir to * `~/.pi/agent/settings.json` (`extensions`, `skills` arrays), * 3. writes `~/.mcpctl/pi-state.json` so the active project survives across * sessions regardless of the shell's cwd. * + * The extension sources are embedded in the CLI (see + * `config/pi-extension.ts`) so an installed binary with no source tree can + * still provision a working pi integration. + * * Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`. */ import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises'; import { dirname, join, basename, resolve } from 'node:path'; import { homedir } from 'node:os'; +import { PI_EXTENSION_FILES } from '../config/pi-extension.js'; export function piAgentDir(): string { return join(homedir(), '.pi', 'agent'); @@ -54,10 +59,27 @@ export function piStatePath(): string { return join(homedir(), '.mcpctl', 'pi-state.json'); } +/** + * Write the embedded extension sources into the pi extensions dir. This is the + * production path used by `mcpctl config pi` — it needs no source tree, so it + * works from an installed binary. The extension imports its http client via a + * sibling `./mcp-http.js`, so both files must land together. + */ +export async function installEmbeddedExtension(dest: string = piExtensionDir()): Promise { + await mkdir(dest, { recursive: true }); + const written: string[] = []; + for (const [name, src] of Object.entries(PI_EXTENSION_FILES)) { + const dst = join(dest, name); + await writeFile(dst, src, 'utf-8'); + written.push(dst); + } + return written; +} + /** * Copy the bundled extension sources (relative to this source tree's - * `src/pi-ext/`) into the pi extensions dir. The extension imports its http - * client via a sibling `./mcp-http.js`, so both files must land together. + * `src/pi-ext/`) into the pi extensions dir. Kept for tests / dev; production + * uses `installEmbeddedExtension` so installed binaries are self-contained. */ export async function installExtensionFiles( srcPiExtDir: string, diff --git a/src/cli/tests/utils/pi-settings.test.ts b/src/cli/tests/utils/pi-settings.test.ts index 2a9b127..721edae 100644 --- a/src/cli/tests/utils/pi-settings.test.ts +++ b/src/cli/tests/utils/pi-settings.test.ts @@ -6,6 +6,7 @@ import { agentInstallRoot } from '../../src/commands/skills.js'; import { registerWithPi, installExtensionFiles, + installEmbeddedExtension, } from '../../src/utils/pi-settings.js'; describe('skills agentInstallRoot', () => { @@ -89,4 +90,20 @@ describe('pi-settings util', () => { expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n'); expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n'); }); + + it('writes the embedded extension sources (self-contained, no source tree)', async () => { + const destDir = join(tmp, 'embedded-ext'); + + const written = await installEmbeddedExtension(destDir); + + expect(written).toHaveLength(2); + // Both filenames must be written. + expect(written.map((w) => w.replace(destDir + '/', ''))).toEqual(['mcpctl-pi.ts', 'mcp-http.ts']); + // The main file must be non-empty and import the sibling client. + const main = readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8'); + expect(main.length).toBeGreaterThan(1000); + expect(main).toContain('./mcp-http.js'); + const http = readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8'); + expect(http).toContain('McpHttpSession'); + }); }); From 739e679d1ae9112fdd6e4d046df3895eacaafa91 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 17:30:42 +0100 Subject: [PATCH 08/77] fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no eslint config, so nothing ever checked it against pi's API. Pointing tsc at the published @earendil-works/pi-coding-agent types found the command surface to be inert. Fixes: - `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the chosen string; it was called with `{value,label}` objects, so the menu rendered five `[object Object]` rows and `choice === "status"` never matched any branch. Labels are now plain strings mapped back to actions. - The headless branch returned a status string from a handler typed `Promise`; pi drops it. Reports via notify instead. - "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an integration whose stated purpose is to not depend on ~/.claude — and said so in its own success message. It also ran execSync with `stdio: "inherit"`, painting raw output over pi's TUI, and interpolated the project name into a shell string. Now execFile with `--agent pi` and captured output. - Tool results typed `content[].type` as `string`; pi's AgentToolResult wants the `"text"` literal. - `callTool` asserted `Promise` to `ToolCallResult`. - Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs `docs-search`). The colliding tool was silently never registered but still reported active, so its calls were forwarded to the first tool. Names are now disambiguated and tracked with the MCP tool they forward to. - `registerWithPi` rewrote settings.json even when nothing changed. Since parsing strips `//` comments, a no-op run destroyed them. Guards, so this class of bug can't return: - `src/pi-ext/tsconfig.json` checks the extension against the real published pi types (dev dependency, not a shim — a shim drifting from the published API is the exact failure being guarded). Wired into `pnpm typecheck`. - eslint now covers `src/pi-ext/*.ts` like every other source file. - A test fails if the embedded copy in `config/pi-extension.ts` is stale; editing the sources without regenerating silently shipped old code. Also: the branch added `config pi` without regenerating shell completions (the committed-completions test was failing), and the doc advertised `mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note on the session-token vs `mcpctl_pat_` bearer difference that would bite against an authenticated `mcplocal serve`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 18 +- docs/pi-extension.md | 56 +- eslint.config.js | 2 +- package.json | 8 +- pnpm-lock.yaml | 1091 ++++++++++++++++- src/cli/src/config/pi-extension.ts | 4 +- src/cli/src/utils/pi-settings.ts | 7 +- .../tests/config/pi-extension-embed.test.ts | 45 + src/pi-ext/mcp-http.ts | 3 +- src/pi-ext/mcpctl-pi.ts | 98 +- src/pi-ext/tsconfig.json | 26 + 12 files changed, 1279 insertions(+), 84 deletions(-) create mode 100644 src/cli/tests/config/pi-extension-embed.test.ts create mode 100644 src/pi-ext/tsconfig.json diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0e2be01..8dbfce5 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -124,6 +124,9 @@ _mcpctl() { claude-generate) COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; + pi) + COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) + ;; prime-agent) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 325719f..b4b8da1 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -239,7 +239,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 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)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,13 +267,14 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration 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 + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a pi -d 'Install the pi extension + sync skills (native, no MCP client, no Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' @@ -296,6 +297,13 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +# config pi options +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l extension-dir -d 'Source-tree src/pi-ext dir (dev only; default: embedded sources)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l skip-skills -d 'Skip the initial skills sync' +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l settings -d 'pi settings.json path (default: ~/.pi/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l pi-dir -d 'Override the pi agent home (default: ~/.pi/agent)' -x + # config prime-agent options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x @@ -517,14 +525,14 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default) or prime-agent' -x +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, or pi' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by SessionStart hook)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l skip-postinstall -d 'Do not run metadata.postInstall scripts (no-op in v1; reserved)' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l keep-orphans -d 'Do not remove skills that are no longer in the server set' diff --git a/docs/pi-extension.md b/docs/pi-extension.md index 3ca45b3..fcf98fc 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -81,8 +81,17 @@ tools are in the active set at any time. Skills already sync in the Agent Skills `SKILL.md` format. `mcpctl config pi` (when given a project) writes them under pi's own directory `~/.pi/agent/skills/` and registers that directory in `~/.pi/agent/settings.json` -— no Claude involvement. `mcpctl pi sync-skills` (a thin wrapper) re-runs the -existing `skills` code path against the pi target directory. +— no Claude involvement. Re-sync later with: + +```bash +mcpctl skills sync --agent pi --project +``` + +`--agent pi` is what keeps skills out of `~/.claude/skills`; the default target +is still Claude Code. The `/mcpctl` menu's **sync skills** action runs exactly +this command. Sync state is tracked separately per agent +(`~/.mcpctl/skills-state-pi.json`), so pi and Claude never fight over the same +bookkeeping. ## Layout @@ -95,3 +104,46 @@ src/pi-ext/ The extension imports only from pi-bundled packages (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it loads standalone. + +## Typechecking + +The extension ships as *source*: it is embedded into the CLI +(`src/cli/src/config/pi-extension.ts`, generated by +`scripts/generate-pi-extension.ts`) and written verbatim into +`~/.pi/agent/extensions/mcpctl/`. The CLI's own build never compiles it, so +without a dedicated project nothing would check it against pi's API — which is +how a `ctx.ui.select()` call with the wrong option shape shipped in the first +place. + +`src/pi-ext/tsconfig.json` closes that gap, checking against the **real** +published `@earendil-works/pi-coding-agent` types (a dev dependency, not a +hand-written shim — a shim that drifts from the published API is the exact +failure this guards against): + +```bash +pnpm run typecheck # includes typecheck:pi-ext +``` + +After editing `src/pi-ext/*.ts`, regenerate the embedded copy or the CLI will +keep installing the old sources: + +```bash +npx tsx scripts/generate-pi-extension.ts +``` + +A test (`tests/config/pi-extension-embed.test.ts`) fails if you forget. + +## Authentication caveat + +The extension sends the mcpd session token from `~/.mcpctl/credentials` as its +bearer. A locally running `mcpctl-local` daemon does not authenticate +`/projects/*`, so this works — the header is simply ignored. + +`mcplocal serve` is different: it registers a token-auth preHandler that accepts +**only** `mcpctl_pat_` bearers (project mcptokens), and rejects a session token +with `401 Only mcpctl_pat_ bearers are accepted on this endpoint`. Pointing the +extension at an authenticated `mcplocal serve` therefore needs a project +mcptoken (`mcpctl create mcptoken --project `, printed once). +Wiring that token through to the extension is not yet implemented — the +`config prime-agent` path does the equivalent by storing it in +`~/.prime/agent/auth.json`. diff --git a/eslint.config.js b/eslint.config.js index d57ac82..53f1093 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index a238b35..5658419 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -32,7 +32,8 @@ "mcpd:build": "bash scripts/build-mcpd.sh", "mcpd:deploy": "bash deploy.sh", "mcpd:deploy-dry": "bash deploy.sh --dry-run", - "mcpd:logs": "bash logs.sh" + "mcpd:logs": "bash logs.sh", + "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json" }, "engines": { "node": ">=20.0.0", @@ -40,6 +41,8 @@ }, "packageManager": "pnpm@9.15.0", "devDependencies": { + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-coding-agent": "0.84.1", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -48,6 +51,7 @@ "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", "tsx": "^4.21.0", + "typebox": "1.3.11", "typescript": "^5.9.3", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10ef364..0a6c99d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,36 +8,45 @@ importers: .: devDependencies: + '@earendil-works/pi-ai': + specifier: ^0.84.1 + version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-coding-agent': + specifier: 0.84.1 + version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@types/node': specifier: ^25.3.0 version: 25.3.0 '@typescript-eslint/eslint-plugin': specifier: ^8.56.0 - version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.56.0 - version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^10.0.1 - version: 10.0.1(jiti@2.6.1) + version: 10.0.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.0.1(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.1(jiti@2.7.0)) rimraf: specifier: ^6.1.3 version: 6.1.3 tsx: specifier: ^4.21.0 version: 4.21.0 + typebox: + specifier: 1.3.11 + version: 1.3.11 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) src/cli: dependencies: @@ -234,7 +243,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.1.16 - version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.7.0 version: 6.9.1 @@ -252,7 +261,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) jsdom: specifier: ^28.0.0 version: 28.1.0 @@ -261,7 +270,7 @@ importers: version: 4.2.4 vite: specifier: ^7.2.0 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) packages: @@ -275,6 +284,15 @@ packages: resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -289,6 +307,103 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.31': + resolution: {integrity: sha512-/BRzvkp46mF6eXBL/l9WKPQQfifLlUPaWli6n9/T/WDLUg8he7TCyuNFnk6RvHP5j5W/kMj5Gxw7W778LJaXDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.26': + resolution: {integrity: sha512-2eIvouTZoxPu5ClHY6ij13De1yhY8Rmllt0dlGeBNXX3wmR7fU1pvMCGb50fKm1GxcuntWK0t1T0cMjTyDoUQA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.49': + resolution: {integrity: sha512-wGYJvu1/stdWuzGcNyh44u8aY7ArU8XFrMesBlzIYn70HWVCRBNEngQexDuPIMu0NEgsKGKmTc0uvnS/bF7KWw==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.8': + resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -423,6 +538,36 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@earendil-works/pi-agent-core@0.84.1': + resolution: {integrity: sha512-evyzXYWCLQGmcaBYHlmSku02r8qoN4SGI60GZABo6iV+H+nqX+P9ud8fEZ4GmRq9mUSREvvfX+w9dA9ThF9C6w==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.84.1': + resolution: {integrity: sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-client@0.84.1': + resolution: {integrity: sha512-/V5hGHE4Zq+jG0GtwIB9PyBUOGd6gBLZ7lkQYFKchKnxYHeH3rmWC5xw4kpnZKKBuBuFTdLVbU9vEjlAGMMb2A==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@0.84.1': + resolution: {integrity: sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-protocol@0.84.1': + resolution: {integrity: sha512-Ox1pciyeSPGEEUcxvR0/dJcrY7C6hrEGA8y71rOsvSIUlXN1Cbp/be/eoL71OGDBk5O97TeQPfWN6Ju/2Ehjww==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@0.84.1': + resolution: {integrity: sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@0.84.1': + resolution: {integrity: sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==} + engines: {node: '>=22.19.0'} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -661,6 +806,15 @@ packages: resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -1031,6 +1185,77 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -1164,6 +1389,10 @@ packages: resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1489,6 +1718,49 @@ packages: resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==} engines: {node: ^20.17.0 || >=22.9.0} + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1688,6 +1960,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -1989,6 +2264,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bin-links@6.0.2: resolution: {integrity: sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2000,6 +2278,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2010,11 +2291,18 @@ packages: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2227,6 +2515,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2287,6 +2579,10 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2312,6 +2608,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2489,6 +2788,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -2535,6 +2837,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2570,6 +2876,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2605,6 +2915,14 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + gaxios@7.3.0: + resolution: {integrity: sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + geist@1.7.0: resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} peerDependencies: @@ -2622,6 +2940,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2659,6 +2981,14 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} @@ -2669,6 +2999,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grok-mermaid@0.2.2: + resolution: {integrity: sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2692,6 +3026,9 @@ packages: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.0: resolution: {integrity: sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==} engines: {node: '>=16.9.0'} @@ -2889,6 +3226,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -2920,6 +3261,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2930,6 +3274,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2965,6 +3313,12 @@ packages: just-diff@6.0.2: resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3097,6 +3451,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3148,6 +3507,10 @@ packages: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3261,6 +3624,11 @@ packages: node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3273,6 +3641,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3371,6 +3743,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} @@ -3390,6 +3774,10 @@ packages: resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} engines: {node: '>=18'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3409,6 +3797,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3512,6 +3903,9 @@ packages: promise-call-limit@3.0.2: resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -3634,6 +4028,14 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3702,6 +4104,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -3995,6 +4402,9 @@ packages: resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4028,6 +4438,12 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typebox@1.3.11: + resolution: {integrity: sha512-tZGKIS02Opbh4EYMmAEVuXl+y3EQJ9ZlkitLZ1CmFCY4ZOqr/xWR9dDSCFmIjNDICGMWkOqMh6WxGTyRXqcSGQ==} + + typebox@1.3.7: + resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4050,6 +4466,10 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -4167,6 +4587,10 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4268,6 +4692,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4306,6 +4735,12 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -4326,6 +4761,220 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/util-locate-window': 3.965.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.2 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/eventstream-handler-node': 3.972.31 + '@aws-sdk/middleware-eventstream': 3.972.26 + '@aws-sdk/middleware-websocket': 3.972.49 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.6': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.73': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.31': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.26': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.49': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.41': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1103.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.8': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4472,6 +5121,92 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@earendil-works/pi-agent-core@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-telemetry': 0.84.1 + diff: 8.0.4 + ignore: 7.0.5 + typebox: 1.3.7 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@earendil-works/pi-telemetry': 0.84.1 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.19.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.3.7 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-client@0.84.1': + dependencies: + '@earendil-works/pi-protocol': 0.84.1 + + '@earendil-works/pi-coding-agent@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-client': 0.84.1 + '@earendil-works/pi-protocol': 0.84.1 + '@earendil-works/pi-tui': 0.84.1 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + grok-mermaid: 0.2.2 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.3.7 + undici: 8.9.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-protocol@0.84.1': + dependencies: + typebox: 1.3.7 + + '@earendil-works/pi-telemetry@0.84.1': {} + + '@earendil-works/pi-tui@0.84.1': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -4555,9 +5290,9 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.7.0))': dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4647,6 +5382,19 @@ snapshots: '@gar/promise-retry@1.0.3': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -4994,6 +5742,62 @@ snapshots: - encoding - supports-color + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 + ws: 8.19.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.0) @@ -5175,6 +5979,8 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)': @@ -5489,6 +6295,61 @@ snapshots: '@sigstore/core': 3.2.1 '@sigstore/protobuf-specs': 0.5.1 + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -5556,12 +6417,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@testing-library/dom@10.4.1': dependencies: @@ -5686,6 +6547,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/retry@0.12.0': {} + '@types/semver@7.7.1': {} '@types/ssh2@1.15.5': @@ -5699,15 +6562,15 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5715,14 +6578,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5745,13 +6608,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5774,13 +6637,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5790,7 +6653,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5798,11 +6661,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -5814,7 +6677,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/expect@4.0.18': dependencies: @@ -5825,13 +6688,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -6010,6 +6873,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@9.3.1: {} + bin-links@6.0.2: dependencies: cmd-shim: 8.0.0 @@ -6038,6 +6903,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6051,6 +6918,10 @@ snapshots: dependencies: balanced-match: 4.0.3 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.3 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.23 @@ -6059,6 +6930,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -6247,6 +7120,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -6284,6 +7159,8 @@ snapshots: diff@8.0.3: {} + diff@8.0.4: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -6321,6 +7198,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.18.4: @@ -6411,9 +7292,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.7.0)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) eslint-scope@9.1.1: dependencies: @@ -6426,9 +7307,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.0.1(jiti@2.6.1): + eslint@10.0.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.2 '@eslint/config-helpers': 0.5.2 @@ -6459,7 +7340,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -6555,6 +7436,8 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -6612,6 +7495,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -6662,6 +7550,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6695,6 +7587,22 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gaxios@7.3.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.0 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + geist@1.7.0(next@16.2.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): dependencies: next: 16.2.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -6705,6 +7613,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6753,7 +7663,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -6766,12 +7676,27 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.0 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + google-protobuf@3.21.4: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} + grok-mermaid@0.2.2: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -6788,6 +7713,8 @@ snapshots: helmet@7.2.0: {} + highlight.js@10.7.3: {} + hono@4.12.0: {} hosted-git-info@7.0.2: @@ -6979,6 +7906,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.1.3: {} js-tokens@10.0.0: {} @@ -7020,6 +7949,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@5.0.0: {} @@ -7028,6 +7961,11 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -7052,6 +7990,17 @@ snapshots: just-diff@6.0.2: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7175,6 +8124,8 @@ snapshots: marked@14.0.0: {} + marked@18.0.5: {} + math-intrinsics@1.1.0: {} mdn-data@2.27.1: {} @@ -7207,6 +8158,10 @@ snapshots: dependencies: brace-expansion: 5.0.2 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -7313,12 +8268,20 @@ snapshots: node-addon-api@5.1.0: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -7433,6 +8396,11 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@6.26.0(ws@8.19.0)(zod@3.25.76): + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + openid-client@6.8.2: dependencies: jose: 6.1.3 @@ -7457,6 +8425,11 @@ snapshots: p-map@7.0.5: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + package-json-from-dist@1.0.1: {} pacote@21.5.1: @@ -7493,6 +8466,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + patch-console@2.0.0: {} path-exists@4.0.0: {} @@ -7590,6 +8565,12 @@ snapshots: promise-call-limit@3.0.2: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7708,6 +8689,10 @@ snapshots: ret@0.5.0: {} + retry@0.12.0: {} + + retry@0.13.1: {} + reusify@1.1.0: {} rfc4648@1.5.4: {} @@ -7792,6 +8777,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -7827,7 +8814,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -8165,6 +9152,8 @@ snapshots: treeverse@3.0.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8202,6 +9191,10 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.3.11: {} + + typebox@1.3.7: {} + typescript@5.9.3: {} undici-types@5.26.5: {} @@ -8214,6 +9207,8 @@ snapshots: undici@7.25.0: {} + undici@8.9.0: {} + unpipe@1.0.0: {} upath@1.2.0: {} @@ -8241,7 +9236,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -8252,15 +9247,15 @@ snapshots: optionalDependencies: '@types/node': 25.3.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.32.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.9.0 - vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -8277,10 +9272,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.9.0 '@types/node': 25.3.0 jsdom: 28.1.0 transitivePeerDependencies: @@ -8302,6 +9297,8 @@ snapshots: walk-up-path@4.0.0: {} + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -8384,6 +9381,8 @@ snapshots: yaml@2.8.2: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index 271e2e6..2aab540 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Pi tool names currently registered for the active project. */\n const projectToolNames = new Set();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = piToolName(project, t.name);\n if (!projectToolNames.has(piName)) {\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.add(piName);\n }\n names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n content: [{ type: \"text\", text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n return `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`;\n }\n const choice = await ctx.ui.select(\"mcpctl\", [\n { value: \"status\", label: \"Status\" },\n { value: \"switch\", label: \"Switch project\" },\n { value: \"refresh\", label: \"Refresh tools\" },\n { value: \"sync skills\", label: \"Sync skills (mcpctl skills sync)\" },\n { value: \"close\", label: \"Close\" },\n ]);\n if (!choice) return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync skills\") {\n try {\n const { execSync } = await import(\"node:child_process\");\n execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : \"\"} --quiet`, {\n stdio: \"inherit\",\n cwd: process.cwd(),\n });\n ctx.ui.notify(\"mcpctl skills synced (into ~/.claude/skills)\", \"info\");\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await ctx.ui.select(\"Switch to project\", projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", - 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n return this.send('tools/call', { name, arguments: args }) as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await ctx.ui.select(\"Switch to project\", projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/src/utils/pi-settings.ts b/src/cli/src/utils/pi-settings.ts index db611a4..dee4d41 100644 --- a/src/cli/src/utils/pi-settings.ts +++ b/src/cli/src/utils/pi-settings.ts @@ -152,7 +152,12 @@ export async function registerWithPi( addedSkills.push(skillsDir); } - await writeSettings(settingsPath, settings); + // Only rewrite when something actually changed. Re-serialising drops any + // comments the file had (readSettings strips them to parse), so a no-op run + // must not silently reformat a hand-maintained settings.json. + if (addedExtensions.length > 0 || addedSkills.length > 0) { + await writeSettings(settingsPath, settings); + } return { addedExtensions, addedSkills }; } diff --git a/src/cli/tests/config/pi-extension-embed.test.ts b/src/cli/tests/config/pi-extension-embed.test.ts new file mode 100644 index 0000000..5453278 --- /dev/null +++ b/src/cli/tests/config/pi-extension-embed.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PI_EXTENSION_FILES, PI_EXTENSION_FILENAMES } from '../../src/config/pi-extension.js'; + +/** + * `mcpctl config pi` installs the *embedded* copy of the extension, not the + * files in src/pi-ext/. Editing the sources without re-running the generator + * therefore ships stale code to users while the repo looks correct — and the + * embedded copy is the one thing no typecheck covers. Same guarantee the + * completions check gives. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const piExtDir = join(repoRoot, 'src', 'pi-ext'); + +describe('embedded pi extension', () => { + it('matches the sources in src/pi-ext (re-run scripts/generate-pi-extension.ts)', () => { + for (const name of PI_EXTENSION_FILENAMES) { + const onDisk = readFileSync(join(piExtDir, name), 'utf-8'); + expect(PI_EXTENSION_FILES[name], `${name} is embedded`).toBeDefined(); + expect(PI_EXTENSION_FILES[name], `${name} is stale — regenerate the embed`).toBe(onDisk); + } + }); + + it('embeds every file the extension needs to load', () => { + // mcpctl-pi.ts imports ./mcp-http.js — installing one without the other + // yields an extension pi cannot load. + expect(Object.keys(PI_EXTENSION_FILES).sort()).toEqual(['mcp-http.ts', 'mcpctl-pi.ts']); + expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); + }); + + it('carries the fixes the pi API requires', () => { + const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; + // ctx.ui.select takes string[] and returns the chosen string. + expect(main).not.toMatch(/select\([^)]*\[\s*\{\s*value:/); + // Skills must land in pi's tree, never ~/.claude: the sync passes + // --agent pi and no longer tells the user it wrote to Claude's tree. + expect(main).toContain('"--agent", "pi"'); + expect(main).not.toContain('(into ~/.claude/skills)'); + // Spawned without a shell, so the project name is never interpolated into + // a command string. Matches the call, not prose mentioning it. + expect(main).toContain('execFile("mcpctl", args'); + expect(main).not.toMatch(/execSync\s*\(/); + }); +}); diff --git a/src/pi-ext/mcp-http.ts b/src/pi-ext/mcp-http.ts index c6df53d..dda0cb8 100644 --- a/src/pi-ext/mcp-http.ts +++ b/src/pi-ext/mcp-http.ts @@ -220,7 +220,8 @@ export class McpHttpSession { /** Call a tool. */ async callTool(name: string, args: Record = {}): Promise { - return this.send('tools/call', { name, arguments: args }) as ToolCallResult; + const result = await this.send('tools/call', { name, arguments: args }); + return result as ToolCallResult; } /** Close the session (DELETE). Safe when no session has been negotiated. */ diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index 2eb726d..1d3dcd4 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -200,8 +200,8 @@ export default function (pi: ExtensionAPI) { let activeProject: string | null = null; /** Persistent session for the active project — MUST be reused to keep gate state. */ let session: McpHttpSession | null = null; - /** Pi tool names currently registered for the active project. */ - const projectToolNames = new Set(); + /** Registered pi tool name → the MCP tool it forwards to, for the active project. */ + const projectToolNames = new Map(); let activeCtx: ExtensionContext | null = null; function endpointUrl(): string { @@ -234,19 +234,41 @@ export default function (pi: ExtensionAPI) { const { tools } = await sess.listTools(); const names: string[] = []; for (const t of tools) { - const piName = piToolName(project, t.name); - if (!projectToolNames.has(piName)) { - pi.registerTool(createTool(piName, t, project)); - projectToolNames.add(piName); - } + const piName = uniqueToolName(project, t.name); + if (piName === null) continue; // already registered for this same MCP tool + pi.registerTool(createTool(piName, t, project)); + projectToolNames.set(piName, t.name); names.push(piName); } + // Names already registered for tools still in the list stay active. + for (const [piName, mcpName] of projectToolNames) { + if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName); + } // Activate built-ins/other extensions + all tools discovered in this project. const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_")); pi.setActiveTools([...new Set([...active, ...names])]); return { tools: names }; } + /** + * The pi tool name to register `mcpTool` under, or null if it is already + * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g. + * `docs.search` and `docs-search`); without a suffix the second tool would + * silently never be registered while still being reported as available, and + * its calls would be forwarded to the first tool instead. + */ + function uniqueToolName(project: string, mcpTool: string): string | null { + const base = piToolName(project, mcpTool); + if (projectToolNames.get(base) === mcpTool) return null; + if (!projectToolNames.has(base)) return base; + for (let i = 2; i < 100; i++) { + const candidate = `${base}_${String(i)}`; + if (projectToolNames.get(candidate) === mcpTool) return null; + if (!projectToolNames.has(candidate)) return candidate; + } + return null; + } + function createTool(piName: string, t: ToolInfo, project: string) { const mcpName = t.name; return { @@ -271,7 +293,9 @@ export default function (pi: ExtensionAPI) { throw new Error(text || `${mcpName} returned an error`); } return { - content: [{ type: "text", text: text || "(no text content)" }], + // `type` must be the literal "text" — pi's AgentToolResult content is + // a TextContent | ImageContent union, not { type: string }. + content: [{ type: "text" as const, text: text || "(no text content)" }], details: { mcpTool: mcpName, project, raw: res }, }; }, @@ -287,20 +311,34 @@ export default function (pi: ExtensionAPI) { } // ── /mcpctl command ── + /** + * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the + * chosen string, so the mapping has to live here rather than in the options. + */ + const MENU: ReadonlyArray = [ + ["Status", "status"], + ["Switch project", "switch"], + ["Refresh tools", "refresh"], + ["Sync skills into pi's skills dir", "sync"], + ["Close", "close"], + ]; + pi.registerCommand("mcpctl", { description: "mcpctl integration: status, switch project, refresh tools", handler: async (_args, ctx) => { if (!ctx.hasUI) { - return `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`; + // A command handler resolves to void — pi ignores a returned string, so + // headless status has to go through notify. + ctx.ui.notify( + `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`, + "info", + ); + return; } - const choice = await ctx.ui.select("mcpctl", [ - { value: "status", label: "Status" }, - { value: "switch", label: "Switch project" }, - { value: "refresh", label: "Refresh tools" }, - { value: "sync skills", label: "Sync skills (mcpctl skills sync)" }, - { value: "close", label: "Close" }, - ]); - if (!choice) return; + const picked = await ctx.ui.select("mcpctl", MENU.map(([label]) => label)); + if (!picked) return; + const choice = MENU.find(([label]) => label === picked)?.[1]; + if (!choice || choice === "close") return; if (choice === "status") { const cs = await readConfig(); @@ -335,14 +373,28 @@ export default function (pi: ExtensionAPI) { return; } - if (choice === "sync skills") { + if (choice === "sync") { try { - const { execSync } = await import("node:child_process"); - execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : ""} --quiet`, { - stdio: "inherit", - cwd: process.cwd(), + // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it + // this syncs into ~/.claude/skills, which the whole point of this + // integration is to avoid depending on. + const args = ["skills", "sync", "--agent", "pi", "--quiet"]; + if (activeProject) args.push("-p", activeProject); + // execFile, not execSync+stdio:"inherit": a shell would need the + // project name quoted, and inherited stdio writes raw output over + // pi's TUI. Capture instead and report through notify. + const { execFile } = await import("node:child_process"); + const stderr = await new Promise((resolve, reject) => { + execFile("mcpctl", args, { timeout: 120_000 }, (err, _stdout, errOut) => { + if (err) reject(new Error((errOut || err.message).trim())); + else resolve(errOut); + }); }); - ctx.ui.notify("mcpctl skills synced (into ~/.claude/skills)", "info"); + const detail = stderr.trim().split("\n").pop(); + ctx.ui.notify( + `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : ""}. Run /reload to pick them up.`, + "info", + ); } catch (e) { ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error"); } diff --git a/src/pi-ext/tsconfig.json b/src/pi-ext/tsconfig.json new file mode 100644 index 0000000..174f31c --- /dev/null +++ b/src/pi-ext/tsconfig.json @@ -0,0 +1,26 @@ +{ + "//": [ + "The pi extension is shipped as source (embedded in the CLI, then written", + "into ~/.pi/agent/extensions/) and is therefore never compiled by the CLI's", + "own build. Without this project it was typechecked by nothing, which is how", + "a ctx.ui.select() call with the wrong option shape shipped.", + "", + "It is checked against the REAL @earendil-works/pi-coding-agent types (a dev", + "dependency) rather than a hand-written shim, because a shim drifting from", + "the published API is the exact failure mode this guards against." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-pi.ts", "mcp-http.ts"] +} From 24a3b8cc0a360e89e49aa0cf6e798548a29abf42 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 17:51:36 +0100 Subject: [PATCH 09/77] feat(pi): filter the project picker instead of scrolling hundreds of rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi's selector is a plain arrow-key list: `ExtensionUIDialogOptions` has no search field and `ExtensionSelectorComponent` ignores typed characters, so filtering has to happen before the list is handed over. With 356 projects — most of them `smoke-proj-none-*` leftovers — arrowing to the one you want is hopeless. Above 20 projects the picker now asks for a filter first. Terms are space-separated and all must match as case-insensitive substrings, so `home auto` finds `homeautomation`. Blank shows everything, Esc cancels. The active project sorts first (most likely pick), then alphabetical. A result set over 50 is capped, and the title says what was dropped — a silently truncated list reads as "that's all of them". The ordering and matching are extracted into an exported `filterProjects` so they are unit-tested rather than eyeballed through a TUI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/pi-extension.ts | 2 +- .../tests/config/pi-project-filter.test.ts | 73 +++++++++++++++++++ src/pi-ext/mcpctl-pi.ts | 67 ++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/cli/tests/config/pi-project-filter.test.ts diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index 2aab540..aacff9d 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await ctx.ui.select(\"Switch to project\", projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /** Never render more than this in one selector, even after filtering. */\n const MAX_SHOWN = 50;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/tests/config/pi-project-filter.test.ts b/src/cli/tests/config/pi-project-filter.test.ts new file mode 100644 index 0000000..0e4c4f1 --- /dev/null +++ b/src/cli/tests/config/pi-project-filter.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { filterProjects } from '../../../pi-ext/mcpctl-pi.js'; + +/** + * pi's selector is a plain arrow-key list — no search — so the project picker + * filters before handing the list over. Real installs run to hundreds of + * projects, where scrolling is hopeless. + */ +const PROJECTS = [ + 'smoke-proj-none-mohimh46', + 'homeautomation', + 'docmost', + 'copy-homeautomation', + 'labctl', + 'mcpctl-development', + 'sre', +]; + +describe('filterProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(filterProjects(PROJECTS, '', 'labctl')).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'mcpctl-development', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(filterProjects(PROJECTS, '', null)[0]).toBe('copy-homeautomation'); + }); + + it('keeps everything for a blank or whitespace query', () => { + expect(filterProjects(PROJECTS, '', null)).toHaveLength(PROJECTS.length); + expect(filterProjects(PROJECTS, ' ', null)).toHaveLength(PROJECTS.length); + }); + + it('matches case-insensitive substrings', () => { + expect(filterProjects(PROJECTS, 'HOMEAUTO', null)).toEqual([ + 'copy-homeautomation', + 'homeautomation', + ]); + }); + + it('requires every space-separated term to match', () => { + // "home auto" finds homeautomation even though the terms aren't adjacent... + expect(filterProjects(PROJECTS, 'home auto', null)).toEqual([ + 'copy-homeautomation', + 'homeautomation', + ]); + // ...and a term that matches nothing eliminates the row. + expect(filterProjects(PROJECTS, 'home zzz', null)).toEqual([]); + }); + + it('narrows the smoke-test noise that motivated this', () => { + const many = [...PROJECTS, ...Array.from({ length: 300 }, (_, i) => `smoke-proj-none-x${String(i)}`)]; + expect(filterProjects(many, 'smoke', null)).toHaveLength(301); + expect(filterProjects(many, 'ctl', null)).toEqual(['labctl', 'mcpctl-development']); + }); + + it('leaves the caller with an empty list rather than a bad match', () => { + expect(filterProjects(PROJECTS, 'nosuchproject', null)).toEqual([]); + }); + + it('does not mutate the input', () => { + const input = [...PROJECTS]; + filterProjects(input, 'home', 'sre'); + expect(input).toEqual(PROJECTS); + }); +}); diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index 1d3dcd4..4414efc 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -177,6 +177,30 @@ function convertProp(raw: unknown): TSchema { } } +/** + * Order and filter the project list for the picker. + * + * Ordering: the active project first (most likely pick), then alphabetical. + * Filtering: space-separated terms, ALL of which must appear as + * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank + * query keeps everything. + * + * Exported so the behaviour is unit-tested rather than eyeballed through a TUI. + */ +export function filterProjects(projects: string[], query: string, active: string | null): string[] { + const ordered = [...projects].sort((a, b) => { + if (a === active) return -1; + if (b === active) return 1; + return a.localeCompare(b); + }); + const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0); + if (terms.length === 0) return ordered; + return ordered.filter((p) => { + const name = p.toLowerCase(); + return terms.every((t) => name.includes(t)); + }); +} + /** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */ function safeSegment(name: string): string { return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x"; @@ -402,6 +426,47 @@ export default function (pi: ExtensionAPI) { }, }); + /** Above this many projects, arrowing through the list stops being usable. */ + const FILTER_THRESHOLD = 20; + /** Never render more than this in one selector, even after filtering. */ + const MAX_SHOWN = 50; + + /** + * Choose a project, asking for a filter first when the list is long. + * + * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no + * search and `ExtensionSelectorComponent` ignores typed characters — so the + * filtering has to happen before the list is handed over. Real installs run + * to hundreds of projects (smoke-test leftovers included), where scrolling is + * hopeless. + * + * Terms are space-separated and ALL must match, case-insensitively, as + * substrings: `home auto` finds `homeautomation`. Blank shows everything. + */ + async function pickProject(ctx: ExtensionContext, projects: string[]): Promise { + let candidates = filterProjects(projects, "", activeProject); + if (candidates.length > FILTER_THRESHOLD) { + const query = await ctx.ui.input( + `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`, + "e.g. home auto", + ); + if (query === undefined) return undefined; // cancelled + candidates = filterProjects(projects, query, activeProject); + if (candidates.length === 0) { + ctx.ui.notify(`No project matches '${query}'`, "warning"); + return undefined; + } + } + + const truncated = candidates.length > MAX_SHOWN; + const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates; + const title = truncated + // Say what was dropped: a silently capped list reads as "that's all of them". + ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)` + : `Switch to project (${String(shown.length)})`; + return ctx.ui.select(title, shown); + } + async function switchProject(ctx: ExtensionContext): Promise { let projects: string[] = []; try { @@ -414,7 +479,7 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("No projects returned by mcpd", "warning"); return; } - const picked = await ctx.ui.select("Switch to project", projects); + const picked = await pickProject(ctx, projects); if (!picked) return; // Tear down old project's session + active tools. From 47809a8942f8c57887d6cb2da3ae5fc4a499d9ae Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:05:33 +0100 Subject: [PATCH 10/77] fix(pi): correct the model's tool context after a project switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching projects twice left the agent believing it had lost MCP access entirely: it kept calling the previous project's tool names, got "Tool mc__begin_session not found", and concluded no MCP tools existed. Root cause is a pi constraint, not a bug in the switch. pi has no way to unregister a tool — `registerTool` only ever does `extension.tools.set(name)` — so the previous project's `mc_*` tools stay registered and merely go inactive. `setActiveTools` correctly drops them from the live set, but the conversation still contains the old project's tool listing, so the model keeps calling names that now answer "not found". Nothing was telling the model the tool set had changed. Now the switch injects a custom message naming the active project, stating that previously listed mcpctl tool names are dead, and listing what is actually callable. Custom messages are converted to user-role messages by `convertToLlm`, so they do reach the model (unlike `appendEntry`, which is explicitly excluded from context). `display: false` keeps it out of the transcript — the notification is what the human reads. The gate is also called out explicitly, in both the notification and the injected message. A freshly switched project gets a new mcp-session-id and is therefore gated again, so "1 tool(s) ready" is correct but reads like a failure; it now says which begin_session call unlocks the rest. `toolChangeAnnouncement` is exported and unit-tested rather than left as wording only reachable through a TUI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/pi-extension.ts | 2 +- .../tests/config/pi-project-filter.test.ts | 32 ++++++++++- src/pi-ext/mcpctl-pi.ts | 56 ++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index aacff9d..f2d5a88 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /** Never render more than this in one selector, even after filtering. */\n const MAX_SHOWN = 50;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /** Never render more than this in one selector, even after filtering. */\n const MAX_SHOWN = 50;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/tests/config/pi-project-filter.test.ts b/src/cli/tests/config/pi-project-filter.test.ts index 0e4c4f1..5ffdfcf 100644 --- a/src/cli/tests/config/pi-project-filter.test.ts +++ b/src/cli/tests/config/pi-project-filter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { filterProjects } from '../../../pi-ext/mcpctl-pi.js'; +import { filterProjects, toolChangeAnnouncement } from '../../../pi-ext/mcpctl-pi.js'; /** * pi's selector is a plain arrow-key list — no search — so the project picker @@ -71,3 +71,33 @@ describe('filterProjects', () => { expect(input).toEqual(PROJECTS); }); }); + +describe('toolChangeAnnouncement', () => { + it('names the active project and the live tools', () => { + const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']); + expect(msg).toContain("active project is now 'sre'"); + expect(msg).toContain('mc_sre_begin_session'); + }); + + it('tells the model to ignore the previous project\'s tool names', () => { + // pi cannot unregister tools, so stale names stay in the conversation and + // the model keeps calling them until told otherwise. + expect(toolChangeAnnouncement('sre', ['mc_sre_x'])).toMatch(/no longer callable|ignore them/); + }); + + it('calls out gating so the model knows to unlock the rest', () => { + const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']); + expect(msg).toContain('gated'); + expect(msg).toMatch(/call mc_sre_begin_session first/); + }); + + it('omits the gate hint when the project is not gated', () => { + const msg = toolChangeAnnouncement('docmost', ['mc_docmost_search', 'mc_docmost_get_page']); + expect(msg).not.toContain('gated'); + expect(msg).toContain('mc_docmost_search, mc_docmost_get_page'); + }); + + it('is explicit when a project exposes nothing', () => { + expect(toolChangeAnnouncement('empty', [])).toContain('No mcpctl tools are currently available'); + }); +}); diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index 4414efc..da3de79 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -201,6 +201,28 @@ export function filterProjects(projects: string[], query: string, active: string }); } +/** + * The message injected into the conversation after a project switch, telling + * the model which mcpctl tools are live now. + * + * Exported so its wording is unit-tested; it is the only thing standing between + * the model and a stale tool list it will otherwise keep calling. + */ +export function toolChangeAnnouncement(project: string, tools: string[]): string { + const gate = tools.find((n) => n.endsWith("_begin_session")); + const parts = [ + `[mcpctl] The active project is now '${project}'.`, + "mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.", + tools.length > 0 + ? `Currently available mcpctl tools: ${tools.join(", ")}.` + : "No mcpctl tools are currently available for this project.", + ]; + if (gate !== undefined) { + parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`); + } + return parts.join(" "); +} + /** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */ function safeSegment(name: string): string { return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x"; @@ -492,13 +514,45 @@ export default function (pi: ExtensionAPI) { await writePiState({ project: picked }); try { const r = await reconcileTools(picked); - ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, "info"); + const gate = r.tools.find((n) => n.endsWith("_begin_session")); + ctx.ui.notify( + `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : ""}`, + "info", + ); setStatus(); + announceToolChange(picked, r.tools); } catch (e) { ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error"); } } + /** + * Tell the *model* that the tool set changed. + * + * pi cannot unregister a tool — `registerTool` only ever adds to the + * extension's tool map — so the previous project's `mc_*` tools stay + * registered and merely go inactive. Meanwhile the conversation still + * contains the old project's tool list, so the model keeps calling names that + * now answer "Tool ... not found" and concludes it has lost MCP access + * entirely, which is what happens in practice on the second switch. + * + * A custom message is converted to a user-role message and does reach the + * LLM (unlike `appendEntry`, which is explicitly excluded from context), so + * this corrects the stale context instead of leaving the model to guess. + * `display: false` keeps it out of the transcript — the notify above is what + * the human reads. + */ + function announceToolChange(project: string, tools: string[]): void { + try { + pi.sendMessage( + { customType: "mcpctl-project-switch", content: toolChangeAnnouncement(project, tools), display: false }, + { triggerTurn: false, deliverAs: "nextTurn" }, + ); + } catch { + // Older pi without sendMessage — the notify above still informs the user. + } + } + // ── session lifecycle ── pi.on("session_start", async (_event, ctx) => { activeCtx = ctx; From 8b006263de1d23b87ec415258fc0abc670dbd364 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:16:22 +0100 Subject: [PATCH 11/77] fix(skills): write the frontmatter every Agent Skills host requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpd keeps `name` and `description` as columns, and a skill's `content` is often just the body — `propose-learnings` starts straight at its `#` heading. The sync wrote `full.content` verbatim, so the SKILL.md landed with no YAML frontmatter and the host rejected it. Observed live: [Skill warning] ~/.prime/agent/skills/propose-learnings/SKILL.md description is required Claude Code and pi require the same keys, so this affected every target; it only surfaced now because prime-agent prints the warning at startup. `ensureSkillFrontmatter` synthesises the block from the columns when it is absent, and fills in only a missing `name`/`description` when the author already supplied a header — an existing complete header is never rewritten. Values are emitted as JSON strings (valid YAML double-quoted scalars) because descriptions routinely contain `:` and `#`, which break a bare scalar. Also: `--force` now re-fetches skills whose server content is unchanged. Without it a skill already on disk could never be repaired by a client-side fix — the content hash still matched, so it was skipped forever, which is exactly what happened on the first attempt to repair the file above. Three existing assertions compared SKILL.md to the raw server content; they now assert the body survives rather than exact bytes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/commands/skills.ts | 12 ++++- src/cli/src/utils/skills-disk.ts | 34 ++++++++++++ .../tests/utils/prime-agent-skills.test.ts | 11 ++-- src/cli/tests/utils/skill-frontmatter.test.ts | 53 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 src/cli/tests/utils/skill-frontmatter.test.ts diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 7c53397..89f95e6 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -14,6 +14,7 @@ import { } from '../utils/skills-state.js'; import { installSkillAtomic, + ensureSkillFrontmatter, removeSkillAtomic, type SkillBody, } from '../utils/skills-disk.js'; @@ -230,7 +231,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise(`/api/v1/skills/${encodeURIComponent(v.id)}`); const body: SkillBody = { - content: full.content, + // mcpd keeps name/description as columns, so a skill whose content is + // just the body would land on disk without the frontmatter every host + // requires and be silently rejected. + content: ensureSkillFrontmatter(full.content, full.name || v.name, full.description || v.description || ''), ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), }; const fileStates = await installSkillAtomic(targetDir, body); diff --git a/src/cli/src/utils/skills-disk.ts b/src/cli/src/utils/skills-disk.ts index a58c15a..23d2264 100644 --- a/src/cli/src/utils/skills-disk.ts +++ b/src/cli/src/utils/skills-disk.ts @@ -18,6 +18,40 @@ export interface SkillBody { files?: Record; } +/** + * Guarantee the YAML frontmatter every Agent Skills host requires. + * + * mcpd stores `name` and `description` as columns, and a skill's `content` is + * frequently just the body — `propose-learnings` starts straight at its `#` + * heading. Written verbatim, such a skill is rejected by the agent that loads + * it (prime-agent logs `description is required` and never loads it; Claude + * Code and pi need the same keys), so the sync has to put the frontmatter back. + * + * Content that already carries a block is left alone apart from filling in a + * missing `name`/`description` — we never rewrite a skill author's own header. + */ +export function ensureSkillFrontmatter(content: string, name: string, description: string): string { + const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(content); + if (match === null) { + return `---\nname: ${yamlScalar(name)}\ndescription: ${yamlScalar(description)}\n---\n\n${content}`; + } + const block = match[1] ?? ''; + const additions: string[] = []; + if (!/^name[ \t]*:/m.test(block)) additions.push(`name: ${yamlScalar(name)}`); + if (!/^description[ \t]*:/m.test(block)) additions.push(`description: ${yamlScalar(description)}`); + if (additions.length === 0) return content; + return `---\n${[block, ...additions].join('\n')}\n---\n${content.slice(match[0].length)}`; +} + +/** + * Emit a YAML scalar. Descriptions routinely contain `:` and `#`, which break a + * bare scalar — a JSON string is a valid YAML double-quoted scalar and escapes + * quotes and newlines correctly. + */ +function yamlScalar(value: string): string { + return JSON.stringify(value); +} + /** * Write a skill atomically into `targetDir`. If a previous install exists, * it's renamed to `.mcpctl-trash-` and rmtree'd after the diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index 284c346..72f14a8 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -192,7 +192,8 @@ describe('runPrimeAgentSkillsSync', () => { const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; const clientA = mockClient({ visible: av, full: af }); await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); - expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); // Project B also has a skill named X with different content. const bv = [ @@ -204,7 +205,8 @@ describe('runPrimeAgentSkillsSync', () => { expect(resultB.preserved).toContain('x-skill'); // A's version is untouched (not clobbered by B's). - expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); }); it('does not delete legacy, ownership-less state belonging to another project', async () => { @@ -279,7 +281,7 @@ describe('runPrimeAgentSkillsSync', () => { expect(resultB.updated).toContain('shared-global'); expect(resultB.preserved).toEqual([]); - expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toBe('# v2\n'); + expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toContain('# v2'); }); it('does not let a global-only sync clobber a project-owned skill', async () => { @@ -299,7 +301,8 @@ describe('runPrimeAgentSkillsSync', () => { const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf }))); expect(result.preserved).toContain('x-skill'); - expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); }); it('removes global orphans on a global-only sync', async () => { diff --git a/src/cli/tests/utils/skill-frontmatter.test.ts b/src/cli/tests/utils/skill-frontmatter.test.ts new file mode 100644 index 0000000..dce0a11 --- /dev/null +++ b/src/cli/tests/utils/skill-frontmatter.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { ensureSkillFrontmatter } from '../../src/utils/skills-disk.js'; + +/** + * mcpd stores name/description as columns; a skill's `content` is often just + * the body. Written verbatim it lands on disk without the frontmatter every + * Agent Skills host requires — prime-agent logs "description is required" and + * refuses to load it. Observed live with the `propose-learnings` skill. + */ +describe('ensureSkillFrontmatter', () => { + it('adds frontmatter to a body that has none', () => { + const out = ensureSkillFrontmatter('# propose-learnings\n\nBody text.\n', 'propose-learnings', 'How to capture knowledge.'); + expect(out).toBe( + '---\nname: "propose-learnings"\ndescription: "How to capture knowledge."\n---\n\n# propose-learnings\n\nBody text.\n', + ); + }); + + it('leaves an existing complete header untouched', () => { + const original = '---\nname: commit\ndescription: Commit changes\n---\n\n# commit\n'; + expect(ensureSkillFrontmatter(original, 'commit', 'something else')).toBe(original); + }); + + it('fills in only the missing key, preserving the author\'s other fields', () => { + const out = ensureSkillFrontmatter('---\nname: x\nallowed-tools: Bash\n---\n\nBody\n', 'x', 'The description'); + expect(out).toContain('allowed-tools: Bash'); + expect(out).toContain('description: "The description"'); + // name was already there — not duplicated + expect(out.match(/name:/g)).toHaveLength(1); + expect(out).toContain('\n\nBody\n'); + }); + + it('quotes descriptions containing YAML metacharacters', () => { + // A bare scalar breaks on ':' and '#'; these are common in descriptions. + const out = ensureSkillFrontmatter('body', 'n', 'Use when: debugging #hard problems'); + expect(out).toContain('description: "Use when: debugging #hard problems"'); + }); + + it('escapes quotes and newlines rather than emitting invalid YAML', () => { + const out = ensureSkillFrontmatter('body', 'n', 'He said "hi"\nthen left'); + expect(out).toContain('description: "He said \\"hi\\"\\nthen left"'); + }); + + it('handles CRLF frontmatter delimiters', () => { + const out = ensureSkillFrontmatter('---\r\nname: x\r\n---\r\nBody', 'x', 'D'); + expect(out).toContain('description: "D"'); + expect(out).toContain('Body'); + }); + + it('still emits the key when the description is empty', () => { + // Can't invent one, but the key must exist for the host to parse it. + expect(ensureSkillFrontmatter('body', 'n', '')).toContain('description: ""'); + }); +}); From ba05b2865c515b3602680ec3b3b1c9d3a20a334a Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:33:23 +0100 Subject: [PATCH 12/77] feat(prime-agent): filterable project picker + a visible active-project indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps the pi switcher had already closed. **The indicator was invisible.** `ctx.ui.setStatus` was the wrong channel: prime-agent stores extension statuses (`FooterDataProvider.setExtensionStatus`) but nothing ever reads them back — there is no `getExtensionStatuses()` call site anywhere in the app, so the value was recorded and never rendered. pi's `footer.js` does render them, which is why the same code showed a status line there and nothing here. The active project is now published as a *widget* (`extensionWidgetsBelow` → `renderWidgets()`), which prime-agent does render. `setStatus` is still called so pi keeps its footer entry. **No search.** With 356 projects the picker was a wall of `smoke-mcptoken-*`. Above 20 projects it now asks for a filter first; space-separated terms must all match, case-insensitively, against name or description, so `home auto` finds `homeautomation`. Active project sorts first, then alphabetical. Deliberately no client-side truncation here, unlike the pi picker: pi's `ExtensionSelectorComponent.updateList()` renders every option, so a long list fills the screen and has to be capped. prime-agent's selector windows the list itself and shows a true `(20/356)` counter — capping would replace an accurate total with a misleading one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 32 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 6509cbd..1a8cf46 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our footer entry is stored under (see ctx.ui.setStatus). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Publish the active project into prime-agent's footer, alongside the model\n * name — so the current mcpctl project is always visible rather than something\n * you have to run a command to discover. Cleared when no project is mounted.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n ctx.ui.setStatus(STATUS_KEY, active ? `mcpctl:${active}` : undefined);\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 9d8a627..ac6c8f9 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -528,6 +528,38 @@ describe('config prime-agent', () => { expect(ext).not.toContain("'success'"); }); + it('the installed switcher shows the active project via a widget, not just setStatus', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // prime-agent stores extension statuses but never renders them, so a + // setStatus-only indicator is invisible there. Widgets are rendered. + expect(ext).toContain('ctx.ui.setWidget(STATUS_KEY'); + expect(ext).toContain("placement: 'belowEditor'"); + // Still set the status: pi's footer does render it. + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + }); + + it('the installed switcher filters the project list before showing it', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Hundreds of projects in a plain arrow-key list is unusable. + expect(ext).toContain('export function filterProjects'); + expect(ext).toContain('ctx.ui.input('); + expect(ext).toContain('FILTER_THRESHOLD'); + }); + it('the installed switcher extension passes --skip-marker', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand( From bc83d36a6835558e9ad294bc96d0bd6ece599a21 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:34:10 +0100 Subject: [PATCH 13/77] fix(pi): cap the project picker at 20 rows so it does not fill the screen pi's ExtensionSelectorComponent.updateList() renders every option with no windowing, so 50 rows scrolled the transcript away. prime-agent's selector windows to ~20 itself and shows a true (20/356) counter; matching that height makes both hosts behave the same. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/pi-extension.ts | 2 +- src/pi-ext/mcpctl-pi.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index f2d5a88..35ccf26 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /** Never render more than this in one selector, even after filtering. */\n const MAX_SHOWN = 50;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index da3de79..d895720 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -450,8 +450,13 @@ export default function (pi: ExtensionAPI) { /** Above this many projects, arrowing through the list stops being usable. */ const FILTER_THRESHOLD = 20; - /** Never render more than this in one selector, even after filtering. */ - const MAX_SHOWN = 50; + /** + * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList() + * renders *every* option with no windowing, so an uncapped list scrolls the + * whole screen away. prime-agent's selector windows to ~20 itself; this + * matches that height so both hosts feel the same. + */ + const MAX_SHOWN = 20; /** * Choose a project, asking for a filter first when the list is long. From a1893a75781e767a4e9396c466cd6c8e6ba938fe Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:38:52 +0100 Subject: [PATCH 14/77] fix(prime-agent): publish the project indicator on turn_start, not only session_start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prime-agent emits session_start from exactly one place — reload() — and never at startup. A session_start-only handler therefore never ran on a fresh session, and the '/mcpctl -> already on X' path returns without reloading, so the indicator stayed blank exactly when it was most wanted. Also guard against the no-op UI context: the runtime hands extensions noOpUIContext until the TUI binds the real one, and every setter on it silently discards. Publishing under it would cache a label that never rendered. Now published on session_start (reload), turn_start (earliest reliable point on a fresh session), and on entering /mcpctl. Repeat calls are a no-op unless the label changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 1a8cf46..26c9ce7 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nlet lastPublished: string | undefined;\n\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n if (label === lastPublished) return; // cheap enough to call on every turn\n lastPublished = label;\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index ac6c8f9..1969e24 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -543,6 +543,9 @@ describe('config prime-agent', () => { expect(ext).toContain("placement: 'belowEditor'"); // Still set the status: pi's footer does render it. expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + // prime-agent emits session_start only from reload(), never at startup, so + // a session_start-only indicator stays blank until the first switch. + expect(ext).toContain("pi.on('turn_start'"); }); it('the installed switcher filters the project list before showing it', async () => { From a90dd091f044c0f6ce6f1f07c53015b268ed09c3 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:49:42 +0100 Subject: [PATCH 15/77] fix(smoke): actually clean up smoke-test resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 341 of 356 projects on the shared mcpd were smoke-test leftovers, plus 11 never-expiring `smoke-*` mcptokens sitting on the real `mcpctl-development` and `sre` projects. Enough to make the project picker unusable. Two causes, both silent: - `delete project X --force` — `--force` is valid on `create project` but NOT on `delete`, so every cleanup call exited non-zero and deleted nothing. project-llm-ref's afterAll looked correct and had never worked, which is why there were 84 each of smoke-proj-{ok,orphan,none}-*. - mcptoken.smoke had no afterAll at all; its cleanup was an `it()` at the end of the file, so it was skipped whenever an earlier assertion failed. Cleanup now lives in `afterAll` (runs on failure too) and is no longer gated on the health probe: the project is created through mcpd, which can be up when the gateway probe is not, and deleting a project that was never created is a no-op. Adds `pnpm smoke:clean` for the case afterAll cannot cover — a killed process (CI timeout, Ctrl-C, OOM). Dry-run by default, `--yes` to apply. Only touches `smoke-*` names and protects the shared `smoke-data` / `smoke-aws-docs` fixture, which five suites depend on and which must survive a concurrent run. Verified end to end: created a throwaway smoke project, confirmed the dry run lists without deleting, then `--yes` removed it and left all 15 real projects untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- package.json | 3 +- scripts/clean-smoke-resources.ts | 101 ++++++++++++++++++ .../tests/smoke/mcptoken.smoke.test.ts | 22 +++- .../tests/smoke/project-llm-ref.smoke.test.ts | 17 +-- 4 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 scripts/clean-smoke-resources.ts diff --git a/package.json b/package.json index 5658419..de493e0 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "mcpd:deploy": "bash deploy.sh", "mcpd:deploy-dry": "bash deploy.sh --dry-run", "mcpd:logs": "bash logs.sh", - "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json" + "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", + "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { "node": ">=20.0.0", diff --git a/scripts/clean-smoke-resources.ts b/scripts/clean-smoke-resources.ts new file mode 100644 index 0000000..238ebe3 --- /dev/null +++ b/scripts/clean-smoke-resources.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Sweep per-run smoke-test resources off the shared mcpd. + * + * The suites clean up in `afterAll`, which covers assertion failures but not a + * killed process (CI timeout, Ctrl-C, OOM). Over time that leaked hundreds of + * `smoke-*` projects and never-expiring `smoke-*` mcptokens onto the shared + * server, to the point where the project picker was unusable. + * + * Run after a smoke run, or any time the server looks cluttered: + * pnpm smoke:clean # show what would go + * pnpm smoke:clean --yes # actually delete + * + * Only names matching SMOKE_PREFIX are ever considered, and the shared fixture + * (`smoke-data` / `smoke-aws-docs`, provisioned from + * tests/smoke/fixtures/smoke-data.yaml and depended on by five suites) is + * protected — deleting it mid-run would break a concurrent suite. + */ +import { execFileSync } from 'node:child_process'; + +const SMOKE_PREFIX = /^smoke-/; + +/** Shared, intentionally long-lived fixture — never swept. */ +const PROTECTED = new Set(['smoke-data', 'smoke-aws-docs']); + +const APPLY = process.argv.includes('--yes') || process.argv.includes('-y'); + +interface Named { id?: string; name?: string; projectName?: string; status?: string } + +function mcpctl(args: string[]): string { + return execFileSync('mcpctl', args, { encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 }); +} + +function list(resource: string, extra: string[] = []): Named[] { + try { + const out = mcpctl(['get', resource, '-o', 'json', ...extra]); + const parsed: unknown = JSON.parse(out || '[]'); + return Array.isArray(parsed) ? (parsed as Named[]) : []; + } catch { + return []; + } +} + +function sweepable(rows: Named[]): Named[] { + return rows.filter((r) => typeof r.name === 'string' && SMOKE_PREFIX.test(r.name) && !PROTECTED.has(r.name)); +} + +function remove(resource: string, row: Named, extra: string[] = []): boolean { + const name = row.name ?? row.id ?? ''; + if (!SMOKE_PREFIX.test(name) || PROTECTED.has(name)) return false; // belt and braces + try { + mcpctl(['delete', resource, name, ...extra]); + return true; + } catch (err) { + process.stderr.write(` ! ${resource} ${name}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}\n`); + return false; + } +} + +const projects = sweepable(list('projects')); +// Tokens live on real projects too (the suites mint smoke-* tokens against +// mcpctl-development and sre), so they need sweeping independently of projects. +const tokens = sweepable(list('mcptoken')); +const servers = sweepable(list('servers')); +const llms = sweepable(list('llms')); +const agents = sweepable(list('agents')); + +const groups: Array<[string, Named[], string[]]> = [ + ['project', projects, []], + ['mcptoken', tokens, []], + ['server', servers, []], + ['llm', llms, []], + ['agent', agents, []], +]; + +let total = 0; +for (const [, rows] of groups) total += rows.length; + +if (total === 0) { + process.stdout.write('smoke:clean — nothing to sweep\n'); + process.exit(0); +} + +for (const [kind, rows] of groups) { + if (rows.length === 0) continue; + process.stdout.write(`${kind}: ${String(rows.length)}\n`); + for (const r of rows.slice(0, 5)) process.stdout.write(` ${r.name ?? ''}${r.projectName ? ` (in ${r.projectName})` : ''}\n`); + if (rows.length > 5) process.stdout.write(` … and ${String(rows.length - 5)} more\n`); +} + +if (!APPLY) { + process.stdout.write(`\n${String(total)} resource(s) would be deleted. Re-run with --yes to apply.\n`); + process.exit(0); +} + +let deleted = 0; +for (const [kind, rows, extra] of groups) { + for (const r of rows) if (remove(kind, r, extra)) deleted++; +} +process.stdout.write(`\nsmoke:clean — deleted ${String(deleted)}/${String(total)}\n`); +process.exit(deleted === total ? 0 : 1); diff --git a/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts b/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts index 1053533..0411ae0 100644 --- a/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts +++ b/src/mcplocal/tests/smoke/mcptoken.smoke.test.ts @@ -14,7 +14,7 @@ * * Run with: pnpm test:smoke */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { execSync } from 'node:child_process'; @@ -87,9 +87,23 @@ describe('mcptoken smoke', () => { } }, 20_000); + // Cleanup belongs in afterAll, not an `it()`: a failing assertion earlier in + // the file must not strand server-side projects. Deliberately NOT gated on + // `gatewayUp` — the project is created through mcpd, which can be reachable + // even when the gateway healthz probe is not, and deleting something that was + // never created is a harmless no-op. + // + // `delete project` takes no --force (only `create project` does); passing it + // made every one of these calls exit non-zero and silently do nothing, which + // is how hundreds of smoke-* projects accumulated on the shared mcpd. + afterAll(() => { + run(`delete project ${PROJECT_NAME}`); + run(`delete project ${OTHER_PROJECT}`); + }); + it('creates the project and a project-scoped mcptoken', () => { if (!gatewayUp) return; - run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort + run(`delete project ${PROJECT_NAME}`); // cleanup leftovers — best-effort const createProj = run(`create project ${PROJECT_NAME} --force`); expect(createProj.code).toBe(0); @@ -149,10 +163,8 @@ describe('mcptoken smoke', () => { expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i); }, 20_000); - it('cleans up test fixtures', () => { + it('recorded a tool name for the gated catalogue', () => { if (!gatewayUp) return; - run(`delete project ${PROJECT_NAME} --force`); - run(`delete project ${OTHER_PROJECT} --force`); expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true); }); }); diff --git a/src/mcplocal/tests/smoke/project-llm-ref.smoke.test.ts b/src/mcplocal/tests/smoke/project-llm-ref.smoke.test.ts index 5c85d35..d442ca2 100644 --- a/src/mcplocal/tests/smoke/project-llm-ref.smoke.test.ts +++ b/src/mcplocal/tests/smoke/project-llm-ref.smoke.test.ts @@ -81,17 +81,20 @@ describe('project-llm-ref smoke', () => { } }, 30_000); + // Not gated on `mcpdUp`: if creation got far enough to leave a project + // behind, cleanup must still run. Deleting a non-existent project is a no-op. + // (`delete project` takes no --force — passing it made these calls fail + // silently and leak a project per run.) afterAll(() => { - if (!mcpdUp) return; - run(`delete project ${PROJ_OK} --force`); - run(`delete project ${PROJ_ORPHAN} --force`); - run(`delete project ${PROJ_NONE} --force`); + run(`delete project ${PROJ_OK}`); + run(`delete project ${PROJ_ORPHAN}`); + run(`delete project ${PROJ_NONE}`); run(`delete llm ${LLM_NAME}`); }); it('project with --llm pointing at a registered Llm describes without warning', () => { if (!mcpdUp) return; - run(`delete project ${PROJ_OK} --force`); + run(`delete project ${PROJ_OK}`); const created = run(`create project ${PROJ_OK} --llm ${LLM_NAME}`); expect(created.code, created.stderr || created.stdout).toBe(0); @@ -104,7 +107,7 @@ describe('project-llm-ref smoke', () => { it('project with --llm naming an unregistered Llm shows the warning line', () => { if (!mcpdUp) return; - run(`delete project ${PROJ_ORPHAN} --force`); + run(`delete project ${PROJ_ORPHAN}`); const created = run(`create project ${PROJ_ORPHAN} --llm claude-ghost-${SUFFIX}`); expect(created.code, created.stderr || created.stdout).toBe(0); @@ -117,7 +120,7 @@ describe('project-llm-ref smoke', () => { it('project with --llm none treats it as an explicit disable (no warning)', () => { if (!mcpdUp) return; - run(`delete project ${PROJ_NONE} --force`); + run(`delete project ${PROJ_NONE}`); const created = run(`create project ${PROJ_NONE} --llm none`); expect(created.code).toBe(0); From 0a29c2fd7f829c9f156c8434a7a2921b0bf48149 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 19:52:07 +0100 Subject: [PATCH 16/77] fix(prime-agent): re-publish the indicator after prime-agent clears extension widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified with a probe extension rather than by reading the bundle: session_start fires with hasUI=true, ctx.ui.setWidget exists and the call returns without throwing — and the widget still never appeared. Cause is prime-agent wiping it immediately afterwards. resetExtensionUI() -> clearExtensionWidgets() runs from onBeforeSessionInvalidate and from the connection-state-snapshot handler, both of which land after session_start, so the indicator was set and cleared before it could be seen. Nothing re-set it until a turn, which is why a fresh session with no messages showed nothing. Re-publishes at 1s/3s/6s after session_start to land past that reset. setWidget is idempotent, so a redundant retry costs one re-render. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 26c9ce7..bbeb9e6 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nlet lastPublished: string | undefined;\n\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n if (label === lastPublished) return; // cheap enough to call on every turn\n lastPublished = label;\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n // Deliberately NOT skipped when the label is unchanged. prime-agent's\n // renderWidgets() bails out early while the TUI containers do not exist yet\n // if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;\n // and session_start fires before they are built, so that first call stores\n // the widget without ever drawing it. Only a later setWidget call re-renders,\n // so caching the label here means the indicator never appears at all.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension widgets shortly after startup:\n // resetExtensionUI() -> clearExtensionWidgets() runs from\n // onBeforeSessionInvalidate and from the connection-state-snapshot\n // handler, both of which land *after* session_start. The indicator set\n // above is therefore cleared before it is ever seen. Re-publish a couple\n // of times to land after that reset; setWidget is idempotent, so the only\n // cost of an unnecessary retry is one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; From 90c49bcb22080c8fac092ce591820336c960ead1 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 19:59:24 +0100 Subject: [PATCH 17/77] refactor(prime-agent): drop the widget fallback, keep the tray status With prime-agent-extension-status.patch in place the tray renders ctx.ui.setStatus() next to the model name, which is what a status line should be. The widget was a workaround for its absence and was never a substitute: widgetContainerBelow sits in the fullscreen *scroll* list, not the dock, so it scrolled away with the transcript, and with both set the project name appeared twice. The startup retries stay: resetExtensionUI() clears extension statuses just as it cleared widgets, so the value set during session_start is still wiped before it can be seen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index bbeb9e6..39f7966 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n // Deliberately NOT skipped when the label is unchanged. prime-agent's\n // renderWidgets() bails out early while the TUI containers do not exist yet\n // if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;\n // and session_start fires before they are built, so that first call stores\n // the widget without ever drawing it. Only a later setWidget call re-renders,\n // so caching the label here means the indicator never appears at all.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension widgets shortly after startup:\n // resetExtensionUI() -> clearExtensionWidgets() runs from\n // onBeforeSessionInvalidate and from the connection-state-snapshot\n // handler, both of which land *after* session_start. The indicator set\n // above is therefore cleared before it is ever seen. Re-publish a couple\n // of times to land after that reset; setWidget is idempotent, so the only\n // cost of an unnecessary retry is one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 1969e24..4507a83 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -537,12 +537,12 @@ describe('config prime-agent', () => { await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); - // prime-agent stores extension statuses but never renders them, so a - // setStatus-only indicator is invisible there. Widgets are rendered. - expect(ext).toContain('ctx.ui.setWidget(STATUS_KEY'); - expect(ext).toContain("placement: 'belowEditor'"); - // Still set the status: pi's footer does render it. + // Both hosts render statuses next to the model name (pi in its footer, + // prime-agent in the tray — the latter via prime-agent-extension-status.patch). expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + // The widget was a workaround for the unpatched tray; it scrolled away with + // the transcript, so it is not a status line and must not come back. + expect(ext).not.toContain('setWidget'); // prime-agent emits session_start only from reload(), never at startup, so // a session_start-only indicator stays blank until the first switch. expect(ext).toContain("pi.on('turn_start'"); From be2a5cb1892d96cf4c4d31d2098fa3fb1cdfad88 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 21:03:53 +0100 Subject: [PATCH 18/77] =?UTF-8?q?feat(opencode):=20native=20opencode=20int?= =?UTF-8?q?egration=20=E2=80=94=20/mcpctl=20switcher,=20live=20project=20s?= =?UTF-8?q?witching,=20footer=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills sync target, so an mcpctl project can be switched from inside opencode's TUI and the active one is visible at a glance. Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into the host's config. opencode exposes an HTTP API for its own MCP registry (`POST /mcp`), so the project is mounted through the running app: - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a mode-0644 opencode.json users paste into bug reports; - switching projects takes effect on the next turn, with no restart. Inside opencode: /mcpctl filterable project picker; switches live /mcpctl-status active project, mount state, gateway URL /mcpctl-skills re-sync this project's skills plus a `mcpctl:` indicator in the prompt footer, next to the model name and one line above the token counter. Design notes: - the MCP server is registered under a constant name, so tools keep a stable `mcpctl_*` prefix and opencode's per-request tool resolution shows the new project's tools by itself — no "your old tool names are dead" message to the model, unlike the pi extension; - an unchanged mount is never re-registered: mcp.add rebuilds the connection and mcplocal binds a gated project's unlocked state to that connection's mcp-session-id, so re-adding would re-lock a project begin_session had just opened; - the server plugin does not mount during setup — setup runs before the server accepts connections and mcp.add calls back into it, which hangs opencode on a blank screen before the TUI draws; - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token minting, state and skills stay in one place; - no usable credential aborts non-zero with the state file untouched, so a failed switch leaves the previous project working rather than swapping it for a mount that 401s. `skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG aware) with the same shared-tree semantics as pi and prime-agent. The credential plumbing shared with `config prime-agent` is lifted to one place and parameterised by agent rather than copied. The plugin sources are embedded in the CLI (generated, freshness-tested) so an installed binary with no source tree can provision them, and are typechecked against the real @opencode-ai/plugin types. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 42 + completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 17 +- docs/opencode-extension.md | 186 ++++ eslint.config.js | 2 +- package.json | 8 +- pnpm-lock.yaml | 947 +++++++++++++++++- scripts/generate-opencode-extension.ts | 62 ++ src/cli/src/commands/config.ts | 405 ++++++-- src/cli/src/commands/skills.ts | 63 +- src/cli/src/config/opencode-extension.ts | 27 + src/cli/src/utils/opencode-settings.ts | 204 ++++ .../tests/commands/config-opencode.test.ts | 210 ++++ .../config/opencode-extension-embed.test.ts | 74 ++ .../config/opencode-project-order.test.ts | 67 ++ src/cli/tests/utils/opencode-settings.test.ts | 169 ++++ src/cli/vitest.config.ts | 7 + src/opencode-ext/mcpctl-opencode-tui.tsx | 298 ++++++ src/opencode-ext/mcpctl-opencode.ts | 165 +++ src/opencode-ext/tsconfig.json | 28 + 20 files changed, 2858 insertions(+), 128 deletions(-) create mode 100644 docs/opencode-extension.md create mode 100644 scripts/generate-opencode-extension.ts create mode 100644 src/cli/src/config/opencode-extension.ts create mode 100644 src/cli/src/utils/opencode-settings.ts create mode 100644 src/cli/tests/commands/config-opencode.test.ts create mode 100644 src/cli/tests/config/opencode-extension-embed.test.ts create mode 100644 src/cli/tests/config/opencode-project-order.test.ts create mode 100644 src/cli/tests/utils/opencode-settings.test.ts create mode 100644 src/opencode-ext/mcpctl-opencode-tui.tsx create mode 100644 src/opencode-ext/mcpctl-opencode.ts create mode 100644 src/opencode-ext/tsconfig.json diff --git a/README.md b/README.md index 3f7f839..60fa274 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,48 @@ Inside pi: See [docs/pi-extension.md](docs/pi-extension.md) for full details. +### Connect opencode + +[opencode](https://opencode.ai) supports MCP natively *and* exposes an API for +its own MCP registry, so mcpctl mounts the project through the running app — +which means you can **switch projects from the TUI without restarting**. + +```bash +mcpctl config opencode --project monitoring +``` + +This mints (or reuses) the project's gateway token into +`~/.mcpctl/opencode-state.json` (0600 — the token never lands in +`opencode.json`), installs a server plugin into +`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into +`~/.config/opencode/mcpctl/`, registers the latter in +`~/.config/opencode/tui.json`, and syncs skills into +`~/.config/opencode/skill/`. + +Inside opencode: + +- `/mcpctl` — **switch project** from a filterable picker; takes effect on the + next turn, no restart +- `/mcpctl-status` — active project, mount state, gateway URL +- `/mcpctl-skills` — re-sync this project's skills +- the active project shows as `mcpctl:` in the prompt footer, next to + the model name + +Skip individual steps as needed: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +``` + +Re-sync skills on their own with +`mcpctl skills sync --agent opencode --project monitoring`. + +See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8dbfce5..f9719fc 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -133,6 +133,9 @@ _mcpctl() { prime-agent-generate) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; + opencode) + COMPREPLY=($(compgen -W "-p --project --gateway-url --token --opencode-dir --skip-skills --skip-plugin --skip-marker --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index b4b8da1..92b6c96 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -267,7 +267,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' @@ -277,6 +277,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a pi -d 'Install the pi extension + sync skills (native, no MCP client, no Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a opencode -d 'Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -324,6 +325,16 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' +# config opencode options +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l token -d 'mcpctl project bearer token to use (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l opencode-dir -d 'Override opencode\'s config dir (default: ~/.config/opencode)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d 'Do not (re)install or register the opencode plugins' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -525,11 +536,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, or pi' -x +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, pi, or opencode' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md new file mode 100644 index 0000000..5a5430d --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,186 @@ +# mcpctl × opencode — project switching from the TUI + +## Motivation + +`mcpctl config claude` and `mcpctl config prime-agent` both work the same way: +write the project's MCP server into the host's config file, then restart the +host so it picks the change up. That is fine when you pick a project once, and +tiresome when you switch between `homeautomation`, `sre` and `docmost` all day. + +[opencode](https://opencode.ai) can do better, because it exposes two things +the other hosts do not: + +- an **HTTP API for its own MCP registry** (`POST /mcp`, + `/mcp/{name}/disconnect`) — so a mount can be re-pointed while the app runs; +- a **TUI plugin API** (`tui.json`) with slash commands, dialogs and UI slots — + so the switcher can be a first-class part of the interface rather than a + shell command you run in another terminal. + +So the opencode integration does not write an MCP entry into `opencode.json` at +all. It ships two plugins, and switching projects takes effect on the next turn +with no restart. + +## How it works + +``` +~/.mcpctl/opencode-state.json (0600) + { project, gatewayUrl, tokens: { : } } + │ + │ read by both plugins + ▼ +~/.config/opencode/plugin/mcpctl.ts server plugin ─┐ +~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤ + │ client.mcp.add({ + │ name: "mcpctl", + ▼ type: "remote", url, headers }) + opencode's MCP registry + │ + ▼ + https:///projects//mcp +``` + +### The state file, not `opencode.json` + +Two reasons the project does not live in opencode's own config: + +1. **The token.** The gateway needs `Authorization: Bearer `. + `opencode.json` is a mode-0644 file people paste into bug reports; + `~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential. +2. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. + +Tokens are kept **per project**, so switching back to a project you have +already used needs no new mint — and a failed mint for project B cannot cost +you the credential for project A. + +### The server plugin (`plugin/mcpctl.ts`) + +Auto-discovered by opencode. It mounts the active project's gateway under the +fixed MCP server name `mcpctl`, on the first server event and again before +every user turn. + +Two details matter: + +- **The name is constant.** Tools keep a stable `mcpctl_*` prefix across + switches, and because opencode re-resolves the tool list per request, the + model simply sees the new project's tools on its next turn. (Contrast pi, + where the extension has to *tell* the model its old tool names are dead.) +- **It does not re-register an unchanged mount.** `mcp.add` rebuilds the + connection, and mcplocal binds a gated project's unlocked state to that + connection's `mcp-session-id` — re-adding every turn would re-lock a project + you had just opened with `begin_session`. + +It also exists so that **headless** runs (`opencode run …`), which load no TUI +plugins at all, still get the project's tools. + +> The mount is deliberately *not* performed during plugin setup. Setup runs +> before opencode's server accepts connections, and `client.mcp.add` calls back +> into that same server — awaiting it there hangs the app on a blank screen +> before the TUI ever draws. + +### The TUI plugin (`mcpctl/mcpctl-tui.tsx`) + +Registered in `~/.config/opencode/tui.json` (opencode does not auto-discover +TUI plugins). It adds: + +| Command | What it does | +|---------|--------------| +| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl-status` | Active project, mount state, gateway URL | +| `/mcpctl-skills` | Re-sync this project's skills | + +and a `mcpctl:` indicator in the prompt footer, next to the model name +and one line above the token counter. + +Switching delegates to the CLI — +`mcpctl config opencode --project X --skip-plugin --skip-marker` — so token +minting, state and skills stay in one place and the plugin stays a UI shell. +`--skip-plugin` avoids rewriting the very file opencode has already loaded; +`--skip-marker` stops a switch from silently re-scoping whichever repository +opencode happened to be started in. + +The picker needs no pre-filter prompt (unlike the pi and prime-agent +switchers): opencode's select dialog filters as you type, so the plugin only +has to order the list — active project first, then alphabetical. + +The indicator is published through `api.kv`, which is a reactive store: writing +it re-renders the slot with no signal plumbing, and it persists across sessions +so the label is right on the first frame. + +#### Why the footer and not the status bar + +opencode exposes UI slots, not arbitrary layout. In the footer region the +options are: + +| Slot | Result | +|------|--------| +| `session_prompt_right` / `home_prompt_right` | **used** — renders on the prompt's bottom line, right of the model name, directly above the token counter | +| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | +| `app_bottom` | costs a whole extra terminal row | + +There is no slot on the status-bar line itself. On the home screen the prompt +box is narrow, so a long project name wraps onto a second line; in a session +(where the prompt is full width) it always fits on one. + +### Skills + +opencode implements the Agent Skills standard and loads `SKILL.md` trees from +`~/.config/opencode/skill/` (XDG-aware). `mcpctl skills sync --agent opencode` +syncs there, with the same shared-tree semantics as `--agent pi` and +`--agent prime-agent`: a flat tree with per-project ownership, its own state +file (`~/.mcpctl/skills-state-opencode.json`), no SessionStart hooks, no +`postInstall`, and untracked skill directories are never clobbered. + +## Usage + +```bash +mcpctl config opencode --project monitoring +``` + +That mints (or reuses) the project token, writes the 0600 state file, installs +both plugins, registers the TUI plugin in `tui.json`, and runs an initial +skills sync. Start opencode and the project's tools are there. + +Skip individual steps: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +mcpctl config opencode --project monitoring --opencode-dir /path # non-default opencode config dir +``` + +## Failure semantics + +- **No usable credential → the switch fails.** The command exits non-zero and + leaves the state file untouched, so the previously active project keeps + working rather than being replaced by a mount that 401s. `/mcpctl` reads that + exit code and reports the switch as failed instead of claiming success over a + project with no tools. +- **Gateway unreachable → no mcpctl tools.** Never a failed startup: the server + plugin swallows mount errors. +- **Corrupt `tui.json` → refuses to write.** One syntax error must not silently + drop every other TUI plugin you installed. + +## Files + +| Path | Purpose | +|------|---------| +| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin source | +| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin source | +| `src/cli/src/config/opencode-extension.ts` | Generated embed of both (do not edit) | +| `scripts/generate-opencode-extension.ts` | Regenerates that embed | +| `src/cli/src/utils/opencode-settings.ts` | Install / register / state helpers | + +The plugin sources are **embedded** in the CLI so `mcpctl config opencode` +works from an installed binary with no source tree. After editing either +source, re-run: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test fails if you forget. They are typechecked against the real +`@opencode-ai/plugin` types via `pnpm typecheck:opencode-ext`. diff --git a/eslint.config.js b/eslint.config.js index 53f1093..b247282 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index de493e0..5d8ab67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -34,6 +34,7 @@ "mcpd:deploy-dry": "bash deploy.sh --dry-run", "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", + "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { @@ -44,6 +45,10 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-coding-agent": "0.84.1", + "@opencode-ai/plugin": "1.18.15", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -51,6 +56,7 @@ "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", + "solid-js": "1.9.12", "tsx": "^4.21.0", "typebox": "1.3.11", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..86a79db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@opencode-ai/plugin': + specifier: 1.18.15 + version: 1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/core': + specifier: 0.4.5 + version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: 0.4.5 + version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: 0.4.5 + version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -35,6 +47,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + solid-js: + specifier: 1.9.12 + version: 1.9.12 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -184,7 +199,7 @@ importers: version: link:../shared '@modelcontextprotocol/sdk': specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) + version: 1.26.0(zod@4.1.8) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -280,10 +295,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -408,10 +431,18 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -420,36 +451,98 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -463,6 +556,29 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -475,6 +591,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -483,14 +611,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1276,6 +1416,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@next/env@16.2.5': resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} @@ -1385,6 +1555,23 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@opencode-ai/plugin@1.18.15': + resolution: {integrity: sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -1491,6 +1678,73 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/keymap@0.4.5': + resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} + peerDependencies: + '@opentui/react': 0.4.5 + '@opentui/solid': 0.4.5 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.4.5': + resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==} + peerDependencies: + solid-js: 1.9.12 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2198,6 +2452,23 @@ packages: react-native-b4a: optional: true + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2313,6 +2584,11 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2583,6 +2859,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2617,6 +2897,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -2641,6 +2924,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2795,6 +3086,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2853,10 +3148,20 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2981,6 +3286,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -3022,6 +3332,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -3049,6 +3363,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3125,6 +3442,10 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -3159,6 +3480,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3287,6 +3612,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3322,6 +3650,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3399,6 +3730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3451,6 +3786,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3514,6 +3854,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3545,6 +3889,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -3581,6 +3929,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3645,6 +4003,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3762,10 +4124,18 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3778,6 +4148,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3790,6 +4164,9 @@ packages: resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} engines: {node: ^20.17.0 || >=22.9.0} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3804,6 +4181,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3816,6 +4197,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3853,6 +4241,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -3924,6 +4316,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4017,9 +4412,17 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4072,6 +4475,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4113,6 +4519,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4188,6 +4604,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4304,6 +4723,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4387,6 +4810,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4492,6 +4919,11 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true validate-npm-package-license@3.0.4: @@ -4591,6 +5023,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4724,17 +5164,29 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + snapshots: '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -4981,8 +5433,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5011,6 +5489,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5019,8 +5509,34 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5544,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5037,12 +5560,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -5054,6 +5612,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5064,6 +5644,28 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -5072,6 +5674,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5084,11 +5692,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} @@ -5819,6 +6444,29 @@ snapshots: zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color + optional: true + + '@modelcontextprotocol/sdk@1.26.0(zod@4.1.8)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.0 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.8 + zod-to-json-schema: 3.25.1(zod@4.1.8) + transitivePeerDependencies: + - supports-color '@monaco-editor/loader@1.7.0': dependencies: @@ -5831,6 +6479,24 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@next/env@16.2.5': {} '@next/swc-darwin-arm64@16.2.5': @@ -5975,6 +6641,21 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@opencode-ai/plugin@1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.18.15 + effect: 4.0.0-beta.83 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@opencode-ai/sdk@1.18.15': + dependencies: + cross-spawn: 7.0.6 + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -6094,6 +6775,76 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.5 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.12 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -6426,7 +7177,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -6459,7 +7210,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.2 + minimatch: 10.2.5 '@types/aria-query@5.0.4': {} @@ -6817,6 +7568,30 @@ snapshots: b4a@1.8.0: {} + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.12): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.12 + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -6942,6 +7717,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bytes@3.1.2: {} c12@3.1.0: @@ -7161,6 +7940,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7990,19 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + electron-to-chromium@1.5.344: {} emoji-regex@10.6.0: {} @@ -7228,6 +8022,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + entities@7.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -7442,6 +8240,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -7519,12 +8321,22 @@ snapshots: transitivePeerDependencies: - supports-color + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-my-way-ts@0.1.6: {} + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.0.0 + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7676,6 +8488,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -7711,6 +8530,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + helmet@7.2.0: {} highlight.js@10.7.3: {} @@ -7733,6 +8556,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8630,8 @@ snapshots: ini@6.0.0: {} + ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -7857,13 +8684,17 @@ snapshots: ipaddr.js@2.3.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7972,6 +8803,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-nice@1.1.4: {} @@ -8005,6 +8838,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8065,6 +8900,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8124,6 +8964,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +9008,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@8.0.7: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -8200,6 +9046,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9078,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mute-stream@2.0.0: {} nan@2.25.0: @@ -8282,6 +9148,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -8415,10 +9286,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -8430,6 +9309,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pacote@21.5.1: @@ -8460,6 +9341,10 @@ snapshots: just-diff: 6.0.2 just-diff-apply: 5.5.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8470,12 +9355,21 @@ snapshots: patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -8519,6 +9413,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -8600,6 +9498,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9580,17 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@4.1.8: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -8755,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9706,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -8918,6 +9835,12 @@ snapshots: ip-address: 10.0.1 smart-buffer: 4.2.0 + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -8998,7 +9921,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string-width@8.2.0: @@ -9035,6 +9958,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -9140,6 +10065,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10154,8 @@ snapshots: uuid@10.0.0: {} + uuid@14.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -9299,6 +10228,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -9405,4 +10336,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.1.8): + dependencies: + zod: 4.1.8 + zod@3.25.76: {} + + zod@4.1.8: {} diff --git a/scripts/generate-opencode-extension.ts b/scripts/generate-opencode-extension.ts new file mode 100644 index 0000000..09d409d --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two + * opencode plugin sources as string constants — mirroring how the pi extension + * (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher + * (`prime-agent-extension.ts`) are embedded. + * + * Embedding matters: `mcpctl config opencode` must work from an installed + * binary that has no access to the source tree. The installed plugin files are + * this exact embedded source, so what the CLI ships is always what opencode + * runs. + * + * Regenerate after editing the plugin sources: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'opencode-ext'); + +const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); +const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from + * \`src/opencode-ext/mcpctl-opencode.ts\` and + * \`src/opencode-ext/mcpctl-opencode-tui.tsx\`. + * + * \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered \`plugin/\` dir, + * where its basename is what shows up in \`opencode plugin list\`; + * - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from + * \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)}; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 676a5e7..a57e359 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,16 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + opencodeConfigDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../utils/opencode-settings.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** @@ -44,6 +54,9 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; */ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; +/** Same, for the tokens `config opencode` mints. */ +const OPENCODE_TOKEN_PREFIX = 'opencode'; + interface McpConfig { mcpServers: Record }>; } @@ -76,6 +89,118 @@ export function createConfigCommand(deps?: Partial, apiDeps?: const config = new Command('config').description('Manage mcpctl configuration'); + // ── shared credential plumbing ───────────────────────────────────────────── + // `config prime-agent` and `config opencode` both provision an mcptoken for + // the project they mount and retire the one they replace. The rules are + // identical; only the token *name* differs, so the agent label is a + // parameter rather than a second copy of this logic. + + interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } + + /** The project's tokens, or null when the API can't be consulted. */ + async function listProjectTokens(project: string): Promise { + if (!skillsClient) return null; + try { + const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); + return Array.isArray(list) ? list as ProjectToken[] : null; + } catch { + return null; + } + } + + /** + * Is the credential we already have for this project still usable? + * + * A key being *present* proves nothing — a revoked or expired token would + * short-circuit provisioning and leave the agent silently unable to reach + * the gateway while the command reported success. mcptokens are only ever + * shown once, so we compare the stored token's 16-char `tokenPrefix` + * against the project's *active* tokens instead of sending the secret. + * + * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some + * other kind), or an unreachable API all mean "keep what's there" rather + * than minting a duplicate on every run. + */ + async function hasUsableCredential(project: string, key: string | null): Promise { + if (key === null) return false; + if (!skillsClient || !isMcpctlToken(key)) return true; + const tokens = await listProjectTokens(project); + if (tokens === null) return true; // can't check → don't churn credentials + const prefix = mcpTokenPrefixOf(key); + const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); + if (!live) { + log(`Stored credential for '${project}' is no longer active — minting a replacement`); + } + return live; + } + + /** + * Retire the token this install used to hold, now that `keepToken` has + * replaced it on disk. + * + * Scoped to that one credential on purpose. Sweeping every `` token + * for the project would revoke the one a *different* install is using — + * another machine, or this machine when the run targeted a custom output + * path. Anything else that looks orphaned is reported, not deleted: an + * unnecessary token costs nothing, a revoked one costs a broken install. + * Best-effort throughout, and only ever called once the replacement is + * safely stored. + */ + async function retireSupersededToken( + agent: string, + project: string, + staleKey: string | null, + keepToken: string, + ): Promise { + if (!skillsClient) return; + const keepPrefix = mcpTokenPrefixOf(keepToken); + const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; + if (stalePrefix === keepPrefix) return; + const tokens = await listProjectTokens(project); + if (tokens === null) return; + + const orphans: string[] = []; + for (const t of tokens) { + if (typeof t.id !== 'string' || t.status !== 'active') continue; + if (t.tokenPrefix === keepPrefix) continue; + // Only ever consider tokens minted for this purpose. + const name = t.name ?? ''; + if (name !== agent && !name.startsWith(`${agent}-`)) continue; + if (t.tokenPrefix === stalePrefix) { + try { + await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); + log(`Revoked the superseded '${name}' token for '${project}'`); + } catch { /* best-effort */ } + } else { + orphans.push(name); + } + } + if (orphans.length > 0) { + log(`Note: '${project}' still has other ${agent} token(s): ${orphans.join(', ')}. ` + + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); + } + } + + /** + * Mint a fresh mcptoken for `project`. + * + * A unique `-` name every time: `McpToken` is unique on + * (name, projectId) and revoke is a soft delete, so a fixed name could only + * ever be minted once per project. + */ + async function mintProjectToken(agent: string, project: string): Promise { + if (!skillsClient) return null; + const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: `${agent}-${stamp}`, + projectName: project, + ttl: 'never', + description: `mcpctl proxy MCP credential for ${agent} (${new Date().toISOString()})`, + }); + return typeof minted?.token === 'string' && minted.token.length > 0 ? minted.token : null; + } + + config .command('view') .description('Show current configuration') @@ -299,7 +424,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // Only when we actually replaced something: `--token` with a fresh // auth.json must stay entirely offline, as documented. if (staleKey !== null) { - await retireSupersededToken(opts.project, staleKey, opts.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, opts.token); } } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); @@ -309,18 +434,12 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // (name, projectId) and revoke is a soft delete, so reusing a fixed // name would collide with the revoked row forever. Retire the old // tokens only *after* the replacement is safely on disk. - const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; - const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, - projectName: opts.project, - ttl: 'never', - description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, - }); - if (typeof minted?.token === 'string' && minted.token.length > 0) { - await writePrimeAgentAuth(opts.project, minted.token, authPath); + const minted = await mintProjectToken(PRIME_AGENT_TOKEN_PREFIX, opts.project); + if (minted !== null) { + await writePrimeAgentAuth(opts.project, minted, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - await retireSupersededToken(opts.project, staleKey, minted.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -417,86 +536,6 @@ export function createConfigCommand(deps?: Partial, apiDeps?: void cmd; } - /** - * Is the credential already in auth.json still usable? - * - * A key being *present* proves nothing — a revoked or expired token would - * short-circuit provisioning and leave prime-agent silently unable to reach - * the gateway while the command reported success. mcptokens are only ever - * shown once, so we compare the stored token's 16-char `tokenPrefix` - * against the project's *active* tokens instead of sending the secret. - * - * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some - * other kind), or an unreachable API all mean "keep what's there" rather - * than minting a duplicate on every run. - */ - async function hasUsableCredential(project: string, key: string | null): Promise { - if (key === null) return false; - if (!skillsClient || !isMcpctlToken(key)) return true; - const tokens = await listProjectTokens(project); - if (tokens === null) return true; // can't check → don't churn credentials - const prefix = mcpTokenPrefixOf(key); - const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); - if (!live) { - log(`Stored credential for '${project}' is no longer active — minting a replacement`); - } - return live; - } - - /** - * Retire the token this auth.json used to hold, now that `keepToken` has - * replaced it on disk. - * - * Scoped to that one credential on purpose. Sweeping every `prime-agent` - * token for the project would revoke the one a *different* auth.json is - * using — another machine, or this machine when the run targeted a custom - * `--output`. Anything else that looks orphaned is reported, not deleted: - * an unnecessary token costs nothing, a revoked one costs a broken install. - * Best-effort throughout, and only ever called once the replacement is - * safely stored. - */ - async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise { - if (!skillsClient) return; - const keepPrefix = mcpTokenPrefixOf(keepToken); - const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; - if (stalePrefix === keepPrefix) return; - const tokens = await listProjectTokens(project); - if (tokens === null) return; - - const orphans: string[] = []; - for (const t of tokens) { - if (typeof t.id !== 'string' || t.status !== 'active') continue; - if (t.tokenPrefix === keepPrefix) continue; - // Only ever consider tokens minted for this purpose. - const name = t.name ?? ''; - if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue; - if (t.tokenPrefix === stalePrefix) { - try { - await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); - log(`Revoked the superseded '${name}' token for '${project}'`); - } catch { /* best-effort */ } - } else { - orphans.push(name); - } - } - if (orphans.length > 0) { - log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. ` - + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); - } - } - - interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } - - /** The project's tokens, or null when the API can't be consulted. */ - async function listProjectTokens(project: string): Promise { - if (!skillsClient) return null; - try { - const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); - return Array.isArray(list) ? list as ProjectToken[] : null; - } catch { - return null; - } - } } registerClaudeCommand('claude', false); @@ -590,6 +629,190 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: install the plugins + provision the gateway credential ── + // + // Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into + // the host's own config: opencode gets a server plugin that mounts the gateway + // through the running server's MCP API, so the bearer token stays in a 0600 + // state file and switching projects needs no restart. See + // `utils/opencode-settings.ts` for the reasoning. + config + .command('opencode') + .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') + .option('-p, --project ', 'Project name to make active') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to use (skips auto-minting)') + .option('--opencode-dir ', 'Override opencode\'s config dir (default: ~/.config/opencode)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-plugin', 'Do not (re)install or register the opencode plugins') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { + project?: string; + gatewayUrl: string; + token?: string; + opencodeDir?: string; + skipSkills?: boolean; + skipPlugin?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for mcpctl config opencode'); + process.exitCode = 1; + return; + } + const project = opts.project; + const configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir(); + const paths = withOpencodeDir(configDir); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + // Isolate the state file under a custom --opencode-dir so tests (and + // side-by-side installs) never write the real ~/.mcpctl/opencode-state.json. + const statePath = opts.opencodeDir !== undefined + ? join(configDir, 'mcpctl-state.json') + : opencodeStatePath(); + + if (opts.dryRun === true) { + log(JSON.stringify({ + opencode: { + serverPlugin: opts.skipPlugin === true ? '' : paths.serverPluginPath(), + tuiPlugin: opts.skipPlugin === true ? '' : paths.tuiPluginPath(), + tuiJson: opts.skipPlugin === true ? '' : paths.tuiJsonPath(), + statePath, + mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`, + skillsDir: opts.skipSkills === true ? '' : paths.skillsDir(), + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'install plugins + register in tui.json + write 0600 state (project, gateway, bearer token) + sync skills', + }, null, 2)); + return; + } + + // 1. Provision the bearer credential the gateway needs. + // + // This runs FIRST and is fatal on failure: the state file is what the + // plugins mount from, so writing a project we have no usable token for + // would swap a working mount for a 401. Keeping the old state means the + // previously active project keeps working, and the `/mcpctl` switcher + // (which reads this command's exit code) reports the switch as failed + // instead of leaving the user staring at a project with no tools. + const priorState = await readOpencodeState(statePath); + const staleKey = storedToken(priorState, project); + let token: string | null = null; + try { + if (opts.token !== undefined && opts.token !== '') { + token = opts.token; + } else if (await hasUsableCredential(project, staleKey)) { + token = staleKey; + log(`Bearer credential for '${project}' already present in ${statePath}`); + } else { + token = await mintProjectToken(OPENCODE_TOKEN_PREFIX, project); + if (token === null) { + log(skillsClient + ? `Error: no token returned minting for '${project}'; pass --token to supply one` + : 'Error: no API client available to mint a project token — pass --token '); + } + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (token === null || token === '') { + process.exitCode = 1; + log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Write the state file (0600) — the single source of truth both + // plugins read for "which project, which gateway, which token". + try { + await writeOpencodeState({ project, gatewayUrl: gatewayBase, token }, statePath); + log(`Active project '${project}' → ${statePath} (${gatewayBase}/projects/${encodeURIComponent(project)}/mcp)`); + } catch (err: unknown) { + log(`Error: could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + // Only once the replacement is safely on disk. + if (token !== staleKey) { + await retireSupersededToken(OPENCODE_TOKEN_PREFIX, project, staleKey, token); + } + + // 3. Install + register the plugins (skippable: the `/mcpctl` switcher + // re-runs this command in-process and must not rewrite the very file + // opencode has already loaded). + if (opts.skipPlugin !== true) { + try { + const written = await installOpencodePlugins(configDir); + log('Installed opencode plugins:'); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install opencode plugins: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + try { + const { added } = await registerOpencodeTuiPlugin(paths.tuiJsonPath(), paths.tuiPluginPath()); + log(added + ? `Registered the TUI plugin in ${paths.tuiJsonPath()}` + : `TUI plugin already registered in ${paths.tuiJsonPath()}`); + } catch (err: unknown) { + // Non-fatal: without tui.json there is no /mcpctl command or footer + // indicator, but the server plugin still mounts the project's tools. + log(`Warning: could not update ${paths.tuiJsonPath()}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 4. Write the .mcpctl-project marker (same semantics as prime-agent: + // an explicit -p is authoritative, $HOME is never scoped, and the + // switcher opts out so it cannot re-scope an unrelated repo). + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 5. Sync skills into opencode's skill dir (skippable). Best-effort: the + // mount is what determines whether the switch succeeded. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project, target: 'opencode', installRoot: paths.skillsDir() }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + log(total > 0 + ? `Skills synced to ${paths.skillsDir()} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)` + : 'Skills: no changes (already up to date)'); + } catch (err: unknown) { + log(`Warning: skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent opencode` separately)'); + } + } + + if (opts.skipPlugin !== true) { + log(''); + log('Next: restart opencode (or start a new session). Use /mcpctl to switch projects'); + log('without restarting; the active project shows in the prompt footer.'); + } + }); + config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 89f95e6..33708a9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,21 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { opencodeSkillsDir } from '../utils/opencode-settings.js'; + +/** + * Every agent whose skill tree `mcpctl skills sync --agent` can write. + * + * `claude` is the only one with Claude-specific behaviour (SessionStart hooks, + * postInstall, mcpServers auto-attach); every other target is a shared flat + * tree with per-project ownership. + */ +export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const; +export type SyncTarget = (typeof SYNC_TARGETS)[number]; + +export function isSyncTarget(value: string): value is SyncTarget { + return (SYNC_TARGETS as readonly string[]).includes(value); +} /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -103,8 +118,10 @@ export interface SyncOpts { * configuring a second project never deletes the * first project's skills, and pre-existing * (untracked) skill dirs are preserved. + * 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in + * ~/.pi/agent/skills and opencode's `skill` dir. */ - target?: 'claude' | 'prime-agent' | 'pi'; + target?: SyncTarget; } export interface SyncResult { @@ -192,32 +209,23 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); - const statePath = opts.statePath ?? (isPrimeAgent - ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') - : isPi - ? join(homeDir, '.mcpctl', 'skills-state-pi.json') - : defaultStatePath()); + const statePath = opts.statePath + ?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`)); const state = await loadState(statePath); // Which project last wrote this state file, captured before step 7 overwrites // it. Skills tracked by a CLI that predates ownership recording carry no // `project` field; this is the only evidence of who installed them. const priorSyncProject = state.lastSyncProject; - const installRoot = opts.installRoot ?? (isPrimeAgent - ? join(homeDir, '.prime', 'agent', 'skills') - : isPi - ? join(homeDir, '.pi', 'agent', 'skills') - : join(homeDir, '.claude', 'skills')); + const installRoot = opts.installRoot ?? agentInstallRoot(target, homeDir); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -566,17 +574,20 @@ export interface SkillsCommandDeps { * "claude" → ~/.claude/skills * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills - * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + * "opencode" → ~/.config/opencode/skill (XDG-aware) + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ -export function agentInstallRoot(agent: string | undefined): string { +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { switch (agent) { case 'prime-agent': - return join(homedir(), '.prime', 'agent', 'skills'); + return join(homeDir, '.prime', 'agent', 'skills'); case 'pi': - return join(homedir(), '.pi', 'agent', 'skills'); + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +600,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target: claude (default), prime-agent, or pi', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') @@ -609,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { // Validate --agent so an unknown value fails loudly instead of silently // running the default (Claude) sync. const agent = opts.agent ?? 'claude'; - if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + if (!isSyncTarget(agent)) { + warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); process.exitCode = 1; return; } @@ -622,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - target: agent as 'claude' | 'prime-agent' | 'pi', + target: agent, installRoot: agentInstallRoot(agent), }, { client, log, warn }, diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts new file mode 100644 index 0000000..4f60f81 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,27 @@ +/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.ts` and + * `src/opencode-ext/mcpctl-opencode-tui.tsx`. + * + * `mcpctl config opencode` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered `plugin/` dir, + * where its basename is what shows up in `opencode plugin list`; + * - the TUI plugin lands beside it in `mcpctl/`, referenced by path from + * `tui.json`, and must keep a `.tsx` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/src/utils/opencode-settings.ts b/src/cli/src/utils/opencode-settings.ts new file mode 100644 index 0000000..13f2d76 --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,204 @@ +/** + * Wiring helpers for the opencode integration. + * + * `mcpctl config opencode --project X`: + * 1. writes the embedded plugin sources into opencode's config dir + * (`plugin/mcpctl.ts` — auto-discovered server plugin — and + * `mcpctl/mcpctl-tui.tsx` — the `/mcpctl` switcher + footer indicator), + * 2. registers the TUI plugin in `tui.json` (opencode does not auto-discover + * TUI plugins; they have to be listed), + * 3. writes `~/.mcpctl/opencode-state.json` (0600) with the active project, + * the gateway URL and the per-project bearer tokens. + * + * WHY THE STATE FILE AND NOT `opencode.json`: + * opencode's own `mcp` block is a fine way to mount a *static* server, but + * this integration needs two things it cannot give. The gateway wants an + * `Authorization` header, and opencode.json is a mode-0644 file users paste + * into bug reports; and switching projects has to work without restarting + * opencode, which a config file cannot do. The plugins read the state file + * and mount through opencode's live MCP API instead, so `opencode.json` is + * never touched at all. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, +} from '../config/opencode-extension.js'; + +/** + * opencode's config directory. + * + * opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its + * config somewhere other than `~/.config`. Reading the same variable is what + * stops us provisioning a directory opencode will never look at. + */ +export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const xdg = env['XDG_CONFIG_HOME']; + const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config'); + return join(base, 'opencode'); +} + +/** Resolve every path `config opencode` writes, under an explicit config dir. */ +export function withOpencodeDir(base: string): { + serverPluginPath: () => string; + tuiPluginDir: () => string; + tuiPluginPath: () => string; + tuiJsonPath: () => string; + skillsDir: () => string; +} { + return { + serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME), + tuiPluginDir: () => join(base, 'mcpctl'), + tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME), + tuiJsonPath: () => join(base, 'tui.json'), + skillsDir: () => join(base, 'skill'), + }; +} + +/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */ +export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir(); +} + +/** Path of the state file both plugins read. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** + * Write the embedded plugin sources into opencode's config dir. + * + * This is the production path — it needs no source tree, so it works from an + * installed binary. Returns the files written. + */ +export async function installOpencodePlugins(base: string): Promise { + const paths = withOpencodeDir(base); + const written: string[] = []; + for (const [path, source] of [ + [paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE], + [paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE], + ] as const) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf-8'); + written.push(path); + } + return written; +} + +interface TuiConfig { + $schema?: string; + plugin?: unknown[]; + [k: string]: unknown; +} + +/** + * Load tui.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax + * error must not silently drop every other TUI plugin the user installed. + */ +async function readTuiConfig(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw) as TuiConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error( + `${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Register the TUI plugin in `tui.json`. + * + * Idempotent, and a merge rather than a rewrite: any other TUI plugin the user + * has installed keeps working. Returns whether anything changed, so a no-op run + * doesn't reformat a hand-maintained file. + */ +export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> { + const config = await readTuiConfig(tuiJsonPath); + const plugins = Array.isArray(config.plugin) ? [...config.plugin] : []; + if (plugins.some((p) => p === pluginPath)) return { added: false }; + + // Drop any stale entry pointing at an older install location of *our* plugin + // (e.g. a rename between releases): leaving it behind makes opencode fail to + // load a file that no longer exists on every start. + const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`))); + kept.push(pluginPath); + + config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json'; + config.plugin = kept; + await writeJsonAtomic(tuiJsonPath, config); + return { added: true }; +} + +export interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +/** Read the state file; missing or corrupt yields an empty state. */ +export async function readOpencodeState(path: string = opencodeStatePath()): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Set the active project (and optionally its bearer token) in the state file. + * + * Tokens for other projects are preserved so switching back to a project + * already provisioned on this machine needs no new mint — and so a mint failure + * for project B cannot cost you the credential for project A. + * + * Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies + * when the file is created, so we chmod after writing too. + */ +export async function writeOpencodeState( + update: { project: string; gatewayUrl: string; token?: string }, + path: string = opencodeStatePath(), +): Promise { + const current = await readOpencodeState(path); + const tokens = { ...(current.tokens ?? {}) }; + if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token; + const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(path, 0o600); + } catch { + /* best-effort: a credential written is better than one refused */ + } + return path; +} + +/** The token currently stored for `project`, if any. */ +export function storedToken(state: OpencodeState, project: string): string | null { + const token = state.tokens?.[project]; + return typeof token === 'string' && token !== '' ? token : null; +} diff --git a/src/cli/tests/commands/config-opencode.test.ts b/src/cli/tests/commands/config-opencode.test.ts new file mode 100644 index 0000000..42d2685 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +interface ClientCalls { posts: Array<{ path: string; body?: unknown }> } + +/** + * @param tokens what `GET /api/v1/mcptokens` reports as existing for a project + */ +function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.startsWith('/api/v1/mcptokens')) return tokens; + if (path.endsWith('/skills/visible')) return []; + return {}; + }), + post: vi.fn(async (path: string, body?: unknown) => { + calls.posts.push({ path, body }); + if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' }; + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let output: string[]; + let tmpDir: string; + let ocDir: string; + let calls: ClientCalls; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const statePath = (): string => join(ocDir, 'mcpctl-state.json'); + + /** Pre-existing state, as a machine that has already run this command has. */ + function seedState(value: unknown): void { + mkdirSync(ocDir, { recursive: true }); + writeFileSync(statePath(), JSON.stringify(value)); + } + + function command(client: ApiClient) { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + beforeEach(() => { + output = []; + calls = { posts: [] }; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-')); + ocDir = join(tmpDir, 'opencode'); + }); + afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + + it('requires a project', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(existsSync(statePath())).toBe(false); + }); + + it('installs both plugins, registers the TUI one and writes the state file', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'docmost', '--opencode-dir', ocDir, + '--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true); + expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true); + + const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8')); + expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]); + + const state = JSON.parse(readFileSync(statePath(), 'utf-8')); + expect(state).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw.example', + tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' }, + }); + }); + + it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/', + '--token', 't', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example'); + }); + + it('--token never mints', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + }); + + it('mints a uniquely-named opencode token when none is stored', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens'); + expect(mint).toBeDefined(); + // A fixed name could only ever be minted once per project: McpToken is + // unique on (name, projectId) and revoke is a soft delete. + expect((mint?.body as { name: string }).name).toMatch(/^opencode-/); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('reuses a stored token that the server still reports as active', async () => { + // 16-char prefix is what the server exposes; the secret is never re-sent. + const stored = 'mcpctl_pat_STORED000000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + expect(output.join('\n')).toContain('already present'); + }); + + it('re-mints when the stored token has been revoked server-side', async () => { + const stored = 'mcpctl_pat_REVOKED00000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('leaves the previous project mounted when no credential can be provisioned', async () => { + // A switch with no usable credential is a FAILURE: exiting 0 here would + // have the /mcpctl switcher report success over a project with no tools. + seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } }); + const client = { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), // mint returns no token + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + const prevExit = process.exitCode; + await command(client).parseAsync( + ['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old'); + expect(output.join('\n')).toContain('Aborted'); + }); + + it('--skip-plugin updates state without touching the loaded plugin files', async () => { + // This is the path the /mcpctl switcher takes: rewriting the very file + // opencode has already loaded buys nothing. + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-plugin', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(ocDir, 'tui.json'))).toBe(false); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' }); + const plan = JSON.parse(output.join('\n')); + expect(plan.opencode.mcpUrl).toContain('/projects/p/mcp'); + expect(plan.opencode.statePath).toBe(statePath()); + expect(existsSync(ocDir)).toBe(false); + expect(calls.posts).toHaveLength(0); + }); + + it('--skip-marker leaves the working directory unscoped', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(workDir, '.mcpctl-project'))).toBe(false); + } finally { + process.chdir(cwd); + } + }); + + it('writes a .mcpctl-project marker by default', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work2'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', '--skip-skills'], + { from: 'user' }); + expect(readFileSync(join(workDir, '.mcpctl-project'), 'utf-8')).toContain('p'); + } finally { + process.chdir(cwd); + } + }); +}); diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts new file mode 100644 index 0000000..422ac8c --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_SOURCE, + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +/** + * `mcpctl config opencode` installs the *embedded* copy of the plugins, not the + * files in src/opencode-ext/. Editing the sources without re-running the + * generator therefore ships stale code to users while the repo looks correct — + * and the embedded copy is the one thing no typecheck covers. Same guarantee + * the completions check gives. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'opencode-ext'); + +describe('embedded opencode plugins', () => { + it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8')); + expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8')); + }); + + it('install under names opencode can actually load', () => { + // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is + // referenced by path from tui.json and must stay .tsx for its JSX to be + // transpiled. + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + }); + + it('are self-contained — the installed files have no mcpctl imports to resolve', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).not.toMatch(/from '@mcpctl\//); + expect(src).not.toMatch(/from '\.\.\//); + } + }); + + it('keep the JSX pragma the TUI plugin needs to render its indicator', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true); + }); + + it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("const SERVER_NAME = 'mcpctl'"); + } + }); + + it('agree on the state file both read', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')"); + } + }); + + it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/); + }); + + it('sync skills into opencode’s own tree, never Claude’s', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); + }); + + it('switch without rewriting the plugin file opencode has already loaded', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); + }); +}); diff --git a/src/cli/tests/config/opencode-project-order.test.ts b/src/cli/tests/config/opencode-project-order.test.ts new file mode 100644 index 0000000..87aec71 --- /dev/null +++ b/src/cli/tests/config/opencode-project-order.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js'; + +/** + * opencode's select dialog filters as you type, so the picker only has to get + * the *order* right — the active project first, because "the one I am on" is + * the most likely pick and real installs run to hundreds of projects (smoke-test + * leftovers included). + */ +const PROJECTS = [ + { name: 'smoke-proj-none-mohimh46' }, + { name: 'homeautomation', description: 'house' }, + { name: 'docmost' }, + { name: 'copy-homeautomation' }, + { name: 'labctl' }, + { name: 'sre' }, +]; + +describe('orderProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([ + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'labctl', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('never drops or duplicates a project', () => { + expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length); + expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length); + }); + + it('does not mutate the caller’s list', () => { + const input = [...PROJECTS]; + orderProjects(input, 'sre'); + expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name)); + }); + + it('keeps descriptions, which the dialog shows under each row', () => { + expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house'); + }); +}); + +describe('indicatorLabel', () => { + it('names the active project', () => { + expect(indicatorLabel('docmost')).toBe('mcpctl:docmost'); + }); + + it('says so when there is none, rather than rendering a bare prefix', () => { + expect(indicatorLabel(null)).toBe('mcpctl:none'); + expect(indicatorLabel('')).toBe('mcpctl:none'); + }); +}); diff --git a/src/cli/tests/utils/opencode-settings.test.ts b/src/cli/tests/utils/opencode-settings.test.ts new file mode 100644 index 0000000..1dc6893 --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + opencodeConfigDir, + opencodeSkillsDir, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../../src/utils/opencode-settings.js'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +describe('opencodeConfigDir', () => { + it('defaults to ~/.config/opencode', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode'); + }); + + it('ignores an empty XDG_CONFIG_HOME rather than resolving against ""', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '' }, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('puts skills where opencode looks for them', () => { + expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill'); + }); +}); + +describe('installOpencodePlugins', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => { + const written = await installOpencodePlugins(dir); + const paths = withOpencodeDir(dir); + expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]); + expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true); + expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true); + }); + + it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => { + await installOpencodePlugins(dir); + expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true); + }); + + it('is idempotent (re-running overwrites in place)', async () => { + await installOpencodePlugins(dir); + const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8'); + await installOpencodePlugins(dir); + expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first); + }); +}); + +describe('registerOpencodeTuiPlugin', () => { + let dir: string; + let tuiJson: string; + const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx'; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); + tuiJson = join(dir, 'tui.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates tui.json with the plugin and a $schema', async () => { + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual([pluginPath]); + expect(parsed.$schema).toBe('https://opencode.ai/tui.json'); + }); + + it('is idempotent and leaves the file untouched on a no-op run', async () => { + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const before = readFileSync(tuiJson, 'utf-8'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false }); + expect(readFileSync(tuiJson, 'utf-8')).toBe(before); + }); + + it('preserves other TUI plugins and unrelated keys', async () => { + writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]); + expect(parsed.theme).toBe('nord'); + }); + + it('drops a stale entry for an older install location of our own plugin', async () => { + // Left behind, opencode fails to load a file that no longer exists on + // every start. + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]); + }); + + it('refuses to overwrite a corrupt tui.json instead of dropping the user’s plugins', async () => { + writeFileSync(tuiJson, '{ this is not json'); + await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/); + expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json'); + }); + + it('treats an empty file as a fresh start', async () => { + writeFileSync(tuiJson, ' \n'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + }); +}); + +describe('opencode state file', () => { + let dir: string; + let statePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); + statePath = join(dir, 'nested', 'opencode-state.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates the directory and writes project + gateway + token', async () => { + await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath); + expect(await readOpencodeState(statePath)).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw', + tokens: { docmost: 'mcpctl_pat_a' }, + }); + }); + + it('is written 0600 — it holds bearer tokens', async () => { + await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + }); + + it('keeps other projects’ tokens, so switching back needs no new mint', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.project).toBe('b'); + expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' }); + }); + + it('switching without a new token leaves the stored one alone', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.gatewayUrl).toBe('https://gw2'); + expect(storedToken(state, 'a')).toBe('tok-a'); + }); + + it('reads a missing or corrupt state as empty rather than throwing', async () => { + expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({}); + mkdirSync(join(dir, 'x'), { recursive: true }); + writeFileSync(join(dir, 'x', 's.json'), 'not json'); + expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({}); + }); + + it('storedToken ignores an empty or missing entry', () => { + expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull(); + expect(storedToken({}, 'a')).toBeNull(); + expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); + }); +}); diff --git a/src/cli/vitest.config.ts b/src/cli/vitest.config.ts index 1cbc8c8..beac373 100644 --- a/src/cli/vitest.config.ts +++ b/src/cli/vitest.config.ts @@ -1,6 +1,13 @@ import { defineProject } from 'vitest/config'; export default defineProject({ + // The opencode TUI plugin is a .tsx that ships as source; its pure helpers are + // unit-tested by importing that file directly, so the test run needs the same + // JSX runtime opencode transpiles it with. + esbuild: { + jsx: 'automatic', + jsxImportSource: '@opentui/solid', + }, test: { name: 'cli', include: ['tests/**/*.test.ts'], diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..bd067f7 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,298 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in + * `~/.config/opencode/tui.json`. + * + * What it adds to opencode: + * - `/mcpctl` — pick the active project from a filterable dialog + * - `/mcpctl-status` — what is mounted, from where, as which user + * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill + * - a `mcpctl:` indicator in the prompt footer, next to the model + * name and the token counter + * + * The switch itself is delegated to the `mcpctl` CLI (the same binary that + * installed this file), so token minting, state and skills stay in one place + * and this stays a UI shell. Once the CLI has rewritten the state file, the + * mount is re-pointed live through opencode's own MCP API — no restart, unlike + * every config-file-based integration. + * + * Only Node builtins + opencode's plugin API are imported. + */ +import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'; +import type { JSX } from '@opentui/solid'; +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name the mount lives under — must match the server plugin. */ +const SERVER_NAME = 'mcpctl'; + +/** + * kv key holding the label the footer renders. + * + * kv is a reactive store, so writing it here re-renders the slot with no + * signal plumbing of our own; it also survives across sessions, so the label is + * correct on the very first frame instead of after the state file is read. + */ +const KV_LABEL = 'mcpctl.project'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +interface ProjectInfo { + name: string; + description?: string; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +/** + * Run the `mcpctl` CLI and resolve its stdout. + * + * execFile, not a shell: project names come from the server and would otherwise + * need quoting, and a shell buys nothing here. + */ +function mcpctl(args: string[], timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || err.message).trim() || String(err))); + else resolve(stdout); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000); + const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>; + return parsed + .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string') + .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined })); +} + +/** + * Active project first, then alphabetical. + * + * opencode's select dialog does its own fuzzy filtering as you type, so unlike + * the pi and prime-agent switchers this needs no pre-filter prompt — only a + * sensible starting order, since the most likely pick is "the one I am on". + * + * Exported so the ordering is unit-tested rather than eyeballed through a TUI. + */ +export function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] { + return [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); +} + +/** The footer label for a project (or the absence of one). */ +export function indicatorLabel(project: string | null): string { + return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none'; +} + +const tui = async (api: TuiPluginApi): Promise => { + /** Re-read the state file and publish the footer label. */ + async function refreshIndicator(): Promise { + const state = await readState(); + const project = state.project ?? null; + api.kv.set(KV_LABEL, indicatorLabel(project)); + return project !== null && project !== '' ? project : null; + } + + /** + * Point the live MCP mount at `project`. + * + * Registering under the same name every time keeps the tool prefix stable + * (`mcpctl_*`), and because opencode re-resolves tools per request the model + * simply sees the new project's tools on its next turn. + */ + async function mount(project: string, state: OpencodeState): Promise { + const gatewayUrl = state.gatewayUrl; + if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `'); + const token = state.tokens?.[project] ?? ''; + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await api.client.mcp.add({ + name: SERVER_NAME, + config: { + type: 'remote', + url: projectUrl(gatewayUrl, project), + headers, + enabled: true, + timeout: 120_000, + }, + }); + } + + async function switchTo(project: string): Promise { + api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' }); + try { + // The CLI mints/reuses the project token, rewrites the state file and + // syncs skills. --skip-plugin leaves this very file alone (rewriting a + // loaded plugin mid-session buys nothing); --skip-marker stops us + // silently re-scoping whatever repo opencode was started in, which + // Claude Code's own skills sync would then pick up. + await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']); + } catch (err) { + api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' }); + return; + } + try { + await mount(project, await readState()); + } catch (err) { + // The state file is already updated, so a restart would recover — say so + // rather than reporting a success the tools do not back up. + api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' }); + await refreshIndicator(); + return; + } + await refreshIndicator(); + api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' }); + } + + api.keymap.registerLayer({ + commands: [ + { + name: 'mcpctl.switch', + title: 'mcpctl: switch project', + description: 'Mount another mcpctl project’s MCP servers and skills', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl', + async run(): Promise { + const active = await refreshIndicator(); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' }); + return; + } + if (projects.length === 0) { + api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' }); + return; + } + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ({ + title: p.name, + value: p.name, + description: p.description, + }))} + onSelect={(option): void => { + api.ui.dialog.clear(); + const picked = option.value; + if (typeof picked !== 'string') return; + if (picked === active) { + api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' }); + return; + } + void switchTo(picked); + }} + /> + )); + }, + }, + { + name: 'mcpctl.status', + title: 'mcpctl: status', + description: 'Show the active mcpctl project and its MCP mount', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-status', + async run(): Promise { + const state = await readState(); + const project = await refreshIndicator(); + let mcpStatus = 'unknown'; + try { + const res = await api.client.mcp.status(); + mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted'; + } catch { + mcpStatus = 'unavailable'; + } + const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a'; + api.ui.toast({ + message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`, + variant: mcpStatus === 'connected' ? 'success' : 'warning', + duration: 12_000, + }); + }, + }, + { + name: 'mcpctl.skills', + title: 'mcpctl: sync skills', + description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-skills', + async run(): Promise { + const project = await refreshIndicator(); + if (project === null) { + api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' }); + return; + } + try { + await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']); + api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' }); + } catch (err) { + api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' }); + } + }, + }, + ], + }); + + // The indicator. `session_prompt_right` and `home_prompt_right` are the only + // slots in the footer cluster opencode exposes to plugins: they render on the + // prompt's bottom line, immediately right of the model name and directly + // above the token counter. (`home_footer` would sit on the counter's line but + // *replaces* the cwd/version footer rather than adding to it, and + // `app_bottom` costs a whole extra terminal row.) + const Indicator = (): JSX.Element => ( + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); + api.slots.register({ + order: 100, + slots: { + session_prompt_right: () => , + home_prompt_right: () => , + }, + }); + + await refreshIndicator(); +}; + +function errText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export default { + id: 'mcpctl', + tui, +} satisfies TuiPluginModule & { id: string }; diff --git a/src/opencode-ext/mcpctl-opencode.ts b/src/opencode-ext/mcpctl-opencode.ts new file mode 100644 index 0000000..7fa6558 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,165 @@ +/** + * mcpctl opencode server plugin — mounts the active project's MCP gateway. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it. + * + * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` header. Putting + * it in opencode.json means a secret in a mode-0644 config file that users + * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest + * of mcpctl's credentials. + * 2. Switching projects has to work *without restarting opencode*. The server + * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can + * be re-pointed live — a config file can't do that. + * + * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that + * headless runs (`opencode run ...`), which load no TUI plugins at all, still + * get the active project's tools. + * + * Only Node builtins + the plugin API are imported, so the installed file needs + * no dependencies of its own. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name we mount under. Constant on purpose — see `mount`. */ +const SERVER_NAME = 'mcpctl'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +/** Proxy MCP URL for a project on the gateway. */ +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +const server: Plugin = async ({ client }) => { + /** + * The (url, token) this process last registered. + * + * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal + * binds a gated project's unlocked state to the `mcp-session-id` of that + * connection. Re-adding an unchanged config every turn would therefore drop + * the gate open by `begin_session` and re-lock the project mid-conversation. + * So we only call `add` when the target actually changed — or when the mount + * is not connected, where reconnecting is the whole point. + */ + let mounted: string | null = null; + + /** + * Mount (or re-point) the active project. + * + * The MCP server is always registered under the same name, so tools keep the + * stable `mcpctl_*` prefix across switches and the model never sees a tool + * namespace vanish mid-conversation. opencode resolves the tool list per + * request, so a re-point is picked up on the next turn with no restart and no + * "the tools you were told about are gone" announcement to the model. + */ + async function mount(): Promise { + const state = await readState(); + const project = state.project; + const gatewayUrl = state.gatewayUrl; + if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return; + const token = state.tokens?.[project] ?? ''; + const url = projectUrl(gatewayUrl, project); + const target = `${url}\u0000${token}`; + + if (mounted === target && (await isConnected())) return; + + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await client.mcp.add({ + body: { + name: SERVER_NAME, + config: { + type: 'remote', + url, + headers, + enabled: true, + timeout: 120_000, + }, + }, + }); + mounted = target; + } + + /** Is our mount currently up? Unknown/unreachable counts as "not connected". */ + async function isConnected(): Promise { + try { + const res = await client.mcp.status(); + return res.data?.[SERVER_NAME]?.status === 'connected'; + } catch { + return false; + } + } + + /** + * `mount`, serialised and never throwing. + * + * Serialised because the two callers below can overlap — the event stream is + * chatty and a message can land while a mount is still connecting — and two + * concurrent `mcp.add` calls would race to register the same name. + * + * Never throwing because an unreachable gateway must degrade to "no mcpctl + * tools", not to "opencode fails to start". + */ + let inflight: Promise | null = null; + function ensureMounted(): Promise { + inflight ??= mount() + .catch(() => { /* best-effort */ }) + .finally(() => { inflight = null; }); + return inflight; + } + + // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is + // accepting connections, and `client.mcp.add` calls back into that same + // server — awaiting it at this point hangs opencode on a blank screen before + // the TUI ever draws. Both hooks below fire only once the server is live. + return { + /** + * First contact: mount as soon as the server is up, so a session that never + * sends a message still shows the project's tools (and the sidebar shows + * the mount as connected). + */ + event: async (): Promise => { + await ensureMounted(); + }, + + /** + * Re-assert the mount before every user turn. + * + * `mcpctl config opencode --project X` (run from a shell, or by the TUI + * switcher in a *different* opencode window) rewrites the state file + * underneath us. Re-reading here is what makes an external switch take + * effect on the next message instead of on the next restart. When nothing + * changed this is a state-file read and a status call — `mount` will not + * re-register a mount that is already pointing at the right place. + */ + 'chat.message': async (): Promise => { + await ensureMounted(); + }, + }; +}; + +export default { + id: 'mcpctl', + server, +} satisfies PluginModule & { id: string }; diff --git a/src/opencode-ext/tsconfig.json b/src/opencode-ext/tsconfig.json new file mode 100644 index 0000000..542bb09 --- /dev/null +++ b/src/opencode-ext/tsconfig.json @@ -0,0 +1,28 @@ +{ + "//": [ + "The opencode plugins are shipped as source (embedded in the CLI, then", + "written into ~/.config/opencode/) and are therefore never compiled by the", + "CLI's own build. Without this project they would be typechecked by nothing.", + "", + "They are checked against the REAL @opencode-ai/plugin types (a dev", + "dependency, pinned to the opencode release they target) rather than a", + "hand-written shim, because a shim drifting from the published API is the", + "exact failure mode this guards against — see src/pi-ext/tsconfig.json." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"] +} From 99f881dd6708821e6aa6977abc973bc54a4201ec Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:02:52 +0100 Subject: [PATCH 19/77] feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements taken from reading the sibling opencode branches (feat/opencode-extension-abhishek in particular): - `m` opens the project picker. Switching is the repeated action and typing `/mcpctl` every time is friction; the other two commands stay palette-only. - A switch disconnects before re-adding. `mcp.add` under the same name does re-point the tools on its own, but leaves it to opencode whether the previous client is closed — and an abandoned one keeps its `mcp-session-id` alive on mcplocal, which is exactly what holds a gated project open. Best-effort, so a first mount still works. - The footer label renders `wrapMode="none" truncate`. The home prompt row is narrow enough that the default wrap broke `mcpctl:homeautomation` across two lines mid-word; clipping the tail of a long name reads far better. Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is now one line, and a disconnect-then-add switch still lands — the model called `mcpctl_begin_session` and listed the new project's tools. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 4 ++-- docs/opencode-extension.md | 19 ++++++++++++---- src/cli/src/config/opencode-extension.ts | 2 +- .../config/opencode-extension-embed.test.ts | 16 ++++++++++++++ src/opencode-ext/mcpctl-opencode-tui.tsx | 22 ++++++++++++++++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 60fa274..43fcd22 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ This mints (or reuses) the project's gateway token into Inside opencode: -- `/mcpctl` — **switch project** from a filterable picker; takes effect on the - next turn, no restart +- `/mcpctl` (or `m`) — **switch project** from a filterable picker; + takes effect on the next turn, no restart - `/mcpctl-status` — active project, mount state, gateway URL - `/mcpctl-skills` — re-sync this project's skills - the active project shows as `mcpctl:` in the prompt footer, next to diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md index 5a5430d..0690a8b 100644 --- a/docs/opencode-extension.md +++ b/docs/opencode-extension.md @@ -85,10 +85,13 @@ TUI plugins). It adds: | Command | What it does | |---------|--------------| -| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl` (or `m`) | Filterable project picker; switches live | | `/mcpctl-status` | Active project, mount state, gateway URL | | `/mcpctl-skills` | Re-sync this project's skills | +Switching is the thing you do repeatedly, so it gets a chord as well as a slash +command; the other two stay palette-only. + and a `mcpctl:` indicator in the prompt footer, next to the model name and one line above the token counter. @@ -103,6 +106,12 @@ The picker needs no pre-filter prompt (unlike the pi and prime-agent switchers): opencode's select dialog filters as you type, so the plugin only has to order the list — active project first, then alphabetical. +A switch **disconnects before re-adding**. `mcp.add` under the same name does +re-point the tools on its own, but leaves it to opencode whether the previous +client is closed, and an abandoned one keeps its `mcp-session-id` alive on +mcplocal — the very thing that holds a gated project open. Best-effort: on a +first mount there is nothing to disconnect. + The indicator is published through `api.kv`, which is a reactive store: writing it re-renders the slot with no signal plumbing, and it persists across sessions so the label is right on the first frame. @@ -118,9 +127,11 @@ options are: | `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | | `app_bottom` | costs a whole extra terminal row | -There is no slot on the status-bar line itself. On the home screen the prompt -box is narrow, so a long project name wraps onto a second line; in a session -(where the prompt is full width) it always fits on one. +There is no slot on the status-bar line itself. The home prompt box is narrow +enough that the default wrap breaks `mcpctl:homeautomation` across two lines +mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of +a long name reads better than a two-line footer. In a session the prompt is full +width and it always fits. ### Skills diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts index 4f60f81..fbc2f5d 100644 --- a/src/cli/src/config/opencode-extension.ts +++ b/src/cli/src/config/opencode-extension.ts @@ -24,4 +24,4 @@ export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; -export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts index 422ac8c..b772315 100644 --- a/src/cli/tests/config/opencode-extension-embed.test.ts +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -67,6 +67,22 @@ describe('embedded opencode plugins', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); }); + it('bind the switcher to a chord as well as a slash command', () => { + // Switching is the repeated action; typing /mcpctl every time is friction. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: 'm'"); + }); + + it('tear the outgoing mount down before re-pointing it', () => { + // An abandoned client keeps its mcp-session-id — and a gated project's + // unlocked state — alive on mcplocal. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })'); + }); + + it('clip rather than wrap the footer label on the narrow home prompt', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"'); + }); + it('switch without rewriting the plugin file opencode has already loaded', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx index bd067f7..40d38e3 100644 --- a/src/opencode-ext/mcpctl-opencode-tui.tsx +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -134,6 +134,16 @@ const tui = async (api: TuiPluginApi): Promise => { const token = state.tokens?.[project] ?? ''; const headers: Record = {}; if (token !== '') headers['Authorization'] = `Bearer ${token}`; + // Tear the outgoing mount down explicitly. `mcp.add` under the same name + // does re-point the tools, but leaves it to opencode whether the previous + // client is closed — and an abandoned one keeps its `mcp-session-id` alive + // on mcplocal, which is what holds a gated project open. Best-effort: on a + // first mount there is nothing to disconnect. + try { + await api.client.mcp.disconnect({ name: SERVER_NAME }); + } catch { + /* not mounted yet */ + } await api.client.mcp.add({ name: SERVER_NAME, config: { @@ -266,6 +276,11 @@ const tui = async (api: TuiPluginApi): Promise => { }, }, ], + // Switching is the thing you do repeatedly, so it gets a chord as well as + // `/mcpctl`. The other two commands stay palette-only — they are occasional. + bindings: [ + { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' }, + ], }); // The indicator. `session_prompt_right` and `home_prompt_right` are the only @@ -274,8 +289,13 @@ const tui = async (api: TuiPluginApi): Promise => { // above the token counter. (`home_footer` would sit on the counter's line but // *replaces* the cwd/version footer rather than adding to it, and // `app_bottom` costs a whole extra terminal row.) + // wrapMode="none" + truncate: the home prompt row is narrow, and the default + // wrap breaks "mcpctl:homeautomation" across two lines mid-word. Clipping the + // tail of a long name reads far better than a two-line footer. const Indicator = (): JSX.Element => ( - {api.kv.get(KV_LABEL, indicatorLabel(null))} + + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); api.slots.register({ order: 100, From b0233918ffa6bf3913f71cdccea1302a55a7b763 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:17:32 +0100 Subject: [PATCH 20/77] feat(servers): persistent volumes + self-hosted web search and docs templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl--` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- README.md | 56 +++++++ completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 1 + docs/web-search.md | 146 ++++++++++++++++++ src/cli/src/commands/apply.ts | 9 ++ src/cli/src/commands/create.ts | 61 ++++++++ src/cli/src/commands/describe.ts | 16 ++ src/cli/tests/commands/create.test.ts | 30 +++- .../migration.sql | 14 ++ src/db/prisma/schema.prisma | 2 + src/db/src/seed/index.ts | 18 +++ .../src/repositories/mcp-server.repository.ts | 2 + .../src/repositories/template.repository.ts | 2 + .../src/services/backup/backup-service.ts | 2 + .../src/services/backup/restore-service.ts | 5 + .../src/services/docker/container-manager.ts | 7 + src/mcpd/src/services/instance.service.ts | 20 +++ .../services/k8s/kubernetes-orchestrator.ts | 48 +++++- .../src/services/k8s/manifest-generator.ts | 77 ++++++++- src/mcpd/src/services/orchestrator.ts | 26 ++++ src/mcpd/src/validation/mcp-server.schema.ts | 14 +- src/mcpd/src/validation/template.schema.ts | 16 ++ src/mcpd/tests/instance-service.test.ts | 50 +++++- src/mcpd/tests/k8s-manifest.test.ts | 65 ++++++++ stack/.env.example | 5 + stack/docker-compose.yml | 34 ++++ stack/searxng/settings.yml | 14 ++ templates/docs-mcp.yaml | 39 +++++ templates/duckduckgo.yaml | 24 +++ templates/searxng.yaml | 25 +++ 30 files changed, 822 insertions(+), 8 deletions(-) create mode 100644 docs/web-search.md create mode 100644 src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql create mode 100644 stack/searxng/settings.yml create mode 100644 templates/docs-mcp.yaml create mode 100644 templates/duckduckgo.yaml create mode 100644 templates/searxng.yaml diff --git a/README.md b/README.md index 3f7f839..90ccf47 100644 --- a/README.md +++ b/README.md @@ -743,6 +743,62 @@ mcpctl create server my-ha \ --env-from-secret ha-secrets ``` +### Persistent volumes + +Instances are immutable — any server edit recreates them — so by default +anything an MCP server writes to its container filesystem is lost at that point. +A server (or template) can declare volumes that outlive its instances: + +```bash +mcpctl create server docs \ + --from-template docs-mcp \ + --volume data:/data:20:longhorn # NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] +``` + +```yaml +# ...or declaratively +volumes: + - name: data + mountPath: /data + sizeGb: 20 + storageClass: longhorn +``` + +The backing store is named after the **server**, not the instance — +`mcpctl--` — which is what lets the data survive. On Kubernetes +that is a PVC created in the servers namespace on first start and left in place +afterwards; on Docker it is a named volume. Neither is deleted when an instance +or the server goes away, so reclaiming the space is a deliberate +`kubectl delete pvc` / `docker volume rm`. + +Notes: + +- Claims are `ReadWriteOnce`, so a server with volumes is limited to one + replica. Asking for more is rejected at validation rather than leaving the + extra replicas unschedulable. +- `storageClass` defaults to `MCPD_VOLUME_STORAGE_CLASS`, and is omitted + entirely when neither is set. **Set it explicitly on any cluster with more + than one default StorageClass**, where an omitted class binds + nondeterministically. +- Growing a volume is an explicit `kubectl edit pvc` (the class must allow + expansion). mcpctl never resizes an existing claim, because most PVC fields + are immutable after binding. +- Backups capture the volume *declaration*, not its contents. + +### Web search and docs lookup + +The `duckduckgo`, `searxng` and `docs-mcp` templates give an agent web search and +version-pinned library documentation. All are self-hosted and none needs an API key. +`duckduckgo` needs no backing service at all: + +```bash +mcpctl create server websearch --from-template duckduckgo +``` + +See [docs/web-search.md](docs/web-search.md) for the SearXNG engine setup (including +the `json` format setting, without which every search silently returns nothing) and +the `docs-mcp` index-persistence caveat. + ## Gated Sessions Projects using the `default` or `gate` plugin are **gated**. When Claude connects to a gated project: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8dbfce5..814d165 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -188,7 +188,7 @@ _mcpctl() { else case "$create_sub" in server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --from-template --env-from-secret --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --volume --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index b4b8da1..cceb9ea 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -357,6 +357,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l command -d 'Comm complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l container-port -d 'Container port number' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l replicas -d 'Number of replicas' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env -d 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l volume -d 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l from-template -d 'Create from template (name or name:version)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env-from-secret -d 'Map template env vars from a secret' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update if already exists' diff --git a/docs/web-search.md b/docs/web-search.md new file mode 100644 index 0000000..af8c389 --- /dev/null +++ b/docs/web-search.md @@ -0,0 +1,146 @@ +# Web search and documentation lookup + +Three templates. All self-hosted, none needs an API key or a vendor account, and +all three are deployed by mcpctl like any other server. + +| Template | Package / image | Needs | +|---|---|---| +| `duckduckgo` | `duckduckgo-mcp-server` (python) | nothing | +| `searxng` | `mcp-searxng` (node) | a SearXNG instance | +| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing (see the persistence caveat) | + +Search and docs are different jobs, not competing options. A search engine will +hand you a 2023 blog post with a stale method signature; a docs index cannot +tell you why a daemon is crash-looping. Attach both to a project that does real +engineering work. + +`docs-mcp` is the open-source replacement for Context7 / Ref.tools — same job, +but the index lives on your infrastructure and can include private repos. + +## Start here: `duckduckgo` + +The only one with no infrastructure behind it. It scrapes DuckDuckGo's HTML +endpoint directly, so there is no engine to run and no key to hold. + +```bash +mcpctl create server websearch --from-template duckduckgo --env DDG_SAFE_SEARCH=OFF +mcpctl get instances | grep websearch # RUNNING / healthy within ~a minute +``` + +Tools: `search` (`query`, `max_results`, `region`) and `fetch_content` (`url`, +`start_index`, `max_length`) for pulling a result as markdown. + +The tradeoff is honest: scraping has no SLA. DuckDuckGo can change its markup or +rate-limit you, and the server caps itself at 30 searches/min. Set +`DDG_SEARCH_BACKEND=curl` if bot checks start biting. When it becomes a problem, +move to `searxng`. + +## Better results: `searxng` + +SearXNG aggregates ~25 engines and gives you real filtering, which is what keeps +search results from flooding a context window: + +- `min_score` (0.0–1.0) — relevance floor. The most useful knob; start at `0.3`. +- `time_range` — `day` / `week` / `month` / `year`. Essential for "did this break + in the last release" questions. +- `language`, `safesearch`, `pageno`. + +`web_url_read` then takes `section`, `paragraphRange`, `startChar`/`maxLength` +and `readHeadings`, so you pull one section of a long page rather than all of it. +Bounded PDF extraction is included, which covers most vendor documentation. + +The cost is that SearXNG is a service you have to run. It is plain +infrastructure, not an MCP server, so mcpctl has nothing to manage it with — it +belongs in your cluster or in the compose stack: + +```bash +cd stack +cp .env.example .env # set SEARXNG_SECRET +docker compose --profile websearch up -d +``` + +Then point the MCP server at it: + +```bash +mcpctl create secret searxng-conf --data SEARXNG_URL=http://mcpctl-searxng:8080 +mcpctl create server searxng --from-template searxng --env-from-secret searxng-conf +``` + +### The one SearXNG gotcha + +SearXNG ships with `search.formats: [html]`. Every `format=json` request against +a stock instance returns **403**, and `mcp-searxng` comes back empty with no +useful error. `stack/searxng/settings.yml` exists only to add `json` to that +list — it is the one setting with no environment-variable override. + +This is also why pointing the template at a *public* SearXNG instance usually +fails: nearly all of them leave the JSON API off. + +The compose healthcheck probes `format=json` specifically, so a misconfigured +instance shows up as unhealthy rather than as silently empty search results. + +## Documentation: `docs-mcp` + +```bash +mcpctl create server docs --from-template docs-mcp +``` + +Tools: `scrape_docs` (index a site, GitHub repo, npm or PyPI package, or local +files), `search_docs` (query, optionally pinned to a version), and `fetch_url`. + +Index the things you actually run — Pulumi, the Kubernetes API, Grafana, the +Terraform provider docs — rather than everything. + +### Persistence + +The scraped index is a SQLite file (`better-sqlite3` + `sqlite-vec`) under +`/data`. There is **no external-database mode** — no `DATABASE_URL`, no +pgvector — so a Postgres cluster cannot help here. The template declares a +volume instead: + +```yaml +volumes: + - name: data + mountPath: /data + sizeGb: 20 +``` + +The backing PVC is named after the *server* (`mcpctl-docs-data`), not the +instance, so editing the server or restarting the pod re-attaches to the same +index rather than starting empty. See "Persistent volumes" in the README. + +The template carries no `healthCheck`, because `search_docs` needs a library +argument that only exists after a scrape — a synthetic probe would report +unhealthy on a fresh instance. + +### Embeddings + +Keyword search works out of the box. Semantic search is noticeably better and +needs an embedding model. To keep it local, point the server at Ollama: + +```bash +ollama pull nomic-embed-text +mcpctl create server docs --from-template docs-mcp \ + --env DOCS_MCP_EMBEDDING_MODEL=openai:nomic-embed-text \ + --env OPENAI_API_BASE=http://ollama:11434/v1 \ + --env OPENAI_API_KEY=ollama +``` + +Ollama's OpenAI-compatible endpoint is why the provider prefix is `openai:` — +the key is a placeholder and never leaves the network. + +Changing the embedding model invalidates the index: embeddings from different +models are not comparable, so everything has to be re-scraped. + +## Alternatives considered + +- **Tavily / Brave / Exa / Perplexity** — better formatted results, all require + an API key and send every query to a vendor. +- **Context7** — the popular docs MCP, but a cloud index of public-docs snippets + only. `docs-mcp` covers the same ground locally and takes private sources. +- **agent-search** — bundles SearXNG and a 10-strategy extraction cascade in one + deploy. Attractive, but at ~67 stars it is too young to build the stack on. + Worth revisiting. +- **Writing our own** — the hard parts are extraction heuristics and the + embedding pipeline, and all three projects above already solved them under + MIT. diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5360a1a..c4a32a6 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -20,6 +20,13 @@ const ServerEnvEntrySchema = z.object({ }).optional(), }); +const VolumeSpecSchema = z.object({ + name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/), + mountPath: z.string().min(1).regex(/^\//, 'mountPath must be absolute'), + sizeGb: z.number().int().min(1).max(1024).default(10), + storageClass: z.string().optional(), +}); + const ServerSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), @@ -34,6 +41,7 @@ const ServerSpecSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(ServerEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), + volumes: z.array(VolumeSpecSchema).default([]), }); const SecretSpecSchema = z.object({ @@ -125,6 +133,7 @@ const TemplateSpecSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(TemplateEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), + volumes: z.array(VolumeSpecSchema).default([]), }); const UserSpecSchema = z.object({ diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 6f64801..1b252fe 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -103,6 +103,53 @@ function parseServerEnv(entries: string[]): ServerEnvEntry[] { }); } +export interface ServerVolume { + name: string; + mountPath: string; + sizeGb: number; + storageClass?: string; +} + +/** + * Parse `NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`. + * + * Split on ':' rather than a regex so a Windows-style or otherwise odd mount + * path fails loudly on the leading-slash check instead of being silently + * mis-parsed into the wrong field. + */ +export function parseServerVolumes(entries: string[]): ServerVolume[] { + return entries.map((entry) => { + const parts = entry.split(':'); + if (parts.length < 2 || parts.length > 4) { + throw new Error( + `Invalid volume format '${entry}'. Expected NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`, + ); + } + const [name, mountPath, sizeRaw, storageClass] = parts; + if (name === undefined || name === '') { + throw new Error(`Invalid volume '${entry}': name is empty`); + } + if (mountPath === undefined || !mountPath.startsWith('/')) { + throw new Error(`Invalid volume '${entry}': mount path must be absolute (start with '/')`); + } + + let sizeGb = 10; + if (sizeRaw !== undefined && sizeRaw !== '') { + sizeGb = Number(sizeRaw); + if (!Number.isInteger(sizeGb) || sizeGb < 1) { + throw new Error(`Invalid volume '${entry}': size must be a positive whole number of GiB`); + } + } + + return { + name, + mountPath, + sizeGb, + ...(storageClass !== undefined && storageClass !== '' ? { storageClass } : {}), + }; + }); +} + function parseEnvEntries(entries: string[]): Record { const result: Record = {}; for (const entry of entries) { @@ -136,6 +183,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--container-port ', 'Container port number') .option('--replicas ', 'Number of replicas') .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) + .option('--volume ', 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)', collect, []) .option('--from-template ', 'Create from template (name or name:version)') .option('--env-from-secret ', 'Map template env vars from a secret') .option('--force', 'Update if already exists') @@ -233,6 +281,19 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } body.env = merged; } + if (opts.volume.length > 0) { + // Merge by volume name, same as --env, so a CLI volume can retarget one + // the template declared without duplicating the mount. + const cliVolumes = parseServerVolumes(opts.volume as string[]); + const existing = (body.volumes as ServerVolume[] | undefined) ?? []; + const merged = [...existing]; + for (const vol of cliVolumes) { + const idx = merged.findIndex((v) => v.name === vol.name); + if (idx >= 0) merged[idx] = vol; + else merged.push(vol); + } + body.volumes = merged; + } // Defaults when no template if (!opts.fromTemplate) { diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index bda4ffb..7bd6a62 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -51,6 +51,22 @@ function formatServerDetail(server: Record): string { } } + const volumes = server.volumes as Array<{ name: string; mountPath: string; sizeGb?: number; storageClass?: string }> | undefined; + if (volumes && volumes.length > 0) { + lines.push(''); + lines.push('Volumes:'); + const volW = Math.max(6, ...volumes.map((v) => v.name.length)) + 2; + lines.push(` ${'NAME'.padEnd(volW)}MOUNT PATH${' '.repeat(12)}SIZE CLAIM`); + for (const v of volumes) { + const size = `${v.sizeGb ?? 10}Gi`; + const cls = v.storageClass !== undefined && v.storageClass !== '' ? ` (${v.storageClass})` : ''; + lines.push( + ` ${v.name.padEnd(volW)}${pad(v.mountPath, 22)}${pad(size, 7)}` + + `mcpctl-${server.name as string}-${v.name}${cls}`, + ); + } + } + const hc = server.healthCheck as { tool: string; arguments?: Record; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null; if (hc) { lines.push(''); diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts index d1d2f32..2840276 100644 --- a/src/cli/tests/commands/create.test.ts +++ b/src/cli/tests/commands/create.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createCreateCommand } from '../../src/commands/create.js'; +import { createCreateCommand, parseServerVolumes } from '../../src/commands/create.js'; import { type ApiClient, ApiError } from '../../src/api-client.js'; function mockClient(): ApiClient { @@ -556,3 +556,31 @@ describe('create command', () => { }); }); }); + +describe('parseServerVolumes', () => { + it('parses name, path, size and storage class', () => { + expect(parseServerVolumes(['data:/data:20:longhorn'])).toEqual([ + { name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }, + ]); + }); + + it('defaults size to 10Gi and omits storageClass', () => { + expect(parseServerVolumes(['data:/data'])).toEqual([ + { name: 'data', mountPath: '/data', sizeGb: 10 }, + ]); + }); + + it('rejects a relative mount path', () => { + expect(() => parseServerVolumes(['data:data'])).toThrow(/must be absolute/); + }); + + it('rejects a non-integer or zero size', () => { + expect(() => parseServerVolumes(['data:/data:abc'])).toThrow(/positive whole number/); + expect(() => parseServerVolumes(['data:/data:0'])).toThrow(/positive whole number/); + }); + + it('rejects a malformed spec', () => { + expect(() => parseServerVolumes(['data'])).toThrow(/Invalid volume format/); + expect(() => parseServerVolumes(['a:/b:1:c:d'])).toThrow(/Invalid volume format/); + }); +}); diff --git a/src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql b/src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql new file mode 100644 index 0000000..c87371a --- /dev/null +++ b/src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql @@ -0,0 +1,14 @@ +-- Persistent volumes for MCP servers. +-- +-- Instances are immutable and get recreated on any server edit, so anything an +-- MCP server writes to its container filesystem was previously lost at that +-- point. A volume is declared on the *server* and backed by a Docker named +-- volume or a Kubernetes PVC whose identity is derived from the server name, +-- so it outlives the instances that mount it. +-- +-- Shape: [{ "name": "data", "mountPath": "/data", "sizeGb": 10, +-- "storageClass": "longhorn" }] +-- +-- Defaults to an empty array, so existing rows keep today's behaviour. +ALTER TABLE "McpServer" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "McpTemplate" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]'; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index b8de194..7b71bc8 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -70,6 +70,7 @@ model McpServer { replicas Int @default(1) env Json @default("[]") healthCheck Json? + volumes Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -107,6 +108,7 @@ model McpTemplate { replicas Int @default(1) env Json @default("[]") healthCheck Json? + volumes Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index e8872e3..50e8ea7 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -16,11 +16,24 @@ export interface HealthCheckSpec { failureThreshold?: number; } +export interface SeedVolumeSpec { + name: string; + mountPath: string; + sizeGb?: number; + storageClass?: string; +} + export interface SeedTemplate { name: string; version: string; description: string; packageName?: string; + /** + * Package runtime (node, python, ...). Selects the runner image and the + * spawn command — `uvx` vs `npx`. Dropping it silently defaults the template + * to node, which makes every PyPI-backed template fail at first start. + */ + runtime?: string; dockerImage?: string; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; repositoryUrl?: string; @@ -30,6 +43,7 @@ export interface SeedTemplate { replicas?: number; env?: TemplateEnvEntry[]; healthCheck?: HealthCheckSpec; + volumes?: SeedVolumeSpec[]; } export async function seedTemplates( @@ -45,6 +59,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -54,12 +69,14 @@ export async function seedTemplates( replicas: tpl.replicas ?? 1, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue, + volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue, }, create: { name: tpl.name, version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -69,6 +86,7 @@ export async function seedTemplates( replicas: tpl.replicas ?? 1, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue, + volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue, }, }); upserted++; diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts index 0f668c7..2e26621 100644 --- a/src/mcpd/src/repositories/mcp-server.repository.ts +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -33,6 +33,7 @@ export class McpServerRepository implements IMcpServerRepository { replicas: data.replicas, env: data.env, healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue, + volumes: data.volumes, }, }); } @@ -51,6 +52,7 @@ export class McpServerRepository implements IMcpServerRepository { if (data.replicas !== undefined) updateData['replicas'] = data.replicas; if (data.env !== undefined) updateData['env'] = data.env; if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue; + if (data.volumes !== undefined) updateData['volumes'] = data.volumes; return this.prisma.mcpServer.update({ where: { id }, data: updateData }); } diff --git a/src/mcpd/src/repositories/template.repository.ts b/src/mcpd/src/repositories/template.repository.ts index f5bdd3a..7be1b9d 100644 --- a/src/mcpd/src/repositories/template.repository.ts +++ b/src/mcpd/src/repositories/template.repository.ts @@ -52,6 +52,7 @@ export class TemplateRepository implements ITemplateRepository { replicas: data.replicas, env: (data.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue, + volumes: (data.volumes ?? []) as unknown as Prisma.InputJsonValue, }, }); } @@ -71,6 +72,7 @@ export class TemplateRepository implements ITemplateRepository { if (data.replicas !== undefined) updateData.replicas = data.replicas; if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue; if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue; + if (data.volumes !== undefined) updateData.volumes = (data.volumes ?? []) as Prisma.InputJsonValue; return this.prisma.mcpTemplate.update({ where: { id }, diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index d3b1f89..c6410b3 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -39,6 +39,7 @@ export interface BackupServer { replicas: number; env: unknown; healthCheck: unknown; + volumes: unknown; } export interface BackupSecret { @@ -140,6 +141,7 @@ export class BackupService { replicas: s.replicas, env: s.env, healthCheck: s.healthCheck, + volumes: s.volumes, })); } diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index feed096..2aa9deb 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -172,6 +172,11 @@ export class RestoreService { transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', replicas: server.replicas ?? 1, env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>, + // Restores the volume *declaration* only. The backing PVC / named + // volume is not part of the backup — a restored server re-attaches to + // a claim of the same name if one survived, and otherwise starts on a + // fresh empty volume. + volumes: (server.volumes ?? []) as Parameters[0]['volumes'], }; if (server.packageName) createData.packageName = server.packageName; if (server.runtime) createData.runtime = server.runtime; diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 236cc71..b591b28 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -91,6 +91,13 @@ export class DockerContainerManager implements McpOrchestrator { Memory: memoryLimit, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}), NetworkMode: spec.network ?? 'bridge', + // Named volumes, created on demand by the engine. Named (not + // anonymous) matters: `removeContainer` passes `v: true`, which reaps + // anonymous volumes but leaves named ones — which is what lets the + // data outlive the instance. + ...(spec.volumes && spec.volumes.length > 0 + ? { Binds: spec.volumes.map((v) => `${v.claimName}:${v.mountPath}`) } + : {}), }, }; if (spec.command) { diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index 39175ff..a722317 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -372,6 +372,26 @@ export class InstanceService { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { spec.containerPort = server.containerPort ?? 3000; } + + // Volumes are keyed on the server, not this instance: `spec.name` carries + // the instance id and a claim named after it would be torn down on the + // next server edit — exactly when the data has to survive. + const volumes = (server.volumes ?? []) as Array<{ + name: string; + mountPath: string; + sizeGb?: number; + storageClass?: string; + }>; + if (volumes.length > 0) { + spec.volumes = volumes.map((v) => ({ + claimName: `mcpctl-${server.name}-${v.name}`, + mountPath: v.mountPath, + sizeGb: v.sizeGb ?? 10, + ...(v.storageClass !== undefined && v.storageClass !== '' + ? { storageClass: v.storageClass } + : {}), + })); + } // Package-based servers: command = [packageName, ...args] (entrypoint handles execution) // Docker-image servers: use explicit command if provided if (pkgCommand) { diff --git a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts index 515aa46..0d6bd58 100644 --- a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts +++ b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts @@ -4,13 +4,14 @@ import type { ContainerSpec, ContainerInfo, ContainerLogs, + ContainerVolume, ExecResult, InteractiveExec, } from '../orchestrator.js'; import { K8sOfficialClient } from './k8s-client-official.js'; import type { K8sOfficialClientConfig } from './k8s-client-official.js'; -import { generatePodSpec } from './manifest-generator.js'; -import type { V1Pod } from '@kubernetes/client-node'; +import { generatePodSpec, generatePvcSpec, sanitizeName } from './manifest-generator.js'; +import type { V1Pod, V1PersistentVolumeClaim } from '@kubernetes/client-node'; function mapPodState(pod: V1Pod): ContainerInfo['state'] { const cs = pod.status?.containerStatuses?.[0]; @@ -82,6 +83,12 @@ export class KubernetesOrchestrator implements McpOrchestrator { async createContainer(spec: ContainerSpec): Promise { await this.ensureNamespace(this.namespace); + // PVCs must exist before the pod references them, or the pod stays Pending + // on an unbound claim. + for (const volume of spec.volumes ?? []) { + await this.ensurePvc(volume, spec.labels); + } + const manifest = generatePodSpec(spec, this.namespace); const pod = await this.client.core.createNamespacedPod({ namespace: this.namespace, @@ -323,6 +330,43 @@ export class KubernetesOrchestrator implements McpOrchestrator { } } + /** + * Create the PVC backing a server volume if it is not already there. + * + * Deliberately never updates an existing claim: most PVC spec fields are + * immutable after binding, and silently resizing someone's storage is not a + * side effect a container start should have. Growing a volume is an explicit + * `kubectl edit pvc` (the class must allow expansion). + */ + private async ensurePvc( + volume: ContainerVolume, + labels?: Record, + ): Promise { + const name = sanitizeName(volume.claimName); + try { + await this.client.core.readNamespacedPersistentVolumeClaim({ + name, + namespace: this.namespace, + }); + return; // Already there — reuse it, data and all. + } catch (err: unknown) { + const status = (err as { statusCode?: number }).statusCode + ?? (err as { response?: { statusCode?: number } }).response?.statusCode; + if (status !== 404) throw err; + } + + try { + await this.client.core.createNamespacedPersistentVolumeClaim({ + namespace: this.namespace, + body: generatePvcSpec(volume, this.namespace, labels) as V1PersistentVolumeClaim, + }); + } catch (createErr: unknown) { + const status = (createErr as { statusCode?: number }).statusCode + ?? (createErr as { response?: { statusCode?: number } }).response?.statusCode; + if (status !== 409) throw createErr; // Lost a create race — fine. + } + } + getNamespace(): string { return this.namespace; } diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 670679d..90de376 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -1,4 +1,4 @@ -import type { ContainerSpec } from '../orchestrator.js'; +import type { ContainerSpec, ContainerVolume } from '../orchestrator.js'; import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js'; const MCPCTL_LABEL = 'mcpctl.managed'; @@ -20,6 +20,7 @@ export interface K8sPodManifest { env?: Array<{ name: string; value: string }>; ports?: Array<{ containerPort: number }>; stdin?: boolean; + volumeMounts?: Array<{ name: string; mountPath: string }>; resources: { limits: { memory: string; cpu: string }; requests: { memory: string; cpu: string }; @@ -35,6 +36,22 @@ export interface K8sPodManifest { restartPolicy: 'Always' | 'Never' | 'OnFailure'; automountServiceAccountToken: boolean; nodeSelector?: Record; + volumes?: Array<{ name: string; persistentVolumeClaim: { claimName: string } }>; + }; +} + +export interface K8sPvcManifest { + apiVersion: 'v1'; + kind: 'PersistentVolumeClaim'; + metadata: { + name: string; + namespace: string; + labels: Record; + }; + spec: { + accessModes: string[]; + resources: { requests: { storage: string } }; + storageClassName?: string; }; } @@ -78,6 +95,16 @@ function sanitizeName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); } +function buildPodVolumes(spec: ContainerSpec): Pick { + if (!spec.volumes || spec.volumes.length === 0) return {}; + return { + volumes: spec.volumes.map((v) => ({ + name: sanitizeName(v.claimName), + persistentVolumeClaim: { claimName: sanitizeName(v.claimName) }, + })), + }; +} + function buildLabels(spec: ContainerSpec): Record { return { [MCPCTL_LABEL]: 'true', @@ -128,9 +155,55 @@ function buildContainerSpec(spec: ContainerSpec) { container.ports = [{ containerPort: spec.containerPort }]; } + if (spec.volumes && spec.volumes.length > 0) { + container.volumeMounts = spec.volumes.map((v) => ({ + name: sanitizeName(v.claimName), + mountPath: v.mountPath, + })); + } + return container; } +/** + * PVC for a server volume. Created once per server and left in place when + * instances come and go — that outliving is the entire point of the resource. + * + * ReadWriteOnce because the backing class is typically a block volume + * (Longhorn, EBS). A server asking for >1 replica plus a volume cannot have + * both; `instance.service` rejects that combination rather than silently + * leaving replicas unschedulable. + */ +export function generatePvcSpec( + volume: ContainerVolume, + namespace: string, + labels: Record = {}, +): K8sPvcManifest { + const storageClass = volume.storageClass ?? process.env['MCPD_VOLUME_STORAGE_CLASS']; + return { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { + name: sanitizeName(volume.claimName), + namespace, + labels: { + [MCPCTL_LABEL]: 'true', + 'app.kubernetes.io/managed-by': 'mcpctl', + ...labels, + }, + }, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { requests: { storage: `${volume.sizeGb}Gi` } }, + // Only set when known: an empty string means "no class" to Kubernetes, + // which is not the same as omitting the field (use the default class). + ...(storageClass !== undefined && storageClass !== '' + ? { storageClassName: storageClass } + : {}), + }, + }; +} + export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest { const labels = buildLabels(spec); return { @@ -146,6 +219,7 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM restartPolicy: 'Always', // MCP server pods don't need k8s API access automountServiceAccountToken: false, + ...buildPodVolumes(spec), // On mixed-arch clusters, constrain to the same arch as mcpd // (runner images are typically single-arch) ...(process.env['MCPD_NODE_SELECTOR'] @@ -179,6 +253,7 @@ export function generateDeploymentSpec(spec: ContainerSpec, namespace: string, r containers: [buildContainerSpec(spec)], restartPolicy: 'Always', automountServiceAccountToken: false, + ...buildPodVolumes(spec), }, }, }, diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index e6767c1..15716ad 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -2,11 +2,37 @@ * Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes. */ +/** + * A persistent volume mounted into an MCP server container. + * + * `claimName` is deliberately caller-supplied and derived from the *server*, + * not the instance: instances are immutable and get recreated on any server + * edit, so an instance-scoped volume would be destroyed exactly when the data + * needs to survive. Backed by a Docker named volume or a Kubernetes PVC, and + * never removed when an instance goes away. + */ +export interface ContainerVolume { + /** Stable backing-volume name, shared by every instance of a server. */ + claimName: string; + /** Absolute path to mount at inside the container. */ + mountPath: string; + /** Requested size in GiB. Kubernetes only — Docker named volumes are unbounded. */ + sizeGb: number; + /** + * StorageClass for the PVC, defaulting to MCPD_VOLUME_STORAGE_CLASS. + * Worth setting explicitly on clusters with more than one default class, + * where an omitted class binds nondeterministically. + */ + storageClass?: string; +} + export interface ContainerSpec { /** Docker/OCI image reference */ image: string; /** Human-readable name (used as container name prefix) */ name: string; + /** Persistent volumes to mount (survive instance recreation) */ + volumes?: ContainerVolume[]; /** Custom command to run (overrides image CMD) */ command?: string[]; /** Environment variables */ diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts index 20b58dc..77b803f 100644 --- a/src/mcpd/src/validation/mcp-server.schema.ts +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { HealthCheckSchema } from './template.schema.js'; +import { HealthCheckSchema, VolumeSpecSchema } from './template.schema.js'; const SecretRefSchema = z.object({ name: z.string().min(1), @@ -33,7 +33,16 @@ export const CreateMcpServerSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(ServerEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), -}); + volumes: z.array(VolumeSpecSchema).default([]), +}).refine( + (s) => s.volumes.length === 0 || s.replicas <= 1, + { + message: + 'A server with volumes cannot have replicas > 1: the backing claim is ReadWriteOnce, ' + + 'so the extra replicas would sit unschedulable on a volume they cannot mount.', + path: ['replicas'], + }, +); export const UpdateMcpServerSchema = z.object({ description: z.string().max(1000).optional(), @@ -48,6 +57,7 @@ export const UpdateMcpServerSchema = z.object({ replicas: z.number().int().min(0).max(10).optional(), env: z.array(ServerEnvEntrySchema).optional(), healthCheck: HealthCheckSchema.nullable().optional(), + volumes: z.array(VolumeSpecSchema).optional(), }); export type CreateMcpServerInput = z.infer; diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 8e4d34c..95cc78b 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -7,6 +7,21 @@ const TemplateEnvEntrySchema = z.object({ defaultValue: z.string().optional(), }); +/** + * A persistent volume declared on a server (or template). + * + * `name` is scoped to the server; the backing Docker volume / Kubernetes PVC is + * named `mcpctl--` so it is stable across instance recreation. + */ +export const VolumeSpecSchema = z.object({ + name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, 'Volume name must be lowercase alphanumeric with hyphens'), + mountPath: z.string().min(1).max(200).regex(/^\//, 'mountPath must be absolute'), + sizeGb: z.number().int().min(1).max(1024).default(10), + storageClass: z.string().max(100).optional(), +}); + +export type VolumeSpecInput = z.infer; + export const HealthCheckSchema = z.object({ tool: z.string().min(1), arguments: z.record(z.unknown()).default({}), @@ -32,6 +47,7 @@ export const CreateTemplateSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(TemplateEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), + volumes: z.array(VolumeSpecSchema).default([]), }); export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true }); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index c19b4bd..6cda9bb 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -70,10 +70,11 @@ function mockOrchestrator(): McpOrchestrator { }; } -function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) { +function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null; volumes: unknown }> = {}) { return { id: overrides.id ?? 'srv-1', name: overrides.name ?? 'slack', + volumes: overrides.volumes ?? [], dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest', packageName: null, transport: overrides.transport ?? 'STDIO', @@ -143,6 +144,53 @@ describe('InstanceService', () => { }); }); + describe('volumes', () => { + it('names the claim after the server, not the instance', async () => { + // The whole point of the feature: instances are recreated on every server + // edit, so an instance-scoped claim would be destroyed exactly when the + // data needs to survive. + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ + name: 'docs', + volumes: [{ name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }], + })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([]); + + await service.reconcile('srv-1'); + + const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0]; + expect(spec.volumes).toEqual([ + { claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }, + ]); + // Container name carries the instance id; the claim must not. + expect(spec.name).toContain('inst-1'); + expect(spec.volumes![0]!.claimName).not.toContain('inst-1'); + }); + + it('defaults size and leaves storageClass unset when unspecified', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ + name: 'docs', + volumes: [{ name: 'data', mountPath: '/data' }], + })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([]); + + await service.reconcile('srv-1'); + + const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0]; + expect(spec.volumes![0]!.sizeGb).toBe(10); + expect(spec.volumes![0]).not.toHaveProperty('storageClass'); + }); + + it('leaves the spec free of volumes when the server declares none', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({})); + vi.mocked(instanceRepo.findAll).mockResolvedValue([]); + + await service.reconcile('srv-1'); + + const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0]; + expect(spec.volumes).toBeUndefined(); + }); + }); + describe('reconcile', () => { it('starts instances when below desired replicas', async () => { vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 })); diff --git a/src/mcpd/tests/k8s-manifest.test.ts b/src/mcpd/tests/k8s-manifest.test.ts index 22d5429..843e80d 100644 --- a/src/mcpd/tests/k8s-manifest.test.ts +++ b/src/mcpd/tests/k8s-manifest.test.ts @@ -3,6 +3,7 @@ import { generatePodSpec, generateDeploymentSpec, generateNamespaceSpec, + generatePvcSpec, formatMemory, formatCpu, sanitizeName, @@ -156,6 +157,70 @@ describe('generateDeploymentSpec', () => { }); }); +describe('volumes', () => { + const volumeSpec: ContainerSpec = { + ...baseSpec, + volumes: [{ claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }], + }; + + it('mounts the claim in the pod and declares the volume', () => { + const pod = generatePodSpec(volumeSpec, 'mcpctl-servers'); + expect(pod.spec.volumes).toEqual([ + { name: 'mcpctl-docs-data', persistentVolumeClaim: { claimName: 'mcpctl-docs-data' } }, + ]); + expect(pod.spec.containers[0]!.volumeMounts).toEqual([ + { name: 'mcpctl-docs-data', mountPath: '/data' }, + ]); + }); + + it('omits volume fields entirely when none are declared', () => { + const pod = generatePodSpec(baseSpec, 'mcpctl-servers'); + expect(pod.spec.volumes).toBeUndefined(); + expect(pod.spec.containers[0]!.volumeMounts).toBeUndefined(); + }); + + it('carries volumes into a deployment pod template', () => { + const dep = generateDeploymentSpec(volumeSpec, 'mcpctl-servers', 1); + expect(dep.spec.template.spec.volumes).toHaveLength(1); + expect(dep.spec.template.spec.containers[0]!.volumeMounts).toHaveLength(1); + }); + + it('builds a PVC with the requested size and class', () => { + const pvc = generatePvcSpec(volumeSpec.volumes![0]!, 'mcpctl-servers'); + expect(pvc.kind).toBe('PersistentVolumeClaim'); + expect(pvc.metadata.name).toBe('mcpctl-docs-data'); + expect(pvc.metadata.namespace).toBe('mcpctl-servers'); + expect(pvc.spec.resources.requests.storage).toBe('20Gi'); + expect(pvc.spec.accessModes).toEqual(['ReadWriteOnce']); + expect(pvc.spec.storageClassName).toBe('longhorn'); + }); + + it('omits storageClassName rather than sending an empty string', () => { + // An empty string means "no storage class" to Kubernetes, which is NOT the + // same as omitting the field (use the cluster default). + const prev = process.env['MCPD_VOLUME_STORAGE_CLASS']; + delete process.env['MCPD_VOLUME_STORAGE_CLASS']; + try { + const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 1 }, 'ns'); + expect('storageClassName' in pvc.spec).toBe(false); + } finally { + if (prev !== undefined) process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev; + } + }); + + it('falls back to MCPD_VOLUME_STORAGE_CLASS when the volume omits a class', () => { + const prev = process.env['MCPD_VOLUME_STORAGE_CLASS']; + process.env['MCPD_VOLUME_STORAGE_CLASS'] = 'longhorn'; + try { + const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 5 }, 'ns'); + expect(pvc.spec.storageClassName).toBe('longhorn'); + } finally { + if (prev === undefined) delete process.env['MCPD_VOLUME_STORAGE_CLASS']; + else process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev; + } + }); +}); + describe('generateNamespaceSpec', () => { it('generates namespace manifest', () => { const ns = generateNamespaceSpec('mcpctl-prod'); diff --git a/stack/.env.example b/stack/.env.example index 3830b78..96ebe30 100644 --- a/stack/.env.example +++ b/stack/.env.example @@ -3,3 +3,8 @@ POSTGRES_PASSWORD=CHANGE_ME POSTGRES_DB=mcpctl MCPD_PORT=3100 MCPD_LOG_LEVEL=info + +# --- websearch profile (docker compose --profile websearch up -d) --- +# The SearXNG engine only. The MCP servers in front of it are mcpctl +# resources, not compose services. No API key needed. +SEARXNG_SECRET=CHANGE_ME diff --git a/stack/docker-compose.yml b/stack/docker-compose.yml index 4d70a48..9809bc3 100644 --- a/stack/docker-compose.yml +++ b/stack/docker-compose.yml @@ -48,6 +48,39 @@ services: retries: 3 start_period: 15s + # --- websearch profile ------------------------------------------------- + # Opt-in: `docker compose --profile websearch up -d`. + # + # Only the SearXNG *engine* lives here — it is plain infrastructure, not an + # MCP server, so mcpctl has nothing to manage it with. The MCP servers that + # sit in front of it (`searxng`, `duckduckgo`, `docs-mcp`) are mcpctl + # resources created from templates. Needs no API key. + + searxng: + image: docker.io/searxng/searxng:latest + container_name: mcpctl-searxng + profiles: ["websearch"] + restart: unless-stopped + environment: + SEARXNG_BASE_URL: http://mcpctl-searxng:8080/ + SEARXNG_SECRET: ${SEARXNG_SECRET:-mcpctl-searxng-internal-only} + # The limiter guards public instances from bots and needs Valkey to work. + # This one binds no host port and is reachable only from mcp-servers. + SEARXNG_LIMITER: "false" + volumes: + - ./searxng/settings.yml:/etc/searxng/settings.yml:ro + - mcpctl-searxng-cache:/var/cache/searxng + networks: + - mcp-servers + healthcheck: + # Probes the JSON API specifically — a healthy HTML UI with `json` missing + # from search.formats is exactly the failure mode worth catching here. + test: ["CMD-SHELL", "wget -q -O /dev/null 'http://localhost:8080/search?q=ping&format=json' || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + networks: mcpctl: driver: bridge @@ -60,3 +93,4 @@ networks: volumes: mcpctl-pgdata: mcpctl-backup: + mcpctl-searxng-cache: diff --git a/stack/searxng/settings.yml b/stack/searxng/settings.yml new file mode 100644 index 0000000..bce22a5 --- /dev/null +++ b/stack/searxng/settings.yml @@ -0,0 +1,14 @@ +# Minimal SearXNG config for API/MCP use. Everything not set here inherits the +# image defaults (engines, locales, categories) via use_default_settings. +# +# secret_key and limiter come from $SEARXNG_SECRET / $SEARXNG_LIMITER in the +# environment — see stack/docker-compose.yml. `formats` has no env override, +# which is the only reason this file has to exist. +use_default_settings: true + +search: + # The upstream default is `[html]` only. Without `json` here every + # /search?format=json request 403s and mcp-searxng returns nothing. + formats: + - html + - json diff --git a/templates/docs-mcp.yaml b/templates/docs-mcp.yaml new file mode 100644 index 0000000..ddb5693 --- /dev/null +++ b/templates/docs-mcp.yaml @@ -0,0 +1,39 @@ +name: docs-mcp +version: "1.0.0" +description: Self-hosted library documentation index — version-pinned docs scraped from sites, GitHub, npm and PyPI +dockerImage: "ghcr.io/arabold/docs-mcp-server:latest" +transport: SSE +containerPort: 6280 +repositoryUrl: https://github.com/arabold/docs-mcp-server +command: + - --protocol + - http + - --host + - 0.0.0.0 + - --port + - "6280" +# The scraped index is a SQLite file (better-sqlite3 + sqlite-vec) under /data. +# There is no external-database mode, so the volume below is what makes the +# index outlive instance recreation. Its claim is named after the server +# (mcpctl--data), not the instance, so a server edit re-attaches to the +# same data instead of starting empty. +# +# Health check omitted deliberately: `search_docs` requires a library argument, +# so a synthetic probe would need a library that is only present after a scrape +# and would report unhealthy on a fresh instance. +volumes: + - name: data + mountPath: /data + sizeGb: 20 +env: + - name: DOCS_MCP_EMBEDDING_MODEL + description: >- + Embedding model as provider:name. Unset = keyword search only. For local + embeddings use openai:nomic-embed-text with OPENAI_API_BASE on Ollama. + required: false + - name: OPENAI_API_BASE + description: OpenAI-compatible embeddings endpoint, e.g. http://ollama:11434/v1 + required: false + - name: OPENAI_API_KEY + description: Embeddings API key. Literal "ollama" when pointing at a local Ollama. + required: false diff --git a/templates/duckduckgo.yaml b/templates/duckduckgo.yaml new file mode 100644 index 0000000..c21f44c --- /dev/null +++ b/templates/duckduckgo.yaml @@ -0,0 +1,24 @@ +name: duckduckgo +version: "1.0.0" +description: DuckDuckGo web search and page-to-markdown fetching — no API key, no backing service +packageName: "duckduckgo-mcp-server" +runtime: python +transport: STDIO +repositoryUrl: https://github.com/nickclyde/duckduckgo-mcp-server +healthCheck: + tool: search + arguments: + query: "mcp" + max_results: 1 +env: + - name: DDG_SAFE_SEARCH + description: Result filtering — STRICT, MODERATE or OFF + required: false + defaultValue: "MODERATE" + - name: DDG_REGION + description: Default region/language code (e.g. us-en, pl-pl, uk-en) + required: false + - name: DDG_SEARCH_BACKEND + description: Fetch backend — auto, httpx or curl. `curl` survives more bot checks. + required: false + defaultValue: "auto" diff --git a/templates/searxng.yaml b/templates/searxng.yaml new file mode 100644 index 0000000..cb16d87 --- /dev/null +++ b/templates/searxng.yaml @@ -0,0 +1,25 @@ +name: searxng +version: "1.0.0" +description: SearXNG MCP server for private web search and URL-to-markdown reading +packageName: "mcp-searxng" +transport: STDIO +repositoryUrl: https://github.com/ihor-sokoliuk/mcp-searxng +healthCheck: + tool: searxng_web_search + arguments: + query: "mcp" +env: + - name: SEARXNG_URL + description: >- + SearXNG instance URL, or ';'-separated replicas + (e.g. http://mcpctl-searxng:8080). The instance must list `json` under + `search.formats` — see stack/searxng/settings.yml. + required: true + - name: SEARXNG_DEFAULT_RESPONSE_FORMAT + description: Result shape when a call omits response_format — "text" or "json" + required: false + defaultValue: "text" + - name: SEARXNG_HTML_FALLBACK + description: Scrape the HTML result page when the JSON API is unavailable + required: false + defaultValue: "false" From c5ea39e959897f79ddbb6168e8d17d7a471e6253 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:39:36 +0100 Subject: [PATCH 21/77] =?UTF-8?q?feat(claude):=20claude-vllm=20=E2=80=94?= =?UTF-8?q?=20run=20Claude=20Code=20against=20the=20homelab=20LLM=20gatewa?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the Anthropic Messages API on /v1/messages — verified with a real completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is the whole integration. `claude-vllm` exists only to stop you pasting four exports each time. It reuses what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then opencode's config, first hit wins — taking the base URL and the credential from the same source so one gateway's URL is never paired with another's key. Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss: - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the background and summarisation calls ask the gateway for a real Haiku it does not serve and every one 404s; - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow, because Claude Code assumes 200k for models it has no table for — and deepseek-v4-* is 393k, so it would auto-compact at half capacity. On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves /api/v1/llms (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves /v1/messages. Routing Claude through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is a mcpd endpoint rather than a wrapper script. Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against deepseek on the homelab, with the unknown-model context warning gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 19 +++++ docs/claude-vllm.md | 92 +++++++++++++++++++++++ nfpm.yaml | 4 + stack/claude-vllm | 178 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 docs/claude-vllm.md create mode 100755 stack/claude-vllm diff --git a/README.md b/README.md index 43fcd22..8be6684 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,25 @@ Re-sync skills on their own with See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. +### Run Claude Code on the homelab LLM + +The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic +Messages API, so Claude Code can talk to it directly — no bridge: + +```bash +claude-vllm # reuse the provider/model/key pi or prime-agent already uses +claude-vllm --model deepseek-v4-max +claude-vllm --list # show what it would use +claude-vllm -- -p "summarise this repo" # args after -- go to claude +``` + +It discovers the endpoint, credential, model and context window from +`~/.pi/agent`, `~/.prime/agent` or opencode's config (first hit wins) and sets +the `ANTHROPIC_*` environment Claude Code needs. + +See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this +through mcpctl would add surface without adding capability. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/docs/claude-vllm.md b/docs/claude-vllm.md new file mode 100644 index 0000000..72c3446 --- /dev/null +++ b/docs/claude-vllm.md @@ -0,0 +1,92 @@ +# `claude-vllm` — Claude Code on the homelab LLM + +## The short version + +```bash +claude-vllm # default provider + model +claude-vllm --model deepseek-v4-max +claude-vllm --list # what would it use? +claude-vllm --print-env # the exports, without running claude +claude-vllm -- -p "summarise this repo" # anything after -- goes to claude +``` + +## Why no bridge is needed + +The homelab gateway at `llm.ad.itaz.eu` is **LiteLLM in front of vLLM**, and +LiteLLM already serves the Anthropic Messages API: + +```console +$ curl -s https://llm.ad.itaz.eu/v1/messages -X POST \ + -H 'x-api-key: ' -H 'anthropic-version: 2023-06-01' \ + -d '{"model":"deepseek-v4-fast","max_tokens":32, + "messages":[{"role":"user","content":"say only: ok"}]}' +{"type":"message","role":"assistant","model":"deepseek-v4-fast", + "content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn", …} +``` + +That is the exact protocol Claude Code speaks. So pointing `ANTHROPIC_BASE_URL` +at the gateway is the whole integration — no translation layer, no proxy, no +mcpctl in the request path. + +### Why not route it through mcpctl + +mcpctl's LLM layer is a **client**, not a server: `mcpd` exposes +`/api/v1/llms` (management) and the adapters in +`src/mcpd/src/services/llm/adapters/` *call out* to Anthropic/OpenAI for gating, +prompt selection and agent chat. Nothing in mcpd or mcplocal serves +`/v1/messages`. A `claude-mcpctl` would therefore mean adding an +Anthropic-shaped passthrough to mcpd that re-wraps what LiteLLM already does — +new surface, new failure mode, no new capability. + +It would only be worth it if you wanted mcpctl *in* the LLM path for its own +sake: per-project gating of model calls, audit of prompts, or budget +enforcement. Those are real features, but they are a passthrough endpoint in +mcpd, not a wrapper script. + +## What it discovers, and from where + +`claude-vllm` exists only so you don't paste four exports each time. It reuses +whatever you already configured for another agent — first hit wins: + +| Order | Source | Supplies | +|-------|--------|----------| +| 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected | +| 2 | `--provider` / `--model` flags | provider, model | +| 3 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context window · `auth.json` → key | +| 4 | `~/.prime/agent` | same shape | +| 5 | `~/.config/opencode/opencode.jsonc` | `provider..options.{baseURL,apiKey}` | + +The base URL and the credential always come from the *same* source, so one +gateway's URL is never paired with another's key. + +## What it sets, and why each one + +| Variable | Reason | +|----------|--------| +| `ANTHROPIC_BASE_URL` | The gateway. A stored OpenAI-style `…/v1` base has the suffix stripped — Claude Code appends `/v1/messages` itself, and `/v1/v1/messages` 404s. | +| `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_API_KEY` | The gateway takes either; which one Claude Code sends has changed between releases. | +| `ANTHROPIC_MODEL` | The model to drive the session. | +| `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Background/summarisation calls otherwise ask for a real Haiku the gateway does not serve, and every one 404s. | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code assumes 200k for models it has no table for. `deepseek-v4-*` is 393k, so without this it auto-compacts at half capacity. Taken from `contextWindow` in the provider config. | +| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Beta headers the gateway does not implement make it reject otherwise-fine requests. | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | No telemetry about a non-Anthropic endpoint. | + +## Expected noise + +``` +⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth + source is set and takes precedence over your claude.ai login +``` + +Unavoidable and harmless: it fires for *any* non-claude.ai auth, including +`ANTHROPIC_AUTH_TOKEN` alone (verified). It means your claude.ai org connectors +are not loaded for this session — which is the point of running against the +homelab. + +## Verified + +```console +$ claude-vllm --model deepseek-v4-fast -- -p "Reply with exactly: PARITY-OK" +claude-vllm: itaz · deepseek-v4-fast · https://llm.ad.itaz.eu +PARITY-OK +``` diff --git a/nfpm.yaml b/nfpm.yaml index 047ecc8..3280d1c 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -16,6 +16,10 @@ contents: dst: /usr/bin/mcpctl-local file_info: mode: 0755 + - src: ./stack/claude-vllm + dst: /usr/bin/claude-vllm + file_info: + mode: 0755 - src: ./deploy/mcplocal.service dst: /usr/lib/systemd/user/mcplocal.service file_info: diff --git a/stack/claude-vllm b/stack/claude-vllm new file mode 100755 index 0000000..17942f8 --- /dev/null +++ b/stack/claude-vllm @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# claude-vllm — run Claude Code against the homelab LLM gateway instead of api.anthropic.com. +# +# The gateway (LiteLLM in front of vLLM) already speaks the Anthropic Messages +# API on /v1/messages, so no bridge or translation layer is needed — Claude Code +# talks to it directly once ANTHROPIC_BASE_URL points there. This script exists +# only to find the endpoint, credential and model you have already configured +# for another agent, instead of making you paste four exports every time. +# +# Discovery order (first hit wins, per field): +# 1. environment already set (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL) +# 2. flags (--provider, --model) +# 3. ~/.pi/agent settings.json + models.json + auth.json +# 4. ~/.prime/agent settings.json + models.json + auth.json +# 5. ~/.config/opencode/opencode.jsonc (provider..options.{baseURL,apiKey}) +# +# Usage: +# claude-vllm # default provider + model, then exec claude +# claude-vllm --model deepseek-v4-max +# claude-vllm --provider itaz --model deepseek-v4-fast -- -p "summarise this repo" +# claude-vllm --list # show discoverable providers/models and exit +# claude-vllm --print-env # print the exports and exit (don't run claude) +set -euo pipefail + +PROVIDER="" +MODEL="" +LIST=0 +PRINT_ENV=0 +CLAUDE_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + --provider) PROVIDER="${2:?--provider needs a value}"; shift 2 ;; + --model) MODEL="${2:?--model needs a value}"; shift 2 ;; + --list) LIST=1; shift ;; + --print-env) PRINT_ENV=1; shift ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --) shift; CLAUDE_ARGS+=("$@"); break ;; + *) CLAUDE_ARGS+=("$1"); shift ;; + esac +done + +command -v jq >/dev/null || { echo "claude-vllm: jq is required" >&2; exit 1; } + +PI_HOME="${PI_AGENT_HOME:-$HOME/.pi/agent}" +PRIME_HOME="${PRIME_AGENT_HOME:-$HOME/.prime/agent}" +OC_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.jsonc" + +# jq tolerates the // comments opencode.jsonc may contain only after we strip +# them; harmless for strict JSON. +read_json() { [ -f "$1" ] && sed 's://[^"]*$::' "$1" | jq -c . 2>/dev/null || echo '{}'; } + +# ── discover ───────────────────────────────────────────────────────────────── +# Each agent home is tried in turn; the first one that yields a base URL wins, +# and the credential is taken from that same home so we never pair one gateway's +# URL with another's key. +discover_from_agent_home() { + local home="$1" settings models auth provider base key model + settings=$(read_json "$home/settings.json") + models=$(read_json "$home/models.json") + auth=$(read_json "$home/auth.json") + + provider="$PROVIDER" + [ -n "$provider" ] || provider=$(jq -r '.defaultProvider // empty' <<<"$settings") + [ -n "$provider" ] || return 1 + + base=$(jq -r --arg p "$provider" '.providers[$p].baseUrl // empty' <<<"$models") + [ -n "$base" ] || return 1 + # `apiKey` in models.json names an env var; the secret itself lives in auth.json. + key=$(jq -r --arg p "$provider" '.[$p].key // empty' <<<"$auth") + + model="$MODEL" + [ -n "$model" ] || model=$(jq -r '.defaultModel // empty' <<<"$settings") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key"; FOUND_MODEL="$model" + FOUND_MODELS=$(jq -r --arg p "$provider" '.providers[$p].models[]?.id' <<<"$models") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$model" \ + '.providers[$p].models[]? | select(.id==$m) | .contextWindow // empty' <<<"$models") + FOUND_SOURCE="$home" + return 0 +} + +discover_from_opencode() { + local cfg provider base key + cfg=$(read_json "$OC_CONFIG") + provider="$PROVIDER" + if [ -z "$provider" ]; then + provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") + fi + [ -n "$provider" ] || return 1 + base=$(jq -r --arg p "$provider" '.provider[$p].options.baseURL // empty' <<<"$cfg") + [ -n "$base" ] || return 1 + key=$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key" + FOUND_MODEL="${MODEL:-$(jq -r '(.model // "") | split("/")[1] // empty' <<<"$cfg")}" + FOUND_MODELS=$(jq -r --arg p "$provider" '.provider[$p].models | keys[]?' <<<"$cfg") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$FOUND_MODEL" \ + '.provider[$p].models[$m].limit.context // empty' <<<"$cfg") + FOUND_SOURCE="$OC_CONFIG" + return 0 +} + +FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" +discover_from_agent_home "$PI_HOME" \ + || discover_from_agent_home "$PRIME_HOME" \ + || discover_from_opencode \ + || true + +BASE="${ANTHROPIC_BASE_URL:-$FOUND_BASE}" +KEY="${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-$FOUND_KEY}}" +MODEL_ID="${MODEL:-${ANTHROPIC_MODEL:-$FOUND_MODEL}}" + +if [ "$LIST" = 1 ]; then + echo "provider: ${FOUND_PROVIDER:-} (from ${FOUND_SOURCE:-nowhere})" + echo "endpoint: ${BASE:-}" + echo "credential: $([ -n "$KEY" ] && echo "found (${KEY:0:10}…)" || echo "")" + echo "default model: ${MODEL_ID:-}${FOUND_CONTEXT:+ (context ${FOUND_CONTEXT})}" + echo "models:" + [ -n "$FOUND_MODELS" ] && printf ' %s\n' $FOUND_MODELS || echo " " + exit 0 +fi + +if [ -z "$BASE" ]; then + echo "claude-vllm: no LLM endpoint found." >&2 + echo " Looked in $PI_HOME, $PRIME_HOME and $OC_CONFIG." >&2 + echo " Set ANTHROPIC_BASE_URL, or configure a provider in one of those." >&2 + exit 1 +fi +if [ -z "$KEY" ]; then + echo "claude-vllm: found $BASE but no credential for '${FOUND_PROVIDER}'." >&2 + echo " Set ANTHROPIC_AUTH_TOKEN, or add the key to ${FOUND_SOURCE}/auth.json." >&2 + exit 1 +fi + +# Claude Code appends /v1/messages itself, so the stored provider baseUrl's +# trailing /v1 (an OpenAI-style base) has to come off or requests go to +# /v1/v1/messages. +BASE="${BASE%/}"; BASE="${BASE%/v1}" + +export ANTHROPIC_BASE_URL="$BASE" +# Both forms: the gateway accepts either, and which one Claude Code sends has +# changed between releases (Authorization: Bearer vs x-api-key). +export ANTHROPIC_AUTH_TOKEN="$KEY" +export ANTHROPIC_API_KEY="$KEY" +[ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" +# Without a substitute, the background/summarisation calls ask the gateway for a +# real Haiku it does not serve, and every one of them 404s. +export ANTHROPIC_SMALL_FAST_MODEL="${ANTHROPIC_SMALL_FAST_MODEL:-${CLAUDE_VLLM_FAST_MODEL:-$MODEL_ID}}" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="${ANTHROPIC_DEFAULT_HAIKU_MODEL:-$ANTHROPIC_SMALL_FAST_MODEL}" +# Claude Code only knows the context window of models it ships a table for, and +# assumes 200k for anything else — so a 393k model would auto-compact at half +# its capacity. Tell it the real number when the provider config states one. +if [ -n "${FOUND_CONTEXT:-}" ] && [ -z "${CLAUDE_CODE_MAX_CONTEXT_TOKENS:-}" ]; then + export CLAUDE_CODE_MAX_CONTEXT_TOKENS="$FOUND_CONTEXT" +fi +# Beta headers the gateway does not implement make it reject otherwise fine +# requests. +export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="${CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS:-1}" +# Stops Claude Code phoning home about a non-Anthropic endpoint. +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}" + +if [ "$PRINT_ENV" = 1 ]; then + for v in ANTHROPIC_BASE_URL ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL \ + ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_MAX_CONTEXT_TOKENS \ + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; do + [ -n "${!v:-}" ] || continue + printf 'export %s=%q\n' "$v" "${!v}" + done + # Never printed: the credential. Use --list to confirm one was found. + echo 'export ANTHROPIC_AUTH_TOKEN= # and ANTHROPIC_API_KEY' + exit 0 +fi + +command -v claude >/dev/null || { echo "claude-vllm: claude is not on PATH" >&2; exit 1; } +echo "claude-vllm: ${FOUND_PROVIDER:-custom} · ${ANTHROPIC_MODEL:-} · $ANTHROPIC_BASE_URL" >&2 +exec claude "${CLAUDE_ARGS[@]}" From d7055a0953b99697025691694d3af30bc4d4f8a9 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:51:31 +0100 Subject: [PATCH 22/77] fix(claude): one constant `mcpctl` MCP entry instead of one per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config claude` named the `.mcp.json` entry after the project, and the file is merged rather than rewritten — so configuring a second project left the first one mounted alongside it. Every project you had ever configured stayed connected, with duplicate tool names and nothing saying which was active. The entry is now always `mcpctl`, and switching rewrites what sits behind that name. Claude Code can reconnect an existing MCP server from inside a session, so a switch lands without restarting the app, and the tool prefix stays stable across switches. Entries an older CLI wrote are retired on the next run — recognised by the pairing that makes retiring them safe: our command, named after the very project it bridges to. A hand-configured server is never touched. The shaping lives in config/claude-mcp.ts as pure functions so the merge, migration and active-project detection are unit-tested rather than inferred from a command's side effects. Also brings two parity gaps in line with `config opencode` / `config prime-agent`: --dry-run, and --skip-marker for when the caller must not re-scope the directory it runs in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 4 + src/cli/src/commands/config.ts | 101 +++++++++++------- src/cli/src/config/claude-mcp.ts | 133 ++++++++++++++++++++++++ src/cli/tests/commands/claude.test.ts | 64 +++++++++--- src/cli/tests/config/claude-mcp.test.ts | 110 ++++++++++++++++++++ 6 files changed, 363 insertions(+), 53 deletions(-) create mode 100644 src/cli/src/config/claude-mcp.ts create mode 100644 src/cli/tests/config/claude-mcp.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index f9719fc..6e00b52 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 92b6c96..38665a4 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -290,6 +290,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d ' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' @@ -297,6 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l ou complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index a57e359..2f78a35 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,12 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + activeProjectIn, + type McpJson, +} from '../config/claude-mcp.js'; import { opencodeConfigDir, opencodeStatePath, @@ -57,8 +63,18 @@ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; /** Same, for the tokens `config opencode` mints. */ const OPENCODE_TOKEN_PREFIX = 'opencode'; -interface McpConfig { - mcpServers: Record }>; +/** + * Read an existing `.mcp.json`. A missing or unparseable file yields null, and + * the caller starts fresh — the same behaviour as before, kept because a + * half-written file must not stop you re-provisioning. + */ +function readMcpJson(path: string): McpJson | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as McpJson; + } catch { + return null; + } } export interface ConfigCommandDeps { @@ -261,63 +277,70 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => { + .option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; dryRun?: boolean }) => { if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; return; } - const servers: McpConfig['mcpServers'] = {}; - if (opts.project) { - servers[opts.project] = { - command: 'mcpctl', - args: ['mcp', '-p', opts.project], - }; - } - if (opts.inspect) { - servers['mcpctl-inspect'] = { - command: 'mcpctl', - args: ['console', '--stdin-mcp'], - }; - } + const outputPath = resolve(opts.output); + const existing = readMcpJson(outputPath); + const { config: finalConfig, retired } = mergeMcpctlServers(existing, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + }); - if (opts.stdout) { - log(JSON.stringify({ mcpServers: servers }, null, 2)); + if (opts.stdout === true) { + log(JSON.stringify(finalConfig, null, 2)); return; } - const outputPath = resolve(opts.output); - let finalConfig: McpConfig = { mcpServers: servers }; - - // Always merge with existing .mcp.json — never overwrite other servers - if (existsSync(outputPath)) { - try { - const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; - finalConfig = { - mcpServers: { - ...existing.mcpServers, - ...servers, - }, - }; - } catch { - // If existing file is invalid, start fresh - } + if (opts.dryRun === true) { + log(JSON.stringify({ + claude: { + output: outputPath, + previousProject: activeProjectIn(existing), + server: MCPCTL_SERVER_NAME, + entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '', + retiredLegacyEntries: retired, + marker: opts.skipMarker === true || opts.project === undefined + ? '' + : join(dirname(outputPath), '.mcpctl-project'), + skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + }, + action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook', + }, null, 2)); + return; } writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); const serverCount = Object.keys(finalConfig.mcpServers).length; log(`Wrote ${outputPath} (${serverCount} server(s))`); + if (retired.length > 0) { + // Before the constant name, every project you configured stayed + // mounted alongside the new one. + log(`Retired legacy per-project entr${retired.length === 1 ? 'y' : 'ies'}: ${retired.join(', ')}`); + } + if (opts.project !== undefined) { + log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - try { - const markerPath = await writeProjectMarker(projectDir, opts.project); - log(`Wrote ${markerPath}`); - } catch (err: unknown) { - log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else { + try { + const markerPath = await writeProjectMarker(projectDir, opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } } if (skillsClient) { diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts new file mode 100644 index 0000000..6feaa0f --- /dev/null +++ b/src/cli/src/config/claude-mcp.ts @@ -0,0 +1,133 @@ +/** + * `.mcp.json` shaping for `mcpctl config claude`. + * + * WHY A CONSTANT SERVER NAME + * + * The entry used to be named after the project (`homeautomation`, + * `docmost`, …). Because `.mcp.json` is *merged* rather than rewritten, running + * `config claude` for a second project left the first one mounted too: every + * project you had ever configured stayed connected, with duplicate tool names + * and no way to tell which one was "active". + * + * The entry is now always called `mcpctl`, and switching projects rewrites what + * is behind that name. Claude Code can reconnect an existing MCP server from + * inside a session (`/mcp`), so a switch takes effect without restarting the + * app — and the tool prefix (`mcpctl__*`) stays stable across switches, so the + * model never sees a tool namespace disappear. + * + * Legacy project-named entries this CLI wrote are retired on the next run; see + * `isLegacyMcpctlEntry` for what counts as ours. + */ + +/** The one MCP server name mcpctl owns in `.mcp.json`. */ +export const MCPCTL_SERVER_NAME = 'mcpctl'; + +/** Name of the optional traffic-inspection server (`--inspect`). */ +export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect'; + +export interface McpServerEntry { + command?: string; + args?: string[]; + url?: string; + env?: Record; + [key: string]: unknown; +} + +export interface McpJson { + mcpServers: Record; + [key: string]: unknown; +} + +/** The stdio-bridge entry that mounts `project`. */ +export function mcpctlStdioServer(project: string): McpServerEntry { + return { command: 'mcpctl', args: ['mcp', '-p', project] }; +} + +/** The `--inspect` traffic monitor entry. */ +export function mcpctlInspectServer(): McpServerEntry { + return { command: 'mcpctl', args: ['console', '--stdin-mcp'] }; +} + +/** + * The project an entry bridges to, or null if it is not an mcpctl stdio bridge. + * + * Reads it straight out of `args` rather than a bookkeeping key, so nothing + * non-standard is written into a file Claude Code owns. + */ +export function projectOfEntry(entry: unknown): string | null { + if (entry === null || typeof entry !== 'object') return null; + const rec = entry as McpServerEntry; + if (rec.command !== 'mcpctl' || !Array.isArray(rec.args)) return null; + const args = rec.args; + if (args[0] !== 'mcp') return null; + const flag = args.indexOf('-p') >= 0 ? args.indexOf('-p') : args.indexOf('--project'); + if (flag < 0) return null; + const project = args[flag + 1]; + return typeof project === 'string' && project !== '' ? project : null; +} + +/** + * Is `name` an entry an older mcpctl wrote — i.e. named after the very project + * its command bridges to? + * + * That pairing is what makes retiring it safe. A server someone configured by + * hand would have to be named exactly after the project it bridges to *and* run + * our command to be mistaken for one of ours, at which point it is functionally + * the same entry anyway. + */ +export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean { + if (name === MCPCTL_SERVER_NAME) return false; + return projectOfEntry(entry) === name; +} + +/** The project currently mounted by `.mcp.json`, preferring the canonical entry. */ +export function activeProjectIn(config: Pick | null | undefined): string | null { + const servers = config?.mcpServers; + if (!servers) return null; + const canonical = projectOfEntry(servers[MCPCTL_SERVER_NAME]); + if (canonical !== null) return canonical; + for (const [name, entry] of Object.entries(servers)) { + if (isLegacyMcpctlEntry(name, entry)) return name; + } + return null; +} + +export interface MergeResult { + config: McpJson; + /** Legacy project-named entries dropped by this merge. */ + retired: string[]; +} + +/** + * Merge mcpctl's entries into an existing `.mcp.json`. + * + * Every server the user configured is preserved; only our own legacy + * project-named entries are dropped, and only once the canonical entry replaces + * them. Passing no project leaves any existing mount alone (`--inspect` on its + * own must not unmount the project you are working in). + */ +export function mergeMcpctlServers( + existing: Partial | null | undefined, + opts: { project?: string; inspect?: boolean }, +): MergeResult { + const servers: Record = { ...(existing?.mcpServers ?? {}) }; + const retired: string[] = []; + + if (opts.project !== undefined && opts.project !== '') { + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(opts.project); + } + if (opts.inspect === true) { + servers[MCPCTL_INSPECT_SERVER_NAME] = mcpctlInspectServer(); + } + + // Preserve any sibling top-level keys the file carried. + const rest = { ...(existing ?? {}) } as Partial; + delete rest.mcpServers; + return { config: { ...rest, mcpServers: servers }, retired }; +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9eb0053..4ebc6fd 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -46,7 +46,7 @@ describe('config claude', () => { expect(client.get).not.toHaveBeenCalled(); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['homeautomation']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'], }); @@ -61,7 +61,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' }); const parsed = JSON.parse(output[0]); - expect(parsed.mcpServers['myproj']).toEqual({ + expect(parsed.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'myproj'], }); @@ -81,7 +81,7 @@ describe('config claude', () => { const written = JSON.parse(readFileSync(outPath, 'utf-8')); expect(written.mcpServers['existing--server']).toBeDefined(); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); @@ -113,7 +113,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['ha']).toBeDefined(); + expect(written.mcpServers['mcpctl']).toBeDefined(); expect(written.mcpServers['mcpctl-inspect']).toBeDefined(); expect(output.join('\n')).toContain('2 server(s)'); }); @@ -127,21 +127,61 @@ describe('config claude', () => { await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); }); - it('uses project name as the server key', async () => { + it('uses one constant server key, whatever the project is called', async () => { + // The key used to be the project name, so `config claude` for a second + // project left the first one mounted too — every project ever configured + // stayed connected, with duplicate tool names. const outPath = join(tmpDir, '.mcp.json'); - const cmd = createConfigCommand( - { configDeps: { configDir: tmpDir }, log }, - ); - await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' }); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']); + }); + + it('switching projects replaces the mount instead of stacking a second one', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' }); + await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']); + }); + + it('retires a legacy project-named entry left by an older CLI', async () => { + const outPath = join(tmpDir, '.mcp.json'); + writeFileSync(outPath, JSON.stringify({ + mcpServers: { + homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }, + 'my-own-server': { command: 'echo', args: [] }, + }, + })); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']); + expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' }); + + const plan = JSON.parse(output.join('\n')); + expect(plan.claude.server).toBe('mcpctl'); + expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']); + expect(existsSync(outPath)).toBe(false); }); }); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts new file mode 100644 index 0000000..45a1a0a --- /dev/null +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + projectOfEntry, + isLegacyMcpctlEntry, + activeProjectIn, +} from '../../src/config/claude-mcp.js'; + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +describe('projectOfEntry', () => { + it('reads the project out of the bridge args', () => { + expect(projectOfEntry(bridge('docmost'))).toBe('docmost'); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '--project', 'sre'] })).toBe('sre'); + }); + + it('ignores anything that is not our stdio bridge', () => { + expect(projectOfEntry({ command: 'echo', args: ['mcp', '-p', 'x'] })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['console', '--stdin-mcp'] })).toBeNull(); + expect(projectOfEntry({ type: 'remote', url: 'https://x/projects/y/mcp' })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '-p'] })).toBeNull(); + expect(projectOfEntry(null)).toBeNull(); + expect(projectOfEntry('nope')).toBeNull(); + }); +}); + +describe('isLegacyMcpctlEntry', () => { + it('recognises an entry named after the very project it bridges to', () => { + expect(isLegacyMcpctlEntry('docmost', bridge('docmost'))).toBe(true); + }); + + it('never claims the canonical entry', () => { + expect(isLegacyMcpctlEntry(MCPCTL_SERVER_NAME, bridge('docmost'))).toBe(false); + }); + + it('leaves a hand-configured server alone', () => { + // Same name, different command — someone else's server. + expect(isLegacyMcpctlEntry('docmost', { command: 'docker', args: ['run', 'docmost'] })).toBe(false); + // Our command, but the name does not match the project: not something this + // CLI ever wrote, so it is the user's to keep. + expect(isLegacyMcpctlEntry('my-shortcut', bridge('docmost'))).toBe(false); + }); +}); + +describe('activeProjectIn', () => { + it('prefers the canonical entry', () => { + expect(activeProjectIn({ mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre') } })).toBe('sre'); + }); + + it('falls back to a legacy entry so pre-migration installs still report', () => { + expect(activeProjectIn({ mcpServers: { docmost: bridge('docmost') } })).toBe('docmost'); + }); + + it('is null when nothing of ours is mounted', () => { + expect(activeProjectIn({ mcpServers: { other: { command: 'echo' } } })).toBeNull(); + expect(activeProjectIn(null)).toBeNull(); + expect(activeProjectIn({ mcpServers: {} })).toBeNull(); + }); +}); + +describe('mergeMcpctlServers', () => { + it('writes one constant entry regardless of project', () => { + const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('my-fancy-project')); + }); + + it('re-points rather than stacking on a second project', () => { + const first = mergeMcpctlServers(null, { project: 'a' }).config; + const { config } = mergeMcpctlServers(first, { project: 'b' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('b')); + }); + + it('retires legacy per-project entries and reports them', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation'), sre: bridge('sre') } }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(retired.sort()).toEqual(['homeautomation', 'sre']); + }); + + it('preserves servers the user configured, and other top-level keys', () => { + const existing = { + mcpServers: { 'my-own': { command: 'echo', args: [] } }, + someOtherKey: { keep: true }, + }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'p' }); + expect(config.mcpServers['my-own']).toEqual({ command: 'echo', args: [] }); + expect(config['someOtherKey']).toEqual({ keep: true }); + expect(retired).toEqual([]); + }); + + it('--inspect alone does not unmount the project you are working in', () => { + const existing = mergeMcpctlServers(null, { project: 'p' }).config; + const { config, retired } = mergeMcpctlServers(existing, { inspect: true }); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('p')); + expect(config.mcpServers['mcpctl-inspect']).toEqual({ command: 'mcpctl', args: ['console', '--stdin-mcp'] }); + expect(retired).toEqual([]); + }); + + it('does not mutate the config it was handed', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation') } }; + mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(existing.mcpServers)).toEqual(['homeautomation']); + }); +}); From b3a062ce28ebe2baf4860697725f1ad255f4b920 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:06:06 +0100 Subject: [PATCH 23/77] feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code had neither of the things opencode, pi and prime-agent all have: a visible active project, and a way to change it from inside a session. It has no plugin API that can draw a widget or open a picker, but it does run a command for its status line and it does load slash commands — which is enough for both. - `mcpctl statusline` prints the active project (from .mcp.json, falling back to a .mcpctl-project marker) and is wired into settings.json. It reads the directory out of the JSON Claude Code pipes in, so it follows /cwd rather than reporting wherever the binary was launched. Prints nothing when no project is active: an empty line beats "none" on every unrelated repo. - `/mcpctl [project]` switches and reminds you to reconnect from /mcp. allowed-tools is scoped to the four exact mcpctl invocations it needs. Three things found by running it rather than reasoning about it: - Claude Code REWRITES settings.json against its own schema and strips unknown keys from `statusLine` — our `_mcpctl_managed` marker came back gone, so ownership is now determined by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - Every `!`-prefixed block in a slash command is permission-checked against allowed-tools. Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn that auth may not work; claude-vllm now sets only the former and clears an inherited API key. Also fixes a pre-existing test-isolation bug this work would have made worse: `config claude` wrote into the developer's real ~/.claude when the suite ran, which is how an untagged duplicate of the skills-sync SessionStart hook got there. Both the hook installer and the new UI installers now honour CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first, isolation second), `config claude` gains --claude-dir for parity with --pi-dir and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical. Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- completions/mcpctl.bash | 9 +- completions/mcpctl.fish | 11 +- src/cli/src/commands/config.ts | 43 +++++- src/cli/src/commands/statusline.ts | 103 +++++++++++++ src/cli/src/index.ts | 2 + src/cli/src/utils/claude-ui.ts | 201 ++++++++++++++++++++++++++ src/cli/src/utils/sessionhook.ts | 7 +- src/cli/tests/commands/claude.test.ts | 15 ++ src/cli/tests/utils/claude-ui.test.ts | 133 +++++++++++++++++ stack/claude-vllm | 12 +- 10 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 src/cli/src/commands/statusline.ts create mode 100644 src/cli/src/utils/claude-ui.ts create mode 100644 src/cli/tests/utils/claude-ui.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 6e00b52..d83ba09 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -94,6 +94,9 @@ _mcpctl() { status) COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur")) return ;; + statusline) + COMPREPLY=($(compgen -W "-d --directory --prefix -h --help" -- "$cur")) + return ;; login) COMPREPLY=($(compgen -W "--mcpd-url -h --help" -- "$cur")) return ;; @@ -119,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 38665a4..0144057 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 chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -221,6 +221,7 @@ end # Top-level commands (without --project) complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show mcpctl status and connectivity' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a statusline -d 'Print the active mcpctl project (for Claude Code\'s statusLine setting)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' @@ -291,6 +292,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Incl complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options @@ -300,6 +303,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options @@ -599,6 +604,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from rotate; and not __fish_seen_s # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x +# statusline options +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -s d -l directory -d 'Directory to resolve the project for (default: from stdin, then cwd)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -l prefix -d 'Text before the project name' -x + # login options complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd URL to authenticate against' -x diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 2f78a35..d37c541 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,12 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + installStatusLine, + installSlashCommand, + claudeConfigDir, + STATUSLINE_COMMAND, +} from '../utils/claude-ui.js'; import { MCPCTL_SERVER_NAME, mergeMcpctlServers, @@ -278,8 +284,17 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') .option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file') + .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') + .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') .option('--dry-run', 'Print what would change without writing or syncing') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; dryRun?: boolean }) => { + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => { + // Resolve Claude's config dir once: an explicit --claude-dir wins, then + // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather + // than letting each helper default) is what keeps the test suite off the + // developer's real ~/.claude. + const claudeDir = opts.claudeDir !== undefined ? resolve(opts.claudeDir) : claudeConfigDir(); + const claudeSettings = join(claudeDir, 'settings.json'); + const claudeCommand = join(claudeDir, 'commands', 'mcpctl.md'); if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; @@ -310,6 +325,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: ? '' : join(dirname(outputPath), '.mcpctl-project'), skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + statusLine: opts.skipUi === true ? '' : `${claudeSettings} (${STATUSLINE_COMMAND})`, + slashCommand: opts.skipUi === true ? '' : claudeCommand, }, action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook', }, null, 2)); @@ -359,12 +376,34 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } try { - const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet'); + const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet', claudeSettings); log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`); } catch (err: unknown) { log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`); } } + + // The UI bits are independent of --skip-skills: they are how you see + // and change the project, not how skills get there. + if (opts.project !== undefined && opts.skipUi !== true) { + try { + const outcome = await installStatusLine(claudeSettings); + if (outcome.status === 'installed') log(`Installed the active-project status line in ${claudeSettings}`); + else if (outcome.status === 'already') log('Status line already up to date'); + else { + // Never clobber a status line someone built. + log(`Left your existing status line alone (${outcome.command}).`); + log(` To show the project too, append: $(${STATUSLINE_COMMAND})`); + } + } catch (err: unknown) { + log(`Warning: failed to install the status line: ${err instanceof Error ? err.message : String(err)}`); + } + try { + log(`Installed the /mcpctl switcher: ${await installSlashCommand(claudeCommand)}`); + } catch (err: unknown) { + log(`Warning: failed to install the /mcpctl command: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { // Commander shows empty-description commands but they won't clutter help output diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts new file mode 100644 index 0000000..a3d7c29 --- /dev/null +++ b/src/cli/src/commands/statusline.ts @@ -0,0 +1,103 @@ +import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { activeProjectIn, type McpJson } from '../config/claude-mcp.js'; +import { findProjectMarker } from '../utils/project-marker.js'; + +/** + * `mcpctl statusline` — print the active mcpctl project, for Claude Code's + * `statusLine` setting. + * + * Claude Code has no plugin API that can draw into its UI, but it does run a + * command for the status line and render whatever that prints. This is that + * command: it is what gives Claude Code the same at-a-glance "which project am + * I in" that opencode gets from a footer slot and pi/prime-agent from + * `setStatus`. + * + * Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We + * only need the directory — the project is whatever `.mcp.json` there mounts, + * falling back to a `.mcpctl-project` marker up the tree so a checkout that is + * scoped but not yet wired still reports. + * + * Prints nothing at all when no project is active: an empty status line is + * better than one that says "none" on every unrelated repo you open. + */ + +interface StatusLineInput { + workspace?: { current_dir?: string; project_dir?: string }; + cwd?: string; +} + +/** Read Claude Code's stdin payload. Absent or unparseable → no directory hint. */ +async function readStdinJson(): Promise { + if (process.stdin.isTTY === true) return {}; + const chunks: Buffer[] = []; + try { + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (raw.length === 0) return {}; + return JSON.parse(raw) as StatusLineInput; + } catch { + return {}; + } +} + +/** + * The directory whose project we should report. + * + * Claude Code's `current_dir` moves with `/cwd`, so it beats the process cwd + * (which is wherever the Claude Code binary was launched, often unrelated). + */ +export function resolveDirectory(input: StatusLineInput, fallback: string): string { + return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; +} + +/** The project `.mcp.json` in `dir` mounts, or null. */ +export function projectFromMcpJson(dir: string): string | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; + return activeProjectIn(parsed); + } catch { + return null; + } +} + +/** Format for the status line. Empty string means "render nothing". */ +export function formatStatus(project: string | null, prefix: string): string { + return project !== null && project !== '' ? `${prefix}${project}` : ''; +} + +export interface StatuslineDeps { + log: (line: string) => void; + cwd: () => string; + homeDir: () => string; +} + +export function createStatuslineCommand(deps?: Partial): Command { + const log = deps?.log ?? ((line: string): void => { process.stdout.write(line); }); + const cwd = deps?.cwd ?? ((): string => process.cwd()); + const homeDir = deps?.homeDir ?? homedir; + + return new Command('statusline') + .description('Print the active mcpctl project (for Claude Code\'s statusLine setting)') + .option('-d, --directory ', 'Directory to resolve the project for (default: from stdin, then cwd)') + .option('--prefix ', 'Text before the project name', 'mcpctl:') + .action(async (opts: { directory?: string; prefix: string }) => { + const input = opts.directory !== undefined ? {} : await readStdinJson(); + const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); + + let project = projectFromMcpJson(dir); + if (project === null) { + // Not wired here (or wired above this directory) — the marker is the + // other thing `config claude` writes, and skills sync already trusts it. + const marker = await findProjectMarker(dir, homeDir()).catch(() => null); + project = marker?.project ?? null; + } + + const line = formatStatus(project, opts.prefix); + // No trailing newline: Claude Code renders the output as one line, and a + // stray newline shows up as a blank second row. + if (line !== '') log(line); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..47c2070 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -26,6 +26,7 @@ import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; import { createReviewCommand } from './commands/review.js'; import { createSkillsCommand } from './commands/skills.js'; +import { createStatuslineCommand } from './commands/statusline.js'; import { createPasswdCommand } from './commands/passwd.js'; import { createErrorsCommand } from './commands/errors.js'; import { ApiClient, ApiError } from './api-client.js'; @@ -44,6 +45,7 @@ export function createProgram(): Command { .option('-p, --project ', 'Target project for project commands'); program.addCommand(createStatusCommand()); + program.addCommand(createStatuslineCommand()); program.addCommand(createLoginCommand()); program.addCommand(createLogoutCommand()); diff --git a/src/cli/src/utils/claude-ui.ts b/src/cli/src/utils/claude-ui.ts new file mode 100644 index 0000000..e0a05d9 --- /dev/null +++ b/src/cli/src/utils/claude-ui.ts @@ -0,0 +1,201 @@ +/** + * The two pieces of Claude Code UI `mcpctl config claude` wires up: + * + * - a **status line** showing the active project, so Claude Code gets the + * same at-a-glance indicator opencode has in its footer and pi/prime-agent + * get from `setStatus`; + * - a **`/mcpctl` slash command** to switch projects from inside a session. + * + * Claude Code has no plugin API that can draw its own widget or open a picker, + * so neither is as native as the opencode switcher. The status line is a + * command Claude Code runs and renders; the slash command is a prompt file that + * drives the model through `mcpctl` CLI calls. That is the whole extension + * surface Claude Code offers, and it is enough for both jobs. + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { MCPCTL_SERVER_NAME } from '../config/claude-mcp.js'; + +/** Same marker the SessionStart hook installer uses to recognise its own rows. */ +export const MARKER_KEY = '_mcpctl_managed'; + +/** The command Claude Code runs to render the status line. */ +export const STATUSLINE_COMMAND = 'mcpctl statusline'; + +/** + * Claude Code's config directory. + * + * `CLAUDE_CONFIG_DIR` is Claude Code's own override, so honouring it is correct + * behaviour first and test isolation second — without it, anything that + * provisions Claude writes into the developer's real ~/.claude when the test + * suite runs. + */ +export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const override = env['CLAUDE_CONFIG_DIR']; + return override !== undefined && override !== '' ? override : join(homeDir, '.claude'); +} + +export function claudeSettingsPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'settings.json'); +} + +export function claudeCommandPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'commands', 'mcpctl.md'); +} + +interface StatusLine { + type?: string; + command?: string; + [k: string]: unknown; +} + +interface Settings { + statusLine?: StatusLine; + [k: string]: unknown; +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + // Same heuristic as the hook installer: strip line comments so a file an + // editor added notes to still parses. + return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as Settings; + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Is this status line ours? + * + * The `_mcpctl_managed` marker alone is not enough: Claude Code rewrites + * settings.json against its own schema (on theme change, for instance) and + * **strips unknown keys from `statusLine`** — verified in a live session, where + * our tagged entry came back as a bare `{type, command}`. Hooks keep their + * marker; statusLine does not. Relying on the tag would mean reporting our own + * status line as somebody else's forever, and never upgrading the command. + * + * So the command string is the real evidence. It must *be* an `mcpctl + * statusline` invocation, not merely contain one — someone who composed ours + * into a bigger line (`my-prompt && mcpctl statusline`) owns that line, and we + * must not overwrite it. + */ +export function isOurStatusLine(current: StatusLine | null | undefined): boolean { + if (current === null || current === undefined) return false; + if (current[MARKER_KEY] === true) return true; + const command = current.command; + return typeof command === 'string' && /^\s*(\S*\/)?mcpctl\s+statusline(\s|$)/.test(command); +} + +export type StatusLineOutcome = + | { status: 'installed' } + | { status: 'already' } + | { status: 'foreign'; command: string }; + +/** + * Install the status line — but never over one the user already has. + * + * A status line is a single slot, so installing ours on top of a custom one + * silently deletes work someone put effort into. When we find a foreign one we + * leave it and report it, so the caller can print the one-line snippet to add + * instead. Ours is tagged, so re-running is idempotent and an upgrade of the + * command string still lands. + */ +export async function installStatusLine( + settingsPath: string = claudeSettingsPath(), + command: string = STATUSLINE_COMMAND, +): Promise { + const settings = await readSettings(settingsPath); + const current = settings.statusLine; + + if (current !== undefined && current !== null) { + if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '') }; + if (current.command === command) return { status: 'already' }; + } + + settings.statusLine = { type: 'command', command, [MARKER_KEY]: true }; + await writeSettings(settingsPath, settings); + return { status: 'installed' }; +} + +/** Remove our status line, leaving a foreign one alone. */ +export async function removeStatusLine(settingsPath: string = claudeSettingsPath()): Promise { + const settings = await readSettings(settingsPath); + if (!isOurStatusLine(settings.statusLine)) return false; + delete settings.statusLine; + await writeSettings(settingsPath, settings); + return true; +} + +/** + * The `/mcpctl` slash command. + * + * Claude Code slash commands are prompt files, not code — so unlike opencode's + * picker this drives the model through CLI calls. `allowed-tools` is scoped to + * the exact `mcpctl` invocations it needs, so accepting the command does not + * hand it a general shell. + * + * Every `!`-prefixed block below is pre-executed by Claude Code and checked + * against that same list — including `statusline`, which is easy to forget + * because it is context-gathering rather than an action. Omitting one fails the + * whole command with a permission error before the model sees anything. + * + * `--skip-marker` matters here for the same reason it does in the opencode + * switcher: the session's directory is whatever you happened to open, and + * re-scoping it would silently change which skills sync into it. + */ +export const MCPCTL_SLASH_COMMAND = `--- +description: Switch the active mcpctl project (MCP servers + skills) +allowed-tools: Bash(mcpctl statusline:*), Bash(mcpctl get projects:*), Bash(mcpctl config claude:*), Bash(mcpctl skills sync:*) +--- + +# Switch the active mcpctl project + +The user wants to change which mcpctl project this session is connected to. +There is exactly one mcpctl MCP server, named \`${MCPCTL_SERVER_NAME}\`; switching +projects changes what sits behind that name. + +Requested project (may be empty): $ARGUMENTS + +## Steps + +1. Show the current project and the available ones: + + !\`mcpctl statusline --prefix 'current: ' --directory .\` + !\`mcpctl get projects -o json\` + +2. If \$ARGUMENTS names a project, use it. Otherwise list the projects + compactly (name — description) and ask which one. Do not guess. + +3. Switch, keeping this directory's scope unchanged: + + \`mcpctl config claude --project --skip-marker\` + +4. Report the switch as "now on ". Do not describe the project as + the server — the server is always \`${MCPCTL_SERVER_NAME}\`, only what sits + behind it changed. + +5. Tell the user, in one short line, that they must now **reconnect the + \`${MCPCTL_SERVER_NAME}\` server from \`/mcp\`** for the new project's tools to + load. The config on disk is already correct; the running session still holds + the old connection until it is reconnected. + +Keep the whole exchange to a few lines. This is a switcher, not a report. +`; + +/** Write the `/mcpctl` slash command into Claude Code's user commands dir. */ +export async function installSlashCommand(path: string = claudeCommandPath()): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8'); + return path; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index 49a9479..f0f6cf4 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -39,7 +39,12 @@ interface Settings { } function defaultSettingsPath(): string { - return join(homedir(), '.claude', 'settings.json'); + // CLAUDE_CONFIG_DIR is Claude Code's own override. Honouring it also stops + // the test suite writing a hook into the developer's real ~/.claude — which + // is how an untagged duplicate of this very hook got there in the first place. + const override = process.env['CLAUDE_CONFIG_DIR']; + const base = override !== undefined && override !== '' ? override : join(homedir(), '.claude'); + return join(base, 'settings.json'); } async function readSettings(path: string): Promise { diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 4ebc6fd..9a079f7 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -21,14 +21,29 @@ describe('config claude', () => { let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); + /** + * Claude Code's config dir, redirected per test. + * + * Without this the suite writes a SessionStart hook, a status line and a + * slash command into the developer's real ~/.claude — which is exactly how an + * untagged duplicate of the skills-sync hook ended up there. + */ + let claudeDir: string; + let priorClaudeConfigDir: string | undefined; + beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-')); + claudeDir = join(tmpDir, 'claude-home'); + priorClaudeConfigDir = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + if (priorClaudeConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = priorClaudeConfigDir; }); it('generates .mcp.json with mcpctl mcp bridge entry', async () => { diff --git a/src/cli/tests/utils/claude-ui.test.ts b/src/cli/tests/utils/claude-ui.test.ts new file mode 100644 index 0000000..9f56e10 --- /dev/null +++ b/src/cli/tests/utils/claude-ui.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + installStatusLine, + removeStatusLine, + installSlashCommand, + MCPCTL_SLASH_COMMAND, + STATUSLINE_COMMAND, + MARKER_KEY, +} from '../../src/utils/claude-ui.js'; + +describe('installStatusLine', () => { + let dir: string; + let settings: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-ui-')); + settings = join(dir, 'settings.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('installs into a missing settings file', async () => { + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.statusLine).toEqual({ type: 'command', command: STATUSLINE_COMMAND, [MARKER_KEY]: true }); + }); + + it('is idempotent', async () => { + await installStatusLine(settings); + const before = readFileSync(settings, 'utf-8'); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + expect(readFileSync(settings, 'utf-8')).toBe(before); + }); + + it('upgrades its own entry when the command changes', async () => { + await installStatusLine(settings, 'mcpctl statusline --prefix old:'); + expect(await installStatusLine(settings, STATUSLINE_COMMAND)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe(STATUSLINE_COMMAND); + }); + + it('still recognises its own line after Claude Code strips the marker', async () => { + // Claude Code rewrites settings.json against its own schema and drops + // unknown keys from statusLine — verified live. Without matching on the + // command we would call our own line foreign forever. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline --prefix p:' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + }); + + it('does not claim a line that merely composes ours into a bigger one', async () => { + // That line is the user's work, even though our command appears in it. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-prompt && mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-prompt && mcpctl statusline' }); + }); + + it('never clobbers a status line the user built', async () => { + // A status line is a single slot; overwriting one silently deletes work. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-fancy-prompt' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-fancy-prompt' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('my-fancy-prompt'); + }); + + it('preserves every other setting', async () => { + writeFileSync(settings, JSON.stringify({ permissions: { allow: ['Bash'] }, hooks: { SessionStart: [] } })); + await installStatusLine(settings); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.permissions).toEqual({ allow: ['Bash'] }); + expect(parsed.hooks).toEqual({ SessionStart: [] }); + }); + + it('tolerates line comments an editor may have added', async () => { + writeFileSync(settings, '{\n // my notes\n "permissions": { "allow": [] }\n}\n'); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).permissions).toEqual({ allow: [] }); + }); + + it('removes only its own entry', async () => { + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'theirs' } })); + expect(await removeStatusLine(settings)).toBe(false); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('theirs'); + + await installStatusLine(join(dir, 'ours.json')); + expect(await removeStatusLine(join(dir, 'ours.json'))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, 'ours.json'), 'utf-8')).statusLine).toBeUndefined(); + }); +}); + +describe('the /mcpctl slash command', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-cmd-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('is written where Claude Code looks for user commands', async () => { + const path = join(dir, 'commands', 'mcpctl.md'); + expect(await installSlashCommand(path)).toBe(path); + expect(existsSync(path)).toBe(true); + }); + + it('scopes allowed-tools to mcpctl, not a general shell', async () => { + // Accepting the command must not hand it arbitrary Bash. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + expect(tools).not.toMatch(/Bash\(\*\)|Bash\)/); + for (const t of tools.split(', ')) expect(t).toMatch(/^Bash\(mcpctl /); + }); + + it('permits every command it pre-executes', () => { + // A `!`-block missing from allowed-tools fails the whole command with a + // permission error before the model sees anything — which is exactly what + // happened live when `statusline` was omitted. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + const permitted = tools.split(', ').map((t) => /^Bash\((.+?):?\*?\)$/.exec(t)?.[1] ?? ''); + const preExecuted = [...MCPCTL_SLASH_COMMAND.matchAll(/!`([^`]+)`/g)].map((m) => m[1] ?? ''); + expect(preExecuted.length).toBeGreaterThan(0); + for (const cmd of preExecuted) { + expect(permitted.some((p) => p !== '' && cmd.startsWith(p)), `"${cmd}" is not covered by allowed-tools`).toBe(true); + } + }); + + it('switches without re-scoping the directory the session opened in', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('--skip-marker'); + }); + + it('tells the user to reconnect, since the running session holds the old connection', () => { + expect(MCPCTL_SLASH_COMMAND).toMatch(/reconnect/i); + expect(MCPCTL_SLASH_COMMAND).toContain('/mcp'); + }); + + it('refers to the one constant server name', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('`mcpctl`'); + }); +}); diff --git a/stack/claude-vllm b/stack/claude-vllm index 17942f8..d7119c0 100755 --- a/stack/claude-vllm +++ b/stack/claude-vllm @@ -139,10 +139,13 @@ fi BASE="${BASE%/}"; BASE="${BASE%/v1}" export ANTHROPIC_BASE_URL="$BASE" -# Both forms: the gateway accepts either, and which one Claude Code sends has -# changed between releases (Authorization: Bearer vs x-api-key). +# ANTHROPIC_AUTH_TOKEN only. Setting ANTHROPIC_API_KEY as well makes Claude Code +# warn that "auth may not work as expected" — it wants exactly one. AUTH_TOKEN +# is the right one for a third-party gateway (sent as `Authorization: Bearer`), +# and an inherited ANTHROPIC_API_KEY would otherwise take precedence over it, so +# clear it for the child process. export ANTHROPIC_AUTH_TOKEN="$KEY" -export ANTHROPIC_API_KEY="$KEY" +unset ANTHROPIC_API_KEY [ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" # Without a substitute, the background/summarisation calls ask the gateway for a # real Haiku it does not serve, and every one of them 404s. @@ -169,7 +172,8 @@ if [ "$PRINT_ENV" = 1 ]; then printf 'export %s=%q\n' "$v" "${!v}" done # Never printed: the credential. Use --list to confirm one was found. - echo 'export ANTHROPIC_AUTH_TOKEN= # and ANTHROPIC_API_KEY' + echo 'export ANTHROPIC_AUTH_TOKEN=' + echo 'unset ANTHROPIC_API_KEY' exit 0 fi From a9fcd83ed883135066c36dd8141081515972ca96 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:20:36 +0100 Subject: [PATCH 24/77] refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prime-agent switcher existed only as a 275-line string literal inside prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap that let a wrong ctx.ui.select() option shape ship in the pi extension. It now lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig checking it against the real @earendil-works/pi-coding-agent types, eslint coverage and an embed-freshness test, matching pi and opencode. The extraction was verified byte-identical before any edit, so the behaviour shipped today is exactly what was captured. Linting it then found six problems in code nothing had ever checked: object-truthiness null guards, a nullable string conditional and a missing return type. All behaviour-preserving to fix, but exactly the class of thing that ships silently when nothing is looking. Also: - `config pi` gains --dry-run, the last agent without it. - The SessionStart hook installer now drops untagged duplicates of its own exact command — rows left behind before the marker existed, or by a suite that used to write into a real ~/.claude. Invisible in the UI; they just run the sync twice per session. A hook the user wrote is never touched, even one calling `mcpctl skills sync` with different flags. - docs/claude-integration.md and docs/prime-agent-extension.md, the two integrations that had no page. prime-agent deliberately keeps its per-project MCP entry name rather than the constant `mcpctl` claude and opencode now use: its switcher already unmounts the previous project, so it never accumulates entries, and re-keying auth.json from mcp: to mcp:mcpctl would give up per-project token caching and needs a migration. Documented as its own change rather than folded in here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 10 + completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 1 + docs/claude-integration.md | 114 ++++++++ docs/prime-agent-extension.md | 101 +++++++ eslint.config.js | 2 +- package.json | 3 +- scripts/generate-prime-agent-extension.ts | 41 +++ src/cli/src/commands/config.ts | 17 +- src/cli/src/config/prime-agent-extension.ts | 9 +- src/cli/src/utils/sessionhook.ts | 26 ++ .../prime-agent-extension-embed.test.ts | 48 +++ src/cli/tests/utils/sessionhook.test.ts | 45 +++ src/prime-agent-ext/mcpctl-switch.ts | 275 ++++++++++++++++++ src/prime-agent-ext/tsconfig.json | 29 ++ 15 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 docs/claude-integration.md create mode 100644 docs/prime-agent-extension.md create mode 100644 scripts/generate-prime-agent-extension.ts create mode 100644 src/cli/tests/config/prime-agent-extension-embed.test.ts create mode 100644 src/prime-agent-ext/mcpctl-switch.ts create mode 100644 src/prime-agent-ext/tsconfig.json diff --git a/README.md b/README.md index 8be6684..02c1515 100644 --- a/README.md +++ b/README.md @@ -164,12 +164,22 @@ The `/mcpctl` switcher runs with `--skip-extension --skip-marker`, so switching projects from inside prime-agent never re-scopes whichever repository prime-agent happened to be started in. +See [docs/prime-agent-extension.md](docs/prime-agent-extension.md) for full details. + Preview the change without writing anything: ```bash mcpctl config prime-agent --project monitoring --dry-run ``` +Inside Claude Code: + +- `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from + `/mcp` (no restart) +- the active project shows in the status line at the bottom of the screen + +See [docs/claude-integration.md](docs/claude-integration.md) for full details. + ### Connect pi [pi](https://github.com/earendil-works/pi) does not support MCP, but it supports diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index d83ba09..80c161a 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -128,7 +128,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) - COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) ;; prime-agent) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 0144057..9918f91 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -313,6 +313,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l extension-dir -d 'So complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l skip-skills -d 'Skip the initial skills sync' complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l settings -d 'pi settings.json path (default: ~/.pi/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l pi-dir -d 'Override the pi agent home (default: ~/.pi/agent)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' diff --git a/docs/claude-integration.md b/docs/claude-integration.md new file mode 100644 index 0000000..da59b78 --- /dev/null +++ b/docs/claude-integration.md @@ -0,0 +1,114 @@ +# mcpctl × Claude Code + +## What `mcpctl config claude --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| MCP entry | `./.mcp.json` | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | +| Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | +| SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | +| Status line | `~/.claude/settings.json` | the active project, bottom of the screen | +| `/mcpctl` | `~/.claude/commands/mcpctl.md` | switch projects from inside a session | + +`--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything +under `~/.claude`. + +## One server named `mcpctl`, not one per project + +The MCP entry used to be named after the project. Because `.mcp.json` is +*merged*, configuring a second project left the first mounted too — every +project you had ever configured stayed connected, with duplicate tool names and +nothing marking which was active. + +There is now exactly one entry, `mcpctl`, and switching rewrites what sits +behind it: + +```jsonc +{ "mcpServers": { "mcpctl": { "command": "mcpctl", "args": ["mcp", "-p", "docmost"] } } } +``` + +Two things follow. The tool prefix is stable across switches, so the model never +sees a tool namespace disappear mid-conversation. And because Claude Code can +reconnect an existing MCP server from `/mcp`, a switch lands without restarting +the app. + +Entries an older CLI wrote are retired on the next run. They are recognised by +the pairing that makes retiring them safe — *our* command, named after the very +project it bridges to. A hand-configured server is never touched, even one +called `docmost`, unless it also runs `mcpctl mcp -p docmost`, at which point it +is the same entry anyway. + +## Switching: `/mcpctl` + +``` +❯ /mcpctl docmost +● Bash(mcpctl config claude --project docmost --skip-marker) + ⎿ Wrote .mcp.json (1 server(s)) +● Now on docmost. Reconnect the mcpctl server from /mcp for its tools to load. +``` + +With no argument it lists the projects and asks. + +Claude Code slash commands are **prompt files, not code**, so unlike opencode's +picker this drives the model through CLI calls — there is no keyboard picker to +be had. `allowed-tools` is scoped to the four exact `mcpctl` invocations it +needs, so accepting it does not hand the session a general shell. + +> Every `` !`…` `` block in a slash command is permission-checked against +> `allowed-tools` *before* the model runs. Omitting one fails the whole command +> with a permission error and no explanation. A test asserts every pre-executed +> command in ours is covered. + +`--skip-marker` is deliberate: the session's directory is whatever you happened +to open, and re-scoping it would silently change which skills sync into it. + +## The status line + +``` + mcpctl:docmost + ⏵⏵ bypass permissions on · ← for agents +``` + +`mcpctl statusline` resolves the project from `.mcp.json`, falling back to a +`.mcpctl-project` marker up the tree so a checkout that is scoped but not yet +wired still reports. It reads the directory from the JSON Claude Code pipes in, +so it follows `/cwd` rather than reporting wherever the binary was launched, and +prints **nothing** when no project is active — an empty status line beats one +saying "none" on every unrelated repo. + +### It is never installed over yours + +A status line is a single slot, so overwriting a custom one silently deletes +work. When `config claude` finds a foreign one it leaves it and prints the +snippet to add instead: + +``` +Left your existing status line alone (my-fancy-prompt). + To show the project too, append: $(mcpctl statusline) +``` + +> **Ownership is decided by the command string, not a marker.** Claude Code +> rewrites `settings.json` against its own schema and **strips unknown keys from +> `statusLine`** — a tagged entry comes back as a bare `{type, command}`. +> (Hooks keep their marker; `statusLine` does not.) Matching on the command is +> what stops us reporting our own line as foreign forever. A line that merely +> *composes* ours — `my-prompt && mcpctl statusline` — is yours, and is left +> alone. + +## Skills + +`mcpctl skills sync` installs into `~/.claude/skills/`. Claude Code is the only +target that also gets hooks, `postInstall` and `mcpServers` auto-attach; pi, +prime-agent and opencode share the simpler flat-tree semantics. + +The SessionStart hook keeps them current. It carries a `_mcpctl_managed` marker, +and an install now also drops **untagged duplicates of that exact command** — +rows left behind before the marker existed (or by a test suite that used to +write into a real `~/.claude`). They are invisible in the UI and just run the +sync twice per session. A hook you wrote is never touched, even one that also +calls `mcpctl skills sync` with different flags. + +## Running Claude Code on the homelab LLM + +See [claude-vllm.md](claude-vllm.md). diff --git a/docs/prime-agent-extension.md b/docs/prime-agent-extension.md new file mode 100644 index 0000000..3d6b48b --- /dev/null +++ b/docs/prime-agent-extension.md @@ -0,0 +1,101 @@ +# mcpctl × prime-agent + +## What `mcpctl config prime-agent --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| Proxy MCP | `~/.prime/agent/settings.json` | `{ type: "http", url: "/projects/X/mcp" }` | +| Bearer credential | `~/.prime/agent/auth.json` | `mcp:X` → a minted mcpctl PAT | +| Skills | `~/.prime/agent/skills/` | the project's `SKILL.md` bundles | +| `/mcpctl` switcher | `~/.prime/agent/extensions/mcpctl-switch.ts` | switch projects, and the active-project indicator | + +Unlike Claude Code (a stdio bridge, no token) and pi (native tools over +JSON-RPC), prime-agent talks to the **HTTP gateway**, so the switch is really +three things: a credential, a settings entry, and a reload. + +## Credentials are the fragile part + +`config prime-agent` mints an `mcptoken` per project, stores it under +`mcp:`, and retires the one it replaced. Three rules make that safe: + +- **A key being present proves nothing.** A revoked token would short-circuit + provisioning and leave prime-agent unable to reach the gateway while the + command reported success. mcptokens are shown once, so the stored token's + 16-char `tokenPrefix` is compared against the project's *active* tokens rather + than sending the secret. +- **No credential means the switch fails.** Non-zero exit, `settings.json` + untouched, so the previously active project keeps working instead of being + replaced by a mount that 401s — and the `/mcpctl` switcher, which reads that + exit code, reports failure rather than success over a project with no tools. +- **Only the token we replaced is revoked.** Sweeping every `prime-agent` token + for a project would kill the one another machine is using. Anything else that + looks orphaned is reported, not deleted. + +This plumbing is shared with `config opencode`, parameterised by agent rather +than copied. + +## The `/mcpctl` switcher + +`pi.registerCommand('mcpctl', …)` opens a picker, shells out to +`mcpctl config prime-agent --project X --skip-extension --skip-marker`, then +calls `ctx.reload()` — which re-reads `settings.json` and `auth.json` and +rebuilds the MCP map, so the switch lands without restarting the app. + +`--skip-extension` stops it rewriting the very file it is running from; +`--skip-marker` stops it re-scoping whatever repository prime-agent was started +in, which Claude Code's own skills sync would then pick up. + +Above 20 projects it asks for a filter first: prime-agent's selector is an +arrow-key list with no search, and real installs run to hundreds of projects. +(opencode's dialog filters as you type, so its switcher needs no such prompt.) + +## The active-project indicator + +Published with `ctx.ui.setStatus('mcpctl', …)`, which prime-agent renders in its +tray line next to the model name. + +Two quirks worth knowing: + +- prime-agent emits `session_start` **only from `reload()`**, never at startup — + so the indicator is also published on `turn_start`, the earliest moment with a + real UI context bound. +- `resetExtensionUI()` clears extension statuses *after* `session_start`, so the + indicator set there is wiped before anyone sees it. It is re-published on a + short retry schedule to land after that reset. + +> The rendering itself only exists in prime-agent from +> `prime-agent-extension-status.patch` (upstream PR pending). On an unpatched +> build `setStatus` silently does nothing and no indicator appears. + +## The extension is real source now + +It used to exist **only** as a string literal inside +`src/cli/src/config/prime-agent-extension.ts` — so nothing typechecked or linted +it, which is precisely the gap that let a wrong `ctx.ui.select()` option shape +ship in the pi extension. + +It now lives at `src/prime-agent-ext/mcpctl-switch.ts`, checked against the real +`@earendil-works/pi-coding-agent` types: + +```bash +pnpm typecheck:prime-agent-ext +npx tsx scripts/generate-prime-agent-extension.ts # after editing it +``` + +A test fails if the embed goes stale. Extracting it found six lint problems in +code that had never been linted — all null-guard and return-type issues rather +than live bugs, but exactly the class of thing that ships silently when nothing +is looking. + +## What it deliberately does *not* do + +The MCP entry is still **named after the project**, not the constant `mcpctl` +that `config claude` and `config opencode` now use. prime-agent's switcher +already unmounts the previous project, so it never accumulates entries the way +`config claude` did — the bug the constant name fixes does not exist here. + +The remaining difference is tool-prefix stability: switching changes tool names, +so the model can hold stale ones. Moving prime-agent to a constant name would +also re-key `auth.json` from `mcp:` to `mcp:mcpctl`, giving up +per-project token caching and needing a migration. Worth doing, but as its own +change rather than folded into a parity pass. diff --git a/eslint.config.js b/eslint.config.js index b247282..b4e6858 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/prime-agent-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index 5d8ab67..ef00c0f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext && pnpm run typecheck:prime-agent-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -35,6 +35,7 @@ "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", + "typecheck:prime-agent-ext": "tsc -p src/prime-agent-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { diff --git a/scripts/generate-prime-agent-extension.ts b/scripts/generate-prime-agent-extension.ts new file mode 100644 index 0000000..6dcc224 --- /dev/null +++ b/scripts/generate-prime-agent-extension.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/prime-agent-extension.ts`, which embeds the + * prime-agent `/mcpctl` switcher as a string constant — the same arrangement + * the pi and opencode extensions use. + * + * The switcher used to live *only* as that string literal, with no source file + * behind it, so nothing typechecked or linted it. Now `src/prime-agent-ext/` + * holds the real source and this script produces the embed, so the file the CLI + * writes into ~/.prime/agent/extensions/ is always exactly what was checked. + * + * Regenerate after editing the extension source: + * npx tsx scripts/generate-prime-agent-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'prime-agent-ext'); + +const SOURCE = readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8'); + +const out = `/** + * The source of the \`/mcpctl\` project-switcher extension, exported as a string + * so \`mcpctl config prime-agent\` can install it into prime-agent's auto- + * discovered extensions directory (\`~/.prime/agent/extensions/\`). + * + * DO NOT EDIT BY HAND. Generated by + * \`npx tsx scripts/generate-prime-agent-extension.ts\` from + * \`src/prime-agent-ext/mcpctl-switch.ts\` — edit that instead, so the change is + * typechecked before it ships. The installed file is this exact source + * (verbatim), so the extension the CLI writes is always the one that ran. + */ +export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; +export const MCPCTL_SWITCH_EXTENSION = ${JSON.stringify(SOURCE)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'prime-agent-extension.ts'), out); +console.log('wrote src/cli/src/config/prime-agent-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index d37c541..84e0eaf 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -612,7 +612,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-skills', 'Skip the initial skills sync') .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') - .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => { + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string; dryRun?: boolean }) => { if (!opts.project) { log('Error: --project is required for mcpctl config pi'); process.exitCode = 1; @@ -628,6 +629,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); + if (opts.dryRun === true) { + log(JSON.stringify({ + pi: { + settingsPath, + extensionDir: extDest, + statePath, + skillsDir: opts.skipSkills === true ? '' : skillsInstall, + source: opts.extensionDir ?? '', + }, + action: 'install extension files + register in settings.json (extensions + skills) + write active project + sync skills', + }, null, 2)); + return; + } + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/. // Default: write the embedded sources (works from an installed binary // with no source tree). --extension-dir overrides with a source-tree diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 39f7966..7231a71 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -3,8 +3,11 @@ * so `mcpctl config prime-agent` can install it into prime-agent's auto- * discovered extensions directory (`~/.prime/agent/extensions/`). * - * The installed file is this exact source (verbatim), so the extension shipped - * by the CLI is always the one that runs. + * DO NOT EDIT BY HAND. Generated by + * `npx tsx scripts/generate-prime-agent-extension.ts` from + * `src/prime-agent-ext/mcpctl-switch.ts` — edit that instead, so the change is + * typechecked before it ships. The installed file is this exact source + * (verbatim), so the extension the CLI writes is always the one that ran. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry !== undefined && entry !== null ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (picked === undefined || picked === '') return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index f0f6cf4..47f9a54 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -100,6 +100,32 @@ export async function installManagedSessionHook( } } + // Drop untagged copies of the very command we manage. + // + // Before this installer carried a marker — and, for a long while, whenever + // the test suite ran against the developer's real ~/.claude — an identical + // but untagged row could be left behind. It is invisible in the UI and simply + // runs the sync a second time on every session start. Scoped to an exact + // string match on our own command, so a hook someone wrote themselves (even + // one that also calls `mcpctl skills sync`, but with different flags) is + // never touched. + if (foundEntry) { + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + const kept = group.hooks.filter((e) => e[MARKER_KEY] === true || e.command !== command); + if (kept.length !== group.hooks.length) { + group.hooks = kept; + entryChanged = true; + } + } + // A group we emptied is noise in the file. + const nonEmpty = groups.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0); + if (nonEmpty.length !== groups.length) { + settings.hooks.SessionStart = nonEmpty; + entryChanged = true; + } + } + if (!foundEntry) { groups.push({ hooks: [{ type: 'command', command, [MARKER_KEY]: true }], diff --git a/src/cli/tests/config/prime-agent-extension-embed.test.ts b/src/cli/tests/config/prime-agent-extension-embed.test.ts new file mode 100644 index 0000000..20a5a2c --- /dev/null +++ b/src/cli/tests/config/prime-agent-extension-embed.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + MCPCTL_SWITCH_EXTENSION, + MCPCTL_SWITCH_EXTENSION_FILENAME, +} from '../../src/config/prime-agent-extension.js'; + +/** + * `mcpctl config prime-agent` installs the *embedded* copy of the switcher, not + * the file in src/prime-agent-ext/. Editing the source without re-running the + * generator therefore ships stale code while the repo looks correct. + * + * This matters more here than for the pi and opencode extensions: until the + * source file existed, the switcher was only a string literal, so nothing + * typechecked or linted it at all. The guarantee only holds while the two stay + * in sync. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'prime-agent-ext'); + +describe('embedded prime-agent switcher', () => { + it('matches the source in src/prime-agent-ext (re-run scripts/generate-prime-agent-extension.ts)', () => { + expect(MCPCTL_SWITCH_EXTENSION, 'stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8')); + }); + + it('installs under the name prime-agent auto-discovers', () => { + expect(MCPCTL_SWITCH_EXTENSION_FILENAME).toBe('mcpctl-switch.ts'); + }); + + it('is self-contained — the installed file has no mcpctl imports to resolve', () => { + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '@mcpctl\//); + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '\.\.\//); + }); + + it('switches without re-installing itself or re-scoping the launch directory', () => { + // Rewriting the extension file prime-agent has already loaded buys nothing; + // writing a marker would silently re-scope whatever repo it was started in. + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-extension'"); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-marker'"); + }); + + it('publishes the active-project indicator, which is the only visible state', () => { + expect(MCPCTL_SWITCH_EXTENSION).toContain('setStatus'); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'turn_start'"); + }); +}); diff --git a/src/cli/tests/utils/sessionhook.test.ts b/src/cli/tests/utils/sessionhook.test.ts index 052e8b3..26d72ad 100644 --- a/src/cli/tests/utils/sessionhook.test.ts +++ b/src/cli/tests/utils/sessionhook.test.ts @@ -104,3 +104,48 @@ describe('sessionhook', () => { expect(settings.hooks.SessionStart).toHaveLength(1); }); }); + +describe('untagged duplicates of the managed hook', () => { + let tmp2: string; + let settings: string; + beforeEach(async () => { + tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-')); + settings = join(tmp2, 'settings.json'); + }); + afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); }); + + it('removes an identical row left behind before the marker existed', async () => { + // Exactly the shape found in a real ~/.claude: one tagged row, one not. + // Invisible in the UI; it just runs the sync twice every session. + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] }, + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] }, + ], + }, + })); + const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings); + expect(updated).toBe(true); + + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks); + expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]); + }); + + it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => { + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', settings); + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command); + expect(rows).toContain('mcpctl skills sync --project mine'); + }); +}); diff --git a/src/prime-agent-ext/mcpctl-switch.ts b/src/prime-agent-ext/mcpctl-switch.ts new file mode 100644 index 0000000..9239412 --- /dev/null +++ b/src/prime-agent-ext/mcpctl-switch.ts @@ -0,0 +1,275 @@ +/** + * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/. + * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy + * MCP + skills) from inside prime-agent, then reloads the session. + * + * It shells out to the `mcpctl` CLI (same binary that wrote the config) to + * list projects and apply the switch, then asks the running TUI to reload so + * the new project's MCP servers, credentials and skills take effect without an + * app restart. Keeping the logic in the CLI means this UI shell stays in + * lock-step with the machinery in the mcpctl repo. + */ +import { exec } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const AGENT_DIR = join(homedir(), '.prime', 'agent'); + +interface ProjectInfo { + name: string; + description?: string; +} + +function mcpctl(...args: string[]): Promise { + const quoted = args.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(' '); + return new Promise((resolve, reject) => { + exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || String(err)).trim() || String(err))); + else resolve(stdout || ''); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl('get', 'projects', '-o', 'json'); + const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>; + return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({ + name: p.name as string, + description: p.description, + })); +} + +/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */ +async function credentialedProjects(): Promise> { + const out = new Set(); + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8'); + const parsed = JSON.parse(raw) as Record; + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = v?.key; + if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4)); + } + } catch { + // no auth.json (or unreadable) — nothing to adopt + } + return out; +} + +/** + * The single *active* mcpctl project. Entries this CLI wrote carry an + * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an + * untagged entry also counts when its URL is the canonical + * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl + * PAT. A hand-configured server has no such credential and is never mistaken + * for the active project. + */ +async function activeProject(): Promise { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8'); + const settings = JSON.parse(raw) as { mcpServers?: Record> }; + if (!settings.mcpServers) return null; + const names = Object.keys(settings.mcpServers); + for (const name of names) { + const entry = settings.mcpServers[name]; + if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name; + } + const credentialed = await credentialedProjects(); + for (const name of names) { + const entry = settings.mcpServers[name]; + const url = entry !== undefined && entry !== null ? entry['url'] : undefined; + if (typeof url !== 'string' || !credentialed.has(name)) continue; + if (url.replace(/\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name; + } + return null; + } catch { + return null; + } +} + +/** Key our indicator is stored under (both the widget and the footer status). */ +const STATUS_KEY = 'mcpctl'; + +interface StatusCapableContext { + hasUI?: boolean; + ui: { setStatus(key: string, text: string | undefined): void }; +} + +/** + * Show the active project in the UI, so it is visible at a glance instead of + * something you run a command to discover. + * + * Published via `setStatus`, which both hosts render next to the model name: + * pi in its footer, prime-agent in the tray line built by + * `getTrayLocationLabel()`. + * + * NOTE: prime-agent only grew that rendering in + * `prime-agent-extension-status.patch` (upstream PR pending) — before it, + * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this + * call silently did nothing. An unpatched build shows no indicator; a widget + * would render there but scrolls away with the transcript, so it is not a + * substitute for a status line. + */ +async function publishStatus(ctx: StatusCapableContext): Promise { + // Before the TUI binds its UI context the runtime hands extensions a no-op + // one, where every setter silently discards. Publishing then would cache a + // label that never rendered. + if (ctx.hasUI === false) return; + let active: string | null = null; + try { + active = await activeProject(); + } catch { + active = null; + } + // Deliberately not skipped when the value is unchanged: prime-agent clears + // extension statuses on reset (see the retries in session_start), so a cached + // "nothing changed" short-circuit would leave the indicator permanently blank. + ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined); +} + +/** Above this many projects, offer a filter before opening the list. */ +const FILTER_THRESHOLD = 20; + +/** + * Order and filter the project list for the picker. + * + * Active project first (most likely pick), then alphabetical. Terms are + * space-separated and ALL must match as case-insensitive substrings against + * the name or description, so `home auto` finds `homeautomation`. A blank + * query keeps everything. + */ +export function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] { + const ordered = [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); + const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0); + if (terms.length === 0) return ordered; + return ordered.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return terms.every((t) => haystack.includes(t)); + }); +} + +/** + * Choose a project, asking for a filter first when the list is long. + * + * The host's selector is a plain arrow-key list with no search, so filtering + * has to happen before the list is handed over. Real installs run to hundreds + * of projects (smoke-test leftovers included), where scrolling is hopeless. + */ +async function pickProject( + ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } }, + projects: ProjectInfo[], + active: string | null, +): Promise { + let candidates = filterProjects(projects, '', active); + if (candidates.length > FILTER_THRESHOLD) { + const query = await ctx.ui.input( + `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`, + 'e.g. home auto', + ); + if (query === undefined) return undefined; // cancelled + candidates = filterProjects(projects, query, active); + if (candidates.length === 0) { + ctx.ui.notify(`No project matches '${query}'`, 'warning'); + return undefined; + } + } + + // No client-side cap: prime-agent's selector windows long lists itself and + // shows a true "(20/356)" counter, so truncating here would only replace an + // accurate total with a misleading one. + const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name)); + const picked = await ctx.ui.select( + (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project') + + ` (${String(candidates.length)})`, + items, + ); + if (picked === undefined) return undefined; + return picked.split(' — ')[0]?.trim(); +} + +export default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void { + // prime-agent emits `session_start` ONLY from reload() — never at startup — + // so this alone would leave the indicator blank until the first switch. + // `turn_start` fires on every user turn with a real UI context bound, which + // is the earliest reliable moment; publishStatus is a no-op when the label + // has not changed, so calling it per turn costs nothing. + pi.on('session_start', async (_event, ctx) => { + await publishStatus(ctx); + // prime-agent wipes extension state shortly after startup: + // resetExtensionUI() calls clearExtensionStatuses() (and + // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the + // connection-state-snapshot handler, both of which land *after* + // session_start. The indicator set above is therefore cleared before it is + // ever seen. Re-publish a few times to land after that reset; setStatus is + // idempotent, so an unnecessary retry costs one re-render. + for (const delay of [1_000, 3_000, 6_000]) { + setTimeout(() => { void publishStatus(ctx); }, delay); + } + }); + pi.on('turn_start', async (_event, ctx) => { + await publishStatus(ctx); + }); + + pi.registerCommand('mcpctl', { + description: 'Switch the active mcpctl project (proxy MCP + skills) and reload', + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify('/mcpctl needs an interactive session', 'error'); + return; + } + // Running the command is itself proof of a real UI, and the "already on + // X" path below returns without reloading — so publish here too. + await publishStatus(ctx); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + if (projects.length === 0) { + ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info'); + return; + } + + const active = await activeProject(); + const picked = await pickProject(ctx, projects, active); + if (picked === undefined || picked === '') return; + + const name = picked; + if (name === active) { + ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info'); + return; + } + + ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info'); + try { + // Mint the project token (if needed), write settings.json + auth.json, + // and sync skills. --skip-extension stops re-installing this very file; + // --skip-marker stops us writing a .mcpctl-project into whatever + // directory prime-agent was launched from, which would silently + // re-scope that repo for Claude Code's own skills sync. + await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker'); + } catch (err) { + ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + + // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP + // integration map from scratch, so the old project's gateway is dropped + // and the new one mounted without restarting the app. + await ctx.reload(); + // reload re-emits session_start, which refreshes the footer — but this + // command's context outlives that, so set it here too rather than relying + // on ordering. + await publishStatus(ctx); + ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info'); + }, + }); +} diff --git a/src/prime-agent-ext/tsconfig.json b/src/prime-agent-ext/tsconfig.json new file mode 100644 index 0000000..215ecbd --- /dev/null +++ b/src/prime-agent-ext/tsconfig.json @@ -0,0 +1,29 @@ +{ + "//": [ + "The prime-agent `/mcpctl` switcher is shipped as source (embedded in the", + "CLI, then written into ~/.prime/agent/extensions/) and is therefore never", + "compiled by the CLI's own build.", + "", + "Until this project existed it lived only as a string literal inside", + "src/cli/src/config/prime-agent-extension.ts, which means nothing typechecked", + "it at all — the same gap that let a `ctx.ui.select()` call with the wrong", + "option shape ship in the pi extension (see src/pi-ext/tsconfig.json).", + "", + "It is checked against the REAL @earendil-works/pi-coding-agent types, the", + "ExtensionAPI prime-agent implements, rather than a hand-written shim." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-switch.ts"] +} From b7c0de2bf00d785b93dd909366f35648bd8fe36d Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:59:48 +0100 Subject: [PATCH 25/77] feat(claude): register the MCP server in user scope by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in every checkout you opened — and in a repo that commits `.mcp.json` (this one does) it dirtied the working tree. Every other integration is already global: pi, prime-agent and opencode each have one active project, wired once. Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in every directory and window. That is now the default. `--scope project`, or an explicit `-o/--output`, keeps the old per-directory file for a repo that wants its own pinned project. `--inspect` stays project-scope — it is a debugging server you turn on for one checkout. Details worth knowing: - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside it. Verified against a live Claude Code run with an isolated config dir. - `.claude.json` also holds onboarding state, caches and a per-project map that Claude Code rewrites while running, so this merges into the document and writes through a temp file + rename. - User scope writes no `.mcpctl-project` marker: it scopes nothing to a directory, and a marker beside `.claude.json` would sit in $HOME and scope every repo under it. - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that pinned itself wins), then user scope, then the marker. Scope selection reads Commander's option source rather than process.argv — argv is the test runner's command line when the command is driven in-process, which the suite caught immediately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 5 ++ completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 6 +- docs/claude-integration.md | 38 ++++++++++- src/cli/src/commands/config.ts | 97 ++++++++++++++++++++++----- src/cli/src/commands/statusline.ts | 15 ++++- src/cli/src/config/claude-mcp.ts | 76 ++++++++++++++++++++- src/cli/tests/commands/claude.test.ts | 84 ++++++++++++++++++++++- 8 files changed, 299 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 02c1515..b76b6d0 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ Preview the change without writing anything: mcpctl config prime-agent --project monitoring --dry-run ``` +`config claude` registers in **user scope** by default — one `mcpctl` MCP server +in `~/.claude.json` that applies in every directory and window, so you wire it +once rather than per checkout. Use `--scope project` (or `-o`) for a repo that +wants its own pinned project. + Inside Claude Code: - `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 80c161a..a045a22 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -122,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9918f91..e79dc3b 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -287,7 +287,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'ou # config claude options complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' @@ -298,7 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Prin # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' diff --git a/docs/claude-integration.md b/docs/claude-integration.md index da59b78..d0163b5 100644 --- a/docs/claude-integration.md +++ b/docs/claude-integration.md @@ -4,7 +4,7 @@ | Piece | Where | Purpose | |-------|-------|---------| -| MCP entry | `./.mcp.json` | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | | Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | | Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | | SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | @@ -14,6 +14,37 @@ `--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything under `~/.claude`. +## User scope by default: wire it once, not per checkout + +Claude Code has two MCP scopes: **project** (`./.mcp.json`, that directory only, +and usually committed — so writing to it dirties the repo) and **user** +(`mcpServers` in `.claude.json`, every directory and every window). + +mcpctl now defaults to **user** scope. One active project everywhere is how the +pi, prime-agent and opencode integrations already behave; per-directory wiring +meant re-running `config claude` in every checkout you opened. Switching the +project is then one command from anywhere. + +```bash +mcpctl config claude --project homeautomation # applies everywhere +mcpctl config claude --project docmost --scope project # just this repo +mcpctl config claude --project docmost -o .mcp.json # same thing; -o implies project scope +``` + +User scope writes **no `.mcpctl-project` marker** — it scopes nothing to a +directory, and a marker beside `.claude.json` would sit in `$HOME` and scope +every repo under it. `--inspect` stays project-scope: it is a debugging server +you turn on for one checkout. + +> `.claude.json` also holds onboarding state, caches and a per-project map that +> Claude Code rewrites while running, so mcpctl merges into it and writes +> through a temp file + rename. A truncated write there costs far more than a +> stale MCP entry. +> +> Note the path asymmetry: with `CLAUDE_CONFIG_DIR` set the file is +> `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` — +> *beside* `~/.claude/`, not inside it. + ## One server named `mcpctl`, not one per project The MCP entry used to be named after the project. Because `.mcp.json` is @@ -70,8 +101,9 @@ to open, and re-scoping it would silently change which skills sync into it. ⏵⏵ bypass permissions on · ← for agents ``` -`mcpctl statusline` resolves the project from `.mcp.json`, falling back to a -`.mcpctl-project` marker up the tree so a checkout that is scoped but not yet +`mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` +first (a repo that pinned itself wins), then the user-scope entry in +`.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet wired still reports. It reads the directory from the JSON Claude Code pipes in, so it follows `/cwd` rather than reporting wherever the binary was launched, and prints **nothing** when no project is active — an empty status line beats one diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 84e0eaf..8623513 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; @@ -43,8 +43,12 @@ import { import { MCPCTL_SERVER_NAME, mergeMcpctlServers, + mergeUserScopeServer, + userScopeProject, activeProjectIn, + claudeJsonPath, type McpJson, + type ClaudeJson, } from '../config/claude-mcp.js'; import { opencodeConfigDir, @@ -74,6 +78,23 @@ const OPENCODE_TOKEN_PREFIX = 'opencode'; * the caller starts fresh — the same behaviour as before, kept because a * half-written file must not stop you re-provisioning. */ +function readJsonFile(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +} + +/** Write JSON through a temp file + rename, so a crash cannot truncate it. */ +function writeJsonAtomicSync(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + renameSync(tmp, path); +} + function readMcpJson(path: string): McpJson | null { if (!existsSync(path)) return null; try { @@ -279,7 +300,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command(name) .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') - .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--scope ', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user') + .option('-o, --output ', 'Project-scope output file path (implies --scope project)', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') @@ -287,7 +309,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') .option('--dry-run', 'Print what would change without writing or syncing') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => { + .action(async (opts: { project?: string; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => { // Resolve Claude's config dir once: an explicit --claude-dir wins, then // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather // than letting each helper default) is what keeps the test suite off the @@ -301,12 +323,41 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - const outputPath = resolve(opts.output); - const existing = readMcpJson(outputPath); - const { config: finalConfig, retired } = mergeMcpctlServers(existing, { - ...(opts.project !== undefined ? { project: opts.project } : {}), - ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), - }); + // An explicit --output only makes sense for the per-directory file, so + // it selects project scope on its own — no need to pass both. + // Commander's source tracking, not process.argv: the latter is the test + // runner's command line when the command is driven in-process. + const explicitOutput = command.getOptionValueSource('output') === 'cli'; + const scope = explicitOutput ? 'project' : opts.scope; + if (scope !== 'user' && scope !== 'project') { + log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`); + process.exitCode = 1; + return; + } + const userScope = scope === 'user'; + + const outputPath = userScope ? claudeJsonPath() : resolve(opts.output); + const existing = userScope + ? (readJsonFile(outputPath) ?? {}) + : readMcpJson(outputPath); + + // `--inspect` is a project-scope idea (a debugging server you turn on + // for one checkout), so it stays on .mcp.json even in user scope. + let finalConfig: McpJson | ClaudeJson; + let retired: string[]; + if (userScope) { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)'); + process.exitCode = 1; + return; + } + ({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project)); + } else { + ({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + })); + } if (opts.stdout === true) { log(JSON.stringify(finalConfig, null, 2)); @@ -316,10 +367,13 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.dryRun === true) { log(JSON.stringify({ claude: { + scope, output: outputPath, - previousProject: activeProjectIn(existing), + previousProject: userScope + ? userScopeProject(existing as ClaudeJson) + : activeProjectIn(existing as McpJson), server: MCPCTL_SERVER_NAME, - entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '', + entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '', retiredLegacyEntries: retired, marker: opts.skipMarker === true || opts.project === undefined ? '' @@ -333,9 +387,14 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); - const serverCount = Object.keys(finalConfig.mcpServers).length; - log(`Wrote ${outputPath} (${serverCount} server(s))`); + // Atomic: `.claude.json` also holds Claude Code's onboarding state and + // per-project map, and Claude Code rewrites it while running — a + // truncated write there costs far more than a stale MCP entry. + writeJsonAtomicSync(outputPath, finalConfig); + const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length; + log(userScope + ? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}` + : `Wrote ${outputPath} (${String(serverCount)} server(s))`); if (retired.length > 0) { // Before the constant name, every project you configured stayed // mounted alongside the new one. @@ -344,12 +403,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.project !== undefined) { log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); } + if (userScope) { + // The whole point of user scope: you do this once, not per checkout. + log('This applies in every directory — no need to re-run it per repo.'); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - if (opts.skipMarker === true) { + if (userScope) { + // User scope deliberately scopes nothing to a directory; writing a + // marker into $HOME would silently scope every repo under it. + log('Skipped .mcpctl-project marker (user scope is not directory-specific)'); + } else if (opts.skipMarker === true) { log('Skipped .mcpctl-project marker (--skip-marker)'); } else { try { diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts index a3d7c29..7f22ede 100644 --- a/src/cli/src/commands/statusline.ts +++ b/src/cli/src/commands/statusline.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { activeProjectIn, type McpJson } from '../config/claude-mcp.js'; +import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js'; import { findProjectMarker } from '../utils/project-marker.js'; /** @@ -53,6 +53,15 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; } +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(path: string): string | null { + try { + return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson); + } catch { + return null; + } +} + /** The project `.mcp.json` in `dir` mounts, or null. */ export function projectFromMcpJson(dir: string): string | null { try { @@ -87,7 +96,9 @@ export function createStatuslineCommand(deps?: Partial): Command const input = opts.directory !== undefined ? {} : await readStdinJson(); const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); - let project = projectFromMcpJson(dir); + // Directory-scoped wiring wins: a repo with its own .mcp.json entry has + // deliberately pinned itself, and that beats the global default. + let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath()); if (project === null) { // Not wired here (or wired above this directory) — the marker is the // other thing `config claude` writes, and skills sync already trusts it. diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts index 6feaa0f..d426b26 100644 --- a/src/cli/src/config/claude-mcp.ts +++ b/src/cli/src/config/claude-mcp.ts @@ -1,3 +1,6 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + /** * `.mcp.json` shaping for `mcpctl config claude`. * @@ -19,7 +22,24 @@ * `isLegacyMcpctlEntry` for what counts as ours. */ -/** The one MCP server name mcpctl owns in `.mcp.json`. */ +/** + * WHERE THE ENTRY LIVES + * + * Claude Code has two MCP scopes: + * - **project** — `./.mcp.json`, which applies only in that directory (and is + * usually committed, so writing to it dirties the repo); + * - **user** — `mcpServers` in `.claude.json`, which applies in every + * directory and every window. + * + * mcpctl defaults to **user** scope, because one active project everywhere is + * how the pi, prime-agent and opencode integrations already behave — and + * because per-directory wiring means re-running `config claude` in every + * checkout you open. `--scope project` (or an explicit `--output`) keeps the + * old per-directory file for a repo that genuinely wants its own pinned + * project. + */ + +/** The one MCP server name mcpctl owns. */ export const MCPCTL_SERVER_NAME = 'mcpctl'; /** Name of the optional traffic-inspection server (`--inspect`). */ @@ -131,3 +151,57 @@ export function mergeMcpctlServers( delete rest.mcpServers; return { config: { ...rest, mcpServers: servers }, retired }; } + + +/** + * Path of Claude Code's user-scope config. + * + * NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is + * `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` + * — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code + * run with an isolated config dir. + */ +export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string { + const override = env['CLAUDE_CONFIG_DIR']; + const home = homeDir ?? homedir(); + return override !== undefined && override !== '' + ? join(override, '.claude.json') + : join(home, '.claude.json'); +} + +/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ +export interface ClaudeJson { + mcpServers?: Record; + [key: string]: unknown; +} + +/** + * Set the user-scope entry, returning the new document and any legacy + * project-named entries retired from it. + * + * `.claude.json` also holds onboarding state, caches and a per-project map that + * Claude Code rewrites constantly — so this merges into the document it was + * given and never reconstructs it. + */ +export function mergeUserScopeServer( + existing: ClaudeJson | null | undefined, + project: string, +): { config: ClaudeJson; retired: string[] } { + const doc: ClaudeJson = { ...(existing ?? {}) }; + const servers: Record = { ...(doc.mcpServers ?? {}) }; + const retired: string[] = []; + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project); + doc.mcpServers = servers; + return { config: doc, retired }; +} + +/** The project the user-scope entry mounts, or null. */ +export function userScopeProject(doc: ClaudeJson | null | undefined): string | null { + return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} }); +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9a079f7..9f43bd6 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -278,3 +278,85 @@ describe('config impersonate', () => { expect(output.join('\n')).toContain('No impersonation session to quit'); }); }); + +describe('config claude — user scope', () => { + let output: string[]; + let tmpDir: string; + let claudeDir: string; + let prior: string | undefined; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const claudeJson = (): string => join(claudeDir, '.claude.json'); + + beforeEach(() => { + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-')); + claudeDir = join(tmpDir, 'claude-home'); + mkdirSync(claudeDir, { recursive: true }); + prior = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = prior; + }); + + const cmd = () => createConfigCommand({ configDeps: {}, log }); + + it('registers in .claude.json by default, not a per-directory .mcp.json', async () => { + // The whole point: wire it once, not in every checkout you open. + await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }); + expect(output.join('\n')).toContain('every directory'); + }); + + it('preserves everything else in .claude.json', async () => { + // That file also holds onboarding state, caches and the per-project map. + writeFileSync(claudeJson(), JSON.stringify({ + numStartups: 42, + mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } }, + projects: { '/some/repo': { allowedTools: [] } }, + })); + await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.numStartups).toBe(42); + expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } }); + expect(parsed.mcpServers['taskmaster-ai']).toBeDefined(); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']); + }); + + it('switching re-points the one entry', async () => { + await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' }); + await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']); + }); + + it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => { + // A marker beside .claude.json would sit in $HOME and scope every repo under it. + const cwd = process.cwd(); + process.chdir(tmpDir); + try { + await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' }); + expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false); + expect(output.join('\n')).toContain('not directory-specific'); + } finally { process.chdir(cwd); } + }); + + it('an explicit --output still means the per-directory file', async () => { + const outPath = join(tmpDir, '.mcp.json'); + await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' }); + expect(existsSync(outPath)).toBe(true); + expect(existsSync(claudeJson())).toBe(false); + }); + + it('rejects an unknown scope instead of silently picking one', async () => { + const prevExit = process.exitCode; + await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(output.join('\n')).toContain("unknown --scope 'global'"); + }); +}); From 80fa8a3c7efa5a1f9a47f9a54d07a17d0f5efd9d Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:07:07 +0100 Subject: [PATCH 26/77] feat(claude-vllm): own config file instead of reading opencode's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-vllm read `~/.config/opencode/opencode.jsonc` directly. Same shape is useful; sharing the actual file is not — a credential rotation in opencode would silently change what Claude Code authenticates with, and it couples two tools' configs for no reason. It now has its own `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc`, shaped like opencode's (`provider..options.{baseURL,apiKey}` plus a `models` map), and takes priority. The pi and prime-agent homes stay as a fallback so the command works before any config exists; reading opencode's file is dropped. No key is stored in the tool. `apiKey` may be a literal in the 0600 file, `${ENV_VAR}`, or a bare env var NAME (the form pi's models.json already uses), so the secret can live in the environment instead of on disk. `--init` reads it from stdin when `--api-key` is omitted — keeping it out of shell history and out of the process table, where an argument is visible to every user via `ps`. `--list` prints at most a 10-character prefix. `--init` records the context window for every model it can see, not just the active one: recording only the default meant `--model something-else` silently fell back to Claude Code's assumed 200k on a 393k model. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 10 ++-- docs/claude-vllm.md | 55 ++++++++++++++++--- stack/claude-vllm | 125 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 163 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b76b6d0..16ead01 100644 --- a/README.md +++ b/README.md @@ -258,15 +258,17 @@ The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic Messages API, so Claude Code can talk to it directly — no bridge: ```bash -claude-vllm # reuse the provider/model/key pi or prime-agent already uses +claude-vllm --init # write ~/.config/mcpctl/claude-vllm.jsonc (0600) +claude-vllm # use it claude-vllm --model deepseek-v4-max claude-vllm --list # show what it would use claude-vllm -- -p "summarise this repo" # args after -- go to claude ``` -It discovers the endpoint, credential, model and context window from -`~/.pi/agent`, `~/.prime/agent` or opencode's config (first hit wins) and sets -the `ANTHROPIC_*` environment Claude Code needs. +It reads its own config (shaped like `opencode.jsonc`, but a separate file), +falling back to `~/.pi/agent` or `~/.prime/agent` so it works before you have +written one. The key may be a literal in the 0600 file, `${ENV_VAR}`, or an env +var name — it is never stored in the tool. See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this through mcpctl would add surface without adding capability. diff --git a/docs/claude-vllm.md b/docs/claude-vllm.md index 72c3446..30ee014 100644 --- a/docs/claude-vllm.md +++ b/docs/claude-vllm.md @@ -43,21 +43,60 @@ sake: per-project gating of model calls, audit of prompts, or budget enforcement. Those are real features, but they are a passthrough endpoint in mcpd, not a wrapper script. -## What it discovers, and from where +## Its own config -`claude-vllm` exists only so you don't paste four exports each time. It reuses -whatever you already configured for another agent — first hit wins: +```bash +claude-vllm --init # prompts for the key, writes 0600 +claude-vllm --init --api-key '${MY_KEY}' # keep the secret in the environment instead +claude-vllm --init --base-url https://other-gateway/v1 +``` + +Written to `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc` (`~/.config/…`), shaped +like `opencode.jsonc` — a `provider` map with `options.baseURL` / +`options.apiKey` and a `models` map: + +```jsonc +{ + "model": "itaz/deepseek-v4-think", + "provider": { + "itaz": { + "options": { "baseURL": "https://llm.ad.itaz.eu/v1", "apiKey": "${MY_LLM_KEY}" }, + "models": { "deepseek-v4-fast": { "limit": { "context": 393216 } } } + } + } +} +``` + +**Same shape as opencode's config, but a separate file.** Reading opencode's own +config would mean a credential rotation there silently changing what Claude Code +authenticates with, and would couple two tools' configs for no reason. + +### Keys are never stored in the tool + +`apiKey` may be a literal (in a 0600 file), `${ENV_VAR}`, or a bare env var +NAME — so the secret can live in your environment or a password manager instead +of on disk. `--init` reads the key from **stdin** when `--api-key` is omitted, +keeping it out of shell history and out of the process table, where `--api-key` +would be visible to every user via `ps`. `--list` never prints more than a +10-character prefix. + +### Discovery order | Order | Source | Supplies | |-------|--------|----------| | 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected | | 2 | `--provider` / `--model` flags | provider, model | -| 3 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context window · `auth.json` → key | -| 4 | `~/.prime/agent` | same shape | -| 5 | `~/.config/opencode/opencode.jsonc` | `provider..options.{baseURL,apiKey}` | +| 3 | `~/.config/mcpctl/claude-vllm.jsonc` | its own config | +| 4 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context windows · `auth.json` → key | +| 5 | `~/.prime/agent` | same shape | -The base URL and the credential always come from the *same* source, so one -gateway's URL is never paired with another's key. +The pi/prime homes remain a convenience fallback so `claude-vllm` works before +you have written a config at all. The base URL and the credential always come +from the *same* source, so one gateway's URL is never paired with another's key. + +`--init` records the context window for **every** model it can see, not just the +active one — otherwise `--model something-else` silently falls back to Claude +Code's assumed 200k. ## What it sets, and why each one diff --git a/stack/claude-vllm b/stack/claude-vllm index d7119c0..aebb04e 100755 --- a/stack/claude-vllm +++ b/stack/claude-vllm @@ -10,22 +10,37 @@ # Discovery order (first hit wins, per field): # 1. environment already set (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL) # 2. flags (--provider, --model) -# 3. ~/.pi/agent settings.json + models.json + auth.json -# 4. ~/.prime/agent settings.json + models.json + auth.json -# 5. ~/.config/opencode/opencode.jsonc (provider..options.{baseURL,apiKey}) +# 3. its own config: $XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc (~/.config/...) +# 4. ~/.pi/agent settings.json + models.json + auth.json +# 5. ~/.prime/agent settings.json + models.json + auth.json +# +# The config file is shaped like opencode's — a `provider` map with +# `options.baseURL` / `options.apiKey` and a `models` map — but it is OUR file. +# Reading opencode's own config would mean a credential rotation there silently +# changing what Claude Code authenticates with, and would tie two tools' configs +# together for no reason. The pi/prime homes remain as a convenience fallback so +# `claude-vllm` works before you have written a config at all. +# +# NO KEY IS EVER STORED IN THIS SCRIPT. `apiKey` may be a literal (in a 0600 +# config), or `\${ENV_VAR}` / a bare env var NAME, so the secret can live in your +# environment or a password manager instead of on disk. # # Usage: # claude-vllm # default provider + model, then exec claude # claude-vllm --model deepseek-v4-max # claude-vllm --provider itaz --model deepseek-v4-fast -- -p "summarise this repo" -# claude-vllm --list # show discoverable providers/models and exit +# claude-vllm --list # show what it would use (never prints the key) # claude-vllm --print-env # print the exports and exit (don't run claude) +# claude-vllm --init --api-key ... # write the config file (0600); reads stdin if omitted set -euo pipefail PROVIDER="" MODEL="" LIST=0 +INIT=0 PRINT_ENV=0 +API_KEY_ARG="" +BASE_URL_ARG="" CLAUDE_ARGS=() while [ $# -gt 0 ]; do @@ -33,6 +48,9 @@ while [ $# -gt 0 ]; do --provider) PROVIDER="${2:?--provider needs a value}"; shift 2 ;; --model) MODEL="${2:?--model needs a value}"; shift 2 ;; --list) LIST=1; shift ;; + --init) INIT=1; shift ;; + --api-key) API_KEY_ARG="${2:?--api-key needs a value}"; shift 2 ;; + --base-url) BASE_URL_ARG="${2:?--base-url needs a value}"; shift 2 ;; --print-env) PRINT_ENV=1; shift ;; -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; --) shift; CLAUDE_ARGS+=("$@"); break ;; @@ -44,12 +62,27 @@ command -v jq >/dev/null || { echo "claude-vllm: jq is required" >&2; exit 1; } PI_HOME="${PI_AGENT_HOME:-$HOME/.pi/agent}" PRIME_HOME="${PRIME_AGENT_HOME:-$HOME/.prime/agent}" -OC_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.jsonc" +CONFIG="${CLAUDE_VLLM_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/mcpctl/claude-vllm.jsonc}" # jq tolerates the // comments opencode.jsonc may contain only after we strip # them; harmless for strict JSON. read_json() { [ -f "$1" ] && sed 's://[^"]*$::' "$1" | jq -c . 2>/dev/null || echo '{}'; } +# Resolve an apiKey field that may be a literal, \${ENV_VAR}, or a bare env var +# NAME (the form pi's models.json uses). Keeping the indirection means the +# secret can live in the environment rather than on disk. +resolve_key() { + local raw="$1" + [ -n "$raw" ] || return 0 + case "$raw" in + '${'*'}') local n="${raw#\$\{}"; n="${n%\}}"; printf '%s' "${!n:-}" ;; + # A bare ALL_CAPS token that names a set variable is an env var reference, + # not a key: no real API key looks like that. + [A-Z_][A-Z0-9_]*) if [ -n "${!raw:-}" ]; then printf '%s' "${!raw}"; else printf '%s' "$raw"; fi ;; + *) printf '%s' "$raw" ;; + esac +} + # ── discover ───────────────────────────────────────────────────────────────── # Each agent home is tried in turn; the first one that yields a base URL wins, # and the credential is taken from that same home so we never pair one gateway's @@ -74,45 +107,107 @@ discover_from_agent_home() { FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key"; FOUND_MODEL="$model" FOUND_MODELS=$(jq -r --arg p "$provider" '.providers[$p].models[]?.id' <<<"$models") + # model -> context for ALL models, so --init records every limit rather than + # only the active one (Claude Code assumes 200k for anything it lacks). + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" \ + '[.providers[$p].models[]? | select(.contextWindow) | {(.id): {limit:{context:.contextWindow}}}] | add // {}' <<<"$models") FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$model" \ '.providers[$p].models[]? | select(.id==$m) | .contextWindow // empty' <<<"$models") FOUND_SOURCE="$home" return 0 } -discover_from_opencode() { +# Our own config, shaped like opencode's but deliberately a separate file. +discover_from_config() { local cfg provider base key - cfg=$(read_json "$OC_CONFIG") + [ -f "$CONFIG" ] || return 1 + cfg=$(read_json "$CONFIG") provider="$PROVIDER" - if [ -z "$provider" ]; then - provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") - fi + [ -n "$provider" ] || provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") + [ -n "$provider" ] || provider=$(jq -r '.provider | keys[0] // empty' <<<"$cfg") [ -n "$provider" ] || return 1 + base=$(jq -r --arg p "$provider" '.provider[$p].options.baseURL // empty' <<<"$cfg") [ -n "$base" ] || return 1 - key=$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg") + key=$(resolve_key "$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg")") FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key" FOUND_MODEL="${MODEL:-$(jq -r '(.model // "") | split("/")[1] // empty' <<<"$cfg")}" FOUND_MODELS=$(jq -r --arg p "$provider" '.provider[$p].models | keys[]?' <<<"$cfg") + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" '.provider[$p].models // {}' <<<"$cfg") FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$FOUND_MODEL" \ '.provider[$p].models[$m].limit.context // empty' <<<"$cfg") - FOUND_SOURCE="$OC_CONFIG" + FOUND_SOURCE="$CONFIG" return 0 } -FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" -discover_from_agent_home "$PI_HOME" \ +FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_MODEL_LIMITS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" +discover_from_config \ + || discover_from_agent_home "$PI_HOME" \ || discover_from_agent_home "$PRIME_HOME" \ - || discover_from_opencode \ || true +# ── --init: write the config file ──────────────────────────────────────────── +if [ "$INIT" = 1 ]; then + init_base="${BASE_URL_ARG:-${FOUND_BASE:-}}" + if [ -z "$init_base" ]; then + echo "claude-vllm --init: no endpoint known. Pass --base-url https://your-gateway/v1" >&2 + exit 1 + fi + init_key="$API_KEY_ARG" + if [ -z "$init_key" ]; then + # Read from stdin so the key never lands in shell history or the process + # table (where --api-key is visible to every user via `ps`). + if [ -t 0 ]; then + printf 'API key (input hidden, or pass ${ENV_VAR} to keep it out of the file): ' >&2 + read -rs init_key; echo >&2 + else + read -r init_key || true + fi + fi + [ -n "$init_key" ] || { echo "claude-vllm --init: no API key given" >&2; exit 1; } + + init_provider="${PROVIDER:-${FOUND_PROVIDER:-default}}" + init_model="${MODEL:-${FOUND_MODEL:-}}" + # Every model's limit, not just the active one — switching with --model must + # not silently drop back to Claude Code's assumed 200k. + init_models_json=$( + if [ -n "$FOUND_MODELS" ]; then + known="${FOUND_MODEL_LIMITS:-{\}}" + for m in $FOUND_MODELS; do jq -n --arg m "$m" '{($m): {}}'; done \ + | jq -s --argjson known "$known" 'add // {} | . * $known' + else + jq -n --arg m "$init_model" 'if $m == "" then {} else {($m): {}} end' + fi + ) + + mkdir -p "$(dirname "$CONFIG")" + umask 077 + jq -n --arg p "$init_provider" --arg model "$init_model" --arg base "$init_base" \ + --arg key "$init_key" --argjson models "$init_models_json" ' + { + "//": "mcpctl claude-vllm config. Shaped like opencode.jsonc, but its own file. apiKey may be a literal, \"${ENV_VAR}\", or a bare env var NAME.", + model: (if $model == "" then null else "\($p)/\($model)" end), + provider: { ($p): { options: { baseURL: $base, apiKey: $key }, models: $models } } + } | del(..|nulls)' > "$CONFIG.tmp.$$" + mv "$CONFIG.tmp.$$" "$CONFIG" + chmod 600 "$CONFIG" + echo "Wrote $CONFIG (0600)" >&2 + echo " provider: $init_provider endpoint: $init_base model: ${init_model:-}" >&2 + case "$init_key" in + '${'*'}'|[A-Z_][A-Z0-9_]*) echo " apiKey: kept as an environment reference, not a literal" >&2 ;; + *) echo " apiKey: stored in the file — readable only by you" >&2 ;; + esac + exit 0 +fi + BASE="${ANTHROPIC_BASE_URL:-$FOUND_BASE}" KEY="${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-$FOUND_KEY}}" MODEL_ID="${MODEL:-${ANTHROPIC_MODEL:-$FOUND_MODEL}}" if [ "$LIST" = 1 ]; then echo "provider: ${FOUND_PROVIDER:-} (from ${FOUND_SOURCE:-nowhere})" + echo "config: $CONFIG $([ -f "$CONFIG" ] && echo '(present)' || echo '(absent — using fallback; run --init)')" echo "endpoint: ${BASE:-}" echo "credential: $([ -n "$KEY" ] && echo "found (${KEY:0:10}…)" || echo "")" echo "default model: ${MODEL_ID:-}${FOUND_CONTEXT:+ (context ${FOUND_CONTEXT})}" From 292506ec985fb077d5b0e8bb788d77a64079e560 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:07:55 +0100 Subject: [PATCH 27/77] fix(install): rebuild when any packaged input changes, not just src/*.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installlocal.sh decided the package was up to date by looking only for `src/**/*.ts` newer than it. nfpm.yaml also packages stack/claude-vllm, the shell completions and the systemd unit — none of which are TypeScript — so editing any of those installed a stale package that reported success. Caught by claude-vllm's new --init being absent from /usr/bin after a clean install. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- installlocal.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/installlocal.sh b/installlocal.sh index 0418811..a68de85 100755 --- a/installlocal.sh +++ b/installlocal.sh @@ -28,6 +28,14 @@ fi echo "==> Detected package format: $PKG_FORMAT (arch: $NFPM_ARCH)" +# Any packaged input newer than $1. Mirrors nfpm.yaml's `contents:` — keep the +# two in step, or the build silently serves a stale binary again. +newer_inputs() { + find src/ -name '*.ts' -newer "$1" 2>/dev/null | head -1 + find stack/ completions/ deploy/ -type f -newer "$1" 2>/dev/null | head -1 + find nfpm.yaml -newer "$1" 2>/dev/null | head -1 +} + # Find package matching the target architecture # RPM uses x86_64/aarch64, DEB uses amd64/arm64 find_pkg() { @@ -38,8 +46,11 @@ find_pkg() { if [ "$PKG_FORMAT" = "rpm" ]; then PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building RPM..." bash scripts/build-rpm.sh PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") @@ -52,8 +63,11 @@ if [ "$PKG_FORMAT" = "rpm" ]; then else PKG_FILE=$(find_pkg "dist/mcpctl*.deb") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building DEB..." bash scripts/build-deb.sh PKG_FILE=$(find_pkg "dist/mcpctl*.deb") From cbd3b95d97d0ff0d88306e36c352966bf6dbccee Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:47:45 +0100 Subject: [PATCH 28/77] fix(opencode): guard state parsing, lint the .tsx, correct an overstated doc claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from a cross-branch review of the competing opencode implementations, all of which are fair. 1. `readState` type-guards the parsed JSON now. A bare try/catch does not cover it: `JSON.parse('null')` succeeds and returns null, so the catch never fires and the next `state.project` throws a TypeError that takes the plugin down. Verified the crash before fixing; a test pins the guard in the embedded copies. Credit to the competing 'opencode-mine' branch, which had this right. 2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the 300-line TUI plugin — the largest file in the addon — was linted by nothing. It was typechecked, which is why this went unnoticed. Confirmed the rules actually fire on it rather than the file being silently skipped. The 'abhishek' branch was the only entry that got this right. 3. docs/opencode-extension.md overstated the security argument. "The token would sit in a 0644 opencode.json" is not a point against a `type: local` stdio bridge, which needs no token at all because it reads your own credentials. That reason is a consequence of having picked the HTTP gateway, not a justification for it. The docs now lead with the real reason — live re-pointing without a restart — and state the trade honestly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- docs/opencode-extension.md | 25 ++++++++++++++----- eslint.config.js | 2 +- src/cli/src/config/opencode-extension.ts | 4 +-- .../config/opencode-extension-embed.test.ts | 13 ++++++++++ src/opencode-ext/mcpctl-opencode-tui.tsx | 7 +++++- src/opencode-ext/mcpctl-opencode.ts | 7 +++++- 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md index 0690a8b..d8e4c7b 100644 --- a/docs/opencode-extension.md +++ b/docs/opencode-extension.md @@ -41,13 +41,26 @@ with no restart. ### The state file, not `opencode.json` -Two reasons the project does not live in opencode's own config: +1. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. This is + the load-bearing reason. +2. **The token.** Having chosen the gateway, we need + `Authorization: Bearer ` somewhere. `opencode.json` is a mode-0644 + file people paste into bug reports; `~/.mcpctl/opencode-state.json` is 0600, + like every other mcpctl credential. -1. **The token.** The gateway needs `Authorization: Bearer `. - `opencode.json` is a mode-0644 file people paste into bug reports; - `~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential. -2. **The restart.** A config file is read at startup. Re-pointing the mount - through the running server's MCP API is what makes `/mcpctl` instant. +> **Reason 2 is not an argument for this design over the alternative.** A +> `type: "local"` entry running `mcpctl mcp -p ` — the same stdio +> bridge `config claude` uses — needs no bearer token at all, because the bridge +> reads your own `~/.mcpctl/credentials`. So "no secret in a 0644 file" is not a +> point against that approach; it is just a consequence of having picked the +> HTTP gateway. +> +> The honest trade is: the gateway works against a remote mcpctl with no local +> `mcplocal` daemon, and mounts through an API that can be re-pointed live. The +> stdio bridge is simpler and credential-free, but requires `mcpctl` and a +> reachable mcplocal on the same machine. Both are defensible; this one was +> chosen for the remote case and for the live re-point, not for the token. Tokens are kept **per project**, so switching back to a project you have already used needs no new mint — and a failed mint for project B cannot cost diff --git a/eslint.config.js b/eslint.config.js index b4e6858..946882a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/prime-agent-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/opencode-ext/*.tsx', 'src/prime-agent-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts index fbc2f5d..2e7a55e 100644 --- a/src/cli/src/config/opencode-extension.ts +++ b/src/cli/src/config/opencode-extension.ts @@ -22,6 +22,6 @@ export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; /** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; -export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; -export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts index b772315..4e19a97 100644 --- a/src/cli/tests/config/opencode-extension-embed.test.ts +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -88,3 +88,16 @@ describe('embedded opencode plugins', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); }); }); + +describe('embedded opencode plugins — state parsing', () => { + it('type-guard the parsed state, not just try/catch', () => { + // `JSON.parse('null')` succeeds and returns null, so a bare try/catch lets + // it through and the next `state.project` throws a TypeError that takes the + // plugin down. A hand-edited or truncated state file must degrade to "no + // project", never to a broken opencode. + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("typeof parsed === 'object' && parsed !== null"); + expect(src).not.toMatch(/return JSON\.parse\(await readFile\([^)]*\)\) as OpencodeState;/); + } + }); +}); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx index 40d38e3..16664c1 100644 --- a/src/opencode-ext/mcpctl-opencode-tui.tsx +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -57,7 +57,12 @@ function statePath(): string { async function readState(): Promise { try { - return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; } catch { return {}; } diff --git a/src/opencode-ext/mcpctl-opencode.ts b/src/opencode-ext/mcpctl-opencode.ts index 7fa6558..2fb4c6d 100644 --- a/src/opencode-ext/mcpctl-opencode.ts +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -40,7 +40,12 @@ function statePath(): string { async function readState(): Promise { try { - return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; } catch { return {}; } From b2547429ca6642bef7ff2b9a084265b812ce8979 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 21:23:33 +0100 Subject: [PATCH 29/77] fix(health): a passing tools/list is `live`, not `healthy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl get instances` showed all eight servers healthy while the UniFi one had never once reached its controller. The default probe is `tools/list`, which MCP servers answer from a static in-process table — no credentials, no upstream, ~3ms. It cannot fail for any reason the user cares about, so it was reporting `healthy` for every process that managed to start. Split the two passes: healthy — readiness: `tools/call` on `healthCheck.tool`. The upstream answered, so the server can actually do its job. live — liveness: `tools/list` only. Process up, upstream unverified. `live` is now the default for any server without a `healthCheck.tool`. It is not a warning; it is an admission that nothing is watching that server. Probe events name which probe ran and which tool ("Readiness check (list_sites) passed"), so the events log distinguishes the two after the fact. Also: - `healthCheck.tool` is optional now, so the timings can be tuned without inventing a readiness probe. - `create server --health-check-tool/-args/-interval/-timeout/ -failure-threshold`, per the rule that everything applyable is a create flag. Merges over a `--from-template` healthCheck rather than replacing it. - `describe instance` explains a `live` verdict instead of leaving it cryptic. - create.ts held a raw NUL byte in a string literal, which made grep treat the whole file as binary and silently skip it. Escaped as `\0`. Verified against the live fleet: with readiness probes configured, my-grafana went unhealthy (Grafana API 403) and my-node-red degraded (connect timeout to a Tailscale address) — both had read healthy for months. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 5 + docs/reliability.md | 60 ++++++ src/cli/src/commands/apply.ts | 3 +- src/cli/src/commands/create.ts | 76 ++++++- src/cli/src/commands/describe.ts | 6 + src/cli/tests/health-check-opts.test.ts | 59 ++++++ src/mcpd/src/services/health-probe.service.ts | 35 +++- src/mcpd/src/validation/template.schema.ts | 8 +- src/mcpd/tests/services/health-probe.test.ts | 65 +++++- .../smoke/health-readiness.smoke.test.ts | 185 ++++++++++++++++++ 11 files changed, 495 insertions(+), 9 deletions(-) create mode 100644 src/cli/tests/health-check-opts.test.ts create mode 100644 src/mcplocal/tests/smoke/health-readiness.smoke.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index a045a22..16a4fe5 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -194,7 +194,7 @@ _mcpctl() { else case "$create_sub" in server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --from-template --env-from-secret --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --health-check-tool --health-check-args --health-check-interval --health-check-timeout --health-check-failure-threshold --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index e79dc3b..822767d 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -380,6 +380,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l command -d 'Comm complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l container-port -d 'Container port number' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l replicas -d 'Number of replicas' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env -d 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-tool -d 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-args -d 'Readiness probe: JSON object of arguments for the probe tool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-interval -d 'Readiness probe interval in seconds (default 60)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-timeout -d 'Readiness probe timeout in seconds (default 10)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-failure-threshold -d 'Consecutive failures before the instance is marked unhealthy (default 3)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l from-template -d 'Create from template (name or name:version)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env-from-secret -d 'Map template env vars from a secret' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update if already exists' diff --git a/docs/reliability.md b/docs/reliability.md index 28a1c25..5c1f2c5 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -28,6 +28,66 @@ Note: the gate's prompt-ranking uses the **heavy client provider's own model** it deliberately does *not* force the project's vLLM model onto it (doing so made every selection fail silently when the model wasn't anthropic-servable). +## Instance health: `live` is not `healthy` + +An MCP server answers `tools/list` from a **static, in-process table**. It costs +a few milliseconds, needs no credentials, and reaches no upstream — so it stays +green while the thing the server exists to talk to is unreachable. Treating that +as a health signal is how `mcpctl get instances` showed eight healthy servers +while the UniFi one had never once reached its controller. + +So the probe reports two different passes: + +| Status | Probe | Means | +|---|---|---| +| `healthy` | **readiness** — `tools/call` on `healthCheck.tool` | The upstream answered. The server can do its job. | +| `live` | **liveness** — `tools/list` only | The process is up and speaks MCP. Its upstream is **unverified**. | +| `degraded` | either, failing | Failing, but under `failureThreshold`. | +| `unhealthy` | either, failing | Failed `failureThreshold` times in a row. | + +`live` is the default for any server with no `healthCheck.tool`. It is not a +warning — it is an admission that nothing is watching that server's upstream. + +**Configure a readiness probe on every server.** Pick a read-only tool that +genuinely round-trips to the upstream, and verify it passes before configuring +it — a probe naming a local-only tool (`get_..._version`) or a tool the server +doesn't expose reproduces the same false green it was meant to remove. + +```bash +mcpctl create server unifi-network --health-check-tool list_sites \ + --health-check-interval 60 --health-check-timeout 15 --force +``` + +or declaratively — `healthCheck` round-trips through `get -o yaml | apply -f`: + +```yaml +healthCheck: + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 + failureThreshold: 3 +``` + +Omit `tool` to keep liveness while still tuning the timings. + +Latency is the tell: a probe answering in single-digit milliseconds is reading a +local table, not crossing a network. The UniFi probe went from 3ms (`tools/list`, +lying) to 1847ms on its first real `list_sites` — login, TLS, controller round +trip — and ~40ms once the session was warm. + +### Two failure modes the probe cannot see for you + +The healthy-looking UniFi server was broken **twice over**, and both are worth +checking first when a readiness probe starts failing: + +1. **Egress.** MCP server pods default to TCP 80/443 only + (`servers-allow-external-egress`). Any upstream on another port — the UniFi + controller on `:8443` — times out on every call. Declare it in Pulumi's + `mcpctl.serverEgressTargets` (name + `/32` + ports); don't widen the blanket rule. +2. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` + address. Config pointing at one connect-timeouts forever. Use LAN IPs. + ## LLM-*essential* operations — failover chain Chat needs *an* LLM but not a *specific* one. Instead of failing when the pinned diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5360a1a..c41325a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -5,7 +5,8 @@ import { z } from 'zod'; import type { ApiClient } from '../api-client.js'; const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** Omit for a liveness-only probe (reports `live`); set it for readiness (`healthy`). */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 6f64801..fe2efd0 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -42,6 +42,69 @@ export function buildFavouriteIndex( return result; } +export interface HealthCheckOpts { + healthCheckTool?: string; + healthCheckArgs?: string; + healthCheckInterval?: string; + healthCheckTimeout?: string; + healthCheckFailureThreshold?: string; +} + +export interface HealthCheckSpec { + tool?: string; + arguments?: Record; + intervalSeconds?: number; + timeoutSeconds?: number; + failureThreshold?: number; +} + +function parsePositiveInt(flag: string, value: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`Invalid ${flag} '${value}'. Expected a positive integer.`); + } + return n; +} + +/** + * Build a server `healthCheck` spec from `--health-check-*` flags, or undefined + * when none were given (so the field is only sent when intended). + * + * Mirrors the `healthCheck:` block accepted by `apply -f`, per the rule that + * everything applyable is also a create flag. + */ +export function buildHealthCheck(opts: HealthCheckOpts): HealthCheckSpec | undefined { + const { healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold } = opts; + const given = [healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold] + .some((v) => v !== undefined); + if (!given) return undefined; + + if (healthCheckArgs !== undefined && healthCheckTool === undefined) { + throw new Error('--health-check-args requires --health-check-tool.'); + } + + const spec: HealthCheckSpec = {}; + if (healthCheckTool !== undefined) spec.tool = healthCheckTool; + if (healthCheckArgs !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(healthCheckArgs); + } catch { + throw new Error(`Invalid --health-check-args: not valid JSON. Expected a JSON object, e.g. '{"site":"default"}'.`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid --health-check-args: expected a JSON object, e.g. '{"site":"default"}'.`); + } + spec.arguments = parsed as Record; + } + if (healthCheckInterval !== undefined) spec.intervalSeconds = parsePositiveInt('--health-check-interval', healthCheckInterval); + if (healthCheckTimeout !== undefined) spec.timeoutSeconds = parsePositiveInt('--health-check-timeout', healthCheckTimeout); + if (healthCheckFailureThreshold !== undefined) { + spec.failureThreshold = parsePositiveInt('--health-check-failure-threshold', healthCheckFailureThreshold); + } + return spec; +} + /** * Parse a `--ttl` value. * @@ -136,6 +199,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--container-port ', 'Container port number') .option('--replicas ', 'Number of replicas') .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) + .option('--health-check-tool ', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")') + .option('--health-check-args ', 'Readiness probe: JSON object of arguments for the probe tool') + .option('--health-check-interval ', 'Readiness probe interval in seconds (default 60)') + .option('--health-check-timeout ', 'Readiness probe timeout in seconds (default 10)') + .option('--health-check-failure-threshold ', 'Consecutive failures before the instance is marked unhealthy (default 3)') .option('--from-template ', 'Create from template (name or name:version)') .option('--env-from-secret ', 'Map template env vars from a secret') .option('--force', 'Update if already exists') @@ -218,6 +286,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.externalUrl) body.externalUrl = opts.externalUrl; if (opts.command.length > 0) body.command = opts.command; if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); + // Merge over any healthCheck inherited from --from-template so partial + // flags (e.g. only --health-check-interval) tune rather than replace it. + const healthCheck = buildHealthCheck(opts as HealthCheckOpts); + if (healthCheck) { + body.healthCheck = { ...(base.healthCheck as HealthCheckSpec | undefined), ...healthCheck }; + } if (opts.env.length > 0) { // Merge: CLI env entries override template env entries by name const cliEnv = parseServerEnv(opts.env); @@ -898,7 +972,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const buf = await fs.readFile(full); // Reject non-UTF8 — v1 is text-only. const text = buf.toString('utf-8'); - if (text.includes('')) { + if (text.includes('\0')) { throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); } files[rel] = text; diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index bda4ffb..cd94d69 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -90,6 +90,12 @@ function formatInstanceDetail(instance: Record, inspect?: Recor lines.push('Health:'); lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`); if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`); + if (healthStatus === 'live') { + lines.push(` ${pad('Probe:', 16)}liveness (tools/list) — process is up, but nothing`); + lines.push(` ${pad('', 16)}calls the server's upstream. Configure a readiness`); + lines.push(` ${pad('', 16)}probe to reach 'healthy':`); + lines.push(` ${pad('', 16)} mcpctl edit server ${server?.name ?? ''} → healthCheck.tool`); + } } const metadata = instance.metadata as Record | undefined; diff --git a/src/cli/tests/health-check-opts.test.ts b/src/cli/tests/health-check-opts.test.ts new file mode 100644 index 0000000..0c02c03 --- /dev/null +++ b/src/cli/tests/health-check-opts.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { buildHealthCheck } from '../src/commands/create.js'; + +describe('buildHealthCheck — CLI flags → server healthCheck', () => { + it('returns undefined when no health-check flag is given', () => { + expect(buildHealthCheck({})).toBeUndefined(); + }); + + it('builds a readiness probe from --health-check-tool', () => { + expect(buildHealthCheck({ healthCheckTool: 'list_sites' })).toEqual({ tool: 'list_sites' }); + }); + + it('parses --health-check-args as a JSON object', () => { + expect(buildHealthCheck({ healthCheckTool: 'get_devices', healthCheckArgs: '{"site":"default"}' })) + .toEqual({ tool: 'get_devices', arguments: { site: 'default' } }); + }); + + it('carries the timing flags through', () => { + expect(buildHealthCheck({ + healthCheckTool: 'list_sites', + healthCheckInterval: '120', + healthCheckTimeout: '15', + healthCheckFailureThreshold: '2', + })).toEqual({ + tool: 'list_sites', + intervalSeconds: 120, + timeoutSeconds: 15, + failureThreshold: 2, + }); + }); + + it('allows tuning the liveness probe without a tool', () => { + // No `tool` → the probe stays liveness-only (reports `live`), but the + // interval is still configurable. + expect(buildHealthCheck({ healthCheckInterval: '300' })).toEqual({ intervalSeconds: 300 }); + }); + + it('rejects --health-check-args without a tool', () => { + expect(() => buildHealthCheck({ healthCheckArgs: '{}' })) + .toThrow(/--health-check-args requires --health-check-tool/); + }); + + it('rejects non-JSON args', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: 'site=default' })) + .toThrow(/not valid JSON/); + }); + + it('rejects JSON args that are not an object', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: '["a"]' })) + .toThrow(/expected a JSON object/); + }); + + it('rejects non-positive-integer timings', () => { + expect(() => buildHealthCheck({ healthCheckInterval: '0' })).toThrow(/--health-check-interval/); + expect(() => buildHealthCheck({ healthCheckTimeout: 'abc' })).toThrow(/--health-check-timeout/); + expect(() => buildHealthCheck({ healthCheckFailureThreshold: '-1' })) + .toThrow(/--health-check-failure-threshold/); + }); +}); diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 8bd4192..8196f29 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -19,10 +19,24 @@ export const DEFAULT_HEALTH_CHECK: HealthCheckSpec = { failureThreshold: 3, }; +/** + * Which probe produced a result. + * + * - `readiness` — a real `tools/call` against the configured probe tool. It + * traverses the server's upstream dependency (controller API, database, + * remote service), so a pass means the server can actually do its job. + * - `liveness` — `tools/list` only. MCP servers answer that from a static + * in-process table, so it proves the process is up and speaking MCP and + * *nothing else*. A server whose upstream is unreachable still answers it. + */ +export type ProbeKind = 'readiness' | 'liveness'; + export interface ProbeResult { healthy: boolean; latencyMs: number; message: string; + /** Set by probeInstance from the healthCheck spec; probe helpers don't fill it. */ + probe?: ProbeKind; } interface ProbeState { @@ -118,6 +132,7 @@ export class HealthProbeRunner { const failureThreshold = healthCheck.failureThreshold ?? 3; const now = new Date(); const start = Date.now(); + const probeKind: ProbeKind = healthCheck.tool === undefined ? 'liveness' : 'readiness'; let result: ProbeResult; @@ -151,6 +166,8 @@ export class HealthProbeRunner { }; } + result.probe = probeKind; + // Update probe state const state = this.probeStates.get(instance.id) ?? { consecutiveFailures: 0, lastProbeAt: 0 }; state.lastProbeAt = Date.now(); @@ -162,18 +179,28 @@ export class HealthProbeRunner { } this.probeStates.set(instance.id, state); - // Determine health status + // Determine health status. + // + // A passing *liveness* probe reports `live`, not `healthy`. `tools/list` + // is answered from a static in-process table, so it stays green while the + // server's upstream is completely unreachable — which is exactly how a + // UniFi server whose controller port was firewalled off sat at "healthy" + // for months. Only a readiness probe (`tools/call` against a real tool) + // earns `healthy`. `live` means "process up, function unverified". const healthStatus = result.healthy - ? 'healthy' + ? (probeKind === 'readiness' ? 'healthy' : 'live') : state.consecutiveFailures >= failureThreshold ? 'unhealthy' : 'degraded'; // Build event + const probeLabel = probeKind === 'readiness' + ? `Readiness check (${healthCheck.tool})` + : 'Liveness check (tools/list)'; const eventType = result.healthy ? 'Normal' : 'Warning'; const eventMessage = result.healthy - ? `Health check passed (${result.latencyMs}ms)` - : `Health check failed: ${result.message}`; + ? `${probeLabel} passed (${result.latencyMs}ms)` + : `${probeLabel} failed: ${result.message}`; const existingEvents = (instance.events as Array<{ timestamp: string; type: string; message: string }>) ?? []; // Keep last 50 events diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 8e4d34c..be1e3a4 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -8,7 +8,13 @@ const TemplateEnvEntrySchema = z.object({ }); export const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** + * Readiness probe tool. Omit it to keep the liveness-only default + * (`tools/list`) while still tuning interval/timeout/failureThreshold — + * a liveness pass reports `live`, not `healthy`, because `tools/list` is + * answered in-process and never touches the server's upstream. + */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/mcpd/tests/services/health-probe.test.ts b/src/mcpd/tests/services/health-probe.test.ts index 072bef9..8c33ccb 100644 --- a/src/mcpd/tests/services/health-probe.test.ts +++ b/src/mcpd/tests/services/health-probe.test.ts @@ -123,13 +123,76 @@ describe('HealthProbeRunner', () => { // No exec fallback — liveness goes through mcpProxyService expect(orchestrator.execInContainer).not.toHaveBeenCalled(); expect(mcpProxyService.execute).toHaveBeenCalledWith({ serverId: 'srv-1', method: 'tools/list' }); + // A passing liveness probe is `live`, never `healthy` — `tools/list` is + // answered in-process and proves nothing about the server's upstream. expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', 'RUNNING', - expect.objectContaining({ healthStatus: 'healthy' }), + expect.objectContaining({ healthStatus: 'live' }), ); }); + it('reports `live` (not `healthy`) even when the upstream is dead, and says so in the event', async () => { + // The regression this guards: a UniFi server whose controller port was + // firewalled off sat at "healthy" for months because `tools/list` kept + // answering from the in-process tool table. + const instance = makeInstance(); + const server = makeServer({ healthCheck: null }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + + const result = await runner.probeInstance(instance, server, { intervalSeconds: 0 }); + + expect(result.healthy).toBe(true); + expect(result.probe).toBe('liveness'); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('live'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Liveness check (tools/list) passed'); + }); + + it('a passing readiness probe earns `healthy` and names the tool in the event', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', intervalSeconds: 0 } as McpServer['healthCheck'], + }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: {} }); + + const result = await runner.probeInstance(instance, server, { tool: 'list_sites' }); + + expect(result.probe).toBe('readiness'); + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('healthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) passed'); + }); + + it('a readiness probe whose tool call fails reports the upstream error, not `live`', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', failureThreshold: 1 } as McpServer['healthCheck'], + }); + + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'connect ETIMEDOUT 192.168.1.5:8443' }, + }); + + await runner.probeInstance(instance, server, { tool: 'list_sites', failureThreshold: 1 }); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('unhealthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) failed'); + expect(events[events.length - 1]?.message).toContain('ETIMEDOUT'); + }); + it('default liveness probe marks unhealthy when tools/list returns JSON-RPC error', async () => { const instance = makeInstance(); const server = makeServer({ diff --git a/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts new file mode 100644 index 0000000..82e266c --- /dev/null +++ b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts @@ -0,0 +1,185 @@ +/** + * Smoke tests: readiness probes actually exercise a server's upstream. + * + * The bug these guard: every instance read `healthy` forever because the + * default probe is `tools/list`, which MCP servers answer from a static + * in-process table. The UniFi server sat green for months while every call to + * its controller timed out (pod egress was capped at 80/443, controller on + * :8443) and while its `controller_type` pointed at the wrong API dialect. + * + * So these tests assert the probe is a real round trip, not a self-report: + * 1. Servers with a `healthCheck.tool` really do reach their upstream when + * that tool is called through the production proxy path. + * 2. A server with no `healthCheck.tool` reports `live`, never `healthy` — + * "process up, function unverified" must not read as "working". + * 3. `tools/list` alone cannot distinguish the two, which is why (2) matters. + * + * Prerequisites: + * - mcplocal running on localhost:3200 + * - mcpd reachable (k8s), servers deployed with readiness probes configured + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const CONFIG_PATH = join(homedir(), '.mcpctl', 'config.json'); +const CREDS_PATH = join(homedir(), '.mcpctl', 'credentials'); + +function loadConfig(): { mcpdUrl: string; token: string } { + let mcpdUrl = 'http://localhost:3100'; + let token = ''; + try { + if (existsSync(CONFIG_PATH)) { + const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as { mcpdUrl?: string }; + if (cfg.mcpdUrl) mcpdUrl = cfg.mcpdUrl; + } + if (existsSync(CREDS_PATH)) { + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8')) as { token?: string }; + if (creds.token) token = creds.token; + } + } catch { /* use defaults */ } + return { mcpdUrl, token }; +} + +const { mcpdUrl, token } = loadConfig(); + +function mcpdRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + return new Promise((resolve, reject) => { + const url = new URL(path, mcpdUrl); + const transport = url.protocol === 'https:' ? https : http; + + const headers: Record = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers['Authorization'] = `Bearer ${token}`; + const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr)); + + const req = transport.request(url, { method, timeout: 60_000, headers, rejectUnauthorized: false }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + try { + resolve({ status: res.statusCode ?? 500, data: raw ? JSON.parse(raw) as T : (undefined as T) }); + } catch { + resolve({ status: res.statusCode ?? 500, data: raw as unknown as T }); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +interface HealthCheck { + tool?: string; + arguments?: Record; +} + +interface Server { + id: string; + name: string; + healthCheck: HealthCheck | null; +} + +interface Instance { + id: string; + serverId: string; + status: string; + healthStatus: string | null; + server?: { name: string }; +} + +interface ProxyResult { + result?: { tools?: Array<{ name: string }>; isError?: boolean; content?: Array<{ text?: string }> }; + error?: { code: number; message: string }; +} + +let servers: Server[] = []; +let instances: Instance[] = []; + +beforeAll(async () => { + const s = await mcpdRequest('GET', '/api/v1/servers'); + expect(s.status, `GET /api/v1/servers returned ${s.status}`).toBe(200); + servers = s.data; + + const i = await mcpdRequest('GET', '/api/v1/instances'); + expect(i.status, `GET /api/v1/instances returned ${i.status}`).toBe(200); + instances = i.data; +}, 120_000); + +describe('readiness probes reach the upstream', () => { + it('every RUNNING server has a readiness probe configured', () => { + const running = instances.filter((i) => i.status === 'RUNNING'); + expect(running.length, 'no RUNNING instances to check').toBeGreaterThan(0); + + const withoutProbe = running + .map((i) => servers.find((s) => s.id === i.serverId)) + .filter((s): s is Server => s !== undefined) + .filter((s) => s.healthCheck?.tool === undefined) + .map((s) => s.name); + + // A server with no readiness probe can only ever report `live`. That is + // honest, but it means nothing is watching its upstream — so the fleet + // should not accumulate them silently. + expect(withoutProbe, `servers with no healthCheck.tool: ${withoutProbe.join(', ')}`).toEqual([]); + }); + + it('each configured probe tool really answers through the proxy', async () => { + const probed = servers.filter((s) => s.healthCheck?.tool !== undefined); + expect(probed.length, 'no servers have readiness probes').toBeGreaterThan(0); + + const failures: string[] = []; + for (const server of probed) { + const hc = server.healthCheck!; + const res = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server.id, + method: 'tools/call', + params: { name: hc.tool, arguments: hc.arguments ?? {} }, + }); + if (res.status !== 200) { + failures.push(`${server.name}/${hc.tool}: HTTP ${res.status}`); + continue; + } + if (res.data.error) { + failures.push(`${server.name}/${hc.tool}: ${res.data.error.message}`); + continue; + } + if (res.data.result?.isError === true) { + failures.push(`${server.name}/${hc.tool}: ${res.data.result.content?.[0]?.text ?? 'isError'}`); + } + } + + // When this fails, the server is genuinely broken — fix the environment + // (credentials, egress, upstream address), never the assertion. + expect(failures, `readiness probe tools failing: ${failures.join(' | ')}`).toEqual([]); + }, 300_000); + + it('a probe tool is a different call from tools/list, and both are reachable', async () => { + const server = servers.find((s) => s.healthCheck?.tool !== undefined); + expect(server, 'need at least one probed server').toBeDefined(); + + const list = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server!.id, + method: 'tools/list', + }); + expect(list.status).toBe(200); + const toolNames = (list.data.result?.tools ?? []).map((t) => t.name); + // The probe must name a tool the server actually exposes, otherwise the + // readiness check fails for a bookkeeping reason rather than a real one. + expect(toolNames).toContain(server!.healthCheck!.tool); + }, 120_000); + + it('no RUNNING instance is stuck at an unknown health status', () => { + const stuck = instances + .filter((i) => i.status === 'RUNNING') + .filter((i) => i.healthStatus === null || i.healthStatus === 'unknown') + .map((i) => i.server?.name ?? i.serverId); + expect(stuck, `instances with no health verdict: ${stuck.join(', ')}`).toEqual([]); + }); +}); From 732ca98ccc8c243e277d8efbbebafb960ba8f750 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:54:33 +0100 Subject: [PATCH 30/77] docs(reliability): record the three shapes a failing readiness probe takes Turning readiness probes on took the fleet from 8/8 healthy to three real failures in under a minute, and all three were network shape rather than code: an egress port (UniFi :8443), an ingress hairpin through the Envoy L7 policy (Grafana 403 `Access denied` with a token that worked from a laptop), and a Tailscale address a pod can never reach (Node-RED, since retired). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- docs/reliability.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/reliability.md b/docs/reliability.md index 5c1f2c5..30423c8 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -76,17 +76,29 @@ local table, not crossing a network. The UniFi probe went from 3ms (`tools/list` lying) to 1847ms on its first real `list_sites` — login, TLS, controller round trip — and ~40ms once the session was warm. -### Two failure modes the probe cannot see for you +### Where a failing readiness probe usually points -The healthy-looking UniFi server was broken **twice over**, and both are worth -checking first when a readiness probe starts failing: +Turning these probes on for the first time took the fleet from "8/8 healthy" to +three genuine failures in under a minute. All three were network shape, not +code — check these before suspecting the server: -1. **Egress.** MCP server pods default to TCP 80/443 only +1. **Egress port.** MCP server pods default to TCP 80/443 only (`servers-allow-external-egress`). Any upstream on another port — the UniFi controller on `:8443` — times out on every call. Declare it in Pulumi's - `mcpctl.serverEgressTargets` (name + `/32` + ports); don't widen the blanket rule. -2. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` - address. Config pointing at one connect-timeouts forever. Use LAN IPs. + `mcpctl.serverEgressTargets`; don't widen the blanket rule. +2. **Ingress hairpin.** A co-located service reached over its *public* hostname + goes out and back through the per-host Envoy L7 policy, which doesn't + reliably carry the caller's identity and replies with a bare `Access denied`. + Grafana 403'd on every call this way while the identical token succeeded from + a laptop. The tell is the error *shape*: plain text, not the upstream's own + JSON error. Use the ClusterIP (`serverEgressTargets` with `namespace:`). +3. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` + address. Config pointing at one connect-timeouts forever. Use LAN IPs. (This + one turned out to be a retired service, which is its own kind of answer.) + +Also check the *dialect*: UniFi's `controller_type` must be `classic` for a +self-hosted controller (login `/api/login`, no `/proxy/network` prefix). +`unifi_os` sends every request to a path that 404s. ## LLM-*essential* operations — failover chain From a158e49ec24bef57b9a7f2b3888b47038445b579 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 23:53:22 +0100 Subject: [PATCH 31/77] fix(templates): make the shipped templates match reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The templates are what `create server --from-template` builds from and what mcpd seeds on start, so drift there ships broken servers. Nothing ever read these files in a test, and they had rotted badly. - grafana: GRAFANA_URL now defaults to the in-cluster ClusterIP and the description spells out why the public hostname is wrong — reaching a co-located Grafana over its ingress hairpins through the per-host Envoy L7 policy, which drops the caller's identity and returns a bare `Access denied` 403 with a perfectly valid token. That cost a day of looking at the token. - unifi-network: was wrong on every field that mattered. `runtime: python` for an npm package, an env contract (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and no probe. Now UNIFI_TARGETS with the classic-vs-unifi_os distinction and the :8443 egress caveat written down. - docmost, gitea: both carried "health check disabled" comments citing a limitation of the old docker-exec probe, which readiness-via-proxy removed. Both probes verified against the live servers. gitea uses search_repos, not get_me, because get_me needs a `read:user` scope a repo-scoped token lacks. - filesystem: packageName was `@anthropic/filesystem-mcp`, which 404s on npm — the template could never have installed. Points at the real package. - terraform: deleted. `@anthropic/terraform-mcp` 404s too and there is no npm-published replacement to point it at. - node-red: deleted, the service is gone. Two supporting fixes: - The seeder declared no `runtime` field and never wrote the column, so a template asking for the python runner silently seeded as null and got node. - A new templates test reads every shipped file: schema-valid, a runner the orchestrator knows, some way to actually start, unique env names, and a readiness probe (without one an instance can only ever report `live`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- README.md | 2 +- src/db/src/seed/index.ts | 9 ++++ src/mcpd/tests/templates.test.ts | 79 ++++++++++++++++++++++++++++++++ templates/docmost.yaml | 11 ++++- templates/filesystem.yaml | 21 ++++++++- templates/gitea.yaml | 13 +++++- templates/grafana.yaml | 16 ++++++- templates/node-red.yaml | 16 ------- templates/terraform.yaml | 6 --- templates/unifi-network.yaml | 53 +++++++++++++-------- 10 files changed, 178 insertions(+), 48 deletions(-) create mode 100644 src/mcpd/tests/templates.test.ts delete mode 100644 templates/node-red.yaml delete mode 100644 templates/terraform.yaml diff --git a/README.md b/README.md index 16ead01..71600b6 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,7 @@ name: home-automation proxyModel: default servers: - home-assistant - - node-red + - unifi-network ``` Via CLI: diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index e8872e3..6c6dfb5 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -21,6 +21,13 @@ export interface SeedTemplate { version: string; description: string; packageName?: string; + /** + * Package runner: 'node' (npx) or 'python' (uvx). McpTemplate has had this + * column all along, but the upsert below never wrote it, so a template + * declaring `runtime: python` seeded as null and every server created from + * it silently got the node runner. + */ + runtime?: string; dockerImage?: string; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; repositoryUrl?: string; @@ -45,6 +52,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -60,6 +68,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, diff --git a/src/mcpd/tests/templates.test.ts b/src/mcpd/tests/templates.test.ts new file mode 100644 index 0000000..a597a47 --- /dev/null +++ b/src/mcpd/tests/templates.test.ts @@ -0,0 +1,79 @@ +/** + * The shipped `templates/*.yaml` are seeded into mcpd and are what `mcpctl + * create server --from-template` builds from, so drift there ships broken + * servers. The unifi-network template had drifted on every field that + * mattered — python runtime for an npm package, an env contract + * (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and a comment + * disabling its health check for a reason that had stopped being true — and + * nothing caught it because no test ever read the files. + */ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { CreateTemplateSchema } from '../src/validation/template.schema.js'; + +const TEMPLATES_DIR = fileURLToPath(new URL('../../../templates', import.meta.url)); + +const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + +interface RawTemplate { + name?: string; + runtime?: string; + packageName?: string; + dockerImage?: string; + externalUrl?: string; + healthCheck?: { tool?: string }; + env?: Array<{ name?: string }>; +} + +function load(file: string): RawTemplate { + return yaml.load(readFileSync(join(TEMPLATES_DIR, file), 'utf-8')) as RawTemplate; +} + +describe('shipped templates', () => { + it('ships at least one template', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files)('%s validates against CreateTemplateSchema', (file) => { + const parsed = CreateTemplateSchema.safeParse(load(file)); + expect(parsed.success ? null : parsed.error.issues).toBeNull(); + }); + + it.each(files)('%s declares a runner the orchestrator knows', (file) => { + const tpl = load(file); + // `runtime` only means anything for package-based servers, and only + // 'node' (npx) and 'python' (uvx) are wired in buildRuntimeSpawnCmd. + if (tpl.runtime !== undefined) { + expect(['node', 'python']).toContain(tpl.runtime); + } + }); + + it.each(files)('%s says how to actually run the server', (file) => { + const tpl = load(file); + const runnable = tpl.packageName !== undefined + || tpl.dockerImage !== undefined + || tpl.externalUrl !== undefined; + expect(runnable, `${file} has no packageName, dockerImage, or externalUrl`).toBe(true); + }); + + it.each(files)('%s names a readiness probe tool, not a bare liveness probe', (file) => { + const tpl = load(file); + // Without a `tool`, an instance from this template can only ever report + // `live` — nothing would ever check its upstream. See docs/reliability.md. + expect(tpl.healthCheck?.tool, `${file} has no healthCheck.tool`).toBeTruthy(); + }); + + it.each(files)('%s declares uniquely-named env entries', (file) => { + const names = (load(file).env ?? []).map((e) => e.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('has no template for a retired server', () => { + // node-red was retired 2026-08-09: it answered on neither its Tailscale + // nor its LAN address and had no deployment anywhere. + expect(files).not.toContain('node-red.yaml'); + }); +}); diff --git a/templates/docmost.yaml b/templates/docmost.yaml index 10d9df1..e608d1c 100644 --- a/templates/docmost.yaml +++ b/templates/docmost.yaml @@ -4,8 +4,15 @@ description: Docmost MCP server for wiki/documentation page management and searc dockerImage: "mysources.co.uk/michal/docmost-mcp:latest" transport: STDIO repositoryUrl: https://github.com/MrMartiniMo/docmost-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses a custom dockerImage. Probe support for dockerImage STDIO servers is TODO. +healthCheck: + # get_workspace calls the Docmost API, so a pass proves URL + login. The old + # "probe requires packageName" caveat here was true of the long-gone + # docker-exec probe; readiness now goes through the MCP proxy, which works + # the same for image-based STDIO servers. Verified against the live server. + tool: get_workspace + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: DOCMOST_API_URL description: Docmost API URL (e.g. http://100.88.157.6:3000/api) diff --git a/templates/filesystem.yaml b/templates/filesystem.yaml index 4c14e7b..cb0c1c9 100644 --- a/templates/filesystem.yaml +++ b/templates/filesystem.yaml @@ -1,6 +1,23 @@ name: filesystem -version: "1.0.0" +version: "2.0.0" description: Filesystem MCP server for reading and writing files -packageName: "@anthropic/filesystem-mcp" +# Was "@anthropic/filesystem-mcp", which 404s on the npm registry — creating a +# server from this template failed at install. This is the real package. +packageName: "@modelcontextprotocol/server-filesystem" +runtime: node transport: STDIO repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem +healthCheck: + # Zero-arg and proves the server resolved its configured roots, which is the + # only thing it can be misconfigured about. + tool: list_allowed_directories + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 +env: + - name: ALLOWED_DIRECTORIES + description: >- + Space-separated directories the server may access. Passed as the + package's positional arguments; without at least one the server exposes + nothing. + required: true diff --git a/templates/gitea.yaml b/templates/gitea.yaml index 9ded49c..c41dbd9 100644 --- a/templates/gitea.yaml +++ b/templates/gitea.yaml @@ -7,7 +7,18 @@ repositoryUrl: https://gitea.com/gitea/gitea-mcp # No command: the image's entrypoint IS the MCP server. mcpd attaches to PID 1 # stdin/stdout (attach mode) rather than exec-ing a new process. The image is # distroless and has no node/shell, so exec-based STDIO would fail. -# Health check disabled: STDIO health probe requires node in the container. +healthCheck: + # search_repos is a real Gitea API call, deliberately chosen over get_me: + # get_me needs the `read:user` token scope, which a repo-scoped token won't + # have, so it would fail for a bookkeeping reason rather than a real one. + # (The "probe requires node in the container" caveat that used to sit here + # described the old docker-exec probe; readiness goes through the MCP proxy + # now, so a distroless image is fine.) Verified against the live server. + tool: search_repos + arguments: + query: mcpctl + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GITEA_HOST description: Gitea instance URL (e.g. https://gitea.example.com) diff --git a/templates/grafana.yaml b/templates/grafana.yaml index 2bf6992..f8445b7 100644 --- a/templates/grafana.yaml +++ b/templates/grafana.yaml @@ -1,16 +1,28 @@ name: grafana -version: "1.0.0" +version: "1.1.0" description: Grafana MCP server for dashboards, datasources, and alerts packageName: "@leval/mcp-grafana" +runtime: node transport: STDIO repositoryUrl: https://github.com/levalhq/mcp-grafana healthCheck: + # Hits the Grafana API, so a pass proves URL + token + reachability. A + # liveness probe (tools/list) cannot: it answers from the server's own tool + # table and stays green while every Grafana call 403s. tool: list_datasources arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GRAFANA_URL - description: Grafana instance URL (e.g. https://grafana.example.com) + description: >- + Grafana base URL. For a Grafana in this cluster use its ClusterIP + (http://grafana..svc.cluster.local:3000) — NOT its public + hostname. Reaching it over the public ingress hairpins the request back + through the per-host Envoy L7 policy, which drops the caller's identity + and answers a bare `Access denied` 403 even when the token is valid. required: true + defaultValue: http://grafana.home-automation.svc.cluster.local:3000 - name: GRAFANA_SERVICE_ACCOUNT_TOKEN description: Grafana service account token (glsa_...) required: true diff --git a/templates/node-red.yaml b/templates/node-red.yaml deleted file mode 100644 index d81b749..0000000 --- a/templates/node-red.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: node-red -version: "1.0.0" -description: Node-RED MCP server for flow management and automation -packageName: "mcp-node-red" -transport: STDIO -repositoryUrl: https://github.com/fx/mcp-node-red -healthCheck: - tool: get_settings - arguments: {} -env: - - name: NODE_RED_URL - description: Node-RED instance URL (e.g. http://nodered.local:1880) - required: true - - name: NODE_RED_TOKEN - description: Node-RED access token (optional if no auth) - required: false diff --git a/templates/terraform.yaml b/templates/terraform.yaml deleted file mode 100644 index 9fd4049..0000000 --- a/templates/terraform.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: terraform -version: "1.0.0" -description: Terraform MCP server for infrastructure documentation and state -packageName: "@anthropic/terraform-mcp" -transport: STDIO -repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/terraform diff --git a/templates/unifi-network.yaml b/templates/unifi-network.yaml index 8d5c14a..242309d 100644 --- a/templates/unifi-network.yaml +++ b/templates/unifi-network.yaml @@ -1,25 +1,42 @@ name: unifi-network -version: "1.0.0" +version: "2.0.0" description: UniFi Network MCP server for managing UniFi network devices, clients, and configuration packageName: "unifi-network-mcp" -runtime: python +runtime: node transport: STDIO repositoryUrl: https://github.com/sirkirby/unifi-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses the Python runner. Probe support for Python runner STDIO servers is TODO. +healthCheck: + # list_sites calls the controller (/api/self/sites), so a pass proves the + # whole path: egress to the controller port, TLS, login, session. The old + # template disabled the probe entirely on the belief that STDIO probes only + # worked for npm packages — that stopped being true once readiness probes + # started going through the MCP proxy, and the gap let this server sit at + # "healthy" for months without ever reaching the controller. + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 env: - - name: UNIFI_HOST - description: UniFi controller hostname or IP (e.g. unifi.example.com — without https://) + - name: UNIFI_TARGETS + description: >- + JSON array of controllers. One object per controller: + {"id", "base_url", "controller_type", "default_site", "auth": + {"username","password"}, "verify_ssl"}. + + controller_type is "classic" for a self-hosted UniFi Network controller + (login /api/login, no path prefix) or "unifi_os" for a UDM/UniFi OS + console (login /api/auth/login, API under /proxy/network). Choosing the + wrong one sends every request to a path that 404s while the server still + starts cleanly. + + base_url must carry the real controller port — a self-hosted controller + is usually :8443, and :443 on the same host is often an unrelated + service. Note that MCP server pods only egress 80/443 by default, so any + other port needs an explicit NetworkPolicy (Pulumi + `mcpctl.serverEgressTargets`). required: true - - name: UNIFI_USERNAME - description: UniFi local admin username - required: true - - name: UNIFI_PASSWORD - description: UniFi admin password - required: true - - name: UNIFI_NETWORK_PORT - description: UniFi controller port (default 443, use 8443 for standalone UniFi Controller) - required: false - - name: UNIFI_NETWORK_VERIFY_SSL - description: Verify SSL certificate (true/false, default true — set false for self-signed certs) - required: false + defaultValue: >- + [{"id": "home", "base_url": "https://unifi.example.com:8443", + "controller_type": "classic", "default_site": "default", + "auth": {"username": "CHANGE_ME", "password": "CHANGE_ME"}, + "verify_ssl": false}] From be7fabd467881b0e84b731650522863e541fbdce Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 00:15:04 +0100 Subject: [PATCH 32/77] fix(pi-ext): stop importing @earendil-works/pi-ai at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension failed to load outright on older pi installs: Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' pi doesn't resolve an extension's bare specifiers the ordinary way — it hands jiti a hard-coded alias table built from its own dependencies, and that table differs between pi distributions. `@earendil-works/pi-coding- agent` (0.84.1) aliases both the `@earendil-works/*` and legacy `@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases only the old ones. Neither resolves the other's namespace, so a single import outside the intersection takes the whole extension down: every tool, the /mcpctl command, and the status line, all gone. The only thing we used from pi-ai was `StringEnum`, a six-line wrapper over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical output, so `typebox` — aliased by every published pi — is now the sole bare runtime import. The call site also passes `description` through, which the pi-ai version was silently dropping. Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in the embedded sources that isn't `node:`, relative, or typebox now fails. Verified against both installs with the same active project: 0.73.1 reproduced the error verbatim before the change and loads cleanly after, and 0.84.1 keeps registering the gate tool exactly as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8 --- docs/pi-extension.md | 31 +++++++++++++++++-- src/cli/src/config/pi-extension.ts | 2 +- .../tests/config/pi-extension-embed.test.ts | 28 +++++++++++++++++ src/pi-ext/mcpctl-pi.ts | 28 +++++++++++++++-- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/pi-extension.md b/docs/pi-extension.md index fcf98fc..f10c6f7 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -101,9 +101,34 @@ src/pi-ext/ mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) ``` -The extension imports only from pi-bundled packages -(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it -loads standalone. +The extension imports only from pi-bundled packages, so it loads standalone. + +### `typebox` is the only bare runtime import + +pi does not let an extension resolve modules the ordinary way: it hands jiti a +hard-coded alias table built from its *own* dependencies, and that table is not +the same across pi distributions. The newer `@earendil-works/pi-coding-agent` +aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names; +older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only +the `@mariozechner/*` ones. Neither resolves the other's namespace. + +So an import of anything outside the intersection kills the *whole* extension on +someone else's pi — every tool, the `/mcpctl` command, the status line — with: + +``` +Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' +``` + +which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did. +`typebox` is aliased by every published pi, so it is the only bare specifier +allowed at runtime. Everything else must be a `node:` builtin, a relative path, +an `import type` (erased before jiti resolves anything), or inlined — pi-ai's +`StringEnum` is now a six-line local `stringEnum`. The +`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced +runtime import. + +If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the +usual cause, and the extension has to load under whichever one wins. ## Typechecking diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index 35ccf26..eff525c 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n *\n * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers\n * through a fixed alias table in its own loader, and that table differs between\n * pi distributions — `@earendil-works/*` exists only in the newer packages,\n * while `@mariozechner/*` installs alias only the old names. `typebox` is the\n * one specifier every published pi aliases, so it is the ONLY runtime import\n * allowed here. Anything else must be `import type` (erased before jiti runs)\n * or inlined — see `stringEnum` below.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\n\n/**\n * `{ type: \"string\", enum: [...] }` rather than a union of literals: Google's\n * API (and other providers that reject anyOf/const) only accept the flat form.\n *\n * Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in\n * `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the\n * whole extension then failed to load. See the import note at the top.\n */\nfunction stringEnum(values: string[], description?: string): TSchema {\n return Type.Unsafe({\n type: \"string\",\n enum: values,\n ...(description ? { description } : {}),\n });\n}\n\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return stringEnum(enumVals as string[], desc);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/tests/config/pi-extension-embed.test.ts b/src/cli/tests/config/pi-extension-embed.test.ts index 5453278..3280d0b 100644 --- a/src/cli/tests/config/pi-extension-embed.test.ts +++ b/src/cli/tests/config/pi-extension-embed.test.ts @@ -29,6 +29,34 @@ describe('embedded pi extension', () => { expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); }); + /** + * pi resolves an extension's bare specifiers through a hard-coded alias table + * in its own loader, and that table is not the same across pi distributions: + * `@earendil-works/*` exists only in the newer packages, `@mariozechner/*` + * installs alias only the old names, and neither resolves the other. An + * import of a package outside the intersection makes the whole extension fail + * to load with `Cannot find module` — every tool gone, on someone else's pi. + * + * `typebox` is aliased by every published pi, so it is the only safe bare + * runtime import. Type-only imports are erased before jiti resolves anything, + * so they may name whatever they like. + */ + it('imports nothing at runtime that some pi build cannot resolve', () => { + // `import x from "s"` / `import {..} from "s"` (but not `import type`), + // plus the side-effect form `import "s"`. + const runtimeImport = + /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm; + const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/; + + for (const name of PI_EXTENSION_FILENAMES) { + const src = PI_EXTENSION_FILES[name] ?? ''; + for (const match of src.matchAll(runtimeImport)) { + const specifier = match[1] ?? match[2] ?? ''; + expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed); + } + } + }); + it('carries the fixes the pi API requires', () => { const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; // ctx.ui.select takes string[] and returns the chosen string. diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index d895720..b42d22d 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -20,9 +20,16 @@ * or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"] * * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude. + * + * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers + * through a fixed alias table in its own loader, and that table differs between + * pi distributions — `@earendil-works/*` exists only in the newer packages, + * while `@mariozechner/*` installs alias only the old names. `typebox` is the + * one specifier every published pi aliases, so it is the ONLY runtime import + * allowed here. Anything else must be `import type` (erased before jiti runs) + * or inlined — see `stringEnum` below. */ import { Type, type TSchema } from "typebox"; -import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { McpHttpSession, @@ -110,6 +117,23 @@ async function listProjects(mcplocalUrl: string, token?: string): Promise({ + type: "string", + enum: values, + ...(description ? { description } : {}), + }); +} + function convertSchema(inputSchema: unknown): TSchema { if (!inputSchema || typeof inputSchema !== "object") { return Type.Object({}); @@ -147,7 +171,7 @@ function convertProp(raw: unknown): TSchema { const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined; if (enumVals && enumVals.every((v) => typeof v === "string")) { - return StringEnum(enumVals as string[]); + return stringEnum(enumVals as string[], desc); } if (enumVals && enumVals.every((v) => typeof v === "number")) { const literals = enumVals.map((v) => Type.Literal(v)); From 2a7bba11ea6638d717b8f5e2143a50b2a63084d0 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 12:15:46 +0100 Subject: [PATCH 33/77] fix(claude): stop pre-migration .mcp.json residue outranking a project switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl config claude --project X` writes user scope and never rewrites a checkout's `.mcp.json`. The status line, however, preferred that file unconditionally — so a legacy project-named entry an older mcpctl left behind (`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project for good, and every switch looked like it had done nothing. Reproduced live: with user scope on `sre`, `mcpctl statusline --directory ~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project Claude Code also had in `disabledMcpServers` for that directory, so the line named a server that was not even mounted. Rank the sources by how deliberate each one is instead: a canonical `mcpctl` pin, then user scope, then legacy residue, then the marker. A pin is a decision and still wins; residue is not and no longer does. At every step, skip a server Claude Code has switched off for that directory. `config claude` now also warns when the working directory's `.mcp.json` contradicts the switch, naming the file — the two scopes are merged rather than chosen between, so nothing else would tell you. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/claude-integration.md | 49 +++++++-- src/cli/src/commands/config.ts | 48 +++++++++ src/cli/src/commands/statusline.ts | 81 ++++++++++++--- src/cli/src/config/claude-mcp.ts | 57 ++++++++-- src/cli/tests/commands/claude.test.ts | 42 ++++++++ src/cli/tests/commands/statusline.test.ts | 121 ++++++++++++++++++++++ src/cli/tests/config/claude-mcp.test.ts | 47 +++++++++ 7 files changed, 414 insertions(+), 31 deletions(-) create mode 100644 src/cli/tests/commands/statusline.test.ts diff --git a/docs/claude-integration.md b/docs/claude-integration.md index d0163b5..425a65e 100644 --- a/docs/claude-integration.md +++ b/docs/claude-integration.md @@ -101,13 +101,48 @@ to open, and re-scoping it would silently change which skills sync into it. ⏵⏵ bypass permissions on · ← for agents ``` -`mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` -first (a repo that pinned itself wins), then the user-scope entry in -`.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet -wired still reports. It reads the directory from the JSON Claude Code pipes in, -so it follows `/cwd` rather than reporting wherever the binary was launched, and -prints **nothing** when no project is active — an empty status line beats one -saying "none" on every unrelated repo. +`mcpctl statusline` reads the directory from the JSON Claude Code pipes in, so it +follows `/cwd` rather than reporting wherever the binary was launched, and prints +**nothing** when no project is active — an empty status line beats one saying +"none" on every unrelated repo. + +It then takes the project from the most deliberate source that names one: + +1. a canonical `mcpctl` entry in that directory's `.mcp.json` — a repo that + pinned itself wins, and it is the scope Claude Code itself prefers when both + define that server name; +2. the user-scope entry in `.claude.json` — what `config claude --project` + writes, so a switch takes effect everywhere it is not overridden; +3. a **legacy** project-named entry in `.mcp.json` (`homeautomation`, + `docmost`, …), left by an mcpctl older than the constant server name; +4. a `.mcpctl-project` marker up the tree, so a checkout that is scoped but not + yet wired still reports. + +> **Legacy entries rank below user scope on purpose.** They used to outrank it, +> which made switching look broken: a user-scope switch never rewrites a +> checkout's `.mcp.json`, so the leftover kept naming the old project for good. +> A pin is a decision; residue is not. + +A server Claude Code has switched off for that directory (`disabledMcpServers` / +`disabledMcpjsonServers`) is skipped at every step — a disabled server is not +mounted, so naming its project would be a lie. A `.mcp.json` server that is in +neither list is still awaiting its approval prompt and does count, since blanking +the status line on a fresh checkout is the more confusing failure. + +### When a directory contradicts a switch + +Claude Code merges the two scopes rather than picking one, so switching in user +scope cannot clean up what a directory declares. `config claude` says so rather +than reporting plain success: + +``` +Warning: /path/to/repo/.mcp.json still registers 'homeautomation' for this +directory — mounted alongside 'sre', not replaced by it. + Re-run with --scope project to retire it, or delete the entry by hand. +``` + +A canonical entry pinned to another project gets the stronger wording — it +*overrides* the switch in that directory rather than sitting beside it. ### It is never installed over yours diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 8623513..917d551 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -46,6 +46,8 @@ import { mergeUserScopeServer, userScopeProject, activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, claudeJsonPath, type McpJson, type ClaudeJson, @@ -104,11 +106,51 @@ function readMcpJson(path: string): McpJson | null { } } +/** + * Warnings about a `.mcp.json` in `dir` that contradicts a user-scope switch to + * `project`. + * + * Claude Code merges the two scopes rather than picking one, so a + * directory-scoped entry does not go away when you switch globally: + * - a canonical `mcpctl` entry shares the name, and project scope wins — the + * switch has no effect in this directory at all; + * - a legacy project-named entry has a *different* name, so it is simply + * mounted alongside and the old project keeps answering here. + * Either way the user is owed the file path, because nothing else will tell + * them. Exported for tests. + */ +export function shadowWarnings(dir: string, project: string | undefined): string[] { + if (project === undefined || project === '') return []; + const path = join(dir, '.mcp.json'); + const parsed = readMcpJson(path); + if (parsed === null) return []; + + const pinned = canonicalProjectIn(parsed); + if (pinned !== null && pinned !== project) { + return [ + `Warning: ${path} pins '${MCPCTL_SERVER_NAME}' to '${pinned}' for this directory, which overrides the switch here.`, + ` Re-run with --scope project to repoint it, or delete the '${MCPCTL_SERVER_NAME}' entry to follow the user-scope project.`, + ]; + } + + const stale = legacyEntriesIn(parsed).filter((e) => e.project !== project); + if (stale.length > 0) { + const names = stale.map((e) => `'${e.server}'`).join(', '); + return [ + `Warning: ${path} still registers ${names} for this directory — mounted alongside '${project}', not replaced by it.`, + ` Re-run with --scope project to retire ${stale.length === 1 ? 'it' : 'them'}, or delete the ${stale.length === 1 ? 'entry' : 'entries'} by hand.`, + ]; + } + return []; +} + export interface ConfigCommandDeps { configDeps: Partial; log: (...args: string[]) => void; /** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */ apiClient?: ApiClient; + /** Working directory to check for a shadowing `.mcp.json`. Injectable so tests need not chdir. */ + cwd?: () => string; } export interface ConfigApiDeps { @@ -124,6 +166,7 @@ const defaultDeps: ConfigCommandDeps = { export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; + const cwd = deps?.cwd ?? ((): string => process.cwd()); // PR-5: api client used by `mcpctl config claude --project` to run the // initial skills sync after wiring the .mcp.json. Threaded through from // index.ts; falls back to apiDeps.client when not explicitly passed (the @@ -406,6 +449,11 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (userScope) { // The whole point of user scope: you do this once, not per checkout. log('This applies in every directory — no need to re-run it per repo.'); + // ...except where a directory-scoped entry contradicts it. That file + // is never rewritten by a user-scope switch, so staying silent is how + // a switch ends up looking like it did nothing: the status line keeps + // naming the old project, and its server keeps answering here. + for (const line of shadowWarnings(cwd(), opts.project)) log(line); } // PR-5: write project marker, run initial skills sync, install diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts index 7f22ede..9d04ef0 100644 --- a/src/cli/src/commands/statusline.ts +++ b/src/cli/src/commands/statusline.ts @@ -2,7 +2,16 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js'; +import { + MCPCTL_SERVER_NAME, + canonicalProjectIn, + claudeJsonPath, + disabledServersFor, + legacyEntriesIn, + userScopeProject, + type McpJson, + type ClaudeJson, +} from '../config/claude-mcp.js'; import { findProjectMarker } from '../utils/project-marker.js'; /** @@ -16,9 +25,10 @@ import { findProjectMarker } from '../utils/project-marker.js'; * `setStatus`. * * Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We - * only need the directory — the project is whatever `.mcp.json` there mounts, - * falling back to a `.mcpctl-project` marker up the tree so a checkout that is - * scoped but not yet wired still reports. + * only need the directory; the project is then resolved from the most + * deliberate source that names one — see the ranking in the action below. + * Whatever it reports has to be a project that is genuinely mounted, so a + * server Claude Code has switched off for that directory is skipped. * * Prints nothing at all when no project is active: an empty status line is * better than one that says "none" on every unrelated repo you open. @@ -53,25 +63,47 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; } -/** The project Claude Code's user-scope config mounts, or null. */ -export function projectFromUserScope(path: string): string | null { +/** Claude Code's user-scope config, or null if it is missing or unreadable. */ +export function readClaudeJson(path: string): ClaudeJson | null { try { - return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson); + return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson; } catch { return null; } } -/** The project `.mcp.json` in `dir` mounts, or null. */ -export function projectFromMcpJson(dir: string): string | null { +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(doc: ClaudeJson | null): string | null { + return userScopeProject(doc); +} + +/** The `.mcp.json` in `dir`, or null if there isn't a readable one. */ +export function readDirMcpJson(dir: string): McpJson | null { try { - const parsed = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; - return activeProjectIn(parsed); + return JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; } catch { return null; } } +/** + * The project the canonical `mcpctl` entry in `dir`'s `.mcp.json` pins, or null + * — skipped when Claude Code has that server switched off for `dir`. + */ +export function projectFromDirPin(mcpJson: McpJson | null, disabled: Set): string | null { + if (disabled.has(MCPCTL_SERVER_NAME)) return null; + return canonicalProjectIn(mcpJson); +} + +/** + * The project a *legacy* project-named entry in `dir`'s `.mcp.json` mounts, or + * null. Disabled entries are skipped, so a leftover the user already turned off + * in `/mcp` stops being reported. + */ +export function projectFromDirLegacy(mcpJson: McpJson | null, disabled: Set): string | null { + return legacyEntriesIn(mcpJson).find((e) => !disabled.has(e.server))?.project ?? null; +} + /** Format for the status line. Empty string means "render nothing". */ export function formatStatus(project: string | null, prefix: string): string { return project !== null && project !== '' ? `${prefix}${project}` : ''; @@ -96,12 +128,29 @@ export function createStatuslineCommand(deps?: Partial): Command const input = opts.directory !== undefined ? {} : await readStdinJson(); const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); - // Directory-scoped wiring wins: a repo with its own .mcp.json entry has - // deliberately pinned itself, and that beats the global default. - let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath()); + const claudeJson = readClaudeJson(claudeJsonPath()); + const mcpJson = readDirMcpJson(dir); + const disabled = disabledServersFor(claudeJson, dir); + + // Ranked by how deliberate each source is, because a switch has to be + // able to win: + // 1. a canonical `mcpctl` entry in this directory's .mcp.json — a + // deliberate pin, and the scope Claude Code itself prefers when both + // define the same server name; + // 2. user scope — what `config claude --project` writes, so switching + // projects must beat anything less deliberate than a pin; + // 3. a *legacy* project-named entry in .mcp.json. This used to outrank + // user scope, which made a switch look like it had done nothing: the + // residue an older mcpctl left in a checkout is not a pin, and never + // gets rewritten by a user-scope switch, so it reported the old + // project forever; + // 4. the .mcpctl-project marker — the other thing `config claude` + // writes, and what skills sync already trusts. + let project = + projectFromDirPin(mcpJson, disabled) + ?? projectFromUserScope(claudeJson) + ?? projectFromDirLegacy(mcpJson, disabled); if (project === null) { - // Not wired here (or wired above this directory) — the marker is the - // other thing `config claude` writes, and skills sync already trusts it. const marker = await findProjectMarker(dir, homeDir()).catch(() => null); project = marker?.project ?? null; } diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts index d426b26..2452e93 100644 --- a/src/cli/src/config/claude-mcp.ts +++ b/src/cli/src/config/claude-mcp.ts @@ -100,16 +100,30 @@ export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean { return projectOfEntry(entry) === name; } +/** The project the canonical `mcpctl` entry mounts, or null if there isn't one. */ +export function canonicalProjectIn(config: Pick | null | undefined): string | null { + return projectOfEntry(config?.mcpServers?.[MCPCTL_SERVER_NAME]); +} + +/** + * Legacy project-named entries still present, in file order. + * + * Kept separate from the canonical entry because the two mean different things + * to a reader: the canonical entry is a deliberate pin, a legacy entry is + * residue from an older mcpctl that nothing has cleaned up yet. Callers that + * rank sources (the status line) must be able to tell them apart. + */ +export function legacyEntriesIn(config: Pick | null | undefined): { server: string; project: string }[] { + const servers = config?.mcpServers; + if (!servers) return []; + return Object.entries(servers) + .filter(([name, entry]) => isLegacyMcpctlEntry(name, entry)) + .map(([name]) => ({ server: name, project: name })); +} + /** The project currently mounted by `.mcp.json`, preferring the canonical entry. */ export function activeProjectIn(config: Pick | null | undefined): string | null { - const servers = config?.mcpServers; - if (!servers) return null; - const canonical = projectOfEntry(servers[MCPCTL_SERVER_NAME]); - if (canonical !== null) return canonical; - for (const [name, entry] of Object.entries(servers)) { - if (isLegacyMcpctlEntry(name, entry)) return name; - } - return null; + return canonicalProjectIn(config) ?? legacyEntriesIn(config)[0]?.project ?? null; } export interface MergeResult { @@ -169,12 +183,39 @@ export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: s : join(home, '.claude.json'); } +/** Per-directory state Claude Code keeps in `.claude.json`'s `projects` map. */ +export interface ClaudeProjectEntry { + /** Servers switched off for this directory, whatever scope they came from. */ + disabledMcpServers?: string[]; + /** `.mcp.json` servers declined at the approval prompt. */ + disabledMcpjsonServers?: string[]; + [key: string]: unknown; +} + /** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ export interface ClaudeJson { mcpServers?: Record; + projects?: Record; [key: string]: unknown; } +/** + * Server names Claude Code has switched off in `dir`. + * + * A disabled server is not mounted, so naming its project as "active" is a + * plain lie — this is what lets the status line skip one. Only *explicit* + * disables count: a `.mcp.json` server in neither list is pending its approval + * prompt, and treating pending as off would blank the status line on a fresh + * checkout, which is the more confusing failure. + */ +export function disabledServersFor(doc: ClaudeJson | null | undefined, dir: string): Set { + const entry = doc?.projects?.[dir]; + return new Set([ + ...(Array.isArray(entry?.disabledMcpServers) ? entry.disabledMcpServers : []), + ...(Array.isArray(entry?.disabledMcpjsonServers) ? entry.disabledMcpjsonServers : []), + ]); +} + /** * Set the user-scope entry, returning the new document and any legacy * project-named entries retired from it. diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9f43bd6..f29a00b 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -359,4 +359,46 @@ describe('config claude — user scope', () => { process.exitCode = prevExit; expect(output.join('\n')).toContain("unknown --scope 'global'"); }); + + // A user-scope switch never rewrites a directory's .mcp.json, so anything of + // ours left in one keeps answering in that directory. Saying so is the only + // way the user finds out — the switch otherwise reports plain success. + describe('warns when the working directory contradicts the switch', () => { + const switchTo = async (project: string): Promise => { + await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir }) + .parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' }); + return output.join('\n'); + }; + + it('names a legacy entry that stays mounted alongside the new project', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } }, + })); + const out = await switchTo('sre'); + expect(out).toContain(join(tmpDir, '.mcp.json')); + expect(out).toContain("'homeautomation'"); + expect(out).toContain('mounted alongside'); + }); + + it('says a canonical pin overrides the switch in that directory', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } }, + })); + expect(await switchTo('sre')).toContain('overrides the switch here'); + }); + + it('stays quiet when the directory already agrees, or wires nothing of ours', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { + mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] }, + 'their-server': { command: 'docker', args: ['run', 'x'] }, + }, + })); + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + + it('stays quiet when there is no .mcp.json at all', async () => { + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + }); }); diff --git a/src/cli/tests/commands/statusline.test.ts b/src/cli/tests/commands/statusline.test.ts new file mode 100644 index 0000000..da6b40b --- /dev/null +++ b/src/cli/tests/commands/statusline.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStatuslineCommand } from '../../src/commands/statusline.js'; + +/** + * The status line is what tells you which project you are in, so the property + * under test throughout is: after a switch, does it name the project you + * switched to? + * + * These drive the real command rather than the resolution helpers, because the + * bug they cover was in the *ranking* of sources, not in any one source. + */ + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +let home: string; +let dir: string; + +/** Claude Code's user-scope config, at the path `claudeJsonPath()` resolves. */ +function writeClaudeJson(doc: unknown): void { + writeFileSync(join(home, '.claude.json'), JSON.stringify(doc)); +} + +function writeMcpJson(doc: unknown): void { + writeFileSync(join(dir, '.mcp.json'), JSON.stringify(doc)); +} + +/** Run `statusline` for `dir` and return exactly what it printed. */ +async function statusline(): Promise { + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir], { from: 'user' }); + return out.join(''); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-home-')); + dir = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-dir-')); + // Point claudeJsonPath() at the fake home; the CLI reads $CLAUDE_CONFIG_DIR + // first, which keeps this off the developer's real ~/.claude.json. + process.env['CLAUDE_CONFIG_DIR'] = home; +}); + +afterEach(() => { + delete process.env['CLAUDE_CONFIG_DIR']; + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('mcpctl statusline', () => { + it('reports the user-scope project when the directory wires nothing', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('prints nothing at all when no project is active', async () => { + writeClaudeJson({ mcpServers: {} }); + expect(await statusline()).toBe(''); + }); + + it('lets a canonical .mcp.json pin override the user-scope project', async () => { + // Same server name in both scopes: Claude Code prefers project scope, so a + // deliberate pin is genuinely what is mounted here. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:docmost'); + }); + + it('does not let a legacy project-named entry outrank a user-scope switch', async () => { + // The regression: an older mcpctl wrote `homeautomation` into a checkout, + // and a user-scope switch never rewrites that file — so the status line + // reported the old project forever and the switch looked like a no-op. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('still reports a legacy entry when nothing more deliberate names a project', async () => { + writeClaudeJson({ mcpServers: {} }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:homeautomation'); + }); + + it('skips a directory server Claude Code has switched off', async () => { + // A disabled server is not mounted, so naming its project is a lie. + writeClaudeJson({ + mcpServers: {}, + projects: { [dir]: { disabledMcpServers: ['homeautomation'] } }, + }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe(''); + }); + + it('skips a disabled pin and falls through to the user-scope project', async () => { + writeClaudeJson({ + mcpServers: { mcpctl: bridge('sre') }, + projects: { [dir]: { disabledMcpjsonServers: ['mcpctl'] } }, + }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('falls back to a .mcpctl-project marker when nothing is wired', async () => { + writeClaudeJson({ mcpServers: {} }); + writeFileSync(join(dir, '.mcpctl-project'), 'lab\n'); + expect(await statusline()).toBe('mcpctl:lab'); + }); + + it('honours a custom prefix', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir, '--prefix', 'proj '], { from: 'user' }); + expect(out.join('')).toBe('proj sre'); + }); +}); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts index 45a1a0a..d4541da 100644 --- a/src/cli/tests/config/claude-mcp.test.ts +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -5,6 +5,9 @@ import { projectOfEntry, isLegacyMcpctlEntry, activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, + disabledServersFor, } from '../../src/config/claude-mcp.js'; const bridge = (project: string): Record => ({ @@ -62,6 +65,50 @@ describe('activeProjectIn', () => { }); }); +describe('canonicalProjectIn / legacyEntriesIn', () => { + it('tells a deliberate pin apart from pre-migration residue', () => { + const config = { mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre'), homeautomation: bridge('homeautomation') } }; + expect(canonicalProjectIn(config)).toBe('sre'); + expect(legacyEntriesIn(config)).toEqual([{ server: 'homeautomation', project: 'homeautomation' }]); + }); + + it('reports no canonical entry when only legacy ones are present', () => { + const config = { mcpServers: { docmost: bridge('docmost') } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([{ server: 'docmost', project: 'docmost' }]); + }); + + it('leaves servers that are not ours out of both', () => { + const config = { mcpServers: { other: { command: 'echo' } } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([]); + expect(legacyEntriesIn(null)).toEqual([]); + }); +}); + +describe('disabledServersFor', () => { + const doc = { + projects: { + '/repo': { disabledMcpServers: ['homeautomation'], disabledMcpjsonServers: ['mcpctl'] }, + '/other': { disabledMcpServers: ['sre'] }, + }, + }; + + it('unions both of Claude Code\'s disable lists for that directory', () => { + expect([...disabledServersFor(doc, '/repo')].sort()).toEqual(['homeautomation', 'mcpctl']); + }); + + it('is scoped to the directory asked about', () => { + expect([...disabledServersFor(doc, '/other')]).toEqual(['sre']); + expect([...disabledServersFor(doc, '/unknown')]).toEqual([]); + expect([...disabledServersFor(null, '/repo')]).toEqual([]); + }); + + it('survives a malformed entry rather than throwing on the status line', () => { + expect([...disabledServersFor({ projects: { '/repo': { disabledMcpServers: 'nope' } } }, '/repo')]).toEqual([]); + }); +}); + describe('mergeMcpctlServers', () => { it('writes one constant entry regardless of project', () => { const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' }); From bb4b0b910fb4c74480ef2ed5b2e0ad535a4edf48 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 12:20:56 +0100 Subject: [PATCH 34/77] fix(k8s): read ApiException.code when classifying client-node errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @kubernetes/client-node v1 raises ApiException, which carries the HTTP status on `.code`. Every check in the orchestrator read only `.statusCode` — the pre-1.0 HttpError field — so `status` came back undefined and each "expected" 404/409 was rethrown instead of handled. Found in production: the first start of a server with a volume read a not-yet-existing PVC, got a 404 that should have meant "create it", and failed the instance instead. The same latent bug sat in removeContainer, where a missing pod failed the delete rather than being treated as already gone. httpStatusOf() checks .code, .statusCode and .response.statusCode, and falls back to parsing the rendered "HTTP-Code: NNN" message for wrappers that keep none of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- .../services/k8s/kubernetes-orchestrator.ts | 38 +++++++++++++++---- src/mcpd/tests/k8s-orchestrator.test.ts | 25 +++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts index 0d6bd58..dcc2760 100644 --- a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts +++ b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts @@ -13,6 +13,32 @@ import type { K8sOfficialClientConfig } from './k8s-client-official.js'; import { generatePodSpec, generatePvcSpec, sanitizeName } from './manifest-generator.js'; import type { V1Pod, V1PersistentVolumeClaim } from '@kubernetes/client-node'; +/** + * HTTP status from a client-node error, across the shapes it actually throws. + * + * @kubernetes/client-node v1 raises `ApiException`, which carries the status on + * `.code` — not `.statusCode` as the pre-1.0 `HttpError` did. Reading only + * `.statusCode` yields undefined and turns every "expected" 404/409 into a + * rethrow, so a missing pod fails a delete and a missing PVC fails a create. + * The message-parsing fallback covers wrappers that preserve neither field. + */ +export function httpStatusOf(err: unknown): number | undefined { + const e = err as { + code?: unknown; + statusCode?: unknown; + response?: { statusCode?: unknown }; + message?: unknown; + }; + for (const candidate of [e.code, e.statusCode, e.response?.statusCode]) { + if (typeof candidate === 'number') return candidate; + } + if (typeof e.message === 'string') { + const match = /HTTP-Code:\s*(\d{3})/.exec(e.message); + if (match?.[1] !== undefined) return Number(match[1]); + } + return undefined; +} + function mapPodState(pod: V1Pod): ContainerInfo['state'] { const cs = pod.status?.containerStatuses?.[0]; if (cs) { @@ -113,8 +139,7 @@ export class KubernetesOrchestrator implements McpOrchestrator { gracePeriodSeconds: 5, }); } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode - ?? (err as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(err); if (status !== 404) throw err; } } @@ -323,8 +348,7 @@ export class KubernetesOrchestrator implements McpOrchestrator { body: { apiVersion: 'v1', kind: 'Namespace', metadata: { name } }, }); } catch (createErr: unknown) { - const status = (createErr as { statusCode?: number }).statusCode - ?? (createErr as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(createErr); if (status !== 409) throw createErr; // Already exists is fine } } @@ -350,8 +374,7 @@ export class KubernetesOrchestrator implements McpOrchestrator { }); return; // Already there — reuse it, data and all. } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode - ?? (err as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(err); if (status !== 404) throw err; } @@ -361,8 +384,7 @@ export class KubernetesOrchestrator implements McpOrchestrator { body: generatePvcSpec(volume, this.namespace, labels) as V1PersistentVolumeClaim, }); } catch (createErr: unknown) { - const status = (createErr as { statusCode?: number }).statusCode - ?? (createErr as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(createErr); if (status !== 409) throw createErr; // Lost a create race — fine. } } diff --git a/src/mcpd/tests/k8s-orchestrator.test.ts b/src/mcpd/tests/k8s-orchestrator.test.ts index 2f983eb..4754520 100644 --- a/src/mcpd/tests/k8s-orchestrator.test.ts +++ b/src/mcpd/tests/k8s-orchestrator.test.ts @@ -87,7 +87,7 @@ vi.mock('@kubernetes/client-node', () => { }); // Import after mock -import { KubernetesOrchestrator } from '../src/services/k8s/kubernetes-orchestrator.js'; +import { KubernetesOrchestrator, httpStatusOf } from '../src/services/k8s/kubernetes-orchestrator.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const k8sMock = await import('@kubernetes/client-node') as any; const { setHandler, clearHandlers, mockCore } = k8sMock.__testHelpers; @@ -356,3 +356,26 @@ describe('KubernetesOrchestrator', () => { }); }); }); + +describe('httpStatusOf', () => { + it('reads .code, which is what client-node v1 ApiException actually sets', () => { + // The bug this exists for: reading only .statusCode returned undefined, so + // an expected 404 was rethrown and a first-start PVC create never happened. + expect(httpStatusOf({ code: 404 })).toBe(404); + expect(httpStatusOf({ code: 409 })).toBe(409); + }); + + it('still reads the pre-1.0 HttpError shapes', () => { + expect(httpStatusOf({ statusCode: 404 })).toBe(404); + expect(httpStatusOf({ response: { statusCode: 409 } })).toBe(409); + }); + + it('falls back to parsing the rendered message', () => { + expect(httpStatusOf(new Error('HTTP-Code: 403\nMessage: Unknown API Status Code!'))).toBe(403); + }); + + it('returns undefined when there is no status to find', () => { + expect(httpStatusOf(new Error('socket hang up'))).toBeUndefined(); + expect(httpStatusOf({ code: 'ECONNREFUSED' })).toBeUndefined(); + }); +}); From b07287baf2fb72b25058dba606e0b7b120872de2 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 12:31:31 +0100 Subject: [PATCH 35/77] fix(k8s): set fsGroup on pods with volumes A freshly provisioned PVC mounts root:root, so any image that drops privileges cannot write to it. docs-mcp-server runs as uid 1000 and died on first start with SQLITE_CANTOPEN; its own Dockerfile says to chown the volume 1000:1000. Pods with volumes now carry securityContext.fsGroup, defaulting to 1000 and overridable per volume. fsGroupChangePolicy is OnRootMismatch so Kubernetes does not walk and re-chown the whole volume on every start. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- README.md | 4 ++++ src/cli/src/commands/apply.ts | 1 + src/mcpd/src/services/instance.service.ts | 2 ++ .../src/services/k8s/manifest-generator.ts | 16 ++++++++++++++-- src/mcpd/src/services/orchestrator.ts | 17 +++++++++++++++++ src/mcpd/src/validation/template.schema.ts | 1 + src/mcpd/tests/k8s-manifest.test.ts | 18 ++++++++++++++++++ 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90ccf47..6ae99af 100644 --- a/README.md +++ b/README.md @@ -776,6 +776,10 @@ Notes: - Claims are `ReadWriteOnce`, so a server with volumes is limited to one replica. Asking for more is rejected at validation rather than leaving the extra replicas unschedulable. +- A fresh claim mounts `root:root`, so mcpctl sets the pod's `fsGroup` to + **1000** — the conventional non-root uid in node/python images. Override with + `fsGroup` on the volume if an image uses a different one; containers running + as root are unaffected either way. - `storageClass` defaults to `MCPD_VOLUME_STORAGE_CLASS`, and is omitted entirely when neither is set. **Set it explicitly on any cluster with more than one default StorageClass**, where an omitted class binds diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index c4a32a6..1fe74e1 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -25,6 +25,7 @@ const VolumeSpecSchema = z.object({ mountPath: z.string().min(1).regex(/^\//, 'mountPath must be absolute'), sizeGb: z.number().int().min(1).max(1024).default(10), storageClass: z.string().optional(), + fsGroup: z.number().int().min(0).max(65535).optional(), }); const ServerSpecSchema = z.object({ diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index a722317..e78e5fb 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -381,6 +381,7 @@ export class InstanceService { mountPath: string; sizeGb?: number; storageClass?: string; + fsGroup?: number; }>; if (volumes.length > 0) { spec.volumes = volumes.map((v) => ({ @@ -390,6 +391,7 @@ export class InstanceService { ...(v.storageClass !== undefined && v.storageClass !== '' ? { storageClass: v.storageClass } : {}), + ...(v.fsGroup !== undefined ? { fsGroup: v.fsGroup } : {}), })); } // Package-based servers: command = [packageName, ...args] (entrypoint handles execution) diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 90de376..b348142 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -1,5 +1,5 @@ import type { ContainerSpec, ContainerVolume } from '../orchestrator.js'; -import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js'; +import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS, DEFAULT_VOLUME_FS_GROUP } from '../orchestrator.js'; const MCPCTL_LABEL = 'mcpctl.managed'; @@ -37,6 +37,7 @@ export interface K8sPodManifest { automountServiceAccountToken: boolean; nodeSelector?: Record; volumes?: Array<{ name: string; persistentVolumeClaim: { claimName: string } }>; + securityContext?: { fsGroup: number; fsGroupChangePolicy: string }; }; } @@ -95,13 +96,24 @@ function sanitizeName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); } -function buildPodVolumes(spec: ContainerSpec): Pick { +function buildPodVolumes( + spec: ContainerSpec, +): Pick { if (!spec.volumes || spec.volumes.length === 0) return {}; return { volumes: spec.volumes.map((v) => ({ name: sanitizeName(v.claimName), persistentVolumeClaim: { claimName: sanitizeName(v.claimName) }, })), + securityContext: { + // Without this a fresh PVC mounts root:root and any image that dropped + // privileges cannot write to it. First declared fsGroup wins — a pod has + // exactly one, so per-volume groups are not expressible here. + fsGroup: spec.volumes[0]?.fsGroup ?? DEFAULT_VOLUME_FS_GROUP, + // Only chown when the top-level ownership is already wrong; the default + // ("Always") walks the whole volume on every single start. + fsGroupChangePolicy: 'OnRootMismatch', + }, }; } diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index 15716ad..7ac03ee 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -24,6 +24,16 @@ export interface ContainerVolume { * where an omitted class binds nondeterministically. */ storageClass?: string; + /** + * Group that owns the mounted volume (Kubernetes `fsGroup`). + * + * A fresh PVC mounts as root:root, so any image that drops privileges cannot + * write to it — docs-mcp-server fails with SQLITE_CANTOPEN, and its own + * Dockerfile tells you to `chown 1000:1000` the volume. Defaults to + * DEFAULT_VOLUME_FS_GROUP, which covers images using the conventional uid + * 1000; containers still running as root are unaffected either way. + */ + fsGroup?: number; } export interface ContainerSpec { @@ -117,3 +127,10 @@ export interface InteractiveExec { /** Default resource limits */ export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU + +/** + * Group applied to mounted volumes when a server does not specify one. + * 1000 is the conventional non-root uid/gid in node, python and distro base + * images, which is what the images that drop privileges actually use. + */ +export const DEFAULT_VOLUME_FS_GROUP = 1000; diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 95cc78b..73a01a6 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -18,6 +18,7 @@ export const VolumeSpecSchema = z.object({ mountPath: z.string().min(1).max(200).regex(/^\//, 'mountPath must be absolute'), sizeGb: z.number().int().min(1).max(1024).default(10), storageClass: z.string().max(100).optional(), + fsGroup: z.number().int().min(0).max(65535).optional(), }); export type VolumeSpecInput = z.infer; diff --git a/src/mcpd/tests/k8s-manifest.test.ts b/src/mcpd/tests/k8s-manifest.test.ts index 843e80d..bb8a20f 100644 --- a/src/mcpd/tests/k8s-manifest.test.ts +++ b/src/mcpd/tests/k8s-manifest.test.ts @@ -177,6 +177,24 @@ describe('volumes', () => { const pod = generatePodSpec(baseSpec, 'mcpctl-servers'); expect(pod.spec.volumes).toBeUndefined(); expect(pod.spec.containers[0]!.volumeMounts).toBeUndefined(); + expect(pod.spec.securityContext).toBeUndefined(); + }); + + it('sets fsGroup so a non-root image can write to a fresh claim', () => { + // A PVC mounts root:root. Without fsGroup, any image that drops privileges + // (docs-mcp-server runs as uid 1000) fails on first write. + const pod = generatePodSpec(volumeSpec, 'mcpctl-servers'); + expect(pod.spec.securityContext?.fsGroup).toBe(1000); + // "Always" re-chowns the entire volume on every start. + expect(pod.spec.securityContext?.fsGroupChangePolicy).toBe('OnRootMismatch'); + }); + + it('honours an explicit fsGroup', () => { + const pod = generatePodSpec( + { ...baseSpec, volumes: [{ claimName: 'c', mountPath: '/d', sizeGb: 1, fsGroup: 65534 }] }, + 'ns', + ); + expect(pod.spec.securityContext?.fsGroup).toBe(65534); }); it('carries volumes into a deployment pod template', () => { From dd29f98f82b8ac8b3a62f17bbc0ba97d3370026f Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 16:38:19 +0100 Subject: [PATCH 36/77] build: refuse to package from a branch that is behind main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone branches off main and builds from their own branch. A branch that is behind main still builds and installs perfectly — it just packages a binary missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the good one with it. Nothing reports an error; the release succeeds and the feature simply vanishes from the installed CLI. That is what happened today: a build from a stale checkout replaced /usr/bin/mcpctl with one that has no `statusline` command at all, months after the status line landed on main (`mcpctl statusline` -> "unknown command"). `check-main-sync.sh` fetches main, compares, and fails before any work happens, listing the commits the branch is missing and the merge that fixes it. Sourced by build-rpm.sh and build-deb.sh — both are run standalone, so neither can rely on the other having checked. A hard failure rather than a warning: a warning scrolls past in a build log, and the whole point is to stop before the artifact exists. MCPCTL_ALLOW_BEHIND_MAIN=1 is the escape hatch for a deliberate old-tree build; MCPCTL_BASE_BRANCH retargets the comparison. Offline it degrades to the last fetched origin/main, then a local main, saying which it used; outside a git checkout it skips. Verified: passes on this branch (up to date with main); against a synthetic ref one commit ahead, build-rpm.sh aborts with exit 1 before ensure_build_deps and before any compilation; the escape hatch bypasses it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 18 +++++++ scripts/build-deb.sh | 5 ++ scripts/build-rpm.sh | 5 ++ scripts/check-main-sync.sh | 99 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100755 scripts/check-main-sync.sh diff --git a/docs/project-summary.md b/docs/project-summary.md index 95552bd..9ec9bd7 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -884,6 +884,24 @@ All pushed to `mysources.co.uk/michal/` registry. source .env && bash scripts/release.sh ``` +**The build refuses to run from a branch that is behind `main`.** Everyone +branches off main, so a stale branch still builds and installs cleanly — it just +ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` +overwrites the good one with it. That happened on 2026-08-10: a build from a +stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` +command, months after the status line landed. `scripts/check-main-sync.sh` +(sourced by `build-rpm.sh` and `build-deb.sh`) fetches `main`, compares, and +fails before any work happens, listing the commits you are missing. + +```bash +git merge main # the fix +MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build +MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch +``` + +Offline it falls back to the last fetched `origin/main`, then to a local `main`, +and says which it used; outside a git checkout it skips entirely. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index e8b1d07..5d0013d 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Same guard as build-rpm.sh: this script is also run on its own, so it cannot +# rely on that one having checked. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index f2db0f1..2f45a98 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Before anything expensive: a branch behind main packages a binary missing +# whatever landed there, and installing it silently downgrades the machine. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh new file mode 100755 index 0000000..246ada2 --- /dev/null +++ b/scripts/check-main-sync.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Refuse to build a package from a branch that main has already moved past. +# +# WHY +# +# Everyone branches off main and builds from their own branch. A branch that is +# behind main still builds and installs perfectly — it just quietly ships a +# binary missing whatever landed on main in the meantime, and `rpm -U --force` +# overwrites the good one with it. +# +# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced +# /usr/bin/mcpctl with one that had no `statusline` command at all, months after +# the status line landed on main. Nothing reported an error — the release +# succeeded, the feature just vanished from the installed CLI. +# +# So this is a hard failure rather than a warning. A warning scrolls past in a +# build log; the whole point is to stop before the artifact exists. +# +# ESCAPE HATCH +# +# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree) +# MCPCTL_BASE_BRANCH= compare against something other than main +# +# Skips itself entirely outside a git checkout, so tarball builds still work. + +# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh +# fetch; falls back to whatever is already on disk so an offline build is +# degraded rather than blocked. Returns 1 when there is nothing to compare to. +_main_sync_ref() { + local base="$1" remote="$2" + + if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then + # FETCH_HEAD rather than refs/remotes//: it is what this fetch + # just wrote, so it cannot be a stale opportunistic update. + echo "FETCH_HEAD" + return 0 + fi + + if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then + echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2 + echo "refs/remotes/$remote/$base" + return 0 + fi + + if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then + echo " (no reachable remote — comparing against local $base)" >&2 + echo "refs/heads/$base" + return 0 + fi + + return 1 +} + +check_main_sync() { + local base="${MCPCTL_BASE_BRANCH:-main}" + + if ! git rev-parse --git-dir >/dev/null 2>&1; then + return 0 # not a checkout; nothing to be behind + fi + + if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then + echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)" + return 0 + fi + + echo "==> Checking this branch is not behind '$base'..." + + local remote ref + remote="$(git remote | head -1)" + if ! ref="$(_main_sync_ref "$base" "$remote")"; then + echo " (no '$base' branch found anywhere — skipping)" + return 0 + fi + + local behind + behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)" + if [ "$behind" -eq 0 ]; then + echo " up to date with $base" + return 0 + fi + + local branch + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + echo "" >&2 + echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 + echo "" >&2 + echo " Building now would package a binary without these, and installing it" >&2 + echo " would overwrite a good one with a version missing them:" >&2 + echo "" >&2 + git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 + if [ "$behind" -gt 15 ]; then + echo " … and $((behind - 15)) more" >&2 + fi + echo "" >&2 + echo " Fix it: git merge $base # or: git rebase $base" >&2 + echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2 + echo "" >&2 + return 1 +} From 96e27c871654812738b5c008263c28f198845eaa Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 16:41:26 +0100 Subject: [PATCH 37/77] build: extend the main-sync gate to the image build and the k8s deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same hazard as the package build, with the cluster on the receiving end: a branch behind main builds images missing whatever landed there, and deploy-k8s.sh pins that sha in Pulumi — making the stale build the cluster's source of truth. build-mcpd.sh gets its own call because it is run standalone as well as from deploy-k8s.sh, so neither can rely on the other having checked. `--dry-run` is exempt. It builds and cuts over nothing, and blocking a read-only inspection is exactly what teaches people to export MCPCTL_ALLOW_BEHIND_MAIN=1 permanently — which would disable the gate for the real deploys too. The failure text is now artifact-agnostic ("produce an artifact" / "shipping it"), since one helper now speaks for packages, images and deploys. Verified against a synthetic ref one commit ahead: build-mcpd.sh exits 1 before any docker work, and deploy-k8s.sh exits 1 before the test gate, the pg_dump, the image build and pulumi. Neither the working tree nor HEAD was moved to test this — the ref was built with git commit-tree and deleted afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 10 ++++++++-- scripts/build-mcpd.sh | 5 +++++ scripts/check-main-sync.sh | 6 ++++-- scripts/deploy-k8s.sh | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/project-summary.md b/docs/project-summary.md index 9ec9bd7..23e7a75 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -890,8 +890,14 @@ ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the good one with it. That happened on 2026-08-10: a build from a stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` command, months after the status line landed. `scripts/check-main-sync.sh` -(sourced by `build-rpm.sh` and `build-deb.sh`) fetches `main`, compares, and -fails before any work happens, listing the commits you are missing. +fetches `main`, compares, and fails before any work happens, listing the commits +you are missing. It gates every path that produces something others consume: +`build-rpm.sh`, `build-deb.sh`, `build-mcpd.sh` (each is also run standalone, so +none can rely on another having checked) and `deploy-k8s.sh` — where a stale +branch would pin its sha in Pulumi and make it the cluster's source of truth. +`deploy-k8s.sh --dry-run` skips the check: it builds and cuts over nothing, and +blocking a read-only inspection only teaches people to export the escape hatch +permanently, disabling the gate for real deploys too. ```bash git merge main # the fix diff --git a/scripts/build-mcpd.sh b/scripts/build-mcpd.sh index 08a7f3a..99ece3a 100755 --- a/scripts/build-mcpd.sh +++ b/scripts/build-mcpd.sh @@ -16,6 +16,11 @@ if [ -f .env ]; then set -a; source .env; set +a fi +# This pushes an image to the registry, so the same staleness gate as the package +# builds applies. Run standalone as well as from deploy-k8s.sh, hence its own copy. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Push directly to internal address (external proxy has body size limit) REGISTRY="10.0.0.194:3012" IMAGE="mcpd" diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh index 246ada2..05c0e3f 100755 --- a/scripts/check-main-sync.sh +++ b/scripts/check-main-sync.sh @@ -84,8 +84,10 @@ check_main_sync() { echo "" >&2 echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 echo "" >&2 - echo " Building now would package a binary without these, and installing it" >&2 - echo " would overwrite a good one with a version missing them:" >&2 + # Deliberately artifact-agnostic: the same helper gates RPM/DEB packages, the + # mcpd image, and the k8s deploy. + echo " Building now would produce an artifact without these, and shipping it" >&2 + echo " would replace a good one with a version missing them:" >&2 echo "" >&2 git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 if [ "$behind" -gt 15 ]; then diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh index e41efad..eff872a 100755 --- a/scripts/deploy-k8s.sh +++ b/scripts/deploy-k8s.sh @@ -80,6 +80,20 @@ cat < Date: Mon, 10 Aug 2026 16:59:41 +0100 Subject: [PATCH 38/77] test(smoke): give the smoke-aws-docs fixture a readiness probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit health-readiness.smoke.test.ts asserts no RUNNING server lacks a healthCheck.tool. The smoke suite's own shared fixture had none, so the suite reported its own scaffolding as a fleet regression — and because the fixture is in the PROTECTED set of clean-smoke-resources.ts, it is long-lived and failed that assertion on every run. Mirrors the production aws-docs probe (same package): search_documentation with a phrase, 300s interval since the call leaves the cluster. Verified live: with this applied, "every RUNNING server has a readiness probe configured" passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- src/mcplocal/tests/smoke/fixtures/smoke-data.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml index faf22e9..6a69da1 100644 --- a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml +++ b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml @@ -12,6 +12,19 @@ servers: env: - name: FASTMCP_LOG_LEVEL value: "ERROR" + # Mirrors the production `aws-docs` probe. Without it this fixture is a + # RUNNING server with no readiness probe, so it fails the very assertion in + # health-readiness.smoke.test.ts that the fixture exists to support — the + # suite reporting its own scaffolding as a fleet regression. + # `search_documentation` needs a phrase; the 300s interval matches aws-docs, + # since the call leaves the cluster. + healthCheck: + tool: search_documentation + arguments: + search_phrase: "s3 bucket" + timeoutSeconds: 20 + intervalSeconds: 300 + failureThreshold: 3 projects: - name: smoke-data From 822c1bb047dbcab8246f0ba4f9addfd7a98876cf Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 17:15:40 +0100 Subject: [PATCH 39/77] build: fail the release when smoke tests fail, and fix the SSE test that hung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh printed `WARNING: Smoke tests failed!` and exited 0. That is how four broken readiness probes shipped unnoticed on 2026-08-10 — the warning scrolled past in the build log and the release reported success. It now exits 1, with `MCPCTL_ALLOW_SMOKE_FAILURE=1` as the escape hatch. The message is explicit that smoke runs LAST, against the installed binary: the package is already published and installed, so the failure reports fleet breakage rather than preventing a bad artifact. Turning the gate on required fixing a latent hang first, or every release would have blocked on it. `security.test.ts > /inspect SSE endpoint …` waited for a response body that by design never ends, so it could only settle via the socket's *inactivity* timeout — and /inspect relays every project's MCP traffic, so during a full smoke run it is never idle. Run alone it passed and looked flaky; run with the suite it failed every time. httpRequest gains `headersOnly`, which resolves on the response headers and hangs up. The assertion only ever needed the status line. Verified: full smoke suite 158/158 (was 157/158 with this test timing out); the gate block lifted verbatim from release.sh exits 1 with a stubbed failing smoke run, and exits 0 reaching subsequent code under MCPCTL_ALLOW_SMOKE_FAILURE=1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 9 ++++++++ scripts/release.sh | 23 +++++++++++++++++-- src/mcplocal/tests/smoke/security.test.ts | 28 ++++++++++++++++++----- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/project-summary.md b/docs/project-summary.md index 23e7a75..5c0a163 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -908,6 +908,15 @@ MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another b Offline it falls back to the last fetched `origin/main`, then to a local `main`, and says which it used; outside a git checkout it skips entirely. +**A failing smoke run fails the release.** It used to print +`WARNING: Smoke tests failed!` and exit 0 — which is exactly how four broken +readiness probes shipped unnoticed (see `docs/reliability.md`): the warning +scrolled past and the release reported success. Note what the gate does and does +not do — smoke runs *last*, against the installed binary, so the package is +already published and installed by the time it fails. It reports the breakage +rather than preventing it, so investigate the fleet rather than assuming the +artifact is bad. Override with `MCPCTL_ALLOW_SMOKE_FAILURE=1`. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/scripts/release.sh b/scripts/release.sh index fc6cfc8..16bbdd4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -75,9 +75,28 @@ echo "==> Running smoke tests..." export PATH="$HOME/.npm-global/bin:$PATH" if pnpm test:smoke; then echo "==> Smoke tests passed!" +elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then + echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)." else - echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running." - echo " Continuing anyway — deployment is complete, but verify manually." + # This used to print a warning and exit 0. That is how four broken readiness + # probes shipped unnoticed on 2026-08-10: the warning scrolled past in the + # build log and the release reported success. A failing smoke run means + # something in the live fleet is genuinely broken — say so in the exit code. + # + # Note what this does and does not do: smoke runs LAST, against the installed + # binary, so the package is already published and installed by now. Failing + # here reports the breakage, it does not prevent it — investigate, do not + # assume the artifact is bad. + echo "" >&2 + echo "ERROR: smoke tests failed — the release is published and installed, but" >&2 + echo " something in the live fleet is broken. Investigate before relying" >&2 + echo " on this build; do not just re-run." >&2 + echo "" >&2 + echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2 + echo " a tool the upstream renamed, or an expired credential." >&2 + echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2 + echo "" >&2 + exit 1 fi echo "" diff --git a/src/mcplocal/tests/smoke/security.test.ts b/src/mcplocal/tests/smoke/security.test.ts index 19a4848..3e7ecbe 100644 --- a/src/mcplocal/tests/smoke/security.test.ts +++ b/src/mcplocal/tests/smoke/security.test.ts @@ -32,6 +32,18 @@ function httpRequest(opts: { headers?: Record; body?: string; timeout?: number; + /** + * Resolve as soon as the response headers arrive, then hang up, instead of + * waiting for the body to end. + * + * Required for a streaming endpoint: SSE responses never end, so the normal + * path can only settle via the socket's *inactivity* timeout — which never + * fires while the stream is busy. `/inspect` relays every project's MCP + * traffic, so during a full smoke run it is never idle, and the request hung + * until vitest killed the test. Alone it looked flaky; under load it failed + * every time. Reading the status does not need the body anyway. + */ + headersOnly?: boolean; }): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { return new Promise((resolve, reject) => { const parsed = new URL(opts.url); @@ -46,6 +58,12 @@ function httpRequest(opts: { timeout: opts.timeout ?? 10_000, }, (res) => { + if (opts.headersOnly === true) { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' }); + res.destroy(); + req.destroy(); + return; + } const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { @@ -93,17 +111,15 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => { // /inspect streams ALL MCP traffic (tool calls, arguments, responses) // for ALL projects to any unauthenticated local client + // headersOnly: the stream never ends, and waiting for it to go idle is what + // made this hang whenever other suites were generating traffic. The status + // line is all this assertion needs. const res = await httpRequest({ url: `${MCPLOCAL_URL}/inspect`, method: 'GET', headers: { 'Accept': 'text/event-stream' }, timeout: 3_000, - }).catch((err) => { - // Timeout is expected (SSE keeps connection open) — still means endpoint is accessible - if ((err as Error).message.includes('timed out')) { - return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' }; - } - throw err; + headersOnly: true, }); // Should be accessible without auth (documenting the vulnerability) From 5a8185d7c92c6e4ad3127fa28e958bd6dc19bf74 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:35:16 +0100 Subject: [PATCH 40/77] fix(mcplocal): stop the 30s proxy timeout killing agent turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl chat ` failed with HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"} while mcpd was answering /healthz in 32ms. The message was wrong in a way that cost real debugging time: mcplocal was reaching mcpd fine and giving up after 30s. journalctl shows the signature plainly — statusCode 503 with responseTime 30003.87 on POST /api/v1/agents/reviewer/chat. This blocks the agentic-teams epic outright. An agent turn is a multi-turn tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path is not a safety net, it is a guaranteed failure for every non-trivial turn. Three defects, all in the same path: 1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is right for CRUD and wrong for chat. Chat, project chat, llm infer and inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI already allowed. mcplocal in the middle was the binding constraint. 2. Timeouts were reported as connection failures. Split UpstreamTimeoutError out of ConnectionError and map it to 504 with an accurate message that says the daemon IS reachable. ConnectionError still means unreachable and still returns 503. Verified nothing else branches on ConnectionError. 3. SSE was buffered. `forward()` reads the whole body through res.text(), so even turns that finished in time arrived as one blob and the CLI's live token output never appeared. Streaming routes now use forwardStream() and pipe the body straight through, preserving content-type and x-accel-buffering (dropping the latter lets intermediaries re-buffer and reintroduces the stall). Also closes the escape that produced the sibling `500 code:23` failure: the body read in forward() was outside the try, so when mcpd had already written SSE headers the raw DOMException reached Fastify unhandled. Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat that outlives the CRUD budget" and "streams SSE through instead of buffering" — were confirmed to FAIL against the pre-fix behaviour and pass after. Three existing mcpd-client tests asserted the old taxonomy and were updated to assert the new one deliberately. Local: build clean, workspace 2375 passed, lint unchanged at 869. NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package rebuild + `systemctl --user restart mcplocal` to take effect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- src/mcplocal/src/http/index.ts | 2 +- src/mcplocal/src/http/mcpd-client.ts | 94 ++++++++- src/mcplocal/src/http/routes/proxy.ts | 131 ++++++++++-- src/mcplocal/src/index.ts | 2 +- src/mcplocal/tests/mcpd-client.test.ts | 18 +- src/mcplocal/tests/proxy-long-running.test.ts | 196 ++++++++++++++++++ 6 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 src/mcplocal/tests/proxy-long-running.test.ts diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 274a655..ffdb7d4 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -2,7 +2,7 @@ export { createHttpServer } from './server.js'; export type { HttpServerDeps } from './server.js'; export { loadHttpConfig } from './config.js'; export type { HttpConfig } from './config.js'; -export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js'; +export { McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError } from './mcpd-client.js'; export { registerProxyRoutes } from './routes/proxy.js'; export { registerMcpEndpoint } from './mcp-endpoint.js'; export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 53511e5..8e755e4 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -20,9 +20,41 @@ export class ConnectionError extends Error { } } +/** + * Thrown when mcpd was reachable but did not finish in time. + * + * Deliberately NOT a ConnectionError. Folding timeouts into "cannot connect" + * is what made this class of failure so expensive to diagnose: mcpd answered + * /healthz in 32ms while the proxy insisted the daemon was down. A timeout and + * an unreachable daemon need different messages and different status codes. + */ +export class UpstreamTimeoutError extends Error { + constructor(readonly url: string, readonly timeoutMs: number) { + super(`mcpd did not respond within ${String(timeoutMs)}ms: ${url}`); + this.name = 'UpstreamTimeoutError'; + } +} + +/** True when `err` is an AbortSignal.timeout() firing. */ +function isTimeout(err: unknown): boolean { + return err instanceof DOMException && err.name === 'TimeoutError'; +} + /** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */ export const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Budget for routes that are *expected* to run long: agent/project chat and + * raw inference. An agent turn is a multi-turn tool-use loop and legitimately + * runs for minutes, so the 30s default is not a safety net there — it is a + * guaranteed failure. Matches `STREAM_TIMEOUT_MS` in the CLI's chat command + * (src/cli/src/commands/chat.ts), which already allowed 10 minutes; mcplocal + * sitting in the middle with 30s was the binding constraint. + * + * Override with `MCPLOCAL_LONG_TIMEOUT_MS`. + */ +export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT_MS']) || 600_000; + /** * Discovery-class operations (tools/list, resources/list, prompts/list) should not share * the full tool-call timeout budget — a single dead upstream would stall session init for @@ -121,9 +153,7 @@ export class McpdClient { try { res = await fetch(url, init); } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`)); - } + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); throw new ConnectionError(this.baseUrl, err); } @@ -131,7 +161,18 @@ export class McpdClient { throw new AuthenticationError(); } - const text = await res.text(); + // The body read MUST be inside a try. mcpd writes SSE headers immediately + // on chat routes, so fetch() resolves long before the turn finishes and the + // abort lands here instead — previously escaping as a raw DOMException and + // surfacing to the user as an opaque `500 code:23`. + let text: string; + try { + text = await res.text(); + } catch (err: unknown) { + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + let parsed: unknown; try { parsed = JSON.parse(text); @@ -142,6 +183,51 @@ export class McpdClient { return { status: res.status, body: parsed }; } + /** + * Forward a request and hand back the raw Response, body unread. + * + * `forward()` buffers through `res.text()`, which is fine for CRUD but + * defeats streaming entirely: an SSE chat arrives at the client as one blob + * after the turn ends, so the token-by-token output the CLI draws never + * appears. Streaming routes use this instead and pipe the body straight + * through. + */ + async forwardStream( + method: string, + path: string, + query: string, + body: unknown | undefined, + authOverride?: string, + ): Promise { + const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; + const headers: Record = { + ...this.extraHeaders, + 'Authorization': `Bearer ${authOverride ?? this.token}`, + // Accept both: mcpd picks SSE or JSON based on the request's `stream` flag. + 'Accept': 'text/event-stream, application/json', + }; + + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }; + if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + try { + const res = await fetch(url, init); + if (res.status === 401) throw new AuthenticationError(); + return res; + } catch (err: unknown) { + if (err instanceof AuthenticationError) throw err; + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + } + private async request(method: string, path: string, body?: unknown): Promise { const result = await this.forward(method, path, '', body); diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 985f6e6..d6ff369 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -1,10 +1,62 @@ /** * Catch-all proxy route that forwards /api/v1/* requests to mcpd. */ -import type { FastifyInstance } from 'fastify'; -import { AuthenticationError, ConnectionError } from '../mcpd-client.js'; +import { Readable } from 'node:stream'; + +import type { FastifyInstance, FastifyReply } from 'fastify'; + +import { AuthenticationError, ConnectionError, UpstreamTimeoutError, LONG_RUNNING_TIMEOUT_MS } from '../mcpd-client.js'; import type { McpdClient } from '../mcpd-client.js'; +/** + * Routes that are expected to run long and/or stream. + * + * An agent turn is a multi-turn tool-use loop — minutes, not seconds — so the + * 30s default budget guarantees failure rather than guarding against it. These + * also stream SSE, which must be piped rather than buffered or the client sees + * one blob at the end instead of live output. + */ +const LONG_RUNNING = [ + /^\/api\/v1\/agents\/[^/]+\/chat\b/, + /^\/api\/v1\/projects\/[^/]+\/chat\b/, + /^\/api\/v1\/llms\/[^/]+\/infer\b/, + /^\/api\/v1\/inference-tasks\/[^/]+\/stream\b/, +]; + +function isLongRunning(path: string): boolean { + return LONG_RUNNING.some((re) => re.test(path)); +} + +/** Headers worth preserving from mcpd; everything else is re-derived by Fastify. */ +const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'x-accel-buffering']; + +function sendUpstreamError(reply: FastifyReply, err: unknown): FastifyReply | undefined { + if (err instanceof AuthenticationError) { + return reply.code(401).send({ + error: 'unauthorized', + message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', + }); + } + if (err instanceof UpstreamTimeoutError) { + // 504, not 503 — mcpd was reachable, it just did not finish. Reporting this + // as "cannot reach mcpd" sent a previous debugging session chasing a + // network fault while /healthz answered in 32ms. + return reply.code(504).send({ + error: 'upstream_timeout', + message: + `mcpd did not respond within ${String(err.timeoutMs)}ms. The daemon is reachable — the ` + + 'request itself ran long. Raise MCPLOCAL_LONG_TIMEOUT_MS if this is a legitimately slow turn.', + }); + } + if (err instanceof ConnectionError) { + return reply.code(503).send({ + error: 'service_unavailable', + message: 'Cannot reach mcpd daemon. Is it running?', + }); + } + return undefined; +} + export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): void { app.all('/api/v1/*', async (request, reply) => { const path = (request.url.split('?')[0]) ?? '/'; @@ -21,23 +73,74 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v const authHeader = request.headers['authorization'] as string | undefined; const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + if (isLongRunning(path)) { + return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); + } + try { const result = await client.forward(request.method, path, querystring, body, userToken); return reply.code(result.status).send(result.body); } catch (err: unknown) { - if (err instanceof AuthenticationError) { - return reply.code(401).send({ - error: 'unauthorized', - message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', - }); - } - if (err instanceof ConnectionError) { - return reply.code(503).send({ - error: 'service_unavailable', - message: 'Cannot reach mcpd daemon. Is it running?', - }); - } + const handled = sendUpstreamError(reply, err); + if (handled) return handled; throw err; } }); } + +/** + * Pipe a long-running response straight through, headers and all. + * + * Hijacks the reply so Fastify does not try to serialize a stream, then copies + * mcpd's status and content-type before piping. `x-accel-buffering` matters: + * mcpd sets it to `no` so intermediaries don't buffer SSE, and dropping it here + * would reintroduce the exact stall we are fixing. + */ +async function proxyStreaming( + reply: FastifyReply, + client: McpdClient, + method: string, + path: string, + querystring: string, + body: unknown, + userToken: string | undefined, +): Promise { + const longClient = client.withTimeout(LONG_RUNNING_TIMEOUT_MS); + + let res: Response; + try { + res = await longClient.forwardStream(method, path, querystring, body, userToken); + } catch (err: unknown) { + const handled = sendUpstreamError(reply, err); + if (handled) return; + throw err; + } + + const headers: Record = {}; + for (const name of PASSTHROUGH_HEADERS) { + const value = res.headers.get(name); + if (value !== null) headers[name] = value; + } + + reply.hijack(); + reply.raw.writeHead(res.status, headers); + + if (res.body === null) { + reply.raw.end(); + return; + } + + try { + // Node's Readable.fromWeb bridges the fetch ReadableStream onto the socket. + await new Promise((resolve, reject) => { + const upstream = Readable.fromWeb(res.body as Parameters[0]); + upstream.on('error', reject); + reply.raw.on('close', () => { upstream.destroy(); resolve(); }); + upstream.pipe(reply.raw).on('finish', resolve).on('error', reject); + }); + } catch { + // Headers are already on the wire, so there is no status left to change. + // Close the socket; the client surfaces the truncated stream. + if (!reply.raw.writableEnded) reply.raw.end(); + } +} diff --git a/src/mcplocal/src/index.ts b/src/mcplocal/src/index.ts index 489e0ba..dbc01d4 100644 --- a/src/mcplocal/src/index.ts +++ b/src/mcplocal/src/index.ts @@ -11,7 +11,7 @@ export type { MainResult } from './main.js'; export { ProviderRegistry } from './providers/index.js'; export type { LlmProvider, CompletionOptions, CompletionResult, ChatMessage } from './providers/index.js'; export { OpenAiProvider, AnthropicProvider, OllamaProvider, GeminiCliProvider, DeepSeekProvider } from './providers/index.js'; -export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, registerProxyRoutes } from './http/index.js'; +export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError, registerProxyRoutes } from './http/index.js'; export type { HttpConfig, HttpServerDeps } from './http/index.js'; export type { JsonRpcRequest, diff --git a/src/mcplocal/tests/mcpd-client.test.ts b/src/mcplocal/tests/mcpd-client.test.ts index c9a50d4..7dac059 100644 --- a/src/mcplocal/tests/mcpd-client.test.ts +++ b/src/mcplocal/tests/mcpd-client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterAll, afterEach } from 'vitest'; import http from 'node:http'; -import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js'; +import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js'; /** * Create a local HTTP server for testing McpdClient behavior. @@ -85,7 +85,7 @@ describe('McpdClient', () => { // ── Timeout behavior ── - it('times out on slow responses and throws ConnectionError', async () => { + it('times out on slow responses and throws UpstreamTimeoutError', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond — simulates a hanging upstream tool call }); @@ -96,7 +96,7 @@ describe('McpdClient', () => { const start = Date.now(); await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow( - /timed out/, + /did not respond within/, ); const elapsed = Date.now() - start; @@ -105,7 +105,7 @@ describe('McpdClient', () => { expect(elapsed).toBeLessThan(3000); }); - it('timeout error is a ConnectionError with descriptive message', async () => { + it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); @@ -117,8 +117,12 @@ describe('McpdClient', () => { await client.get('/test'); expect.unreachable('Should have thrown'); } catch (err) { - expect(err).toBeInstanceOf(ConnectionError); - expect((err as Error).message).toContain('Request timed out after 200ms'); + // Reporting a timeout as "cannot connect" is what sent a previous + // debugging session chasing a network fault that did not exist. + expect(err).toBeInstanceOf(UpstreamTimeoutError); + expect(err).not.toBeInstanceOf(ConnectionError); + expect((err as UpstreamTimeoutError).timeoutMs).toBe(200); + expect((err as Error).message).toContain('did not respond within 200ms'); } }); @@ -146,7 +150,7 @@ describe('McpdClient', () => { const derived = client.withHeaders({ 'X-Custom': 'val' }); const start = Date.now(); - await expect(derived.get('/test')).rejects.toThrow(/timed out/); + await expect(derived.get('/test')).rejects.toThrow(/did not respond within/); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2000); }); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts new file mode 100644 index 0000000..0c5fd04 --- /dev/null +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -0,0 +1,196 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { + McpdClient, + UpstreamTimeoutError, + ConnectionError, + LONG_RUNNING_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, +} from '../src/http/mcpd-client.js'; +import { registerProxyRoutes } from '../src/http/routes/proxy.js'; + +/** + * Regression cover for the 30s proxy timeout that made `mcpctl chat` fail with + * a misleading "Cannot reach mcpd daemon" 503 while mcpd was answering + * /healthz in 32ms. + * + * Three separate defects are pinned here: + * 1. chat routes inherited the 30s CRUD budget, so any turn longer than 30s + * failed — and an agent turn is a tool-use loop that routinely exceeds it; + * 2. a timeout was reported as a connection failure, sending diagnosis after + * a network fault that did not exist; + * 3. SSE was buffered through res.text(), so streaming never reached the + * client even when the turn finished in time. + */ +let app: FastifyInstance | null = null; +let upstream: FastifyInstance | null = null; + +afterEach(async () => { + if (app) { await app.close(); app = null; } + if (upstream) { await upstream.close(); upstream = null; } +}); + +/** A stand-in mcpd. Returns its base URL. */ +async function startUpstream(register: (a: FastifyInstance) => void): Promise { + upstream = Fastify(); + register(upstream); + await upstream.listen({ port: 0, host: '127.0.0.1' }); + const addr = upstream.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + return `http://127.0.0.1:${String(addr.port)}`; +} + +async function startProxy(baseUrl: string, timeoutMs?: number): Promise { + app = Fastify(); + registerProxyRoutes(app, new McpdClient(baseUrl, 'test-token', {}, timeoutMs)); + await app.ready(); + return app; +} + +describe('proxy — long-running route budget', () => { + it('gives chat routes the long budget, not the 30s CRUD default', () => { + // The constants themselves are the contract: a 30s cap on an agent turn is + // a guaranteed failure, not a safety net. + expect(DEFAULT_TIMEOUT_MS).toBe(30_000); + expect(LONG_RUNNING_TIMEOUT_MS).toBeGreaterThanOrEqual(600_000); + }); + + it('does not abort an agent chat that outlives the CRUD budget', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async () => { + // Longer than the (deliberately tiny) CRUD budget below. Before the + // fix this inherited that budget and 503'd. + await new Promise((r) => setTimeout(r, 250)); + return { answer: 'pong' }; + }); + }); + // CRUD budget of 50ms — a chat route must NOT inherit it. + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ answer: 'pong' }); + }); + + it('still applies the short budget to ordinary CRUD routes', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + // Times out — and is now reported honestly as a timeout, not a connection fault. + expect(res.statusCode).toBe(504); + expect(res.json().error).toBe('upstream_timeout'); + }); + + it('reports a timeout as 504, never as "cannot reach mcpd"', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + const body = res.json(); + expect(body.message).toMatch(/did not respond within/); + expect(body.message).not.toMatch(/Cannot reach mcpd/); + expect(body.message).toMatch(/reachable/); + }); + + it('streams SSE through instead of buffering it', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('data: {"type":"text","delta":"po"}\n\n'); + reply.raw.write('data: {"type":"text","delta":"ng"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi', stream: true }, + }); + + expect(res.statusCode).toBe(200); + // Content-type must survive — a client that gets application/json will not + // parse the event stream. + expect(res.headers['content-type']).toMatch(/text\/event-stream/); + // x-accel-buffering=no must survive too, or intermediaries re-buffer the + // stream and reintroduce the stall. + expect(res.headers['x-accel-buffering']).toBe('no'); + expect(res.body).toContain('"delta":"po"'); + expect(res.body).toContain('"delta":"ng"'); + expect(res.body).toContain('[DONE]'); + }); + + it('relays a non-200 status from a streaming route', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + return reply.code(404).send({ error: 'Agent not found' }); + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/ghost/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.body).toContain('Agent not found'); + }); + + it('still reports a genuinely unreachable daemon as 503', async () => { + // Port 1 is reserved and refuses instantly. + const proxy = await startProxy('http://127.0.0.1:1', 500); + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + + expect(res.statusCode).toBe(503); + expect(res.json().error).toBe('service_unavailable'); + }); + + it('propagates 401 from a streaming route so login guidance still fires', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => reply.code(401).send({})); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().message).toMatch(/mcpctl login/); + }); +}); + +describe('error taxonomy', () => { + it('keeps timeout and unreachable as distinct types', () => { + const timeout = new UpstreamTimeoutError('http://mcpd', 30_000); + expect(timeout).not.toBeInstanceOf(ConnectionError); + expect(timeout.timeoutMs).toBe(30_000); + expect(timeout.message).toMatch(/did not respond within 30000ms/); + }); +}); From bbd2195c64a0bcdfd3f41833a670165e31325628 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 22:27:21 +0100 Subject: [PATCH 41/77] test(mcplocal): prove chat SSE streams live through the proxy, not buffered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl chat reviewer` showed nothing until the turn finished, then dumped the whole answer at once. mcpd streams token deltas and the CLI renders them incrementally — the sole buffering point was mcplocal's catch-all /api/v1/* proxy reading the whole SSE body via res.text() before replying. The previous commit (cherry-picked from feat/agentic-teams) pipes the body through instead; this one adds the cover that was missing: - proxy-long-running.test.ts: a progressive-delivery test in which the stand-in mcpd withholds its final frame until the client has observed the first delta through the proxy. A buffering proxy cannot satisfy that ordering — verified: the test fails in 3s (no hang) against a res.text() proxy and passes against the piped one. The existing SSE test used inject(), which collects the whole body and so passes either way. - agent-chat.smoke.test.ts: a live smoke that posts through mcplocal on localhost:3200 (the path `mcpctl chat` actually takes — every other chat smoke uses --direct and bypasses the proxy entirely) and asserts delta frames arrive spread across the generation window, not in one burst at stream end. Uses its own agent: the shared smoke agent pins replies to a single token, too short to tell live streaming from a buffer dump. Also settles the strict-boolean-expressions lint on the auth-header check the streaming split touched. Local: workspace 2546 passed, proxy suite 10/10, smoke file loads + self-skips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP --- src/mcplocal/src/http/routes/proxy.ts | 4 +- src/mcplocal/tests/proxy-long-running.test.ts | 59 ++++++++++++ .../tests/smoke/agent-chat.smoke.test.ts | 95 +++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index d6ff369..9b2bebd 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -71,7 +71,9 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v // Forward the user's auth token to mcpd so RBAC applies per-user. // If no user token is present, mcpd will use its auth hook to reject. const authHeader = request.headers['authorization'] as string | undefined; - const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + const userToken = authHeader !== undefined && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; if (isLongRunning(path)) { return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts index 0c5fd04..a793084 100644 --- a/src/mcplocal/tests/proxy-long-running.test.ts +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -1,3 +1,5 @@ +import http from 'node:http'; + import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, afterEach } from 'vitest'; @@ -144,6 +146,63 @@ describe('proxy — long-running route budget', () => { expect(res.body).toContain('[DONE]'); }); + it('delivers each SSE frame while the upstream is still generating', async () => { + // The buffering regression is invisible to the pass-through test above: + // `inject()` collects the whole body, so a proxy that buffers via + // res.text() still passes it. This test proves *progressive* delivery by + // making the upstream withhold its final frame until the client has + // observed the first one. A buffering proxy can never satisfy that + // ordering — the 3s guard resolves the gate so the run fails cleanly + // instead of deadlocking. + let openGate: (seen: boolean) => void = () => {}; + const clientSawFirstFrame = new Promise((r) => { openGate = r; }); + const guard = setTimeout(() => openGate(false), 3_000); + + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' }); + reply.raw.write('data: {"type":"text","delta":"live"}\n\n'); + await clientSawFirstFrame; + reply.raw.write('data: {"type":"final"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + await proxy.listen({ port: 0, host: '127.0.0.1' }); + const addr = proxy.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + + const body = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: addr.port, + path: '/api/v1/agents/reviewer/chat', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, (res) => { + let acc = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + acc += chunk; + if (acc.includes('"delta":"live"')) openGate(true); + }); + res.on('end', () => resolve(acc)); + res.on('error', reject); + }); + req.on('error', reject); + req.end(JSON.stringify({ message: 'hi', stream: true })); + }); + clearTimeout(guard); + + // The ordering proof: the first frame reached the client while the + // upstream was still holding the stream open. + await expect(clientSawFirstFrame).resolves.toBe(true); + expect(body).toContain('"type":"final"'); + expect(body).toContain('[DONE]'); + }); + it('relays a non-200 status from a streaming route', async () => { const base = await startUpstream((a) => { a.post('/api/v1/agents/:name/chat', async (_req, reply) => { diff --git a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts index e445511..3261c43 100644 --- a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts +++ b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts @@ -18,8 +18,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { spawnSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200'; const LLM_URL = process.env.MCPCTL_SMOKE_LLM_URL; const LLM_MODEL = process.env.MCPCTL_SMOKE_LLM_MODEL ?? 'qwen3-thinking'; const LLM_KEY = process.env.MCPCTL_SMOKE_LLM_KEY; @@ -27,6 +31,10 @@ const SUFFIX = Date.now().toString(36); const SECRET_NAME = `smoke-chat-sec-${SUFFIX}`; const LLM_NAME = `smoke-chat-llm-${SUFFIX}`; const AGENT_NAME = `smoke-chat-agent-${SUFFIX}`; +// Dedicated agent for the streaming-timing test: the shared agent's system +// prompt pins the reply to a single token, which is too short to distinguish +// live streaming from an end-of-turn buffer dump. +const STREAM_AGENT_NAME = `smoke-stream-agent-${SUFFIX}`; interface CliResult { code: number; stdout: string; stderr: string } @@ -99,6 +107,7 @@ describe('agent chat smoke (live LLM)', () => { afterAll(() => { if (!liveLlmConfigured || !mcpdUp) return; run(`delete agent ${AGENT_NAME}`); + run(`delete agent ${STREAM_AGENT_NAME}`); run(`delete llm ${LLM_NAME}`); run(`delete secret ${SECRET_NAME}`); }); @@ -139,6 +148,92 @@ describe('agent chat smoke (live LLM)', () => { expect(result.stderr).toMatch(/thread:\s+c[a-z0-9]+/); }); + it('streams progressively THROUGH mcplocal — frames arrive during generation, not in one burst', async () => { + if (!liveLlmConfigured || !mcpdUp) return; + // The regression this pins: mcplocal's /api/v1/* proxy buffered SSE via + // res.text(), so the CLI showed nothing until the turn finished and then + // dumped the whole answer at once. The --direct tests above bypass + // mcplocal entirely and cannot catch that. This one posts to the local + // proxy (the path `mcpctl chat` actually takes) and asserts frames are + // spread across the generation window: with buffering, everything lands + // within a few ms of stream end. + if (!(await healthz(MCPLOCAL_URL))) { + // eslint-disable-next-line no-console + console.warn(`\n ○ mcplocal streaming smoke: skipped — ${MCPLOCAL_URL}/healthz unreachable.\n`); + return; + } + let token = ''; + try { + const credsPath = join(homedir(), '.mcpctl', 'credentials'); + if (existsSync(credsPath)) { + const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) as { token?: string }; + if (creds.token !== undefined) token = creds.token; + } + } catch { /* unauthenticated — the request will 401 and fail loudly */ } + + run(`delete agent ${STREAM_AGENT_NAME}`); + const agent = run([ + `create agent ${STREAM_AGENT_NAME}`, + `--llm ${LLM_NAME}`, + `--description "mcplocal streaming smoke"`, + `--system-prompt "You are a smoke test. Follow the user's instructions exactly."`, + '--default-temperature 0', + '--default-max-tokens 512', + ].join(' ')); + expect(agent.code, agent.stderr).toBe(0); + + const url = new URL(`${MCPLOCAL_URL.replace(/\/$/, '')}/api/v1/agents/${STREAM_AGENT_NAME}/chat`); + const deltaTimes: number[] = []; + let endTime = 0; + let status = 0; + let raw = ''; + + await new Promise((resolve, reject) => { + const req = http.request({ + hostname: url.hostname, + port: url.port || 80, + path: url.pathname, + method: 'POST', + timeout: 120_000, + headers: { + 'Content-Type': 'application/json', + ...(token !== '' ? { Authorization: `Bearer ${token}` } : {}), + }, + }, (res) => { + status = res.statusCode ?? 0; + res.setEncoding('utf-8'); + let buf = ''; + res.on('data', (chunk: string) => { + raw += chunk; + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, nl); + buf = buf.slice(nl + 2); + if (/"type":"(text|thinking)"/.test(frame)) deltaTimes.push(Date.now()); + } + }); + res.on('end', () => { endTime = Date.now(); resolve(); }); + res.on('error', reject); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('stream timed out')); }); + req.end(JSON.stringify({ + message: 'Count from 1 to 40, one number per line. No other text.', + stream: true, + max_tokens: 400, + })); + }); + + expect(status, raw.slice(0, 500)).toBe(200); + expect(deltaTimes.length).toBeGreaterThanOrEqual(2); + // The buffering signature: every frame lands in the same final burst as + // stream end. Live streaming puts the first delta well before the end — + // a 40-line generation spans seconds; 300ms is a conservative floor. + const firstDelta = deltaTimes[0]!; + expect(endTime - firstDelta).toBeGreaterThanOrEqual(300); + }, 150_000); + it('streaming `mcpctl chat` emits text deltas', () => { if (!liveLlmConfigured || !mcpdUp) return; // Default mode is streaming. Pipe stdout/stderr separately. From ac5dee906ea3b07887b9a3db2dcaa470ea415154 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 22:54:53 +0100 Subject: [PATCH 42/77] fix(cli): don't brick the chat REPL when the first turn fails upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live: turn 1 died with an anthropic 429 before the stream's `final` frame, streamOnce resolved '' as the thread id, the REPL stored it, and every later message sent `threadId: ""` — rejected by mcpd's z.string().min(1) with HTTP 400. Permanently stuck: no turn could succeed again, so no `final` frame could ever repair the id. Two independent layers: - streamOnce now resolves `string | undefined` — undefined when no `final` frame arrived — and the REPL keeps its previous thread state on undefined instead of overwriting it. One-shot mode skips the `(thread: ...)` footer when there is none to report. - chatBody refuses to serialize an empty threadId at all, so even a leaked '' can never reach the wire. Regression cover in chat-thread-brick.test.ts (7 tests), including the full REPL chain: failed turn 1 → turn 2 body carries no threadId key. The assertions are the direct inverse of the old behavior, so they fail pre-fix by construction. CLI suite 726 passed, lint clean, tsc clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP --- src/cli/src/commands/chat.ts | 35 ++++-- .../tests/commands/chat-thread-brick.test.ts | 116 ++++++++++++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/cli/tests/commands/chat-thread-brick.test.ts diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..864cf46 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -70,7 +70,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { } /** What the chat is bound to: a named Agent or a Project. */ -interface ChatSubject { +export interface ChatSubject { kind: 'agent' | 'project'; name: string; /** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */ @@ -97,14 +97,17 @@ function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject * `personality` overlay (the project schema rejects unknown fields) and adds * `allowSecrets` when requested. */ -function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { +export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { const o: Record = { ...overrides }; if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; } const body: Record = { message, ...o }; - if (threadId !== undefined) body.threadId = threadId; + // Guard the empty string, not just undefined: a turn that dies before its + // `final` frame yields no thread id, and sending `threadId: ""` trips mcpd's + // min(1) validation — bricking every later message in the REPL with a 400. + if (threadId !== undefined && threadId !== '') body.threadId = threadId; if (stream === true) body.stream = true; return body; } @@ -205,7 +208,11 @@ async function runOneShot( const bar = installStatusBar(); try { const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar); - process.stderr.write(`\n(thread: ${finalThread})\n`); + if (finalThread !== undefined) { + process.stderr.write(`\n(thread: ${finalThread})\n`); + } else { + process.stderr.write('\n'); + } } finally { bar?.teardown(); } @@ -262,7 +269,9 @@ async function runRepl( const answered = formatAnswered(res.llm, res.model, res.failedOver); if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`); } else { - threadId = await streamOnce(deps, subject, line, threadId, overrides, bar); + // A failed turn resolves undefined — keep the previous thread (or + // none) instead of overwriting it, so the next message still works. + threadId = await streamOnce(deps, subject, line, threadId, overrides, bar) ?? threadId; process.stdout.write('\n'); } } catch (err) { @@ -502,15 +511,21 @@ async function chatRequestNonStream( }); } -/** Stream a single chat call. Returns the resolved threadId. */ -async function streamOnce( +/** + * Stream a single chat call. Returns the resolved threadId, or undefined when + * the turn never produced a `final` frame (upstream error, early disconnect). + * Returning undefined — instead of the old '' — lets callers keep their + * previous thread state rather than poisoning the next request with an empty + * id that mcpd's validation rejects. + */ +export async function streamOnce( deps: ChatCommandDeps, subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, bar: StatusBar | null = null, -): Promise { +): Promise { const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`); const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true)); @@ -531,7 +546,7 @@ async function streamOnce( } } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const driver = url.protocol === 'https:' ? https : http; const req = driver.request({ hostname: url.hostname, @@ -552,7 +567,7 @@ async function streamOnce( return; } let buf = ''; - let resolvedThread = threadId ?? ''; + let resolvedThread: string | undefined = threadId; let answered = ''; res.setEncoding('utf-8'); res.on('data', (chunk: string) => { diff --git a/src/cli/tests/commands/chat-thread-brick.test.ts b/src/cli/tests/commands/chat-thread-brick.test.ts new file mode 100644 index 0000000..0e09392 --- /dev/null +++ b/src/cli/tests/commands/chat-thread-brick.test.ts @@ -0,0 +1,116 @@ +/** + * Regression: a failed first turn must not brick the REPL. + * + * Observed live: the first message died upstream (anthropic 429) before the + * stream's `final` frame, streamOnce resolved '' as the thread id, the REPL + * stored it, and every later message sent `threadId: ""` — which mcpd's + * `z.string().min(1)` rejects with HTTP 400. The session was permanently + * stuck: no turn could succeed again, so no `final` frame could ever repair + * the thread id. + * + * The fix has two independent layers, pinned separately below: + * 1. streamOnce resolves `undefined` (not '') when no `final` frame arrived, + * and the REPL keeps its previous thread state on undefined; + * 2. chatBody never serializes an empty threadId, even if one leaks in. + */ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { chatBody, streamOnce } from '../../src/commands/chat.js'; +import type { ChatCommandDeps, ChatSubject } from '../../src/commands/chat.js'; +import type { ApiClient } from '../../src/api-client.js'; + +const subject: ChatSubject = { + kind: 'agent', + name: 'reviewer', + path: 'agents/reviewer', + allowSecrets: false, +}; + +// streamOnce only touches baseUrl + token; the ApiClient is for the +// non-streaming path and never dereferenced here. +function depsFor(baseUrl: string): ChatCommandDeps { + return { client: null as unknown as ApiClient, baseUrl, log: () => {} }; +} + +let server: http.Server | null = null; + +afterEach(async () => { + if (server !== null) { + await new Promise((r) => server!.close(() => r())); + server = null; + } +}); + +/** Serve one SSE response body for any POST, return the base URL. */ +async function serveSse(frames: string[]): Promise { + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const f of frames) res.write(`data: ${f}\n\n`); + res.end(); + }); + await new Promise((r) => server!.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${String(port)}`; +} + +describe('chatBody — threadId serialization', () => { + it('omits threadId when undefined', () => { + expect(chatBody(subject, 'hi', undefined, {})).not.toHaveProperty('threadId'); + }); + + it('omits threadId when empty — the exact payload that 400s against mcpd', () => { + expect(chatBody(subject, 'hi', '', {})).not.toHaveProperty('threadId'); + }); + + it('includes a real threadId', () => { + expect(chatBody(subject, 'hi', 'cthread123', {})).toHaveProperty('threadId', 'cthread123'); + }); +}); + +describe('streamOnce — thread id after a failed turn', () => { + it('resolves undefined when the stream errors before any final frame', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBeUndefined(); + }); + + it('keeps the caller-supplied thread when the turn fails mid-conversation', async () => { + const base = await serveSse([ + '{"type":"error","message":"upstream died"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', 'cexisting1', {}); + expect(resolved).toBe('cexisting1'); + }); + + it('resolves the threadId announced by the final frame', async () => { + const base = await serveSse([ + '{"type":"text","delta":"pong"}', + '{"type":"final","threadId":"cfresh42"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBe('cfresh42'); + }); + + it('REPL chain: failed turn 1 leaves turn 2 sendable (the brick)', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + // Mirrors runRepl's assignment: threadId = streamOnce(...) ?? threadId + let threadId: string | undefined = undefined; + threadId = (await streamOnce(depsFor(base), subject, 'hi', threadId, {})) ?? threadId; + + // Turn 2's body must be valid for mcpd: no threadId key at all. + const body = chatBody(subject, 'hi again', threadId, {}, true); + expect(body).not.toHaveProperty('threadId'); + expect(body).toHaveProperty('message', 'hi again'); + }); +}); From 03350856ea43662086ce97bc47ae87f889b1eb71 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 14 Aug 2026 23:27:36 +0100 Subject: [PATCH 43/77] fix(mcplocal): make the paginated-result contract usable by any MCP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UniFi tools were unusable from non-Claude agents. A tool result over 2000 chars is replaced with a table of contents and re-read by calling the tool again with _resultId/_section, but those params were never declared on the tool's inputSchema — and unifi-network and my-grafana ship `additionalProperties: false`, so for a client that validates arguments the drill-down call was illegal and the data unreachable. The stub's own wording made it worse: "Use section parameter" names a param that does not exist; sending it fell through to the upstream, re-paginated, and minted a fresh _resultId. An unbounded loop. UniFi took the blame because it is the one server whose results always trip the threshold: get_devices was 11,718 chars, get_clients 136,696 across 19 pages with a 92-char page-1. Grafana and Gitea return compact results. - content-pipeline gains onToolsList, declaring _resultId/_section on every tool it can paginate (gate tools excluded — they are intercepted before the pipeline runs). additionalProperties stays false: a property listed in `properties` is already legal under it, so declaring is enough and the upstream keeps its typo protection. - createDefaultPlugin wired only the gate's onToolsList, so the pipeline's would have been dropped on the floor. Both now chain, gate first. - _resultId without _section re-shows the table of contents instead of forwarding an unknown argument to a strict upstream. - The stub names the tool, the live _resultId and a real section id. - Nested MCP envelopes are collapsed. A server fronting another MCP server returns the inner result wrapped in its own content/structuredContent pair, so the payload arrives two or three times over. A layer is peeled only when it carries nothing the inner value lacks, which keeps it lossless. Live against the sre project: get_devices 11,718 -> 5,210 chars and no longer paginates at all; get_clients 136,696 -> 62,358 across 8 pages instead of 19. - deploy/mcplocal.service shipped MCPLOCAL_MCPD_URL=http://10.0.0.194:3100, which is dead — every working machine had a hand-written drop-in. Points at the k8s ingress now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU --- deploy/mcplocal.service | 6 +- .../proxymodel/plugins/content-pipeline.ts | 246 ++++++++++++- .../src/proxymodel/plugins/default.ts | 12 +- .../src/proxymodel/stages/paginate.ts | 4 +- .../src/proxymodel/stages/section-split.ts | 2 +- .../src/proxymodel/stages/summarize-tree.ts | 4 +- src/mcplocal/src/router.ts | 2 +- .../plugin-content-pipeline-drilldown.test.ts | 339 ++++++++++++++++++ .../tests/prompt-section-drilldown.test.ts | 6 +- .../tests/smoke/prompt-drilldown.test.ts | 2 +- .../tests/smoke/proxy-pipeline.test.ts | 2 +- .../tests/smoke/tool-drilldown.test.ts | 109 ++++++ 12 files changed, 710 insertions(+), 24 deletions(-) create mode 100644 src/mcplocal/tests/plugin-content-pipeline-drilldown.test.ts create mode 100644 src/mcplocal/tests/smoke/tool-drilldown.test.ts diff --git a/deploy/mcplocal.service b/deploy/mcplocal.service index fc26b40..8d3216e 100644 --- a/deploy/mcplocal.service +++ b/deploy/mcplocal.service @@ -7,7 +7,11 @@ Type=simple ExecStart=/usr/bin/mcpctl-local Restart=on-failure RestartSec=5 -Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100 +# mcpd now runs on Kubernetes behind this ingress. The previous default, +# http://10.0.0.194:3100, is dead — a fresh install pointed at it and every +# machine that worked did so via a hand-written drop-in. Override per host with: +# systemctl --user edit mcplocal -> Environment=MCPLOCAL_MCPD_URL=... +Environment=MCPLOCAL_MCPD_URL=https://mcpctl.ad.itaz.eu Environment=MCPLOCAL_HTTP_PORT=3200 Environment=MCPLOCAL_HTTP_HOST=127.0.0.1 diff --git a/src/mcplocal/src/proxymodel/plugins/content-pipeline.ts b/src/mcplocal/src/proxymodel/plugins/content-pipeline.ts index 568caf4..a714aa8 100644 --- a/src/mcplocal/src/proxymodel/plugins/content-pipeline.ts +++ b/src/mcplocal/src/proxymodel/plugins/content-pipeline.ts @@ -7,20 +7,37 @@ * - sectionStore management * * This plugin handles: - * 1. onToolCallBefore: intercept section drill-down requests (_resultId + _section params) - * 2. onToolCallAfter: run tool results through the proxymodel pipeline + * 1. onToolsList: declare the drill-down params on every tool it may paginate + * 2. onToolCallBefore: intercept section drill-down requests (_resultId + _section params) + * 3. onToolCallAfter: run tool results through the proxymodel pipeline */ import type { JsonRpcRequest, JsonRpcResponse } from '../../types.js'; -import type { Section } from '../types.js'; +import type { Section, ToolDefinition } from '../types.js'; import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js'; const SECTION_STORE_TTL_MS = 300_000; // 5 minutes +/** + * Tools owned by the gate plugin. Their calls are intercepted in + * onToolCallBefore and returned before onToolCallAfter ever runs, so their + * results never paginate and they must not advertise drill-down params. + */ +const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill']); + export function createContentPipelinePlugin(): ProxyModelPlugin { return { name: 'content-pipeline', description: 'Content transformation pipeline: paginate, section-split, summarize tool results.', + async onToolsList(tools): Promise { + // The drill-down params are part of this plugin's contract with the + // client, so they belong in the advertised schema. Without them a client + // that validates arguments against inputSchema (and upstreams that set + // `additionalProperties: false`, e.g. unifi-network) can never send + // _resultId/_section, which makes every paginated result unreadable. + return tools.map(withDrillDownParams); + }, + async onToolCallBefore(_toolName, args, request, ctx) { // Intercept section drill-down requests const resultId = args['_resultId'] as string | undefined; @@ -30,6 +47,13 @@ export function createContentPipelinePlugin(): ProxyModelPlugin { return handleSectionDrillDown(request, resultId, section, ctx); } + // _resultId without _section: the caller wants the cached result but did + // not name a section. Re-show the table of contents instead of + // forwarding _resultId upstream, where it is an unknown argument. + if (resultId !== undefined && resultId !== '') { + return handleSectionListing(request, resultId, ctx); + } + return null; }, @@ -50,8 +74,20 @@ export function createContentPipelinePlugin(): ProxyModelPlugin { if (response.error) return response; // Extract text content from the response - const raw = extractTextContent(response); - if (!raw || raw.length <= 2000) return response; + const extracted = extractTextContent(response); + if (extracted === null) return response; + + // Collapse nested MCP envelopes first. A server that proxies another MCP + // server (unifi-network does) returns the inner result wrapped in its own + // content/structuredContent pair, so the same payload arrives two or + // three times over. Unwrapping halves it, which keeps many results under + // the pagination threshold entirely. + const raw = unwrapNestedEnvelopes(extracted); + const unwrapped = raw !== extracted; + + if (raw.length <= 2000) { + return unwrapped ? textResponse(response.id, raw) : response; + } try { const result = await ctx.processContent(toolName, raw, 'toolResult'); @@ -61,7 +97,7 @@ export function createContentPipelinePlugin(): ProxyModelPlugin { const resultId = `pm-${Date.now().toString(36)}`; storeSections(ctx, resultId, result.sections); - const text = `${result.content}\n\n_resultId: ${resultId} — use _resultId and _section parameters to drill into a section.`; + const text = `${result.content}\n\n${drillDownInstruction(toolName, resultId, result.sections)}`; return { jsonrpc: '2.0', id: response.id, @@ -71,21 +107,174 @@ export function createContentPipelinePlugin(): ProxyModelPlugin { // Pipeline ran but no sections — return processed content if it changed if (result.content !== raw) { - return { - jsonrpc: '2.0', - id: response.id, - result: { content: [{ type: 'text', text: result.content }] }, - }; + return textResponse(response.id, result.content); } } catch { - // Pipeline failed — return original response + // Pipeline failed — fall through, but keep the unwrap } + if (unwrapped) return textResponse(response.id, raw); + return response; }, }; } +/** + * Build the drill-down instruction appended to a sectioned result. + * + * Names both parameters exactly as the schema declares them and shows a real + * section id, so a model can copy the call rather than guess at it. Guessing + * used to mean sending a bare `section`, which fell through to the upstream + * and re-paginated with a fresh _resultId — an unbounded loop. + */ +function drillDownInstruction(toolName: string, resultId: string, sections: Section[]): string { + const example = sections[0]?.id ?? 'page-1'; + return `To read one, call ${toolName} again with _resultId="${resultId}" and _section="${example}". ` + + 'Both parameters are required together; pass no other arguments.'; +} + +/** + * Declare the drill-down params on a tool's advertised input schema. + * + * Only `properties` is extended: `additionalProperties: false` stays as the + * upstream set it, because a property listed in `properties` is allowed by + * that keyword. Keeping it false preserves the upstream's typo protection. + */ +function withDrillDownParams(tool: ToolDefinition): ToolDefinition { + if (GATE_TOOLS.has(tool.name)) return tool; + + const schema = tool.inputSchema; + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return tool; + + const s = schema as Record; + if (s['type'] !== undefined && s['type'] !== 'object') return tool; + + const existing = s['properties']; + const props: Record = + typeof existing === 'object' && existing !== null && !Array.isArray(existing) + ? { ...(existing as Record) } + : {}; + + if ('_resultId' in props || '_section' in props) return tool; + + props['_resultId'] = { + type: 'string', + description: + 'Set only when re-reading a large result this tool already returned. ' + + 'Pass the _resultId from that result together with _section.', + }; + props['_section'] = { + type: 'string', + description: + 'Section id to read (e.g. "page-1"), from the table of contents of a ' + + 'previous large result. Requires _resultId.', + }; + + return { ...tool, inputSchema: { ...s, properties: props } }; +} + +/** Build a single-text-part tool result. */ +function textResponse(id: JsonRpcResponse['id'], text: string): JsonRpcResponse { + return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } }; +} + +/** Maximum envelope layers to peel. Guards against pathological nesting. */ +const MAX_UNWRAP_DEPTH = 5; + +/** + * Collapse nested MCP result envelopes. + * + * A server that fronts another MCP server hands back the inner result still + * wrapped: `{ ...meta, content: [{ type: 'text', text: "" }], + * structuredContent: }`. The payload is then present twice + * — once escaped inside `content`, once parsed in `structuredContent` — and + * the whole thing is itself a string inside the outer result. unifi-network's + * get_clients arrives at 136K this way. + * + * A layer is peeled only when it carries nothing the inner value doesn't + * already have: every sibling key must reappear in the inner value with an + * equal value, and `structuredContent` (if present) must equal it too. That + * makes the collapse lossless; anything else is left alone. + */ +function unwrapNestedEnvelopes(text: string): string { + let current: unknown; + try { + current = JSON.parse(text); + } catch { + return text; // Not JSON — nothing to unwrap. + } + + let peeled = false; + for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) { + const inner = peelEnvelope(current); + if (inner === null) break; + current = inner; + peeled = true; + } + + if (!peeled) return text; + return typeof current === 'string' ? current : JSON.stringify(current, null, 2); +} + +/** Peel one envelope layer, or return null when this value is not a redundant wrapper. */ +function peelEnvelope(value: unknown): unknown | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + + const obj = value as Record; + const parts = obj['content']; + if (!Array.isArray(parts) || parts.length === 0) return null; + + // Only text parts can be collapsed; images and resources must survive intact. + const texts: string[] = []; + for (const part of parts) { + if (typeof part !== 'object' || part === null) return null; + const p = part as Record; + if (p['type'] !== 'text' || typeof p['text'] !== 'string') return null; + texts.push(p['text']); + } + + const innerText = texts.join('\n'); + let inner: unknown; + try { + inner = JSON.parse(innerText); + } catch { + // Inner payload is plain text. Collapsing to it is still lossless when the + // envelope adds nothing but a duplicate structuredContent. + inner = innerText; + } + + // structuredContent must not carry anything the inner payload lacks. + if ('structuredContent' in obj && !deepEqual(obj['structuredContent'], inner)) return null; + + // Every other sibling key must already be present, and equal, inside. + for (const [key, v] of Object.entries(obj)) { + if (key === 'content' || key === 'structuredContent') continue; + if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) return null; + if (!deepEqual((inner as Record)[key], v)) return null; + } + + return inner; +} + +/** Structural equality for JSON-shaped values. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (typeof a !== 'object' || a === null || b === null) return false; + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => deepEqual(item, b[i])); + } + + const ao = a as Record; + const bo = b as Record; + const aKeys = Object.keys(ao); + if (aKeys.length !== Object.keys(bo).length) return false; + return aKeys.every((k) => k in bo && deepEqual(ao[k], bo[k])); +} + /** Extract text content from a tool result response. */ function extractTextContent(response: JsonRpcResponse): string | null { if (!response.result || typeof response.result !== 'object') return null; @@ -145,6 +334,39 @@ function handleSectionDrillDown( }; } +/** Re-show the table of contents for a cached result (no _section given). */ +function handleSectionListing( + request: JsonRpcRequest, + resultId: string, + ctx: PluginSessionContext, +): JsonRpcResponse { + const sections = getSections(ctx, resultId); + if (!sections) { + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ + type: 'text', + text: 'Cached result not found (expired or invalid _resultId). Please re-call the tool without _resultId/_section to get a fresh result.', + }], + }, + }; + } + + const toc = sections.map((s) => `[${s.id}] ${s.title}`).join('\n'); + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ + type: 'text', + text: `${sections.length} sections:\n${toc}\n\nAdd _section="" alongside _resultId="${resultId}" to read one.`, + }], + }, + }; +} + /** Find a section by ID, searching recursively through children. */ function findSection(sections: Section[], id: string): Section | null { for (const section of sections) { diff --git a/src/mcplocal/src/proxymodel/plugins/default.ts b/src/mcplocal/src/proxymodel/plugins/default.ts index c97e7e7..4e39778 100644 --- a/src/mcplocal/src/proxymodel/plugins/default.ts +++ b/src/mcplocal/src/proxymodel/plugins/default.ts @@ -10,6 +10,7 @@ * overlap on hooks, no conflicts arise. */ import type { ProxyModelPlugin } from '../plugin.js'; +import type { ToolDefinition } from '../types.js'; import { createGatePlugin, type GatePluginConfig } from './gate.js'; import { createContentPipelinePlugin } from './content-pipeline.js'; @@ -59,8 +60,15 @@ export function createDefaultPlugin(config: DefaultPluginConfig = {}): ProxyMode if (gate.onInitialize) { plugin.onInitialize = gate.onInitialize.bind(gate); } - if (gate.onToolsList) { - plugin.onToolsList = gate.onToolsList.bind(gate); + // Tools list: gate first (it decides which tools are visible at all), then + // content-pipeline (it annotates whatever survived with drill-down params). + if (gate.onToolsList || pipeline.onToolsList) { + plugin.onToolsList = async (tools, ctx): Promise => { + let acc = tools; + if (gate.onToolsList) acc = await gate.onToolsList(acc, ctx); + if (pipeline.onToolsList) acc = await pipeline.onToolsList(acc, ctx); + return acc; + }; } if (pipeline.onToolCallAfter) { plugin.onToolCallAfter = pipeline.onToolCallAfter.bind(pipeline); diff --git a/src/mcplocal/src/proxymodel/stages/paginate.ts b/src/mcplocal/src/proxymodel/stages/paginate.ts index 33ef52a..7b522e0 100644 --- a/src/mcplocal/src/proxymodel/stages/paginate.ts +++ b/src/mcplocal/src/proxymodel/stages/paginate.ts @@ -37,7 +37,9 @@ const handler: StageHandler = async (content, ctx) => { ).join('\n'); return { - content: `Content split into ${sections.length} pages (${content.length} total chars):\n${toc}\n\nUse section parameter to read a specific page.`, + // No navigation hint here — the caller (content-pipeline / router) appends + // the authoritative _resultId/_section instruction once sections are stored. + content: `Content split into ${sections.length} pages (${content.length} total chars):\n${toc}`, sections, }; }; diff --git a/src/mcplocal/src/proxymodel/stages/section-split.ts b/src/mcplocal/src/proxymodel/stages/section-split.ts index adbd698..e18db77 100644 --- a/src/mcplocal/src/proxymodel/stages/section-split.ts +++ b/src/mcplocal/src/proxymodel/stages/section-split.ts @@ -65,7 +65,7 @@ const handler: StageHandler = async (content, ctx) => { }).join('\n'); return { - content: `${sections.length} sections (${contentType}):\n${toc}\n\nUse section parameter to read a specific section.`, + content: `${sections.length} sections (${contentType}):\n${toc}`, sections, }; }; diff --git a/src/mcplocal/src/proxymodel/stages/summarize-tree.ts b/src/mcplocal/src/proxymodel/stages/summarize-tree.ts index 8528053..b75e419 100644 --- a/src/mcplocal/src/proxymodel/stages/summarize-tree.ts +++ b/src/mcplocal/src/proxymodel/stages/summarize-tree.ts @@ -39,7 +39,7 @@ const handler: StageHandler = async (content, ctx) => { const summary = await cachedSummarize(ctx, ctx.originalContent, maxTokens); return { - content: summary + '\n\nUse section parameter with id "full" to read the complete content.', + content: summary + '\n\nSection "full" holds the complete content.', sections: [{ id: 'full', title: 'Full Content', content: ctx.originalContent }], }; } @@ -56,7 +56,7 @@ const handler: StageHandler = async (content, ctx) => { }).join('\n'); return { - content: `${tree.length} sections:\n${toc}\n\nUse section parameter to read details.`, + content: `${tree.length} sections:\n${toc}`, sections: tree, }; }; diff --git a/src/mcplocal/src/router.ts b/src/mcplocal/src/router.ts index a5411bb..3715a01 100644 --- a/src/mcplocal/src/router.ts +++ b/src/mcplocal/src/router.ts @@ -1191,6 +1191,6 @@ function injectSectionsIntoPromptResponse( if (now - entry.createdAt > SECTION_TTL_MS) store.delete(key); } - const text = `${tocContent}\n\n_resultId: ${resultId} — use _resultId and _section parameters to drill into a section.`; + const text = `${tocContent}\n\nTo read one, request this prompt again with _resultId="${resultId}" and _section="". Both parameters are required together.`; return replacePromptText(response, text); } diff --git a/src/mcplocal/tests/plugin-content-pipeline-drilldown.test.ts b/src/mcplocal/tests/plugin-content-pipeline-drilldown.test.ts new file mode 100644 index 0000000..f665785 --- /dev/null +++ b/src/mcplocal/tests/plugin-content-pipeline-drilldown.test.ts @@ -0,0 +1,339 @@ +/** + * content-pipeline drill-down contract tests. + * + * Regression cover for the defect that made UniFi tools unusable from + * non-Claude MCP clients: a large tool result was replaced with a stub that + * told the caller to pass _resultId/_section, but those parameters were never + * advertised on the tool's inputSchema. Upstreams that declare + * `additionalProperties: false` (unifi-network, my-grafana) therefore made the + * drill-down call invalid for any client that validates against the schema, + * and the stub's own wording ("Use section parameter") pointed at a parameter + * that does not exist — sending it re-paginated and minted a fresh _resultId, + * an unbounded loop. + * + * Driven through a real McpRouter with the default plugin (gate + + * content-pipeline), mirroring project-mcp-endpoint wiring. + */ +import { describe, it, expect, vi } from 'vitest'; +import { McpRouter } from '../src/router.js'; +import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse } from '../src/types.js'; +import type { McpdClient } from '../src/http/mcpd-client.js'; +import { createDefaultPlugin } from '../src/proxymodel/plugins/default.js'; +import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js'; +import { MemoryCache } from '../src/proxymodel/cache.js'; + +/** Mirrors unifi-network: strict schema, and a payload big enough to paginate. */ +const STRICT_SCHEMA = { + type: 'object', + properties: { + targetId: { type: 'string' }, + site: { type: 'string' }, + }, + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', +}; + +const BIG_PAYLOAD = 'x'.repeat(20_000); + +/** + * The shape unifi-network actually returns: the inner MCP result is wrapped in + * the server's own content/structuredContent pair, so the payload arrives + * twice — once escaped inside `content`, once parsed in `structuredContent`. + */ +function wrapLikeUnifi(tool: string, inner: unknown): string { + const innerEnvelope = { tool, targetId: 'home', result: inner }; + return JSON.stringify( + { + tool, + targetId: 'home', + content: [{ type: 'text', text: JSON.stringify(innerEnvelope, null, 2) }], + structuredContent: innerEnvelope, + }, + null, + 2, + ); +} + +/** Inner payload stays under the 8000-char page size; wrapped, it does not. */ +const DEVICE_ROWS = Array.from({ length: 30 }, (_, i) => ({ + mac: `0c:ea:14:38:af:${i.toString(16).padStart(2, '0')}`, + name: `Switch ${i} — office floor plan position ${i}`, + model: 'USPM16', + ip: `192.168.1.${i + 10}`, + version: '7.4.1.16850', +})); + +const PAYLOADS: Record = { + get_devices: BIG_PAYLOAD, + get_clients: wrapLikeUnifi('get_clients', { data: DEVICE_ROWS }), + get_alarms: wrapLikeUnifi('get_alarms', { data: [] }), +}; + +interface Upstream { + conn: UpstreamConnection; + calls: Array>; +} + +function mockUpstream(name: string, payloads: Record = PAYLOADS): Upstream { + const calls: Array> = []; + const conn = { + name, + isAlive: vi.fn(() => true), + close: vi.fn(async () => {}), + onNotification: vi.fn(), + send: vi.fn(async (req: JsonRpcRequest): Promise => { + if (req.method === 'tools/list') { + return { + jsonrpc: '2.0', + id: req.id, + result: { + tools: Object.keys(payloads).map((n) => ({ + name: n, + description: `Retrieve ${n}`, + inputSchema: STRICT_SCHEMA, + })), + }, + }; + } + if (req.method === 'tools/call') { + const params = (req.params ?? {}) as Record; + calls.push((params['arguments'] as Record) ?? {}); + const tool = String(params['name'] ?? '').split('/').pop() ?? ''; + return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: payloads[tool] ?? '' }] } }; + } + if (req.method === 'resources/list') return { jsonrpc: '2.0', id: req.id, result: { resources: [] } }; + if (req.method === 'prompts/list') return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } }; + return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } }; + }), + } as unknown as UpstreamConnection; + return { conn, calls }; +} + +function mockMcpdClient(): McpdClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + forward: vi.fn(async () => ({ status: 200, body: {} })), + withHeaders: vi.fn(function (this: McpdClient) { return this; }), + } as unknown as McpdClient; +} + +function setup(opts: { gated?: boolean; payloads?: Record } = {}) { + const router = new McpRouter(); + router.setPromptConfig(mockMcpdClient(), 'test-project'); + router.setPlugin(createDefaultPlugin({ gated: opts.gated ?? false, providerRegistry: null })); + router.setProxyModel( + 'default', + { complete: async () => '', available: () => false } as unknown as LLMProviderAdapter, + new MemoryCache(), + ); + const upstream = mockUpstream('unifi-network', opts.payloads ?? PAYLOADS); + router.addUpstream(upstream.conn); + return { router, upstream }; +} + +async function listTools(router: McpRouter, sessionId = 's1') { + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId }); + const res = await router.route({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId }); + return (res.result as { tools: Array<{ name: string; inputSchema?: unknown }> }).tools; +} + +function textOf(res: JsonRpcResponse): string { + return (res.result as { content: Array<{ text: string }> }).content[0]!.text; +} + +async function callNamed( + router: McpRouter, + tool: string, + args: Record, + id: number, + sessionId = 's1', +): Promise { + return router.route( + { jsonrpc: '2.0', id, method: 'tools/call', params: { name: `unifi-network/${tool}`, arguments: args } }, + { sessionId }, + ); +} + +async function callTool( + router: McpRouter, + args: Record, + id: number, + sessionId = 's1', +): Promise { + return router.route( + { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'unifi-network/get_devices', arguments: args } }, + { sessionId }, + ); +} + +describe('content-pipeline drill-down contract', () => { + it('advertises _resultId/_section on a strict-schema upstream tool', async () => { + const { router } = setup(); + const tool = (await listTools(router)).find((t) => t.name.endsWith('get_devices')); + + expect(tool).toBeDefined(); + const schema = tool!.inputSchema as Record; + const props = schema['properties'] as Record; + + expect(props['_resultId']).toMatchObject({ type: 'string' }); + expect(props['_section']).toMatchObject({ type: 'string' }); + // The upstream's own params survive untouched. + expect(props['targetId']).toMatchObject({ type: 'string' }); + // Declaring the params in `properties` is what makes them legal; the + // upstream's strictness is preserved rather than loosened. + expect(schema['additionalProperties']).toBe(false); + }); + + it('leaves gate tools alone (they never reach the pipeline)', async () => { + const { router } = setup({ gated: true }); + const tools = await listTools(router); + + const beginSession = tools.find((t) => t.name === 'begin_session'); + expect(beginSession).toBeDefined(); + const props = (beginSession!.inputSchema as Record)['properties'] as Record; + expect(props['_resultId']).toBeUndefined(); + expect(props['_section']).toBeUndefined(); + }); + + it('stubs a large result with an instruction naming the real parameters', async () => { + const { router } = setup(); + await listTools(router); + + const text = textOf(await callTool(router, {}, 3)); + + expect(text).toContain('_resultId='); + expect(text).toContain('_section='); + // The old wording pointed at a parameter that does not exist. + expect(text).not.toContain('Use section parameter'); + }); + + it('serves the page from cache without a second upstream call', async () => { + const { router, upstream } = setup(); + await listTools(router); + + const stub = textOf(await callTool(router, {}, 3)); + const resultId = /_resultId="([^"]+)"/.exec(stub)?.[1]; + expect(resultId).toBeTruthy(); + + const callsAfterFirst = upstream.calls.length; + const page = textOf(await callTool(router, { _resultId: resultId!, _section: 'page-1' }, 4)); + + expect(page).toContain('x'.repeat(100)); + // Drill-down is answered locally, so the strict upstream never sees the + // params it would reject. + expect(upstream.calls.length).toBe(callsAfterFirst); + }); + + it('re-shows the table of contents when _resultId arrives without _section', async () => { + const { router, upstream } = setup(); + await listTools(router); + + const stub = textOf(await callTool(router, {}, 3)); + const resultId = /_resultId="([^"]+)"/.exec(stub)?.[1]; + const callsAfterFirst = upstream.calls.length; + + const res = textOf(await callTool(router, { _resultId: resultId! }, 4)); + + expect(res).toContain('page-1'); + expect(res).toContain('_section='); + expect(upstream.calls.length).toBe(callsAfterFirst); + }); + + it('reports an expired or unknown _resultId instead of forwarding it', async () => { + const { router, upstream } = setup(); + await listTools(router); + const callsBefore = upstream.calls.length; + + const res = textOf(await callTool(router, { _resultId: 'pm-nope', _section: 'page-1' }, 3)); + + expect(res).toContain('Cached result not found'); + expect(upstream.calls.length).toBe(callsBefore); + }); +}); + +describe('nested MCP envelope collapse', () => { + it('collapses a doubly-wrapped result to its inner payload', async () => { + const { router } = setup(); + await listTools(router); + + const text = textOf(await callNamed(router, 'get_alarms', {}, 3)); + const parsed = JSON.parse(text) as Record; + + // The wrapper's content/structuredContent pair is gone; the payload it + // carried twice now appears once. + expect(parsed['content']).toBeUndefined(); + expect(parsed['structuredContent']).toBeUndefined(); + expect(parsed['tool']).toBe('get_alarms'); + expect(parsed['targetId']).toBe('home'); + expect(parsed['result']).toEqual({ data: [] }); + }); + + it('drops a formerly-paginated result below the pagination threshold', async () => { + const { router } = setup(); + await listTools(router); + + const wrapped = PAYLOADS['get_clients']!; + expect(wrapped.length).toBeGreaterThan(8000); // would paginate as-is + + const text = textOf(await callNamed(router, 'get_clients', {}, 3)); + + // Delivered whole, not as a table of contents. + expect(text).not.toContain('Content split into'); + expect(text).not.toContain('_resultId='); + expect(text.length).toBeLessThan(wrapped.length); + + const parsed = JSON.parse(text) as { result: { data: unknown[] } }; + expect(parsed.result.data).toHaveLength(30); + }); + + it('leaves a wrapper alone when structuredContent disagrees with the text', async () => { + const divergent = JSON.stringify({ + tool: 'get_devices', + content: [{ type: 'text', text: JSON.stringify({ tool: 'get_devices', result: { data: [1] } }) }], + structuredContent: { tool: 'get_devices', result: { data: [1, 2, 3] } }, + }); + const { router } = setup({ payloads: { get_devices: divergent } }); + await listTools(router); + + const text = textOf(await callTool(router, {}, 3)); + + // Collapsing here would silently drop rows, so the envelope survives. + expect(text).toBe(divergent); + }); + + it('leaves a wrapper alone when it carries a key the payload lacks', async () => { + const extraKey = JSON.stringify({ + tool: 'get_devices', + warning: 'partial results — controller unreachable', + content: [{ type: 'text', text: JSON.stringify({ tool: 'get_devices', result: { data: [] } }) }], + }); + const { router } = setup({ payloads: { get_devices: extraKey } }); + await listTools(router); + + expect(textOf(await callTool(router, {}, 3))).toBe(extraKey); + }); + + it('leaves non-text content parts intact', async () => { + const withImage = JSON.stringify({ + tool: 'get_devices', + content: [ + { type: 'text', text: '{"tool":"get_devices"}' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ], + }); + const { router } = setup({ payloads: { get_devices: withImage } }); + await listTools(router); + + expect(textOf(await callTool(router, {}, 3))).toBe(withImage); + }); + + it('leaves plain non-JSON results untouched', async () => { + const { router } = setup({ payloads: { get_devices: 'plain text, no envelope' } }); + await listTools(router); + + expect(textOf(await callTool(router, {}, 3))).toBe('plain text, no envelope'); + }); +}); diff --git a/src/mcplocal/tests/prompt-section-drilldown.test.ts b/src/mcplocal/tests/prompt-section-drilldown.test.ts index 5949e16..19fa261 100644 --- a/src/mcplocal/tests/prompt-section-drilldown.test.ts +++ b/src/mcplocal/tests/prompt-section-drilldown.test.ts @@ -261,9 +261,11 @@ describe('Prompt section drill-down', () => { expect(result.sections).toBeDefined(); expect(result.sections!.length).toBeGreaterThanOrEqual(3); - // TOC should list sections + // TOC should list sections. The stage must NOT name a navigation + // parameter: the caller appends the authoritative _resultId/_section + // instruction, and a bare `section` hint here sends models into a loop. expect(result.content).toContain('sections'); - expect(result.content).toContain('Use section parameter'); + expect(result.content).not.toContain('Use section parameter'); // Original was ~16K, TOC should be much shorter expect(result.content.length).toBeLessThan(largePrompt.length); diff --git a/src/mcplocal/tests/smoke/prompt-drilldown.test.ts b/src/mcplocal/tests/smoke/prompt-drilldown.test.ts index 1917d93..1925611 100644 --- a/src/mcplocal/tests/smoke/prompt-drilldown.test.ts +++ b/src/mcplocal/tests/smoke/prompt-drilldown.test.ts @@ -4,7 +4,7 @@ * Verifies that large prompts served via prompts/get are section-split * and that subsequent calls with _resultId + _section return cached sections. * - * Requires: mcplocal running on localhost:3200, mcpd on 10.0.0.194:3100 + * Requires: mcplocal running on localhost:3200, mcpd at https://mcpctl.ad.itaz.eu */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { SmokeMcpSession, isMcplocalRunning } from './mcp-client.js'; diff --git a/src/mcplocal/tests/smoke/proxy-pipeline.test.ts b/src/mcplocal/tests/smoke/proxy-pipeline.test.ts index fe56248..e660252 100644 --- a/src/mcplocal/tests/smoke/proxy-pipeline.test.ts +++ b/src/mcplocal/tests/smoke/proxy-pipeline.test.ts @@ -6,7 +6,7 @@ * * Prerequisites: * - mcplocal running on localhost:3200 - * - mcpd running on 10.0.0.194:3100 + * - mcpd reachable at https://mcpctl.ad.itaz.eu * - smoke-aws-docs server deployed (runtime: python) * * The test suite uses the fixture at fixtures/smoke-data.yaml which diff --git a/src/mcplocal/tests/smoke/tool-drilldown.test.ts b/src/mcplocal/tests/smoke/tool-drilldown.test.ts new file mode 100644 index 0000000..d1c5935 --- /dev/null +++ b/src/mcplocal/tests/smoke/tool-drilldown.test.ts @@ -0,0 +1,109 @@ +/** + * Smoke tests: tool drill-down contract. + * + * A large tool result is replaced with a table of contents and re-read by + * calling the same tool with _resultId + _section. Those two parameters must + * appear in the tool's advertised inputSchema, because upstreams such as + * unifi-network and my-grafana declare `additionalProperties: false` — a + * client validating against the schema cannot send an undeclared parameter, + * which used to make every paginated UniFi result unreadable. + * + * Requires: mcplocal running (localhost:3200) with a project whose servers are + * reachable. Set SMOKE_PROJECT to target a specific project. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SmokeMcpSession, isMcplocalRunning } from './mcp-client.js'; + +const PROJECT_NAME = process.env['SMOKE_PROJECT'] ?? 'smoke-data'; +/** HTTP-mode mcplocal authenticates every request with an McpToken. */ +const TOKEN = process.env['SMOKE_MCPTOKEN']; + +interface Tool { + name: string; + inputSchema?: { type?: string; properties?: Record; additionalProperties?: unknown }; +} + +/** Tools served by the gate plugin — intercepted before the pipeline, so exempt. */ +const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill']); + +describe('Smoke: tool drill-down contract', () => { + let available = false; + let session: SmokeMcpSession; + let tools: Tool[] = []; + + beforeAll(async () => { + available = await isMcplocalRunning(); + if (!available) return; + + session = new SmokeMcpSession(PROJECT_NAME, TOKEN); + await session.initialize(); + await session.sendNotification('notifications/initialized'); + + // Open the gate if the project is gated, so the real catalog is visible. + const gated = await session.send('tools/list') as { tools: Tool[] }; + if (gated.tools.some((t) => t.name === 'begin_session')) { + await session.send('tools/call', { + name: 'begin_session', + arguments: { description: 'Verify the paginated tool-result drill-down contract' }, + }, 180_000); + } + + tools = ((await session.send('tools/list')) as { tools: Tool[] }).tools; + }, 240_000); + + afterAll(async () => { + if (session) await session.close(); + }); + + it('every upstream tool advertises _resultId and _section', async () => { + if (!available) return; + + const upstreamTools = tools.filter((t) => !GATE_TOOLS.has(t.name)); + if (upstreamTools.length === 0) { + console.log(` No upstream tools in project "${PROJECT_NAME}" — skipping`); + return; + } + + const missing = upstreamTools.filter((t) => { + const props = t.inputSchema?.properties; + return props === undefined || props['_resultId'] === undefined || props['_section'] === undefined; + }); + + if (missing.length > 0) { + console.log(` Missing drill-down params: ${missing.map((t) => t.name).join(', ')}`); + } + expect(missing).toEqual([]); + console.log(` ${upstreamTools.length} tools carry the drill-down contract`); + }); + + it('strict upstream schemas stay strict (params are declared, not permitted)', async () => { + if (!available) return; + + // Declaring the params in `properties` is what makes them legal under + // `additionalProperties: false`; loosening it instead would drop the + // upstream's own typo protection. + const strict = tools.filter( + (t) => !GATE_TOOLS.has(t.name) && t.inputSchema?.additionalProperties === false, + ); + if (strict.length === 0) { + console.log(' No strict-schema tools in this project — skipping'); + return; + } + + for (const t of strict) { + expect(t.inputSchema!.properties!['_resultId']).toBeDefined(); + expect(t.inputSchema!.properties!['_section']).toBeDefined(); + } + console.log(` ${strict.length} strict-schema tools keep additionalProperties: false`); + }); + + it('gate tools do not advertise drill-down params', async () => { + if (!available) return; + + for (const t of tools.filter((x) => GATE_TOOLS.has(x.name))) { + const props = t.inputSchema?.properties ?? {}; + expect(props['_resultId']).toBeUndefined(); + expect(props['_section']).toBeUndefined(); + } + }); +}); From 21aadf6d82a2e89f4d19ed407adf0566e2062f3f Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 14 Aug 2026 23:45:16 +0100 Subject: [PATCH 44/77] fix(deploy): stop running the smoke suite twice and crying wolf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7 called release.sh — which restarts mcplocal and runs the smoke suite against the binary it just installed — and then restarted mcplocal and ran the whole suite again. Two full suites inside a minute trips mcpd's rate limiter, so the second run came back with six 429s and printed SMOKE TESTS FAILED — system may be unhealthy. Consider rollback over a deploy that was fine. Seen for real on 35d506d: the first suite was 162/162 green, the second failed only on `mcpd returned 429: Rate limit exceeded`, and a clean re-run afterwards was 162/162 again. A deploy script that recommends rolling back a healthy release is worse than one that says nothing. One run, one verdict — release.sh's exit status. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU --- scripts/deploy-k8s.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh index eff872a..189c93e 100755 --- a/scripts/deploy-k8s.sh +++ b/scripts/deploy-k8s.sh @@ -187,14 +187,16 @@ trap - ERR # ── 7. RPM + smoke ── say "7/7 Build/install CLI RPM + smoke tests" -bash scripts/release.sh -systemctl --user restart mcplocal && sleep 2 -if pnpm test:smoke > /tmp/deploy-smoke.log 2>&1; then - grep -E "Tests |passed" /tmp/deploy-smoke.log | tail -2 +# release.sh already restarts mcplocal and runs the smoke suite against the +# binary it just installed. This step used to restart and re-run it a second +# time, which put two full suites inside a minute and tripped mcpd's rate +# limiter: the second run failed with 429s and printed a false +# "SMOKE TESTS FAILED — consider rollback" over a perfectly healthy deploy. +# One run, one verdict. +if bash scripts/release.sh; then say "Deploy complete — $TAG live. Rollback tag: $ROLLBACK_TAG" else - tail -40 /tmp/deploy-smoke.log - warn "SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:" + warn "RELEASE OR SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:" rollback_recipe exit 1 fi From 740ce31469d8f536c3cdeff7832f1ab94e2cdb38 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 21:43:00 +0100 Subject: [PATCH 45/77] fix(mcplocal): give proxied tools/call a 120s budget, not the 30s default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpdUpstream.send routed every non-list method through the default-timeout client, so any tool that legitimately runs past 30s — web_url_read through a browser solver, a large PDF extraction, a slow retail site — died as "mcpd proxy error: mcpd did not respond within 30000ms" while mcpd was still working on it. LibreChat agents hit this constantly on real pages. New TOOLCALL_TIMEOUT_MS (default 120s, env MCPLOCAL_TOOLCALL_TIMEOUT_MS) sits between DISCOVERY_TIMEOUT_MS (list calls must stay short so a dead upstream can't stall session init) and LONG_RUNNING_TIMEOUT_MS (chat and inference, minutes). withTimeout preserves the caller token and headers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- src/mcplocal/src/discovery.ts | 8 ++++++-- src/mcplocal/src/http/mcpd-client.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 2cf8970..50279b3 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -1,5 +1,5 @@ import type { McpdClient } from './http/mcpd-client.js'; -import { DISCOVERY_TIMEOUT_MS } from './http/mcpd-client.js'; +import { DISCOVERY_TIMEOUT_MS, TOOLCALL_TIMEOUT_MS } from './http/mcpd-client.js'; import type { McpRouter } from './router.js'; import { McpdUpstream } from './upstream/mcpd.js'; @@ -152,6 +152,10 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS // unreachable upstream cannot stall session init for the full tool-call window. const discoveryClient = mcpdClient.withTimeout(DISCOVERY_TIMEOUT_MS); + // Everything else an upstream receives is a proxied tools/call, which can + // legitimately run past the 30s default (browser solvers, PDF extraction). + const toolClient = mcpdClient.withTimeout(TOOLCALL_TIMEOUT_MS); + // Remove stale upstreams const currentNames = new Set(router.getUpstreamNames()); const serverNames = new Set(servers.map((s) => s.name)); @@ -164,7 +168,7 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS // Add/update upstreams for each server for (const server of servers) { if (!currentNames.has(server.name)) { - const upstream = new McpdUpstream(server.id, server.name, mcpdClient, server.description, discoveryClient); + const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient); router.addUpstream(upstream); } registered.push(server.name); diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 8e755e4..1734076 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -62,6 +62,17 @@ export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT */ export const DISCOVERY_TIMEOUT_MS = Number(process.env['MCPLOCAL_DISCOVERY_TIMEOUT_MS']) || 8_000; +/** + * Budget for proxied tools/call. The 30s DEFAULT_TIMEOUT_MS is a guaranteed + * failure for tools that legitimately run long — web_url_read through a + * browser solver, a large PDF extraction, a slow retail site — all of which + * died as `mcpd proxy error: mcpd did not respond within 30000ms` while mcpd + * was still working. Distinct from LONG_RUNNING_TIMEOUT_MS (chat/inference, + * minutes) and DISCOVERY_TIMEOUT_MS (list calls, must stay short so a dead + * upstream cannot stall session init). Override via `MCPLOCAL_TOOLCALL_TIMEOUT_MS`. + */ +export const TOOLCALL_TIMEOUT_MS = Number(process.env['MCPLOCAL_TOOLCALL_TIMEOUT_MS']) || 120_000; + export class McpdClient { private readonly baseUrl: string; private readonly token: string; From c66502e5900cc3a665a31bbfed1decb57e0943f0 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 21:43:00 +0100 Subject: [PATCH 46/77] fix(mcplocal): fetch project instructions with the caller's token (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instructions fetch was the one downstream call in getOrCreateRouter still using mcpdClient — whose token is an empty string in HTTP mode — so mcpd answered 401, the catch swallowed it, and every session initialized with no instructions while nothing looked broken. requestClient exists precisely for this; use it. Closes #113 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- src/mcplocal/src/http/project-mcp-endpoint.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 274b6b7..a66645f 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -176,9 +176,13 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp chain.push(createAgentsPlugin()); router.setPlugin(composePlugins(chain)); - // Fetch project instructions and set on router + // Fetch project instructions and set on router. Must ride the CALLER's + // token (requestClient) like every other downstream call here: in HTTP + // mode mcpdClient's own token is empty, mcpd answers 401, and the catch + // below swallowed it — every session initialized with no instructions + // while nothing looked broken (#113). try { - const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>( + const instructions = await requestClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>( `/api/v1/projects/${encodeURIComponent(projectName)}/instructions`, ); const parts: string[] = []; From b022f322f07ca6e4c240d3b5532a01cd4497835c Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:32:23 +0100 Subject: [PATCH 47/77] fix(mcpd): recover k8s STDIO instances after in-place container restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container restart (OOMKill, crash, transient npx failure) used to strand the instance in ERROR forever while its pod sat 1/1 Running, because five gaps lined up (#114): - the exec/attach websocket died without ending the stdout PassThrough (client-node installs no onclose), so PersistentStdioClient — whose only death signal was stdout 'end' — kept believing it was connected and every request rode the 120s timeout into a dead pipe; - the stdioClients cache is keyed by pod name, which survives a restart, so nothing ever evicted the corpse; - syncStatus never re-inspected ERROR rows and never read restartCount, so neither the recovery nor the in-place restart was visible; - its ERROR writes clobbered retry metadata, making the row instantly dueForRetry, and the retry recreated the pod under the SAME name — an uncaught 409 that looped ERROR against a healthy pod; - the stuck row consumed the whole replica budget, blocking a fresh-id replacement. The fix, layer by layer: - ws 'close'/'error' now end stdout in both k8s interactive paths (and the docker interactive path mirrors its own one-shot handlers), funneling into an identity-guarded teardown in PersistentStdioClient that also listens for stream 'close'/'error' — a late event from a previous session cannot clobber a reconnected one; - syncStatus re-inspects ERROR rows (pod running again → back to RUNNING with retry metadata cleared), tracks restartCount in instance metadata to catch restarts between polls, MERGES retry metadata instead of clobbering it, and evicts the cached stdio client through a setter-injected hook whenever the pipe is known-dead; - createContainer adopts an alive pod on 409 instead of throwing (and only replaces a genuinely dead one, waiting out the deletion grace period); - attach mode retries once through a fresh client before surfacing an error, so the first call after a detected death succeeds; - the health probe evicts the cached client when failures cross the threshold, and shutdown finally calls closeAll(). Closes #114 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- src/mcpd/src/main.ts | 5 + .../src/services/docker/container-manager.ts | 10 ++ src/mcpd/src/services/health-probe.service.ts | 7 + src/mcpd/src/services/instance.service.ts | 136 ++++++++++++++--- .../services/k8s/kubernetes-orchestrator.ts | 67 +++++++- src/mcpd/src/services/mcp-proxy-service.ts | 30 ++-- src/mcpd/src/services/orchestrator.ts | 8 + .../services/transport/persistent-stdio.ts | 35 +++-- src/mcpd/tests/instance-service.test.ts | 127 ++++++++++++++++ src/mcpd/tests/k8s-orchestrator.test.ts | 143 +++++++++++++++++- src/mcpd/tests/mcp-server-flow.test.ts | 100 ++++++++++++ src/mcpd/tests/persistent-stdio.test.ts | 107 +++++++++++++ src/mcpd/tests/services/health-probe.test.ts | 29 ++++ 13 files changed, 754 insertions(+), 50 deletions(-) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 02a92a5..57e405d 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -511,6 +511,10 @@ async function main(): Promise { const authService = new AuthService(prisma); const templateService = new TemplateService(templateRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); + // When syncStatus observes a container restart/replacement, the cached + // STDIO pipe under that containerId is dead — evict it so the next call + // redials (mcpctl#114). Setter injection, same as setInstanceService above. + instanceService.setStdioInvalidator((cid) => mcpProxyService.removeClient(cid)); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacService = new RbacService(rbacDefinitionRepo, prisma); const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService); @@ -969,6 +973,7 @@ async function main(): Promise { healthProbeRunner.stop(); secretBackendRotatorLoop.stop(); gitBackup.stop(); + mcpProxyService.closeAll(); await prisma.$disconnect(); }, }); diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index b591b28..79b758d 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -265,6 +265,16 @@ export class DockerContainerManager implements McpOrchestrator { const stderr = new PassThrough(); this.docker.modem.demuxStream(stream, stdout, stderr); + // demuxStream never propagates end/close/error to the demuxed streams, so + // a dead container left consumers waiting on a silent pipe — same funnel + // as the one-shot execInContainer path uses (mcpctl#114). + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + stream.on('end', endStdout); + stream.on('close', endStdout); + stream.on('error', endStdout); + return { stdout, write(data: string) { diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 8196f29..0079848 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -193,6 +193,13 @@ export class HealthProbeRunner { ? 'unhealthy' : 'degraded'; + // Crossing the threshold means every probe rode the same cached STDIO + // pipe — evict it once so the next probe/call redials instead of failing + // forever against a connection the other side already dropped. + if (!result.healthy && state.consecutiveFailures === failureThreshold && instance.containerId) { + this.mcpProxyService?.removeClient(instance.containerId); + } + // Build event const probeLabel = probeKind === 'readiness' ? `Readiness check (${healthCheck.tool})` diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index e78e5fb..6464fbf 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -32,6 +32,9 @@ interface RetryMetadata { attemptCount?: number; lastAttemptAt?: string; nextRetryAt?: string; + /** containerStatuses[0].restartCount at last sync — a bump means every + * cached STDIO pipe to this (unchanged) containerId is dead. */ + lastRestartCount?: number; [k: string]: unknown; } @@ -52,6 +55,8 @@ export class InvalidStateError extends Error { } export class InstanceService { + private stdioInvalidator?: (containerId: string) => void; + constructor( private instanceRepo: IMcpInstanceRepository, private serverRepo: IMcpServerRepository, @@ -59,6 +64,25 @@ export class InstanceService { private secretResolver?: SecretResolver, ) {} + /** + * Hook for evicting cached STDIO clients (McpProxyService.removeClient). + * Setter injection, matching serverService.setInstanceService in main.ts — + * McpProxyService is constructed after this service and already imports + * from this file, so a constructor arg would be a circular import. + */ + setStdioInvalidator(fn: (containerId: string) => void): void { + this.stdioInvalidator = fn; + } + + private invalidateStdio(containerId: string | null | undefined): void { + if (!containerId) return; + try { + this.stdioInvalidator?.(containerId); + } catch { + /* best-effort */ + } + } + async list(serverId?: string): Promise { return this.instanceRepo.findAll(serverId); } @@ -71,37 +95,96 @@ export class InstanceService { /** * Sync instance statuses with actual container state. - * Detects crashed/stopped containers and marks them ERROR. + * + * Beyond marking crashed containers ERROR, this is the recovery path for + * mcpctl#114: ERROR rows whose pod came back are re-adopted (the pod has + * restartPolicy Always, so kubelet restarts the container in place and the + * row must follow it back), and an in-place restart under an unchanged + * containerId — visible only as a restartCount bump — invalidates any + * cached STDIO pipe, which is dead by definition. + * + * Every metadata write here MERGES via readRetryMeta: updateStatus replaces + * the JSON column wholesale, and clobbering nextRetryAt is what used to + * make ERROR rows instantly dueForRetry and hot-loop against a 409. */ async syncStatus(): Promise { const instances = await this.instanceRepo.findAll(); for (const inst of instances) { - if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) { - try { - const info = await this.orchestrator.inspectContainer(inst.containerId); + if (!inst.containerId) continue; + if (inst.status !== 'RUNNING' && inst.status !== 'STARTING' && inst.status !== 'ERROR') { + continue; + } - if (info.state === 'stopped' || info.state === 'error') { - // Container died — get last logs for error context - let errorMsg = `Container ${info.state}`; - try { - const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 }); - const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop(); - if (lastLog) errorMsg = lastLog; - } catch { /* best-effort */ } - await this.instanceRepo.updateStatus(inst.id, 'ERROR', { - metadata: { error: errorMsg }, - }); - } else if (info.state === 'starting' && inst.status === 'RUNNING') { - // Pod went back to starting (e.g. CrashLoopBackOff restart) - await this.instanceRepo.updateStatus(inst.id, 'STARTING', {}); - } else if (info.state === 'running' && inst.status === 'STARTING') { - // Pod became ready — promote to RUNNING - await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {}); - } - } catch { - // Container gone entirely + let info: ContainerInfo; + try { + info = await this.orchestrator.inspectContainer(inst.containerId); + } catch { + // Container gone entirely. ERROR rows with a missing pod stay as they + // are — the retry/backoff path owns recreating them. + if (inst.status !== 'ERROR') { await this.instanceRepo.updateStatus(inst.id, 'ERROR', { - metadata: { error: 'Container not found' }, + metadata: { ...readRetryMeta(inst), error: 'Container not found' }, + }); + this.invalidateStdio(inst.containerId); + } + continue; + } + + const meta = readRetryMeta(inst); + + if (inst.status === 'ERROR') { + // The pod outlived the ERROR verdict (kubelet restarted the container + // in place). Re-adopt instead of leaving the row stuck forever. + if (info.state === 'running') { + const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta; + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 }, + }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'starting') { + // Keep retry metadata until it is actually running. + await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta }); + this.invalidateStdio(inst.containerId); + } + // stopped/error: leave for the backoff/retry path. + continue; + } + + if (info.state === 'stopped' || info.state === 'error') { + // Container died — get last logs for error context + let errorMsg = `Container ${info.state}`; + try { + const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 }); + const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop(); + if (lastLog) errorMsg = lastLog; + } catch { /* best-effort */ } + await this.instanceRepo.updateStatus(inst.id, 'ERROR', { + metadata: { ...meta, error: errorMsg }, + }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'starting' && inst.status === 'RUNNING') { + // Pod went back to starting (e.g. CrashLoopBackOff restart) + await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'running' && inst.status === 'STARTING') { + // Pod became ready — promote to RUNNING and clear retry state. + const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta; + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 }, + }); + // A fresh/restarted pod under this name invalidates any cached pipe. + this.invalidateStdio(inst.containerId); + } else if (info.state === 'running' && info.restartCount !== undefined) { + if (typeof meta.lastRestartCount === 'number' && info.restartCount > meta.lastRestartCount) { + // In-place restart between polls: same pod name, dead pipes. + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...meta, lastRestartCount: info.restartCount }, + }); + this.invalidateStdio(inst.containerId); + } else if (meta.lastRestartCount === undefined) { + // First sighting: record the baseline without invalidating. + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...meta, lastRestartCount: info.restartCount }, }); } } @@ -232,6 +315,7 @@ export class InstanceService { } catch { // Container may already be gone } + this.invalidateStdio(instance.containerId); } await this.instanceRepo.delete(id); @@ -256,6 +340,7 @@ export class InstanceService { } catch { // best-effort } + this.invalidateStdio(inst.containerId); } } } @@ -489,6 +574,7 @@ export class InstanceService { try { await this.orchestrator.removeContainer(instance.containerId, true); } catch { /* best-effort */ } + this.invalidateStdio(instance.containerId); } await this.instanceRepo.delete(instance.id); } diff --git a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts index dcc2760..27ace79 100644 --- a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts +++ b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts @@ -81,6 +81,18 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo { info.port = ports[0].containerPort; } + // Restart visibility: a container that crashes and restarts IN PLACE keeps + // the same pod name and reports state=running again — restartCount is the + // only durable evidence, and syncStatus uses it to invalidate stale STDIO + // pipes (mcpctl#114). + const cs = pod.status?.containerStatuses?.[0]; + if (cs?.restartCount !== undefined) { + info.restartCount = cs.restartCount; + } + if (cs?.state?.running?.startedAt) { + info.startedAt = new Date(cs.state.running.startedAt as unknown as string); + } + return info; } @@ -116,10 +128,39 @@ export class KubernetesOrchestrator implements McpOrchestrator { } const manifest = generatePodSpec(spec, this.namespace); - const pod = await this.client.core.createNamespacedPod({ - namespace: this.namespace, - body: manifest as V1Pod, - }); + let pod; + try { + pod = await this.client.core.createNamespacedPod({ + namespace: this.namespace, + body: manifest as V1Pod, + }); + } catch (err) { + if (httpStatusOf(err) !== 409) throw err; + // AlreadyExists: a retry raced a pod that is still there. If it is + // alive, ADOPT it — recreating under the same name can never succeed + // and used to loop the instance in ERROR forever (mcpctl#114). Only a + // genuinely dead pod is replaced. + const podName = (manifest as V1Pod).metadata!.name!; + const existing = await this.inspectContainer(podName); + if (existing.state === 'running' || existing.state === 'starting') { + return existing; + } + await this.removeContainer(podName, true); + // deleteNamespacedPod returns before the pod is gone (grace period); + // recreating while it is Terminating would 409 again. Bounded wait. + for (let i = 0; i < 15; i++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + await this.inspectContainer(podName); + } catch { + break; // 404 — pod is gone + } + } + pod = await this.client.core.createNamespacedPod({ + namespace: this.namespace, + body: manifest as V1Pod, + }); + } // Wait briefly for pod to start scheduling await new Promise((resolve) => setTimeout(resolve, 500)); @@ -276,6 +317,17 @@ export class KubernetesOrchestrator implements McpOrchestrator { // Wait for WebSocket connection to establish const ws = await wsPromise; + // client-node's WebSocketHandler only ends stdout on an explicit + // CloseStream/Status frame from the apiserver. An abnormal socket death + // (container OOMKilled, node reboot) emits neither — without these + // handlers the PassThrough stays open and the consumer never learns the + // pipe is dead (mcpctl#114). + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + ws.on('close', endStdout); + ws.on('error', endStdout); + return { stdout, write(data: string) { @@ -316,6 +368,13 @@ export class KubernetesOrchestrator implements McpOrchestrator { false, // tty ); + // Same abnormal-death funnel as execInteractive — see comment there. + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + ws.on('close', endStdout); + ws.on('error', endStdout); + return { stdout, write(data: string) { diff --git a/src/mcpd/src/services/mcp-proxy-service.ts b/src/mcpd/src/services/mcp-proxy-service.ts index ea3767c..8e92079 100644 --- a/src/mcpd/src/services/mcp-proxy-service.ts +++ b/src/mcpd/src/services/mcp-proxy-service.ts @@ -180,20 +180,28 @@ export class McpProxyService { } catch (err) { this.removeClient(instance.containerId); // Fall back to one-shot exec when we have a command to run. - // Attach mode has no equivalent one-shot fallback — surface the error. if (mode.kind === 'exec') { return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command, runtime); } - const detail = formatError(err); - console.error(`[mcp-proxy] attach to ${instance.containerId} failed:`, err); - return { - jsonrpc: '2.0', - id: 1, - error: { - code: -32000, - message: `STDIO attach to '${instance.containerId}' failed: ${detail}`, - }, - }; + // Attach mode has no one-shot equivalent, but the failure is usually + // a stale pipe from an in-place container restart — retry once + // through a fresh client (which redials) before surfacing the error, + // so the FIRST call after a detected death succeeds (mcpctl#114). + try { + return await this.sendViaPersistentStdio(instance.containerId, mode, method, params); + } catch (retryErr) { + this.removeClient(instance.containerId); + const detail = formatError(retryErr); + console.error(`[mcp-proxy] attach to ${instance.containerId} failed (after retry):`, retryErr); + return { + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: `STDIO attach to '${instance.containerId}' failed: ${detail}`, + }, + }; + } } } diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index 7ac03ee..6b7a160 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -69,6 +69,14 @@ export interface ContainerInfo { /** Container IP on the first non-default network (for internal communication) */ ip?: string; createdAt: Date; + /** + * Times the container restarted in place (k8s containerStatuses[0]). + * A bump with an unchanged containerId means every cached STDIO pipe to it + * is dead — syncStatus uses this to invalidate them (mcpctl#114). + */ + restartCount?: number; + /** When the current container process started (k8s state.running.startedAt). */ + startedAt?: Date; } export interface ContainerLogs { diff --git a/src/mcpd/src/services/transport/persistent-stdio.ts b/src/mcpd/src/services/transport/persistent-stdio.ts index c832485..f9312ba 100644 --- a/src/mcpd/src/services/transport/persistent-stdio.ts +++ b/src/mcpd/src/services/transport/persistent-stdio.ts @@ -121,15 +121,15 @@ export class PersistentStdioClient { this.processBuffer(); }); - exec.stdout.on('end', () => { - this.initialized = false; - this.exec = null; - for (const [, pending] of this.pendingRequests) { - clearTimeout(pending.timer); - pending.reject(new Error('STDIO process exited')); - } - this.pendingRequests.clear(); - }); + // All three events funnel into the same teardown: 'end' is the graceful + // path, but an abnormal websocket death may only surface as 'close' or + // 'error' on the stream — before this, isConnected stayed true and every + // request rode out the full timeout against a dead pipe (mcpctl#114). + exec.stdout.on('end', () => this.teardown(exec, 'STDIO process exited')); + exec.stdout.on('close', () => this.teardown(exec, 'STDIO stream closed')); + exec.stdout.on('error', (err: Error) => + this.teardown(exec, `STDIO stream error: ${err.message}`), + ); // Run MCP init handshake const initId = this.nextId++; @@ -174,6 +174,23 @@ export class PersistentStdioClient { this.initialized = true; } + /** + * Tear down a dead session: reject in-flight requests and mark the client + * disconnected so the next send() redials via ensureReady(). The identity + * guard is load-bearing: after a reconnect, a LATE 'close'/'end' from the + * previous session's stream must not clobber the new session. + */ + private teardown(exec: InteractiveExec, reason: string): void { + if (this.exec !== exec) return; + this.initialized = false; + this.exec = null; + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pendingRequests.clear(); + } + private write(msg: Record): void { if (!this.exec) throw new Error('Not connected'); this.exec.write(JSON.stringify(msg) + '\n'); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index 6cda9bb..2e4d89e 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -511,3 +511,130 @@ describe('InstanceService', () => { }); }); }); + +// ── syncStatus (mcpctl#114) — first-ever coverage of this method ── + +describe('syncStatus', () => { + let instanceRepo: IMcpInstanceRepository; + let serverRepo: IMcpServerRepository; + let orchestrator: McpOrchestrator; + let service: InstanceService; + let invalidator: ReturnType; + + function inspect(info: Partial<{ state: string; restartCount: number }>) { + vi.mocked(orchestrator.inspectContainer).mockResolvedValue({ + containerId: 'ctr-abc', + name: 'test', + state: (info.state ?? 'running') as 'running', + createdAt: new Date(), + ...(info.restartCount !== undefined ? { restartCount: info.restartCount } : {}), + }); + } + + beforeEach(() => { + instanceRepo = mockInstanceRepo(); + serverRepo = mockServerRepo(); + orchestrator = mockOrchestrator(); + service = new InstanceService(instanceRepo, serverRepo, orchestrator); + invalidator = vi.fn(); + service.setStdioInvalidator(invalidator); + }); + + it('marks a stopped container ERROR and MERGES retry metadata instead of clobbering it', async () => { + const future = new Date(Date.now() + 60_000).toISOString(); + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ status: 'RUNNING', metadata: { attemptCount: 3, nextRetryAt: future } }), + ]); + inspect({ state: 'stopped' }); + + await service.syncStatus(); + + const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!; + expect(status).toBe('ERROR'); + const meta = fields!.metadata as Record; + expect(meta['error']).toBe('log output'); // last log line + expect(meta['attemptCount']).toBe(3); // preserved — was clobbered before + expect(meta['nextRetryAt']).toBe(future); // preserved — hot-loop killer + expect(invalidator).toHaveBeenCalledWith('ctr-abc'); + }); + + it('recovers an ERROR instance whose pod is running again (the stuck state)', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ + status: 'ERROR', + metadata: { error: 'Container stopped', attemptCount: 4, nextRetryAt: 'x', lastAttemptAt: 'y' }, + }), + ]); + inspect({ state: 'running', restartCount: 2 }); + + await service.syncStatus(); + + const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!; + expect(status).toBe('RUNNING'); + const meta = fields!.metadata as Record; + expect(meta['error']).toBeUndefined(); + expect(meta['attemptCount']).toBeUndefined(); + expect(meta['nextRetryAt']).toBeUndefined(); + expect(meta['lastRestartCount']).toBe(2); + expect(invalidator).toHaveBeenCalledWith('ctr-abc'); + }); + + it('leaves an ERROR instance alone when its pod is genuinely gone', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ status: 'ERROR', metadata: { error: 'x' } }), + ]); + vi.mocked(orchestrator.inspectContainer).mockRejectedValue(new Error('not found')); + + await service.syncStatus(); + + expect(instanceRepo.updateStatus).not.toHaveBeenCalled(); + expect(invalidator).not.toHaveBeenCalled(); + }); + + it('detects an in-place restart via restartCount and invalidates the stdio pipe', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ status: 'RUNNING', metadata: { lastRestartCount: 1 } }), + ]); + inspect({ state: 'running', restartCount: 2 }); + + await service.syncStatus(); + + const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!; + expect(status).toBe('RUNNING'); + expect((fields!.metadata as Record)['lastRestartCount']).toBe(2); + expect(invalidator).toHaveBeenCalledWith('ctr-abc'); + }); + + it('baselines a first-seen restartCount without invalidating', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ status: 'RUNNING', metadata: {} }), + ]); + inspect({ state: 'running', restartCount: 0 }); + + await service.syncStatus(); + + const [, , fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!; + expect((fields!.metadata as Record)['lastRestartCount']).toBe(0); + expect(invalidator).not.toHaveBeenCalled(); + }); + + it('does nothing for a steady RUNNING instance with an unchanged restartCount', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ status: 'RUNNING', metadata: { lastRestartCount: 2 } }), + ]); + inspect({ state: 'running', restartCount: 2 }); + + await service.syncStatus(); + + expect(instanceRepo.updateStatus).not.toHaveBeenCalled(); + expect(invalidator).not.toHaveBeenCalled(); + }); + + it('remove() evicts the cached stdio client', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ status: 'RUNNING' })); + + await service.remove('inst-1'); + + expect(invalidator).toHaveBeenCalledWith('ctr-abc'); + }); +}); diff --git a/src/mcpd/tests/k8s-orchestrator.test.ts b/src/mcpd/tests/k8s-orchestrator.test.ts index 4754520..dddd3e8 100644 --- a/src/mcpd/tests/k8s-orchestrator.test.ts +++ b/src/mcpd/tests/k8s-orchestrator.test.ts @@ -63,12 +63,22 @@ vi.mock('@kubernetes/client-node', () => { makeApiClient = vi.fn(() => mockCore); } + // Track the live Exec/Attach instances so tests can program their vi.fn()s + // (each K8sOfficialClient constructs fresh ones). + const instances: { exec?: { exec: ReturnType }; attach?: { attach: ReturnType } } = {}; + class MockExec { exec = vi.fn(); + constructor() { + instances.exec = this; + } } class MockAttach { attach = vi.fn(); + constructor() { + instances.attach = this; + } } class MockLog { @@ -82,7 +92,7 @@ vi.mock('@kubernetes/client-node', () => { Attach: MockAttach, Log: MockLog, // Export test helpers - __testHelpers: { setHandler, getHandler, clearHandlers, mockCore }, + __testHelpers: { setHandler, getHandler, clearHandlers, mockCore, instances }, }; }); @@ -379,3 +389,134 @@ describe('httpStatusOf', () => { expect(httpStatusOf({ code: 'ECONNREFUSED' })).toBeUndefined(); }); }); + +// ── mcpctl#114: interactive-session death signals, restartCount, 409-adopt ── + +import { EventEmitter } from 'node:events'; + +function makeFakeWs(): EventEmitter & { close: ReturnType } { + const ws = new EventEmitter() as EventEmitter & { close: ReturnType }; + ws.close = vi.fn(); + return ws; +} + +const podRestarted = { + ...podRunning, + status: { + ...podRunning.status, + containerStatuses: [{ + state: { running: { startedAt: '2026-01-02T00:00:00Z' } }, + restartCount: 3, + lastState: { terminated: { reason: 'OOMKilled', exitCode: 137 } }, + }], + }, +}; + +const podTerminated = { + ...podRunning, + status: { + phase: 'Failed', + containerStatuses: [{ + state: { terminated: { reason: 'Error', exitCode: 1 } }, + restartCount: 1, + }], + }, +}; + +describe('KubernetesOrchestrator interactive sessions (mcpctl#114)', () => { + let orch: KubernetesOrchestrator; + + beforeEach(() => { + clearHandlers(); + vi.clearAllMocks(); + orch = new KubernetesOrchestrator({ serversNamespace: 'mcpctl-servers' }); + }); + + it("execInteractive: ws 'close' ends stdout so the consumer sees the death", async () => { + setHandler('readNamespacedPod:my-server', podRunning); + const ws = makeFakeWs(); + k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws); + + const iexec = await orch.execInteractive!('my-server', ['node', 'index.js']); + iexec.stdout.resume(); // PassThrough only emits 'end' once it is being read + const ended = new Promise((resolve) => iexec.stdout.on('end', resolve)); + ws.emit('close'); + await expect(ended).resolves.toBeUndefined(); + }); + + it("execInteractive: ws 'error' also ends stdout, and close() closes the ws", async () => { + setHandler('readNamespacedPod:my-server', podRunning); + const ws = makeFakeWs(); + k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws); + + const iexec = await orch.execInteractive!('my-server', ['node']); + iexec.stdout.resume(); + const ended = new Promise((resolve) => iexec.stdout.on('end', resolve)); + ws.emit('error', new Error('abnormal closure')); + await expect(ended).resolves.toBeUndefined(); + + iexec.close(); + expect(ws.close).toHaveBeenCalled(); + }); + + it("attachInteractive: ws 'close' ends stdout (first-ever attach coverage)", async () => { + setHandler('readNamespacedPod:my-server', podRunning); + const ws = makeFakeWs(); + k8sMock.__testHelpers.instances.attach!.attach.mockResolvedValue(ws); + + const iexec = await orch.attachInteractive!('my-server'); + iexec.stdout.resume(); + const ended = new Promise((resolve) => iexec.stdout.on('end', resolve)); + ws.emit('close'); + await expect(ended).resolves.toBeUndefined(); + expect(k8sMock.__testHelpers.instances.attach!.attach).toHaveBeenCalledOnce(); + }); + + it('inspectContainer surfaces restartCount and startedAt', async () => { + setHandler('readNamespacedPod:my-server', podRestarted); + const info = await orch.inspectContainer('my-server'); + expect(info.state).toBe('running'); + expect(info.restartCount).toBe(3); + expect(info.startedAt).toEqual(new Date('2026-01-02T00:00:00Z')); + }); + + it('createContainer ADOPTS an alive pod on 409 instead of throwing', async () => { + setHandler('readNamespace:mcpctl-servers', {}); + setHandler('createNamespacedPod', undefined, { code: 409 }); + setHandler('readNamespacedPod:my-server', podRestarted); + + const info = await orch.createContainer(testSpec); + expect(info.containerId).toBe('my-server'); + expect(info.state).toBe('running'); + expect(mockCore.deleteNamespacedPod).not.toHaveBeenCalled(); + }); + + it('createContainer replaces a genuinely dead pod on 409', async () => { + setHandler('readNamespace:mcpctl-servers', {}); + setHandler('createNamespacedPod', undefined, { code: 409 }); + setHandler('readNamespacedPod:my-server', podTerminated); + setHandler('deleteNamespacedPod:my-server', {}); + + const promise = orch.createContainer(testSpec); + // Give it a tick to inspect + delete, then flip the world: pod is gone, + // create succeeds. + await new Promise((r) => setTimeout(r, 50)); + // The gone-wait loop polls once per second: serve 404 so it breaks, then + // restore the pod before the post-create inspect (~500ms later) runs. + setHandler('readNamespacedPod:my-server', undefined, { code: 404 }); + setHandler('createNamespacedPod', podRunning); + setTimeout(() => setHandler('readNamespacedPod:my-server', podRunning), 1200); + + const info = await promise; + expect(info.state).toBe('running'); + expect(mockCore.deleteNamespacedPod).toHaveBeenCalled(); + expect(mockCore.createNamespacedPod).toHaveBeenCalledTimes(2); + }); + + it('createContainer still throws on non-409 errors', async () => { + setHandler('readNamespace:mcpctl-servers', {}); + setHandler('createNamespacedPod', undefined, { code: 500 }); + await expect(orch.createContainer(testSpec)).rejects.toBeTruthy(); + }); +}); + diff --git a/src/mcpd/tests/mcp-server-flow.test.ts b/src/mcpd/tests/mcp-server-flow.test.ts index ce3c348..3280997 100644 --- a/src/mcpd/tests/mcp-server-flow.test.ts +++ b/src/mcpd/tests/mcp-server-flow.test.ts @@ -725,3 +725,103 @@ describe('MCP server full flow', () => { }); }); }); + +// ── mcpctl#114: attach server survives an in-place container restart ── + +import { PassThrough } from 'node:stream'; +import type { InteractiveExec } from '../src/services/orchestrator.js'; + +/** + * A scripted MCP server on the other end of an attach session: answers + * initialize and every request with a result carrying its session number, + * so the test can prove which session served which call. + */ +function makeScriptedAttach(session: number): InteractiveExec { + const stdout = new PassThrough(); + return { + stdout, + write(data: string) { + for (const line of data.split('\n')) { + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id?: number; method?: string }; + if (msg.id === undefined) continue; // notifications + const result = msg.method === 'initialize' + ? { capabilities: {} } + : { ok: true, session }; + stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n'); + } + }, + close() { + stdout.destroy(); + }, + }; +} + +describe('attach-mode STDIO restart recovery (mcpctl#114)', () => { + it('survives an in-place container restart: syncStatus invalidates, next call rides a new session', async () => { + const serverRepo = createInMemoryServerRepo(); + const instanceRepo = createInMemoryInstanceRepo(); + + const server = await serverRepo.create({ + name: 'gitea', + transport: 'STDIO', + dockerImage: 'ghcr.io/gitea-mcp:latest', + replicas: 1, + } as never); + const instance = await instanceRepo.create({ + serverId: server.id, + containerId: 'pod-gitea', + status: 'RUNNING', + metadata: { lastRestartCount: 0 }, + } as never); + + let restartCount = 0; + const sessions: InteractiveExec[] = []; + const orchestrator = { + ping: vi.fn(async () => true), + pullImage: vi.fn(async () => {}), + createContainer: vi.fn(), + stopContainer: vi.fn(async () => {}), + removeContainer: vi.fn(async () => {}), + inspectContainer: vi.fn(async () => ({ + containerId: 'pod-gitea', + name: 'pod-gitea', + state: 'running' as const, + createdAt: new Date(), + restartCount, + })), + getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })), + execInContainer: vi.fn(), + attachInteractive: vi.fn(async () => { + const s = makeScriptedAttach(sessions.length + 1); + sessions.push(s); + return s; + }), + } as unknown as McpOrchestrator; + + const proxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); + const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator); + instanceService.setStdioInvalidator((cid) => proxyService.removeClient(cid)); + + // 1. A call round-trips on session 1. + const first = await proxyService.execute({ serverId: server.id, method: 'tools/list' }); + expect((first.result as { session: number }).session).toBe(1); + + // 2. The container crashes and kubelet restarts it in place: same pod + // name, restartCount bumps, the old session's pipe dies. + sessions[0]!.stdout.end(); + restartCount = 1; + + // 3. One reconcile tick detects the bump, keeps the row RUNNING, and + // evicts the stale client. + await instanceService.syncStatus(); + const after = await instanceRepo.findById(instance.id); + expect(after!.status).toBe('RUNNING'); + expect((after!.metadata as { lastRestartCount: number }).lastRestartCount).toBe(1); + + // 4. The next call succeeds first-try on a fresh attach session. + const second = await proxyService.execute({ serverId: server.id, method: 'tools/list' }); + expect((second.result as { session: number }).session).toBe(2); + expect(orchestrator.attachInteractive).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/mcpd/tests/persistent-stdio.test.ts b/src/mcpd/tests/persistent-stdio.test.ts index 50afe78..72d9c2c 100644 --- a/src/mcpd/tests/persistent-stdio.test.ts +++ b/src/mcpd/tests/persistent-stdio.test.ts @@ -109,3 +109,110 @@ describe('PersistentStdioClient', () => { await expect(client.send('tools/list')).rejects.toThrow(/interactive exec/i); }); }); + +// Drive a fake session through the init handshake so send() can round-trip. +async function completeHandshake(fake: ReturnType): Promise { + await new Promise((r) => setTimeout(r, 10)); + const init = JSON.parse(fake.written[0]!); + fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } }); + await new Promise((r) => setTimeout(r, 150)); +} + +describe('PersistentStdioClient teardown & reconnect (mcpctl#114)', () => { + it("stdout 'end' rejects in-flight requests and the next send() redials", async () => { + const fakes = [makeFakeExec(), makeFakeExec()]; + let call = 0; + const execInteractive = vi.fn(async () => fakes[call++]!.iexec); + const client = new PersistentStdioClient( + makeOrchestrator({ execInteractive }), + 'c1', + { kind: 'exec', command: ['node'] }, + ); + + const first = client.send('tools/list'); + await completeHandshake(fakes[0]!); + // Kill the session while tools/list is in flight. + fakes[0]!.iexec.stdout.end(); + await expect(first).rejects.toThrow('STDIO process exited'); + expect(client.isConnected).toBe(false); + + // Next send must redial through a fresh interactive session. + const second = client.send('tools/list'); + await completeHandshake(fakes[1]!); + const req = JSON.parse(fakes[1]!.written[2]!); + fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [] } }); + const res = await second; + expect(res.result).toEqual({ tools: [] }); + expect(execInteractive).toHaveBeenCalledTimes(2); + client.close(); + }); + + it("stdout 'error' tears down with the error message", async () => { + const fake = makeFakeExec(); + const client = new PersistentStdioClient( + makeOrchestrator({ execInteractive: vi.fn(async () => fake.iexec) }), + 'c1', + { kind: 'exec', command: ['node'] }, + ); + const pending = client.send('tools/list'); + await completeHandshake(fake); + fake.iexec.stdout.emit('error', new Error('socket hang up')); + await expect(pending).rejects.toThrow('STDIO stream error: socket hang up'); + expect(client.isConnected).toBe(false); + client.close(); + }); + + it("a LATE 'end' from the previous session does not clobber the reconnected one", async () => { + const fakes = [makeFakeExec(), makeFakeExec()]; + let call = 0; + const client = new PersistentStdioClient( + makeOrchestrator({ attachInteractive: vi.fn(async () => fakes[call++]!.iexec) }), + 'c1', + { kind: 'attach' }, + ); + + const first = client.send('tools/list'); + await completeHandshake(fakes[0]!); + fakes[0]!.iexec.stdout.end(); + await expect(first).rejects.toThrow('STDIO process exited'); + + // Reconnect on a fresh session. + const second = client.send('tools/list'); + await completeHandshake(fakes[1]!); + + // The old session's stream fires a stale 'close' AFTER the reconnect — + // the identity guard must ignore it. + fakes[0]!.iexec.stdout.emit('close'); + expect(client.isConnected).toBe(true); + + const req = JSON.parse(fakes[1]!.written[2]!); + fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 't' }] } }); + const res = await second; + expect((res.result as { tools: unknown[] }).tools).toHaveLength(1); + client.close(); + }); + + it('attach mode also redials after teardown', async () => { + const fakes = [makeFakeExec(), makeFakeExec()]; + let call = 0; + const attachInteractive = vi.fn(async () => fakes[call++]!.iexec); + const client = new PersistentStdioClient( + makeOrchestrator({ attachInteractive }), + 'c-gitea', + { kind: 'attach' }, + ); + + const first = client.send('tools/list'); + await completeHandshake(fakes[0]!); + fakes[0]!.iexec.stdout.emit('close'); + await expect(first).rejects.toThrow('STDIO stream closed'); + + const second = client.send('tools/list'); + await completeHandshake(fakes[1]!); + const req = JSON.parse(fakes[1]!.written[2]!); + fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [] } }); + await expect(second).resolves.toMatchObject({ result: { tools: [] } }); + expect(attachInteractive).toHaveBeenCalledTimes(2); + client.close(); + }); +}); diff --git a/src/mcpd/tests/services/health-probe.test.ts b/src/mcpd/tests/services/health-probe.test.ts index 8c33ccb..544a993 100644 --- a/src/mcpd/tests/services/health-probe.test.ts +++ b/src/mcpd/tests/services/health-probe.test.ts @@ -544,3 +544,32 @@ describe('HealthProbeRunner', () => { expect(result.message).toBe('ECONNREFUSED 10.0.0.1:3000'); }); }); + +describe('HealthProbeRunner stale-pipe eviction (mcpctl#114)', () => { + it('evicts the cached stdio client exactly once, when failures cross the threshold', async () => { + const instanceRepo = mockInstanceRepo(); + const serverRepo = mockServerRepo(); + const orchestrator = mockOrchestrator(); + const mcpProxyService = mockMcpProxyService(); + const runner = new HealthProbeRunner(instanceRepo, serverRepo, orchestrator, undefined, mcpProxyService); + + const instance = makeInstance(); + // intervalSeconds: 0 so every tick actually probes; failureThreshold: 3. + const server = makeServer({ + healthCheck: { tool: 'list_datasources', arguments: {}, intervalSeconds: 0, timeoutSeconds: 10, failureThreshold: 3 }, + } as Partial); + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockRejectedValue(new Error('pipe is dead')); + + await runner.tick(); // failure 1 — degraded + await runner.tick(); // failure 2 — degraded + expect(mcpProxyService.removeClient).not.toHaveBeenCalled(); + + await runner.tick(); // failure 3 — crosses threshold → evict once + expect(mcpProxyService.removeClient).toHaveBeenCalledExactlyOnceWith('container-abc'); + + await runner.tick(); // failure 4 — already past threshold, no re-evict + expect(mcpProxyService.removeClient).toHaveBeenCalledTimes(1); + }); +}); From bd3e1134c9dbcea51557eca6a79e533b9f5f92b4 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 22:08:10 +0100 Subject: [PATCH 48/77] feat(secrets): survive OpenBao outages instead of cascading them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpd re-read the secret backend on every use — server env resolution, LLM api keys, chat, git providers, code repos, webhooks — with no value cache, no request timeout, and a retry that only fired on HTTP 403. A few seconds of OpenBao unavailability therefore turned into minutes of degraded service: instances that restarted during the blip failed env resolution, got marked ERROR, and entered the 30s x5 then 5min backoff. Three changes, in dependency order: 1. Typed errors. `SecretNotFoundError` (definitive) vs `SecretBackendUnavailableError` (transport). The distinction has to be typed rather than string-matched — a mis-classified "not found" would resurrect deleted secrets, and a mis-classified auth failure would paper over revoked grants, which is how an upstream re-init once broke every secret write for four days (e51b924). 2. Driver resilience. Every request now carries an AbortSignal timeout (there was none, so an unreachable backend hung its caller) and retries 5xx/429/network with full-jitter backoff — 503 is what a sealed OpenBao returns and used to be an immediate hard failure. The 403 purge-and-retry stays single-shot and outside the retry budget: it is a credential refresh, not a backend-unavailable condition, and looping on it would hide a genuinely revoked grant. `healthCheck()` no longer routes through the authenticated path, so an expired role stops reporting as "OpenBao is down"; it maps OpenBao's status codes (sealed/standby/uninitialised) instead. New `authCheck()` covers the readiness half via list(), which exercises the capability we actually depend on — unlike lookup-self, which only proves the token exists. 3. CachingSecretBackendDriver. Fresh reads inside the TTL never touch the network; past it we always try the backend, and on a transport failure serve the last known-good value instead of throwing. That is what stops the ERROR storm. Deleted secrets evict and rethrow — serving those stale would resurrect a revoked credential, strictly worse than an outage — and non-transport errors rethrow untouched. plaintext is not wrapped: its read() is an identity function over the row handed in. A cold cache during an outage still fails, loudly and by design (e6cd735). Also routes BACKEND_TOKEN_DEAD / BACKEND_ROTATION_FAILED through pino rather than bare console.error. They bypassed the multistream feeding ErrorLogBuffer, so the one failure `mcpctl errors` exists to surface was the one it never showed. Tests: 20 new. The two load-bearing guards (never serve a deleted secret stale; never serve stale for a non-transport error) were confirmed to fail against deliberately broken code before being kept. The 403 purge-retry path had no coverage at all until now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- src/mcpd/src/main.ts | 16 ++ .../services/secret-backend-rotator-loop.ts | 25 ++- .../secret-backend-rotator.service.ts | 42 +++- .../src/services/secret-backend.service.ts | 28 ++- .../src/services/secret-backends/caching.ts | 198 ++++++++++++++++++ .../src/services/secret-backends/openbao.ts | 180 +++++++++++++--- .../src/services/secret-backends/types.ts | 55 ++++- src/mcpd/tests/secret-backends.test.ts | 89 ++++++++ src/mcpd/tests/secret-cache.test.ts | 198 ++++++++++++++++++ 9 files changed, 780 insertions(+), 51 deletions(-) create mode 100644 src/mcpd/src/services/secret-backends/caching.ts create mode 100644 src/mcpd/tests/secret-cache.test.ts diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 02a92a5..4b72346 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -474,16 +474,32 @@ async function main(): Promise { }, }, secretRefResolver: secretResolverBridge, + }, { + // Cache-transition events go through pino so BACKEND_UNREACHABLE / + // BACKEND_RECOVERED land in ErrorLogBuffer and `mcpctl errors`. + log: { + warn: (obj: Record, msg: string): void => { app.log.warn(obj, msg); }, + info: (obj: Record, msg: string): void => { app.log.info(obj, msg); }, + }, }); const secretService = new SecretService(secretRepo, secretBackendService); const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService); const secretBackendRotator = new SecretBackendRotator({ backends: secretBackendService, secrets: secretService, + log: { + error: (obj: Record, msg: string): void => { app.log.error(obj, msg); }, + warn: (msg: string): void => { app.log.warn(msg); }, + }, }); const secretBackendRotatorLoop = new SecretBackendRotatorLoop({ backends: secretBackendService, rotator: secretBackendRotator, + log: { + info: (msg: string): void => { app.log.info(`[rotator] ${msg}`); }, + warn: (msg: string): void => { app.log.warn(`[rotator] ${msg}`); }, + error: (obj: Record, msg: string): void => { app.log.error(obj, msg); }, + }, }); const llmAdapters = new LlmAdapterRegistry(); // LlmService takes the adapter registry so create()/update() can run an diff --git a/src/mcpd/src/services/secret-backend-rotator-loop.ts b/src/mcpd/src/services/secret-backend-rotator-loop.ts index 2fae8ce..0cbef82 100644 --- a/src/mcpd/src/services/secret-backend-rotator-loop.ts +++ b/src/mcpd/src/services/secret-backend-rotator-loop.ts @@ -26,7 +26,11 @@ export interface SecretBackendRotatorLoopDeps { /** Override in tests. */ setTimeout?: (cb: () => void, ms: number) => NodeJS.Timeout; clearTimeout?: (t: NodeJS.Timeout) => void; - log?: { info: (msg: string) => void; warn: (msg: string) => void }; + log?: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (obj: Record, msg: string) => void; + }; } const DEFAULT_INTERVAL_MS = 24 * 3600 * 1000; @@ -36,7 +40,7 @@ export class SecretBackendRotatorLoop { private readonly timers = new Map(); private readonly setT: (cb: () => void, ms: number) => NodeJS.Timeout; private readonly clearT: (t: NodeJS.Timeout) => void; - private readonly log: { info: (msg: string) => void; warn: (msg: string) => void }; + private readonly log: NonNullable; private stopped = false; constructor(private readonly deps: SecretBackendRotatorLoopDeps) { @@ -44,9 +48,11 @@ export class SecretBackendRotatorLoop { this.clearT = deps.clearTimeout ?? ((t) => global.clearTimeout(t)); this.log = deps.log ?? { // eslint-disable-next-line no-console - info: (m) => console.log(`[rotator] ${m}`), + info: (m: string): void => { console.log(`[rotator] ${m}`); }, // eslint-disable-next-line no-console - warn: (m) => console.warn(`[rotator] ${m}`), + warn: (m: string): void => { console.warn(`[rotator] ${m}`); }, + // eslint-disable-next-line no-console + error: (obj: Record, m: string): void => { console.error(JSON.stringify({ level: 'fatal', ...obj, message: m })); }, }; } @@ -70,13 +76,10 @@ export class SecretBackendRotatorLoop { this.deps.rotator.healthCheck(b.id) .then((res) => { if (!res.ok) { - // eslint-disable-next-line no-console - console.error(JSON.stringify({ - level: 'fatal', - kind: 'BACKEND_TOKEN_DEAD', - backend: b.name, - message: res.message ?? 'unknown', - })); + this.log.error( + { kind: 'BACKEND_TOKEN_DEAD', backend: b.name }, + res.message ?? 'unknown', + ); this.log.warn(`backend '${b.name}' health check failed: ${res.message ?? 'unknown'}`); } }) diff --git a/src/mcpd/src/services/secret-backend-rotator.service.ts b/src/mcpd/src/services/secret-backend-rotator.service.ts index 0a8ed11..27a8989 100644 --- a/src/mcpd/src/services/secret-backend-rotator.service.ts +++ b/src/mcpd/src/services/secret-backend-rotator.service.ts @@ -53,18 +53,37 @@ export interface TokenMeta { rotatable?: boolean; } +/** + * Structured logger. Must be a real pino-shaped logger in production: the + * `BACKEND_TOKEN_DEAD` fatals below used to go out via bare `console.error`, + * which bypasses the pino multistream feeding `ErrorLogBuffer` — so the one + * failure `mcpctl errors` exists to surface was the one it never saw. + */ +export interface RotatorLog { + error(obj: Record, msg: string): void; + warn(msg: string): void; +} + export interface SecretBackendRotatorDeps { backends: SecretBackendService; secrets: SecretService; fetch?: typeof globalThis.fetch; now?: () => Date; + log?: RotatorLog; } export class SecretBackendRotator { private readonly now: () => Date; + private readonly log: RotatorLog; constructor(private readonly deps: SecretBackendRotatorDeps) { this.now = deps.now ?? (() => new Date()); + this.log = deps.log ?? { + // eslint-disable-next-line no-console + error: (obj: Record, msg: string): void => { console.error(JSON.stringify({ level: 'fatal', ...obj, message: msg })); }, + // eslint-disable-next-line no-console + warn: (msg: string): void => { console.warn(msg); }, + }; } /** True iff this backend is a wizard-provisioned token-auth openbao with rotation enabled. */ @@ -144,15 +163,16 @@ export class SecretBackendRotator { : err; const wrappedMsg = wrapped instanceof Error ? wrapped.message : String(wrapped); await this.recordError(backendId, meta, wrappedMsg); - // Loud, structured log so the operator sees it in `kubectl logs deploy/mcpd`. - // eslint-disable-next-line no-console - console.error(JSON.stringify({ - level: 'fatal', - kind: tokenDead ? 'BACKEND_TOKEN_DEAD' : 'BACKEND_ROTATION_FAILED', - backend: backend.name, - url: cfg.url, - message: wrappedMsg, - })); + // Loud and structured, through pino so it also lands in ErrorLogBuffer + // and therefore in `mcpctl errors` — not just in `kubectl logs`. + this.log.error( + { + kind: tokenDead ? 'BACKEND_TOKEN_DEAD' : 'BACKEND_ROTATION_FAILED', + backend: backend.name, + url: cfg.url, + }, + wrappedMsg, + ); throw wrapped; } @@ -164,7 +184,7 @@ export class SecretBackendRotator { // Log but don't fail the rotation — the new token is already live. const msg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console - console.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`); + this.log.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`); } } @@ -249,7 +269,7 @@ export class SecretBackendRotator { } catch (inner) { // Don't mask the original error — just log the DB failure. // eslint-disable-next-line no-console - console.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`); + this.log.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`); } } } diff --git a/src/mcpd/src/services/secret-backend.service.ts b/src/mcpd/src/services/secret-backend.service.ts index fcf5d97..c1578a1 100644 --- a/src/mcpd/src/services/secret-backend.service.ts +++ b/src/mcpd/src/services/secret-backend.service.ts @@ -2,6 +2,7 @@ import type { SecretBackend } from '@prisma/client'; import type { ISecretBackendRepository } from '../repositories/secret-backend.repository.js'; import type { SecretBackendDriver } from './secret-backends/types.js'; import { createDriver, type DriverFactoryDeps } from './secret-backends/factory.js'; +import { CachingSecretBackendDriver, type CachingDriverOptions, type CacheStats } from './secret-backends/caching.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; export class SecretBackendInUseError extends Error { @@ -17,6 +18,7 @@ export class SecretBackendService { constructor( private readonly repo: ISecretBackendRepository, private readonly driverDeps: DriverFactoryDeps, + private readonly cacheOpts: CachingDriverOptions = {}, ) {} async list(): Promise { @@ -87,12 +89,34 @@ export class SecretBackendService { this.driverCache.delete(id); } - /** Get the driver for a given backend id, creating + caching on first call. */ + /** + * Get the driver for a given backend id, creating + caching on first call. + * + * Remote backends are wrapped in `CachingSecretBackendDriver` so a backend + * outage degrades to "serving last known-good" instead of failing every + * caller. `plaintext` is deliberately NOT wrapped: its `read()` is an + * identity function over the DB row passed in by the caller, so a value cache + * there would serve pre-update data with nothing to invalidate it. + * + * Config changes invalidate for free — `update()` and `delete()` drop this + * map, and the value cache lives inside the driver instance, which is right: + * if `url`/`mount`/`pathPrefix` change, the cached names now mean something + * different. + */ driverFor(backend: SecretBackend): SecretBackendDriver { const cached = this.driverCache.get(backend.id); if (cached) return cached; - const driver = createDriver(backend, this.driverDeps); + const base = createDriver(backend, this.driverDeps); + const driver = backend.type === 'plaintext' + ? base + : new CachingSecretBackendDriver(base, { ...this.cacheOpts, backendName: backend.name }); this.driverCache.set(backend.id, driver); return driver; } + + /** Cache state for a backend, for the health endpoint. Never exposes values. */ + cacheStatsFor(backend: SecretBackend): CacheStats | undefined { + const driver = this.driverFor(backend); + return driver instanceof CachingSecretBackendDriver ? driver.stats() : undefined; + } } diff --git a/src/mcpd/src/services/secret-backends/caching.ts b/src/mcpd/src/services/secret-backends/caching.ts new file mode 100644 index 0000000..22b4276 --- /dev/null +++ b/src/mcpd/src/services/secret-backends/caching.ts @@ -0,0 +1,198 @@ +/** + * Caching + stale-while-error decorator for any `SecretBackendDriver`. + * + * ## Why this exists + * + * `SecretService.resolveData()` calls `driver.read()` on *every* use, and every + * consumer funnels through it: server env resolution, LLM api keys, chat, git + * providers, code repos, webhooks. With a remote backend that means one network + * round-trip per secret per call, and — worse — any OpenBao blip propagates + * straight through. An instance that restarts during a blip fails env + * resolution, gets marked ERROR, and enters a 30s×5-then-5min backoff + * (`instance.service.ts`), so a few seconds of backend unavailability turns + * into minutes of degraded service. + * + * ## Semantics + * + * - **Fresh** (age < ttlMs): served from memory, no network. + * - **Stale-while-error**: past the TTL we always try the backend first. If it + * answers, we refresh. If it fails *as a transport failure* + * (`SecretBackendUnavailableError`), we serve the last known-good value + * instead of throwing. This is the part that actually stops the ERROR storm. + * - **`SecretNotFoundError` evicts and rethrows.** Never served stale — that + * would resurrect a deliberately deleted or revoked credential, which is + * strictly worse than an outage. + * - **Any other error rethrows, without stale.** A 403 that survives a token + * refresh means our grants were revoked; papering over it with cached data is + * exactly how an upstream OpenBao re-init once went unnoticed for four days. + * - **No negative caching.** A miss must re-check; retry/backoff already lives + * in the driver. + * + * The stale window is deliberately unbounded. A cap would mean a long outage + * eventually takes mcpd down anyway, which defeats the purpose, and the + * revoked-credential case is already handled definitively by `SecretNotFound`. + * + * ## What this does NOT fix + * + * A cold cache during an outage. If mcpd restarts while the backend is + * unreachable, nothing has a last-known-good value and secret-bearing servers + * fail to start — honestly, with a loud error. That is the correct behaviour: + * booting a server with an empty credential is the failure mode that had + * gitea-mcp reporting healthy while every authed call failed. The mitigation is + * to warm this cache at boot, not to invent a value. + * + * Values live in heap in cleartext for the TTL, so the map is bounded (LRU) and + * values are never logged. + */ +import type { SecretBackendDriver, SecretData, ExternalRef } from './types.js'; +import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js'; + +export interface CachingDriverLog { + warn(obj: Record, msg: string): void; + info(obj: Record, msg: string): void; +} + +export interface CachingDriverOptions { + /** How long a value is served without consulting the backend. */ + ttlMs?: number; + /** LRU bound — these are plaintext credentials held in memory. */ + maxEntries?: number; + /** Backend name, for log context only. */ + backendName?: string; + now?: () => number; + log?: CachingDriverLog; +} + +interface CacheEntry { + data: SecretData; + fetchedAt: number; + /** Set when we last served this past its TTL because the backend was down. */ + staleSince: number | undefined; +} + +export const DEFAULT_CACHE_TTL_MS = 300_000; +export const DEFAULT_CACHE_MAX_ENTRIES = 500; + +const NOOP_LOG: CachingDriverLog = { warn: () => undefined, info: () => undefined }; + +export interface CacheStats { + entries: number; + servingStale: number; + oldestStaleSince: number | undefined; +} + +export class CachingSecretBackendDriver implements SecretBackendDriver { + readonly kind: string; + + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly backendName: string; + private readonly nowFn: () => number; + private readonly log: CachingDriverLog; + + constructor(private readonly inner: SecretBackendDriver, opts: CachingDriverOptions = {}) { + this.kind = `cached:${inner.kind}`; + this.ttlMs = opts.ttlMs ?? DEFAULT_CACHE_TTL_MS; + this.maxEntries = opts.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES; + this.backendName = opts.backendName ?? inner.kind; + this.nowFn = opts.now ?? ((): number => Date.now()); + this.log = opts.log ?? NOOP_LOG; + } + + async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise { + const now = this.nowFn(); + const cached = this.entries.get(input.name); + + if (cached !== undefined && now - cached.fetchedAt < this.ttlMs) { + this.touch(input.name, cached); + return cached.data; + } + + try { + const data = await this.inner.read(input); + if (cached?.staleSince !== undefined) { + // Edge-triggered: only on the transition back to healthy. + this.log.info( + { kind: 'BACKEND_RECOVERED', backend: this.backendName, secret: input.name, + staleForMs: now - cached.staleSince }, + `secret backend '${this.backendName}' recovered; '${input.name}' is live again`, + ); + } + this.store(input.name, { data, fetchedAt: now, staleSince: undefined }); + return data; + } catch (err) { + if (err instanceof SecretNotFoundError) { + // Definitive. Drop the stale copy so we can never hand it out later. + this.entries.delete(input.name); + throw err; + } + if (!(err instanceof SecretBackendUnavailableError) || cached === undefined) { + throw err; + } + if (cached.staleSince === undefined) { + cached.staleSince = now; + this.log.warn( + { kind: 'BACKEND_UNREACHABLE', backend: this.backendName, secret: input.name, + ageMs: now - cached.fetchedAt, reason: err.message }, + `secret backend '${this.backendName}' unreachable; serving cached '${input.name}'`, + ); + } + this.touch(input.name, cached); + return cached.data; + } + } + + async write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }> { + const result = await this.inner.write(input); + // Cache what a subsequent read() would return — the values just written — + // not `storedData`, which remote drivers deliberately leave empty. + this.store(input.name, { data: input.data, fetchedAt: this.nowFn(), staleSince: undefined }); + return result; + } + + async delete(input: { name: string; externalRef: ExternalRef }): Promise { + await this.inner.delete(input); + this.entries.delete(input.name); + } + + async list(): Promise> { + return this.inner.list(); + } + + async healthCheck(): Promise<{ ok: boolean; detail?: string }> { + return this.inner.healthCheck?.() ?? { ok: true, detail: 'no probe' }; + } + + async authCheck(): Promise<{ ok: boolean; detail?: string }> { + return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' }; + } + + /** Cache state for the backend health endpoint. Never exposes values. */ + stats(): CacheStats { + let servingStale = 0; + let oldestStaleSince: number | undefined; + for (const e of this.entries.values()) { + if (e.staleSince === undefined) continue; + servingStale++; + if (oldestStaleSince === undefined || e.staleSince < oldestStaleSince) oldestStaleSince = e.staleSince; + } + return { entries: this.entries.size, servingStale, oldestStaleSince }; + } + + /** Move an entry to the MRU end of the insertion-ordered Map. */ + private touch(name: string, entry: CacheEntry): void { + this.entries.delete(name); + this.entries.set(name, entry); + } + + private store(name: string, entry: CacheEntry): void { + this.entries.delete(name); + this.entries.set(name, entry); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next(); + if (oldest.done === true) break; + this.entries.delete(oldest.value); + } + } +} diff --git a/src/mcpd/src/services/secret-backends/openbao.ts b/src/mcpd/src/services/secret-backends/openbao.ts index 93e902c..34ddedc 100644 --- a/src/mcpd/src/services/secret-backends/openbao.ts +++ b/src/mcpd/src/services/secret-backends/openbao.ts @@ -28,6 +28,7 @@ */ import { readFile } from 'node:fs/promises'; import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js'; +import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js'; /** Best-effort read of a response body for error messages. Empty on parse failure. */ async function bodyText(res: Response): Promise { @@ -77,10 +78,23 @@ export interface OpenBaoDriverDeps { readServiceAccountToken?: (path: string) => Promise; /** Clock for cache TTL — overridable in tests. */ now?: () => number; + /** Per-request timeout. Without one, an unreachable OpenBao hangs every caller. */ + timeoutMs?: number; + /** Total attempts for retryable failures (network / 5xx / 429). 1 disables retry. */ + maxAttempts?: number; + /** Base for exponential backoff between retries; full jitter is applied. */ + backoffBaseMs?: number; + /** Test seam — real sleeps would make the retry tests take seconds. */ + sleep?: (ms: number) => Promise; } const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'; const TOKEN_RENEW_GRACE_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BACKOFF_BASE_MS = 200; +/** Statuses worth retrying: the backend is up but cannot answer right now. */ +const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); export class OpenBaoDriver implements SecretBackendDriver { readonly kind = 'openbao'; @@ -98,6 +112,10 @@ export class OpenBaoDriver implements SecretBackendDriver { private readonly resolver: SecretRefResolver | undefined; private readonly readSaToken: (path: string) => Promise; private readonly nowFn: () => number; + private readonly timeoutMs: number; + private readonly maxAttempts: number; + private readonly backoffBaseMs: number; + private readonly sleep: (ms: number) => Promise; // Cached vault token + when (epoch ms) it should be considered expired and refetched. private cachedToken: string | undefined; @@ -131,13 +149,19 @@ export class OpenBaoDriver implements SecretBackendDriver { if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver; this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim())); this.nowFn = deps.now ?? (() => Date.now()); + this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxAttempts = deps.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + this.backoffBaseMs = deps.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS; + this.sleep = deps.sleep ?? ((ms: number): Promise => new Promise((r) => { setTimeout(r, ms); })); } async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise { const path = this.pathFor(input.name); const res = await this.request('GET', `/v1/${this.mount}/data/${path}`); if (res.status === 404) { - throw new Error(`OpenBao: secret '${input.name}' not found at ${path}`); + // Definitive answer, not a transport failure — the caching decorator + // must evict rather than serve a stale value here. + throw new SecretNotFoundError(`OpenBao: secret '${input.name}' not found at ${path}`); } if (!res.ok) throw new Error(`OpenBao read ${path}: HTTP ${res.status} ${await bodyText(res)}`); const body = await res.json() as { data?: { data?: SecretData } }; @@ -174,10 +198,50 @@ export class OpenBaoDriver implements SecretBackendDriver { })); } + /** + * LIVENESS. Deliberately unauthenticated: `sys/health` needs no token, and + * routing it through `request()` (as this used to) took a login first — so an + * expired role reported as "OpenBao is down", and every probe cost a login. + * + * OpenBao encodes its state in the status code, so map it rather than + * collapsing everything to ok/not-ok. + */ async healthCheck(): Promise<{ ok: boolean; detail?: string }> { try { - const res = await this.request('GET', '/v1/sys/health'); - return { ok: res.ok, detail: `HTTP ${res.status}` }; + const headers: Record = {}; + if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace; + const res = await this.fetchImpl(`${this.url}/v1/sys/health`, { + method: 'GET', + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }); + switch (res.status) { + case 200: return { ok: true, detail: 'active' }; + case 429: return { ok: true, detail: 'standby' }; + case 472: case 473: return { ok: true, detail: 'replication secondary' }; + case 501: return { ok: false, detail: 'not initialized' }; + case 503: return { ok: false, detail: 'sealed' }; + default: return { ok: res.ok, detail: `HTTP ${String(res.status)}` }; + } + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } + } + + /** + * READINESS. Exercises the capability we actually depend on — read/list under + * `//` — using the credentials we hold. + * + * `list()` rather than `auth/token/lookup-self` on purpose: lookup-self only + * proves the token exists, not that its policy still grants anything. The + * four-day outage in e51b924 was exactly a live token whose grants had been + * dropped by an upstream re-init. The existing read policy already permits + * this call, so it needs no bao-side change. + */ + async authCheck(): Promise<{ ok: boolean; detail?: string }> { + try { + await this.list(); + return { ok: true, detail: `readable at ${this.mount}/${this.pathPrefix}` }; } catch (err) { return { ok: false, detail: err instanceof Error ? err.message : String(err) }; } @@ -206,11 +270,28 @@ export class OpenBaoDriver implements SecretBackendDriver { const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`; const headers: Record = { 'Content-Type': 'application/json' }; if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace; - const res = await this.fetchImpl(loginUrl, { - method: 'POST', - headers, - body: JSON.stringify({ role: this.k8sRole, jwt }), - }); + // Bounded like every other call: a hung login is indistinguishable from a + // hung read to the caller, and this one used to have no timeout at all. + let res: Response; + try { + res = await this.fetchImpl(loginUrl, { + method: 'POST', + headers, + body: JSON.stringify({ role: this.k8sRole, jwt }), + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (err) { + throw new SecretBackendUnavailableError( + `OpenBao kubernetes login (role=${this.k8sRole!}): ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + if (RETRYABLE_STATUS.has(res.status)) { + throw new SecretBackendUnavailableError( + `OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)}`, + { lastStatus: res.status }, + ); + } if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`); @@ -229,30 +310,77 @@ export class OpenBaoDriver implements SecretBackendDriver { return clientToken; } - private async request(method: string, path: string, body?: unknown): Promise { - const token = await this.getToken(); + /** Build a fresh RequestInit — headers must not be shared across attempts. */ + private buildInit(method: string, token: string, body?: unknown): RequestInit { const headers: Record = { 'X-Vault-Token': token }; if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace; if (body !== undefined) headers['Content-Type'] = 'application/json'; - - const init: RequestInit = { method, headers }; + const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) }; if (body !== undefined) init.body = JSON.stringify(body); + return init; + } - const res = await this.fetchImpl(`${this.url}${path}`, init); + /** Full-jitter exponential backoff, so concurrent callers don't resonate. */ + private backoffFor(attempt: number): number { + return Math.random() * this.backoffBaseMs * Math.pow(2, attempt - 1); + } - // If the cached token expired between cache-check and request (k8s clock - // skew, server-side revocation, etc.), purge cache and retry once. - if (res.status === 403 && this.cachedToken !== undefined) { - this.cachedToken = undefined; - this.cachedTokenExpiresAt = 0; - const fresh = await this.getToken(); - const retryHeaders: Record = { 'X-Vault-Token': fresh }; - if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace; - if (body !== undefined) retryHeaders['Content-Type'] = 'application/json'; - const retryInit: RequestInit = { method, headers: retryHeaders }; - if (body !== undefined) retryInit.body = JSON.stringify(body); - return this.fetchImpl(`${this.url}${path}`, retryInit); + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${this.url}${path}`; + let lastStatus: number | undefined; + let lastErr: unknown; + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + let res: Response; + try { + const token = await this.getToken(); + res = await this.fetchImpl(url, this.buildInit(method, token, body)); + } catch (err) { + // Network failure, DNS failure, or our own AbortSignal firing. + lastErr = err; + if (attempt < this.maxAttempts) { + await this.sleep(this.backoffFor(attempt)); + continue; + } + throw new SecretBackendUnavailableError( + `OpenBao ${method} ${path}: ${err instanceof Error ? err.message : String(err)} (after ${String(attempt)} attempt(s))`, + { cause: err }, + ); + } + + // If the cached token expired between cache-check and request (k8s clock + // skew, server-side revocation, etc.), purge cache and retry once. This + // is deliberately OUTSIDE the retry budget: it is a credential refresh, + // not a backend-unavailable condition, and it must stay single-shot so a + // genuinely revoked grant fails loudly instead of looping. + if (res.status === 403 && this.cachedToken !== undefined) { + this.cachedToken = undefined; + this.cachedTokenExpiresAt = 0; + const fresh = await this.getToken(); + return this.fetchImpl(url, this.buildInit(method, fresh, body)); + } + + // The backend is up but cannot answer right now — 503 is also what a + // sealed OpenBao returns, which used to be an immediate hard failure. + if (RETRYABLE_STATUS.has(res.status) && attempt < this.maxAttempts) { + lastStatus = res.status; + await this.sleep(this.backoffFor(attempt)); + continue; + } + if (RETRYABLE_STATUS.has(res.status)) { + throw new SecretBackendUnavailableError( + `OpenBao ${method} ${path}: HTTP ${String(res.status)} after ${String(attempt)} attempt(s)`, + { lastStatus: res.status }, + ); + } + + return res; } - return res; + + /* c8 ignore next 5 -- unreachable: every loop exit above returns or throws */ + throw new SecretBackendUnavailableError( + `OpenBao ${method} ${path}: exhausted ${String(this.maxAttempts)} attempt(s)`, + lastErr !== undefined ? { cause: lastErr, ...(lastStatus !== undefined ? { lastStatus } : {}) } : (lastStatus !== undefined ? { lastStatus } : {}), + ); } } diff --git a/src/mcpd/src/services/secret-backends/types.ts b/src/mcpd/src/services/secret-backends/types.ts index bab41d5..8d91ce9 100644 --- a/src/mcpd/src/services/secret-backends/types.ts +++ b/src/mcpd/src/services/secret-backends/types.ts @@ -46,8 +46,24 @@ export interface SecretBackendDriver { /** List everything the backend knows about. Used for migration + drift detection. */ list(): Promise>; - /** Optional: health probe. Used by `mcpctl describe secretbackend`. */ + /** + * Optional LIVENESS probe: is the backend reachable at all? + * + * Must NOT require authentication — the whole point is to separate "the + * backend is down/sealed" from "our credentials stopped working". Compare + * `authCheck()`, which is the readiness half. + */ healthCheck?(): Promise<{ ok: boolean; detail?: string }>; + + /** + * Optional READINESS probe: can we actually read through this backend with + * the credentials we hold? + * + * A backend that answers `healthCheck()` but fails here is the exact shape of + * the incident where a re-initialised OpenBao left mcpd holding valid-looking + * tokens that granted nothing. Reporting one signal for both hides it. + */ + authCheck?(): Promise<{ ok: boolean; detail?: string }>; } /** Stored config for a SecretBackend row; dispatched on `type`. */ @@ -66,3 +82,40 @@ export interface BackendRow { export interface SecretRefResolver { resolve(secretName: string, key: string): Promise; } + +/** + * The backend gave a definitive answer: this secret (or key) does not exist. + * + * Callers may treat this as final. The caching decorator EVICTS on this and + * never serves a stale value for it — serving stale here would resurrect a + * deliberately deleted or revoked credential, which is strictly worse than an + * outage. + */ +export class SecretNotFoundError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'SecretNotFoundError'; + } +} + +/** + * The backend could not be reached or did not answer: DNS/TCP failure, request + * timeout, or an exhausted retry budget against 5xx/429. + * + * This is the ONLY error the caching decorator will serve a stale value for. + * The distinction has to be typed rather than string-matched: a mis-classified + * "not found" would resurrect deleted secrets, and a mis-classified auth + * failure would silently paper over a backend whose grants were revoked — the + * failure mode that let an OpenBao re-init break every secret write for four + * days (commit e51b924). + */ +export class SecretBackendUnavailableError extends Error { + /** HTTP status of the last attempt, when the failure was an HTTP response. */ + readonly lastStatus: number | undefined; + + constructor(message: string, options?: { cause?: unknown; lastStatus?: number }) { + super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'SecretBackendUnavailableError'; + this.lastStatus = options?.lastStatus; + } +} diff --git a/src/mcpd/tests/secret-backends.test.ts b/src/mcpd/tests/secret-backends.test.ts index 0f86075..43785a2 100644 --- a/src/mcpd/tests/secret-backends.test.ts +++ b/src/mcpd/tests/secret-backends.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { PlaintextDriver } from '../src/services/secret-backends/plaintext.js'; import { OpenBaoDriver } from '../src/services/secret-backends/openbao.js'; +import { SecretNotFoundError, SecretBackendUnavailableError } from '../src/services/secret-backends/types.js'; describe('PlaintextDriver', () => { const driver = new PlaintextDriver({ listAllPlaintext: async () => [{ name: 'a', data: { k: 'v' } }] }); @@ -242,3 +243,91 @@ describe('OpenBaoDriver', () => { }); }); }); + +describe('OpenBaoDriver: resilience', () => { + const resolver = { resolve: vi.fn(async () => 'test-vault-token') }; + /** No real sleeping — otherwise the backoff tests take seconds. */ + const noSleep = async (): Promise => undefined; + + function driverWith(fetchFn: ReturnType, opts: Record = {}): OpenBaoDriver { + return new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver, sleep: noSleep, ...opts }, + ); + } + + it('maps a 404 read to SecretNotFoundError', async () => { + const fetchFn = vi.fn(async () => new Response('', { status: 404 })); + await expect(driverWith(fetchFn).read({ name: 'gone', externalRef: '', data: {} })) + .rejects.toThrow(SecretNotFoundError); + }); + + it('purges the token cache and retries once on 403 — outside the retry budget', async () => { + // This path existed but was never covered; it is the revocation/re-init case. + let n = 0; + const fetchFn = vi.fn(async () => { + n++; + if (n === 1) return new Response('', { status: 403 }); + return new Response(JSON.stringify({ data: { data: { token: 'ok' } } }), { status: 200 }); + }); + const d = driverWith(fetchFn); + await expect(d.read({ name: 's', externalRef: '', data: {} })).resolves.toEqual({ token: 'ok' }); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('retries a 503 (sealed) and succeeds', async () => { + let n = 0; + const fetchFn = vi.fn(async () => { + n++; + if (n < 3) return new Response('', { status: 503 }); + return new Response(JSON.stringify({ data: { data: { token: 'ok' } } }), { status: 200 }); + }); + await expect(driverWith(fetchFn).read({ name: 's', externalRef: '', data: {} })) + .resolves.toEqual({ token: 'ok' }); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it('throws SecretBackendUnavailableError once the retry budget is exhausted', async () => { + const fetchFn = vi.fn(async () => new Response('', { status: 503 })); + await expect(driverWith(fetchFn, { maxAttempts: 3 }).read({ name: 's', externalRef: '', data: {} })) + .rejects.toThrow(SecretBackendUnavailableError); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it('classifies a network/abort failure as SecretBackendUnavailableError', async () => { + const fetchFn = vi.fn(async () => { throw new DOMException('timed out', 'TimeoutError'); }); + await expect(driverWith(fetchFn, { maxAttempts: 2 }).read({ name: 's', externalRef: '', data: {} })) + .rejects.toThrow(SecretBackendUnavailableError); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('passes an AbortSignal on every request', async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ data: { data: {} } }), { status: 200 })); + await driverWith(fetchFn, { timeoutMs: 1234 }).read({ name: 's', externalRef: '', data: {} }); + const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('healthCheck is unauthenticated and maps OpenBao status codes', async () => { + const cases: Array<[number, boolean, string]> = [ + [200, true, 'active'], + [429, true, 'standby'], + [501, false, 'not initialized'], + [503, false, 'sealed'], + ]; + for (const [status, ok, detail] of cases) { + const fetchFn = vi.fn(async () => new Response('', { status })); + const result = await driverWith(fetchFn).healthCheck(); + expect(result).toEqual({ ok, detail }); + // The whole point of the split: no token is minted for a liveness probe. + const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit]; + expect((init.headers as Record)['X-Vault-Token']).toBeUndefined(); + } + }); + + it('authCheck reports false when the token can no longer list', async () => { + const fetchFn = vi.fn(async () => new Response('', { status: 403 })); + const result = await driverWith(fetchFn).authCheck(); + expect(result.ok).toBe(false); + }); +}); diff --git a/src/mcpd/tests/secret-cache.test.ts b/src/mcpd/tests/secret-cache.test.ts new file mode 100644 index 0000000..fbbf047 --- /dev/null +++ b/src/mcpd/tests/secret-cache.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + CachingSecretBackendDriver, + type CachingDriverLog, +} from '../src/services/secret-backends/caching.js'; +import { + SecretNotFoundError, + SecretBackendUnavailableError, + type SecretBackendDriver, + type SecretData, +} from '../src/services/secret-backends/types.js'; + +/** Minimal fake backing driver whose read() behaviour the tests drive. */ +function makeInner(overrides: Partial = {}): SecretBackendDriver & { + read: ReturnType; + write: ReturnType; + delete: ReturnType; +} { + return { + kind: 'fake', + read: vi.fn(async () => ({ token: 'live' } as SecretData)), + write: vi.fn(async () => ({ externalRef: 'ref', storedData: {} as SecretData })), + delete: vi.fn(async () => undefined), + list: vi.fn(async () => []), + ...overrides, + } as never; +} + +function makeLog(): CachingDriverLog & { warns: Array>; infos: Array> } { + const warns: Array> = []; + const infos: Array> = []; + return { warns, infos, warn: (o) => { warns.push(o); }, info: (o) => { infos.push(o); } }; +} + +const REQ = { name: 'gitea-creds', externalRef: 'secret/mcpctl/gitea-creds', data: {} }; + +describe('CachingSecretBackendDriver', () => { + it('serves from cache within the TTL without touching the backend', async () => { + const inner = makeInner(); + let now = 1_000; + const d = new CachingSecretBackendDriver(inner, { ttlMs: 5_000, now: () => now }); + + expect(await d.read(REQ)).toEqual({ token: 'live' }); + now += 4_999; + expect(await d.read(REQ)).toEqual({ token: 'live' }); + + expect(inner.read).toHaveBeenCalledTimes(1); + }); + + it('refetches once the TTL has elapsed', async () => { + const inner = makeInner(); + let now = 1_000; + const d = new CachingSecretBackendDriver(inner, { ttlMs: 5_000, now: () => now }); + + await d.read(REQ); + now += 5_001; + await d.read(REQ); + + expect(inner.read).toHaveBeenCalledTimes(2); + }); + + it('serves the stale value when the backend is unavailable', async () => { + const inner = makeInner(); + let now = 1_000; + const log = makeLog(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log, backendName: 'bao' }); + + await d.read(REQ); + inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down')); + now += 10_000; + + // This is the whole point: no throw, so instance.service never marks ERROR. + expect(await d.read(REQ)).toEqual({ token: 'live' }); + expect(log.warns[0]?.kind).toBe('BACKEND_UNREACHABLE'); + }); + + it('logs BACKEND_UNREACHABLE only on the transition, not on every stale read', async () => { + const inner = makeInner(); + let now = 1_000; + const log = makeLog(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log }); + + await d.read(REQ); + inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down')); + for (let i = 0; i < 5; i++) { now += 2_000; await d.read(REQ); } + + expect(log.warns.filter((w) => w.kind === 'BACKEND_UNREACHABLE')).toHaveLength(1); + }); + + it('logs BACKEND_RECOVERED once the backend answers again', async () => { + const inner = makeInner(); + let now = 1_000; + const log = makeLog(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log }); + + await d.read(REQ); + inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down')); + now += 2_000; + await d.read(REQ); + + inner.read.mockResolvedValue({ token: 'rotated' }); + now += 2_000; + expect(await d.read(REQ)).toEqual({ token: 'rotated' }); + expect(log.infos.filter((i) => i.kind === 'BACKEND_RECOVERED')).toHaveLength(1); + }); + + it('NEVER serves stale for a deleted secret — evicts and rethrows', async () => { + // Regression guard. Serving stale here would resurrect a revoked + // credential, which is strictly worse than an outage. + const inner = makeInner(); + let now = 1_000; + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now }); + + await d.read(REQ); + inner.read.mockRejectedValue(new SecretNotFoundError('gone')); + now += 2_000; + + await expect(d.read(REQ)).rejects.toThrow(SecretNotFoundError); + expect(d.stats().entries).toBe(0); + + // And the entry really is gone — a later unavailable error has nothing to serve. + inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down')); + await expect(d.read(REQ)).rejects.toThrow(SecretBackendUnavailableError); + }); + + it('does not serve stale for a non-transport error (e.g. revoked grants)', async () => { + const inner = makeInner(); + let now = 1_000; + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now }); + + await d.read(REQ); + inner.read.mockRejectedValue(new Error('OpenBao read: HTTP 403 permission denied')); + now += 2_000; + + await expect(d.read(REQ)).rejects.toThrow(/403/); + }); + + it('rethrows on a cold cache even when the backend is unavailable', async () => { + const inner = makeInner({ read: vi.fn(async () => { throw new SecretBackendUnavailableError('bao down'); }) as never }); + const d = new CachingSecretBackendDriver(inner); + + await expect(d.read(REQ)).rejects.toThrow(SecretBackendUnavailableError); + }); + + it('write() refreshes the cache so a read-after-write does not lag', async () => { + const inner = makeInner(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000 }); + + await d.read(REQ); + await d.write({ name: REQ.name, data: { token: 'brand-new' } }); + + expect(await d.read(REQ)).toEqual({ token: 'brand-new' }); + expect(inner.read).toHaveBeenCalledTimes(1); + }); + + it('delete() evicts', async () => { + const inner = makeInner(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000 }); + + await d.read(REQ); + await d.delete({ name: REQ.name, externalRef: REQ.externalRef }); + + expect(d.stats().entries).toBe(0); + }); + + it('bounds the map with an LRU eviction', async () => { + const inner = makeInner(); + const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000, maxEntries: 2 }); + + await d.read({ ...REQ, name: 'a' }); + await d.read({ ...REQ, name: 'b' }); + await d.read({ ...REQ, name: 'a' }); // 'a' becomes most-recently-used + await d.read({ ...REQ, name: 'c' }); // evicts 'b' + + expect(d.stats().entries).toBe(2); + inner.read.mockClear(); + await d.read({ ...REQ, name: 'a' }); + expect(inner.read).not.toHaveBeenCalled(); // 'a' survived + await d.read({ ...REQ, name: 'b' }); + expect(inner.read).toHaveBeenCalledTimes(1); // 'b' was evicted + }); + + it('reports stale count and age via stats()', async () => { + const inner = makeInner(); + let now = 1_000; + const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now }); + + await d.read({ ...REQ, name: 'a' }); + await d.read({ ...REQ, name: 'b' }); + expect(d.stats()).toMatchObject({ entries: 2, servingStale: 0 }); + + inner.read.mockRejectedValue(new SecretBackendUnavailableError('down')); + now += 2_000; + await d.read({ ...REQ, name: 'a' }); + + expect(d.stats()).toMatchObject({ entries: 2, servingStale: 1, oldestStaleSince: 3_000 }); + }); +}); From 0fbfc72d68f9adebbf2b8521217968468b4994ec Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 22:14:12 +0100 Subject: [PATCH 49/77] feat(secrets): report real backend health instead of a hard-coded tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl status` derived its Secrets verdict entirely from `tokenMeta.lastRotationError`. The rotator writes that field only for `auth: 'token'` backends (SecretBackendRotator.isRotatable), so a `kubernetes`-auth backend never wrote it and the line rendered a green tick unconditionally — including with OpenBao sealed, unreachable, or answering 403 to every read. The one signal we had was structurally incapable of going red for the backend we actually run. New `GET /api/v1/secretbackends/:id/health` reports two signals, kept separate on purpose: live — reachable at all? (unauthenticated sys/health) ready — can we read through it? (uses our credentials) live-but-not-ready is the exact shape of a re-initialised OpenBao handing back valid-looking tokens that grant nothing; collapsing both into one boolean is what hid that for four days. Needs no RBAC mapping — it falls through to the generic `secretbackends` resource, so a GET is `view:secretbackends`. `mcpctl status` now renders four states — reachable / degraded (serving N cached secrets) / unreachable / auth failed — with rotation error demoted to a trailing clause rather than the verdict. A failed probe renders "? unknown", never green: not knowing is not health. JSON output carries the same probe, so scripts stop being told every k8s-auth backend is fine. Also adds a boot-time cache warm. The stale-while-error cache can only absorb an outage for secrets it has already seen, so a cold mcpd during a backend outage still fails; resolving each running server's refs once at startup closes that for the common case. Best-effort and deliberately partial — if the backend is also down at boot this is a no-op and instances fail loudly, which is correct. Persisting last-known-good to Postgres or disk would just be plaintext-at-rest again. Tests: 15 new. The five status assertions were confirmed to fail against the old rotation-only logic before being kept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- src/cli/src/commands/status.ts | 130 ++++++++++++++++-- src/cli/tests/commands/status.test.ts | 57 ++++++-- src/mcpd/src/bootstrap/warm-secret-cache.ts | 67 +++++++++ src/mcpd/src/main.ts | 15 ++ src/mcpd/src/routes/secret-backend-health.ts | 76 ++++++++++ .../tests/secret-backend-health-route.test.ts | 98 +++++++++++++ src/mcpd/tests/warm-secret-cache.test.ts | 66 +++++++++ 7 files changed, 488 insertions(+), 21 deletions(-) create mode 100644 src/mcpd/src/bootstrap/warm-secret-cache.ts create mode 100644 src/mcpd/src/routes/secret-backend-health.ts create mode 100644 src/mcpd/tests/secret-backend-health-route.test.ts create mode 100644 src/mcpd/tests/warm-secret-cache.test.ts diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index a523d9d..c2ce0b3 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -50,6 +50,7 @@ interface ServerLlm { * of the last credential-rotation failure (e.g. a dead OpenBao token). */ interface SecretBackendInfo { + id: string; name: string; type: string; isDefault?: boolean; @@ -60,6 +61,22 @@ interface SecretBackendInfo { } | null; } +/** + * Live probe result from GET /api/v1/secretbackends/:id/health. + * + * `live` and `ready` are deliberately separate: a backend that is reachable but + * whose credentials no longer grant anything is the failure mode that hid an + * OpenBao re-init for four days. `null` means the probe itself failed, which we + * report as unknown rather than pretending it means healthy. + */ +interface SecretBackendHealth { + live: boolean; + liveDetail?: string; + ready: boolean; + readyDetail?: string; + cache?: { entries: number; servingStale: number; oldestStaleSince?: number | null } | null; +} + /** * Result of a live "say hi" probe against a server LLM. `ok` says we got a * 200 + non-empty content back; `say` is the trimmed first 16 chars of the @@ -99,6 +116,7 @@ export interface StatusCommandDeps { probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise; /** Fetch SecretBackends from mcpd to surface backend health. Null on error. */ fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise; + probeSecretBackend: (mcpdUrl: string, id: string, token: string | null) => Promise; isTTY: boolean; } @@ -275,6 +293,38 @@ function defaultFetchSecretBackends(mcpdUrl: string, token: string | null): Prom }); } +/** + * Live-probe one SecretBackend. Resolves to null on any unhappy path — same + * never-throw discipline as the other probes here, and `null` renders as + * "unknown", never as healthy. + */ +function defaultProbeSecretBackend(mcpdUrl: string, id: string, token: string | null): Promise { + return new Promise((resolve) => { + let req: http.ClientRequest; + const headers: Record = { Accept: 'application/json' }; + if (token !== null) headers['Authorization'] = `Bearer ${token}`; + try { + req = httpDriverFor(mcpdUrl).get(`${mcpdUrl}/api/v1/secretbackends/${id}/health`, { timeout: 5000, headers }, (res) => { + if (res.statusCode !== 200) { resolve(null); res.resume(); return; } + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SecretBackendHealth); + } catch { + resolve(null); + } + }); + }); + } catch { + resolve(null); + return; + } + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + /** * POST a tiny "say hi" prompt to /api/v1/llms//infer and decide if * the LLM actually serves inference. Returns ok=true when the response is @@ -386,6 +436,7 @@ const defaultDeps: StatusCommandDeps = { fetchProviders: defaultFetchProviders, fetchServerLlms: defaultFetchServerLlms, fetchSecretBackends: defaultFetchSecretBackends, + probeSecretBackend: defaultProbeSecretBackend, probeServerLlm: defaultProbeServerLlm, isTTY: process.stdout.isTTY ?? false, }; @@ -448,7 +499,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): } export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, isTTY } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, probeSecretBackend, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -482,6 +533,30 @@ export function createStatusCommand(deps?: Partial): Command }))) : null; + // Same live probe the table view uses. `healthy` is derived from the + // probe, NOT from tokenMeta.lastRotationError — that field is only ever + // written for token-auth backends, so scripts consuming it were told + // every kubernetes-auth backend was healthy unconditionally. + const secretBackendsWithHealth = secretBackends !== null + ? await Promise.all(secretBackends.map(async (b) => { + const health = await probeSecretBackend(config.mcpdUrl, b.id, token); + return { + name: b.name, + type: b.type, + healthy: health !== null && health.live && health.ready, + live: health?.live ?? null, + ready: health?.ready ?? null, + servingStale: health?.cache?.servingStale ?? 0, + error: health === null + ? 'health probe failed' + : !health.live ? (health.liveDetail ?? 'unreachable') + : !health.ready ? (health.readyDetail ?? 'auth failed') + : null, + rotationError: b.tokenMeta?.lastRotationError ?? null, + }; + })) + : null; + const llm = llmLabel ? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})` : null; @@ -499,7 +574,7 @@ export function createStatusCommand(deps?: Partial): Command llmStatus, ...(providersInfo ? { providers: providersInfo } : {}), ...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}), - ...(secretBackends !== null ? { secretBackends: secretBackends.map((b) => ({ name: b.name, type: b.type, healthy: !b.tokenMeta?.lastRotationError, error: b.tokenMeta?.lastRotationError ?? null })) } : {}), + ...(secretBackends !== null ? { secretBackends: secretBackendsWithHealth } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); @@ -530,7 +605,7 @@ export function createStatusCommand(deps?: Partial): Command if (!llmLabel) { log(`LLM: not configured (run 'mcpctl config setup')`); - await renderSecretBackendsSection(secretBackendsPromise, isTTY); + await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); return; } @@ -595,7 +670,7 @@ export function createStatusCommand(deps?: Partial): Command } } - await renderSecretBackendsSection(secretBackendsPromise, isTTY); + await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); }); @@ -609,21 +684,50 @@ export function createStatusCommand(deps?: Partial): Command async function renderSecretBackendsSection( backendsPromise: Promise, ansi: boolean, + mcpdUrl: string, + token: string | null, ): Promise { const backends = await backendsPromise; if (backends === null || backends.length === 0) return; - const parts = backends.map((b) => { - const err = b.tokenMeta?.lastRotationError; - const tag = b.isDefault ? `${b.name}*` : b.name; - if (err) { - const short = err.split('\n')[0]?.slice(0, 80) ?? 'error'; - return ansi ? `${tag} ${RED}✗ ${short}${RESET}` : `${tag} ✗ ${short}`; - } - return ansi ? `${tag} ${GREEN}✓${RESET}` : `${tag} ✓`; - }); + const healths = await Promise.all(backends.map((b) => probeSecretBackend(mcpdUrl, b.id, token))); + const parts = backends.map((b, i) => renderOneBackend(b, healths[i] ?? null, ansi)); log(`Secrets: ${parts.join(', ')}`); } + /** + * Render one backend's status line. + * + * This used to be `tokenMeta.lastRotationError ? red : green`, which was a + * hard-coded green tick for every `auth: kubernetes` backend — the rotator + * only writes that field for token-auth backends, so it was never set and + * `mcpctl status` reported OpenBao healthy even when it was unreachable. + * Rotation state is now one clause among several, not the only signal. + */ + function renderOneBackend(b: SecretBackendInfo, health: SecretBackendHealth | null, ansi: boolean): string { + const tag = b.isDefault === true ? `${b.name}*` : b.name; + const paint = (colour: string, text: string): string => (ansi ? `${colour}${text}${RESET}` : text); + const rotationErr = b.tokenMeta?.lastRotationError ?? ''; + const rotationClause = rotationErr === '' + ? '' + : ` (rotation: ${rotationErr.split('\n')[0]?.slice(0, 60) ?? 'error'})`; + + if (health === null) { + // The probe itself failed. Unknown is not healthy — say so. + return `${tag} ${paint(YELLOW, '? unknown')}${rotationClause}`; + } + if (!health.live) { + return `${tag} ${paint(RED, `✗ unreachable: ${health.liveDetail ?? 'no detail'}`)}${rotationClause}`; + } + if (!health.ready) { + return `${tag} ${paint(RED, `✗ auth failed: ${(health.readyDetail ?? 'no detail').slice(0, 60)}`)}${rotationClause}`; + } + const stale = health.cache?.servingStale ?? 0; + if (stale > 0) { + return `${tag} ${paint(YELLOW, `⚠ degraded — serving ${String(stale)} cached secret(s)`)}${rotationClause}`; + } + return `${tag} ${paint(GREEN, '✓ reachable')}${rotationClause}`; + } + /** * Print a "Server LLMs:" section listing mcpd-managed Llm rows by tier * with a per-LLM "say hi" liveness probe. Distinct from the mcplocal-side diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index d627b55..b6b5ee9 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -30,6 +30,7 @@ function baseDeps(overrides?: Partial): Partial null, probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }), fetchSecretBackends: async () => null, + probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 0, servingStale: 0 } }), isTTY: false, ...overrides, }; @@ -46,33 +47,73 @@ afterEach(() => { }); describe('status command', () => { + const BAO = { id: 'b1', name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }; + it('shows a healthy secret backend in the Secrets line', async () => { const cmd = createStatusCommand(baseDeps({ - fetchSecretBackends: async () => [ - { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }, - { name: 'default', type: 'plaintext' }, - ], + fetchSecretBackends: async () => [BAO, { id: 'b2', name: 'default', type: 'plaintext' }], })); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); expect(out).toContain('Secrets:'); - expect(out).toContain('bao* ✓'); - expect(out).toContain('default ✓'); + expect(out).toContain('bao* ✓ reachable'); + expect(out).toContain('default ✓ reachable'); }); it('flags a dead secret-backend token in the Secrets line', async () => { const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => [ - { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, + { ...BAO, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, ], })); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); - expect(out).toContain('bao* ✗'); expect(out).toContain('BACKEND_TOKEN_DEAD'); expect(out).not.toContain('more detail'); // only first line, truncated }); + it('reports an unreachable backend even when rotation never errored', async () => { + // THE bug: a kubernetes-auth backend never writes tokenMeta.lastRotationError, + // so this line used to render a green tick with OpenBao completely down. + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: false, liveDetail: 'sealed', ready: false }), + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ✗ unreachable: sealed'); + expect(out).not.toContain('✓'); + }); + + it('distinguishes reachable-but-unusable from unreachable', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: true, ready: false, readyDetail: 'HTTP 403 permission denied' }), + })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('bao* ✗ auth failed: HTTP 403 permission denied'); + }); + + it('reports degraded while serving cached secrets', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 9, servingStale: 7 } }), + })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('bao* ⚠ degraded — serving 7 cached secret(s)'); + }); + + it('reports unknown — never healthy — when the probe itself fails', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => null, + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ? unknown'); + expect(out).not.toContain('✓'); + }); + it('omits the Secrets line when mcpd returns no backends', async () => { const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => null })); await cmd.parseAsync([], { from: 'user' }); diff --git a/src/mcpd/src/bootstrap/warm-secret-cache.ts b/src/mcpd/src/bootstrap/warm-secret-cache.ts new file mode 100644 index 0000000..665f984 --- /dev/null +++ b/src/mcpd/src/bootstrap/warm-secret-cache.ts @@ -0,0 +1,67 @@ +/** + * One-shot: resolve every secret that a running server depends on, so the + * value cache holds a last-known-good copy before anything needs it. + * + * The caching driver absorbs a backend outage by serving the last value it saw + * — but only for secrets it has actually seen. Without this, a cold mcpd (fresh + * deploy, pod reschedule, crash-restart) has an empty cache, and if the backend + * is unreachable at that moment every secret-bearing server fails to start. + * + * This is the honest mitigation, and it is deliberately partial: if the backend + * is ALSO down at boot, this changes nothing and instances fail loudly, which + * is correct. The alternatives — persisting last-known-good to Postgres or to + * disk — are just "plaintext secrets at rest" wearing a hat, which is the thing + * we are trying to move away from. + * + * Best-effort by construction: a failure here must never block startup, and the + * warm is per-secret so one bad reference doesn't abandon the rest. + */ +import type { PrismaClient } from '@prisma/client'; +import type { SecretService } from '../services/secret.service.js'; +import type { ServerEnvEntry } from '../validation/mcp-server.schema.js'; + +export interface WarmLog { + info: (msg: string) => void; + warn: (msg: string) => void; +} + +export async function warmSecretCache( + prisma: PrismaClient, + secrets: SecretService, + log: WarmLog, +): Promise<{ warmed: number; failed: number }> { + const servers = await prisma.mcpServer.findMany({ + where: { replicas: { gt: 0 } }, + select: { name: true, env: true }, + }); + + // Distinct (secret, key) pairs — several servers commonly share one secret, + // and there is no point paying for the same read more than once. + const refs = new Map(); + for (const server of servers) { + for (const entry of (server.env ?? []) as ServerEnvEntry[]) { + const ref = entry.valueFrom?.secretRef; + if (ref === undefined) continue; + refs.set(`${ref.name}/${ref.key}`, { name: ref.name, key: ref.key }); + } + } + if (refs.size === 0) return { warmed: 0, failed: 0 }; + + let warmed = 0; + let failed = 0; + for (const ref of refs.values()) { + try { + // Value deliberately discarded — we only want it in the cache. + await secrets.resolve(ref.name, ref.key); + warmed++; + } catch { + // Expected when the backend is down, or when a server references a + // secret that no longer exists. Neither should block startup, and both + // surface loudly at instance-start time anyway. + failed++; + } + } + + log.info(`secret cache warm: ${String(warmed)} resolved, ${String(failed)} unavailable`); + return { warmed, failed }; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 4b72346..16964bd 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -25,11 +25,13 @@ import { SecretBackendService } from './services/secret-backend.service.js'; import { SecretMigrateService } from './services/secret-migrate.service.js'; import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js'; +import { warmSecretCache } from './bootstrap/warm-secret-cache.js'; import { registerSecretBackendRoutes } from './routes/secret-backends.js'; import { registerSecretMigrateRoutes } from './routes/secret-migrate.js'; import { SecretBackendRotator } from './services/secret-backend-rotator.service.js'; import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop.js'; import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js'; +import { registerSecretBackendHealthRoutes } from './routes/secret-backend-health.js'; import { LlmRepository } from './repositories/llm.repository.js'; import { LlmService } from './services/llm.service.js'; import { InferenceTaskRepository } from './repositories/inference-task.repository.js'; @@ -689,6 +691,7 @@ async function main(): Promise { registerSecretRoutes(app, secretService); registerSecretBackendRoutes(app, secretBackendService); registerSecretBackendRotateRoutes(app, secretBackendRotator); + registerSecretBackendHealthRoutes(app, secretBackendService); registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); @@ -976,6 +979,18 @@ async function main(): Promise { app.log.error({ err }, 'secret keyNames backfill failed'); }); + // One-shot: pre-populate the secret value cache so a later backend outage is + // absorbed rather than cascading into instance ERROR loops. Best-effort — if + // the backend is already down at boot this is a no-op and instances fail + // honestly. See bootstrap/warm-secret-cache.ts. + warmSecretCache( + prisma, + secretService, + { info: (m: string): void => { app.log.info(m); }, warn: (m: string): void => { app.log.warn(m); } }, + ).catch((err: unknown) => { + app.log.warn({ err }, 'secret cache warm failed (non-fatal)'); + }); + // Graceful shutdown setupGracefulShutdown(app, { disconnectDb: async () => { diff --git a/src/mcpd/src/routes/secret-backend-health.ts b/src/mcpd/src/routes/secret-backend-health.ts new file mode 100644 index 0000000..8a0e457 --- /dev/null +++ b/src/mcpd/src/routes/secret-backend-health.ts @@ -0,0 +1,76 @@ +/** + * GET /api/v1/secretbackends/:id/health — a live probe of a secret backend. + * + * Exists because the only health signal we had was `tokenMeta.lastRotationError`, + * and the rotator writes that field ONLY for `auth: 'token'` backends + * (`SecretBackendRotator.isRotatable()`). A `kubernetes`-auth backend therefore + * never wrote it and rendered a hard-coded green tick in `mcpctl status` — even + * with OpenBao completely unreachable. + * + * Two signals, deliberately separate, mirroring the liveness/readiness split + * that instance health probes already use: + * + * live — is the backend reachable at all? (unauthenticated) + * ready — can we actually read through it? (uses our credentials) + * + * A backend that is `live` but not `ready` is the exact shape of a re-initialised + * OpenBao that left us holding valid-looking tokens granting nothing. Collapsing + * the two into one boolean is what hid that for four days. + * + * RBAC: no special mapping needed — `mapUrlToPermission` falls through to the + * generic `secretbackends` resource, so a GET requires `view:secretbackends`. + */ +import type { FastifyInstance } from 'fastify'; +import type { SecretBackendService } from '../services/secret-backend.service.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +interface TokenMetaShape { + lastRotationAt?: string; + lastRotationError?: string | null; + rotatable?: boolean; +} + +export function registerSecretBackendHealthRoutes( + app: FastifyInstance, + backends: SecretBackendService, +): void { + app.get<{ Params: { id: string } }>('/api/v1/secretbackends/:id/health', async (request, reply) => { + try { + const backend = await backends.getById(request.params.id); + const driver = backends.driverFor(backend); + + const live = await driver.healthCheck?.() ?? { ok: true, detail: 'no probe' }; + // Only probe readiness if the backend answered at all — otherwise the + // auth check just re-reports the same outage with a confusing message. + const ready = live.ok + ? await driver.authCheck?.() ?? { ok: true, detail: 'no probe' } + : { ok: false, detail: 'not probed (backend unreachable)' }; + + const meta = (backend.tokenMeta ?? {}) as TokenMetaShape; + const cache = backends.cacheStatsFor(backend); + + return { + backend: backend.name, + type: backend.type, + live: live.ok, + liveDetail: live.detail, + ready: ready.ok, + readyDetail: ready.detail, + // Present only for cached (remote) backends; plaintext has no cache. + cache: cache ?? null, + rotation: { + rotatable: meta.rotatable ?? false, + lastRotationAt: meta.lastRotationAt ?? null, + lastRotationError: meta.lastRotationError ?? null, + }, + }; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + reply.code(502); + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/src/mcpd/tests/secret-backend-health-route.test.ts b/src/mcpd/tests/secret-backend-health-route.test.ts new file mode 100644 index 0000000..809b3f2 --- /dev/null +++ b/src/mcpd/tests/secret-backend-health-route.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type { SecretBackend } from '@prisma/client'; +import { registerSecretBackendHealthRoutes } from '../src/routes/secret-backend-health.js'; +import { SecretBackendService } from '../src/services/secret-backend.service.js'; +import type { ISecretBackendRepository } from '../src/repositories/secret-backend.repository.js'; +import type { SecretBackendDriver } from '../src/services/secret-backends/types.js'; + +let app: FastifyInstance; +afterEach(async () => { await app?.close(); }); + +function backendRow(overrides: Partial = {}): SecretBackend { + return { + id: 'b1', name: 'bao-k8s', type: 'openbao', + config: { url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' }, + isDefault: true, description: '', version: 1, + createdAt: new Date(), updatedAt: new Date(), + ...overrides, + } as SecretBackend; +} + +/** + * Build the route over a service whose driver is stubbed. We override + * `driverFor` rather than the factory so the test drives the two probes + * directly — the point here is the route's reporting, not driver internals. + */ +async function buildApp( + probes: Pick, + row: SecretBackend = backendRow(), +): Promise { + const repo = { + findById: vi.fn(async (id: string) => (id === row.id ? row : null)), + } as unknown as ISecretBackendRepository; + const svc = new SecretBackendService(repo, { + plaintext: { listAllPlaintext: async () => [] }, + secretRefResolver: { resolve: async () => 'tok' }, + }); + vi.spyOn(svc, 'driverFor').mockReturnValue({ kind: 'openbao', ...probes } as SecretBackendDriver); + vi.spyOn(svc, 'cacheStatsFor').mockReturnValue({ entries: 3, servingStale: 0, oldestStaleSince: undefined }); + + const a = Fastify(); + registerSecretBackendHealthRoutes(a, svc); + await a.ready(); + return a; +} + +describe('GET /api/v1/secretbackends/:id/health', () => { + it('reports live+ready when the backend is fully working', async () => { + app = await buildApp({ + healthCheck: async () => ({ ok: true, detail: 'active' }), + authCheck: async () => ({ ok: true, detail: 'readable at secret/mcpctl' }), + }); + const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ backend: 'bao-k8s', live: true, ready: true }); + }); + + it('reports NOT live when OpenBao is sealed — regardless of rotation state', async () => { + // The bug this endpoint exists for: a kubernetes-auth backend never writes + // tokenMeta.lastRotationError, so the old status line stayed green here. + app = await buildApp({ + healthCheck: async () => ({ ok: false, detail: 'sealed' }), + authCheck: async () => ({ ok: true, detail: 'should not be consulted' }), + }); + const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json(); + expect(body.live).toBe(false); + expect(body.liveDetail).toBe('sealed'); + expect(body.ready).toBe(false); + expect(body.readyDetail).toMatch(/not probed/); + expect(body.rotation.lastRotationError).toBeNull(); + }); + + it('distinguishes reachable-but-unusable (revoked grants) from unreachable', async () => { + app = await buildApp({ + healthCheck: async () => ({ ok: true, detail: 'active' }), + authCheck: async () => ({ ok: false, detail: 'OpenBao list: HTTP 403 permission denied' }), + }); + const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json(); + expect(body).toMatchObject({ live: true, ready: false }); + expect(body.readyDetail).toMatch(/403/); + }); + + it('surfaces cache state so degraded serving is visible', async () => { + app = await buildApp({ + healthCheck: async () => ({ ok: true }), + authCheck: async () => ({ ok: true }), + }); + const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json(); + expect(body.cache).toMatchObject({ entries: 3, servingStale: 0 }); + }); + + it('404s for an unknown backend', async () => { + app = await buildApp({ healthCheck: async () => ({ ok: true }), authCheck: async () => ({ ok: true }) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/nope/health' }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/src/mcpd/tests/warm-secret-cache.test.ts b/src/mcpd/tests/warm-secret-cache.test.ts new file mode 100644 index 0000000..dfc6a95 --- /dev/null +++ b/src/mcpd/tests/warm-secret-cache.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { warmSecretCache } from '../src/bootstrap/warm-secret-cache.js'; +import type { PrismaClient } from '@prisma/client'; +import type { SecretService } from '../src/services/secret.service.js'; + +function prismaWith(servers: Array<{ name: string; env: unknown }>): PrismaClient { + return { mcpServer: { findMany: vi.fn(async () => servers) } } as unknown as PrismaClient; +} +const noLog = { info: (): void => undefined, warn: (): void => undefined }; + +const envRef = (name: string, secret: string, key: string): unknown => + ({ name, valueFrom: { secretRef: { name: secret, key } } }); + +describe('warmSecretCache', () => { + it('resolves every distinct secret ref exactly once', async () => { + const prisma = prismaWith([ + { name: 'gitea', env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN')] }, + // Two servers sharing one secret must not cost two reads. + { name: 'a', env: [envRef('T', 'shared', 'TOKEN')] }, + { name: 'b', env: [envRef('T', 'shared', 'TOKEN')] }, + ]); + const resolve = vi.fn(async () => 'value'); + const result = await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(result).toEqual({ warmed: 2, failed: 0 }); + }); + + it('ignores inline env values', async () => { + const prisma = prismaWith([{ name: 's', env: [{ name: 'PLAIN', value: 'x' }] }]); + const resolve = vi.fn(async () => 'v'); + expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog)) + .toEqual({ warmed: 0, failed: 0 }); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('never throws when the backend is down — startup must not block', async () => { + const prisma = prismaWith([ + { name: 'a', env: [envRef('T', 's1', 'K')] }, + { name: 'b', env: [envRef('T', 's2', 'K')] }, + ]); + const resolve = vi.fn(async () => { throw new Error('bao unreachable'); }); + await expect(warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog)) + .resolves.toEqual({ warmed: 0, failed: 2 }); + }); + + it('keeps going after one bad reference', async () => { + const prisma = prismaWith([ + { name: 'a', env: [envRef('T', 'missing', 'K')] }, + { name: 'b', env: [envRef('T', 'present', 'K')] }, + ]); + const resolve = vi.fn(async (n: string) => { + if (n === 'missing') throw new Error('no such secret'); + return 'v'; + }); + expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog)) + .toEqual({ warmed: 1, failed: 1 }); + }); + + it('only considers servers with replicas > 0', async () => { + const prisma = prismaWith([]); + await warmSecretCache(prisma, { resolve: vi.fn() } as unknown as SecretService, noLog); + const findMany = (prisma.mcpServer.findMany as unknown as ReturnType); + expect(findMany.mock.calls[0]?.[0]).toMatchObject({ where: { replicas: { gt: 0 } } }); + }); +}); From fe9987cefd430ab7017e7b8e5aeba87b15e88f55 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 22:16:04 +0100 Subject: [PATCH 50/77] test(secrets): smoke-cover backend health honesty + cache correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of these four assertions currently FAIL against the live cluster, which is the point: mcpd there still renders `Secrets: bao-k8s* ✓` from the rotation field, and its JSON status carries no live/ready pair. Per the project rule the fix is to deploy, not to relax the assertion. The status check specifically rejects a bare "name ✓" with no qualifier — that string is the old rendering and proves the probe was never consulted. Deliberately does not take the real OpenBao down. Simulating an outage against shared infrastructure to satisfy a test would be worse than the bug it covers; the outage paths are unit-tested with an injected clock. Docs: adds a Reliability section (request hardening, the stale-while-error table, the cold-cache gap and why persisting values is not the answer, live-vs-ready) and replaces the stale "Kubernetes ServiceAccount auth is not shipped yet" note — it shipped in 5152066, and it is what the live backend has used since June. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- docs/secret-backends.md | 106 +++++++++++++- .../smoke/secret-resilience.smoke.test.ts | 133 ++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/mcplocal/tests/smoke/secret-resilience.smoke.test.ts diff --git a/docs/secret-backends.md b/docs/secret-backends.md index d53d358..78df3bf 100644 --- a/docs/secret-backends.md +++ b/docs/secret-backends.md @@ -118,8 +118,110 @@ That's the whole point of keeping plaintext around — it's the trust root: token itself. DB access is now equivalent to OpenBao token access (a single key), not equivalent to all API keys in the system. -Follow-up work (not shipped yet) replaces static token auth with Kubernetes -ServiceAccount auth so no bootstrap token is needed at all. +#### Kubernetes ServiceAccount auth (no bootstrap token) + +`auth: kubernetes` removes the chicken-and-egg entirely: mcpd exchanges its +projected ServiceAccount JWT for an OpenBao token at +`auth//role/`, so there is no static credential in the database +at all. The token is cached for its lease and re-minted lazily with a 60s grace +window. + +```yaml +kind: secretbackend +name: bao-k8s +type: openbao +isDefault: true +config: + url: https://bao.example + auth: kubernetes + role: mcpctl + authMount: kubernetes-worker0 # defaults to `kubernetes` +``` + +Note that the daily **rotator does not apply** to these backends — there is no +stored token to rotate. That has a consequence for monitoring, see below. + +## Reliability + +Remote backends are network dependencies on the critical path of nearly +everything: server env resolution, LLM api keys, chat, git providers, code +repos, webhooks. Three mechanisms keep an outage from cascading. + +### Request hardening + +Every call carries a timeout (default 5s) and retries `5xx`/`429`/network +failures with full-jitter exponential backoff (3 attempts). A **sealed** OpenBao +answers `503`, so this covers unseal windows and failovers. + +The `403` path is separate and deliberately single-shot: the driver purges its +cached token, re-authenticates and retries **once**. That is a credential +refresh, not a backend-unavailable condition — looping on it would hide a +genuinely revoked grant. + +### Value cache with stale-while-error + +Resolved values are cached per backend (default TTL 5 minutes, LRU-bounded). +Past the TTL the backend is always consulted; if it fails *as a transport +failure*, the last known-good value is served instead of throwing. + +| Failure | Behaviour | +|---|---| +| Backend unreachable / timeout / exhausted 5xx | Serve last known-good, mark degraded, log `BACKEND_UNREACHABLE` once | +| Secret deleted (404) | **Evict and throw.** Never served stale — that would resurrect a revoked credential | +| 403 after a token refresh | Throw. Revoked grants must stay loud | +| Nothing cached yet | Throw | + +The stale window is unbounded on purpose: a cap would mean a long outage +eventually takes mcpd down anyway. + +`plaintext` backends are not cached — their `read()` is an identity function +over the row the caller already supplied. + +**Cold cache is the known gap.** If mcpd restarts *while* the backend is +unreachable, nothing has a last-known-good value and secret-bearing servers fail +to start. That is deliberate: booting a server with an empty credential is worse +(gitea-mcp once ran for weeks with an empty `GITEA_ACCESS_TOKEN`, answering +`tools/list` and reporting healthy while every authenticated call failed). mcpd +mitigates it by warming the cache at boot — one read per referenced secret — so +an outage that starts *after* startup is fully absorbed. + +### Health: `live` vs `ready` + +```bash +curl $MCPD/api/v1/secretbackends//health +``` + +```json +{ "live": true, "liveDetail": "active", + "ready": false, "readyDetail": "OpenBao list: HTTP 403 permission denied", + "cache": { "entries": 9, "servingStale": 0 }, + "rotation": { "rotatable": false, "lastRotationError": null } } +``` + +- **`live`** — unauthenticated `sys/health`. Distinguishes *down* from *sealed* + from *standby*. +- **`ready`** — a real read with our credentials. + +The two are separate because `live && !ready` is a distinct, important state: a +re-initialised OpenBao hands back valid-looking tokens that grant nothing. +Collapsing them into one boolean is what let that go unnoticed for four days. + +`mcpctl status` renders the probe directly: + +``` +Secrets: bao-k8s* ✓ reachable, default ✓ reachable +Secrets: bao-k8s* ⚠ degraded — serving 7 cached secret(s) +Secrets: bao-k8s* ✗ unreachable: sealed +Secrets: bao-k8s* ✗ auth failed: HTTP 403 permission denied +Secrets: bao-k8s* ? unknown +``` + +> **Historical note.** This verdict used to come solely from +> `tokenMeta.lastRotationError`, which only the rotator writes — and the rotator +> skips `auth: kubernetes` backends. The Secrets line was therefore *incapable* +> of going red for a k8s-auth backend, and reported OpenBao healthy while it was +> unreachable. `?` (probe failed) renders yellow, never green: not knowing is not +> health. ## Migration — `mcpctl migrate secrets` diff --git a/src/mcplocal/tests/smoke/secret-resilience.smoke.test.ts b/src/mcplocal/tests/smoke/secret-resilience.smoke.test.ts new file mode 100644 index 0000000..358bf84 --- /dev/null +++ b/src/mcplocal/tests/smoke/secret-resilience.smoke.test.ts @@ -0,0 +1,133 @@ +/** + * Smoke tests: secret-backend health honesty + value caching, against live mcpd. + * + * Covers the two behaviours that unit tests cannot prove, because both are + * about what the REAL backend and the REAL CLI do together: + * + * 1. `mcpctl status` reports a probed verdict, not a hard-coded tick. The bug + * being guarded is that the verdict used to come from + * `tokenMeta.lastRotationError`, which a `kubernetes`-auth backend never + * writes — so the line was structurally incapable of going red. + * 2. The value cache does not corrupt reads, and a delete really evicts. + * + * Deliberately does NOT take the real backend down. Simulating an outage + * against shared infrastructure to satisfy a test would be worse than the bug. + * + * Target: mcpd direct (`--direct`), same skip-if-unreachable discipline as the + * other smokes here. + * + * Run with: pnpm test:smoke + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { execSync } from 'node:child_process'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SECRET_NAME = `smoke-cache-${Date.now().toString(36)}`; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + return { code: 0, stdout: execSync(`mcpctl --direct ${args}`, { encoding: 'utf-8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout ? String(e.stdout) : '', + stderr: e.stderr ? String(e.stderr) : '', + }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get( + { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname, timeout: timeoutMs }, + (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +let mcpdUp = false; + +describe('secret resilience smoke', () => { + beforeAll(async () => { + mcpdUp = await healthz(MCPD_URL); + if (!mcpdUp) { + // eslint-disable-next-line no-console + console.warn(`\n ○ secret resilience smoke: skipped — ${MCPD_URL}/healthz unreachable. Set MCPD_URL to override.\n`); + } + }, 20_000); + + afterAll(() => { + if (!mcpdUp) return; + run(`delete secret ${SECRET_NAME}`); + }); + + it('status reports a probed backend verdict, not an unconditional tick', () => { + if (!mcpdUp) return; + const result = run('status'); + expect(result.code, result.stderr).toBe(0); + const line = result.stdout.split('\n').find((l) => l.startsWith('Secrets:')); + expect(line, 'status must include a Secrets: line').toBeDefined(); + // The verdict must be one the live probe can produce. A bare "name ✓" with + // no qualifier is the OLD rendering and means the probe was not consulted. + expect(line).toMatch(/reachable|degraded|unreachable|auth failed|unknown/); + }); + + it('reports live and ready separately per backend in JSON output', () => { + if (!mcpdUp) return; + const result = run('status -o json'); + expect(result.code, result.stderr).toBe(0); + const parsed = JSON.parse(result.stdout) as { + secretBackends?: Array<{ name: string; healthy: boolean; live: boolean | null; ready: boolean | null }>; + }; + expect(parsed.secretBackends, 'JSON status must carry secretBackends').toBeDefined(); + for (const b of parsed.secretBackends ?? []) { + // Both signals present and independent — not one boolean copied twice. + expect(b, `backend ${b.name}`).toHaveProperty('live'); + expect(b, `backend ${b.name}`).toHaveProperty('ready'); + expect(b.healthy).toBe(b.live === true && b.ready === true); + } + }); + + it('caching does not corrupt repeated reads, and delete evicts', () => { + if (!mcpdUp) return; + const created = run(`create secret ${SECRET_NAME} --data TOKEN=cache-probe-value`); + expect(created.code, created.stderr).toBe(0); + + // Two reads back-to-back: the second is a cache hit. Both must agree. + const first = run(`describe secret ${SECRET_NAME} --show-values`); + const second = run(`describe secret ${SECRET_NAME} --show-values`); + expect(first.code, first.stderr).toBe(0); + expect(second.code, second.stderr).toBe(0); + expect(first.stdout).toContain('cache-probe-value'); + expect(second.stdout).toContain('cache-probe-value'); + + // Delete must evict — a cached value surviving a delete is exactly the + // "resurrected revoked credential" failure the cache guards against. + const deleted = run(`delete secret ${SECRET_NAME}`); + expect(deleted.code, deleted.stderr).toBe(0); + const after = run(`describe secret ${SECRET_NAME} --show-values`); + expect(after.code, 'reading a deleted secret must fail, not serve cache').not.toBe(0); + }); + + it('exposes the per-backend health endpoint used by status', () => { + if (!mcpdUp) return; + const backends = run('get secretbackends -o json'); + expect(backends.code, backends.stderr).toBe(0); + const rows = JSON.parse(backends.stdout) as Array<{ id: string; name: string }>; + expect(rows.length).toBeGreaterThan(0); + // describe must surface the same probe, for every backend type — the old + // Token health block was gated on tokenMeta.rotatable and so rendered + // nothing at all for kubernetes-auth backends. + const described = run(`describe secretbackend ${rows[0]?.name ?? ''}`); + expect(described.code, described.stderr).toBe(0); + }); +}); From 545e7745dafe627c94c9e0fb1e840153e6f812a4 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 22:17:24 +0100 Subject: [PATCH 51/77] test(secrets): cover the rotator loop's boot-time dead-token detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecretBackendRotatorLoop had zero tests, despite being the detector added in e51b924 specifically so a re-initialised OpenBao surfaces the moment mcpd boots rather than 24h later when the scheduled rotation finally fires. The class already injects setTimeout/clearTimeout and a logger, so this needed no production change. Nine cases, weighted to what actually breaks: the boot health check runs per rotatable backend; a dead token emits kind BACKEND_TOKEN_DEAD through the injected logger (the point of the earlier console.error removal — a bare console call never reaches ErrorLogBuffer, so `mcpctl errors` could not see it); a throwing health check does not abort start(); overdue backends rotate immediately and still get scheduled; the 60s floor holds across 50 adversarial-jitter draws; and stop() both clears timers and trips the `stopped` guard against rescheduling, which had never been exercised. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- .../tests/secret-backend-rotator-loop.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/mcpd/tests/secret-backend-rotator-loop.test.ts diff --git a/src/mcpd/tests/secret-backend-rotator-loop.test.ts b/src/mcpd/tests/secret-backend-rotator-loop.test.ts new file mode 100644 index 0000000..197ff47 --- /dev/null +++ b/src/mcpd/tests/secret-backend-rotator-loop.test.ts @@ -0,0 +1,155 @@ +/** + * SecretBackendRotatorLoop had no coverage at all, despite being the boot-time + * detector added after an upstream OpenBao re-init silently broke every secret + * write for four days (e51b924). These pin the behaviours that matter when that + * recurs: the boot health check fires, it reports through the injected logger + * (so `mcpctl errors` sees it), and stop() genuinely stops. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { SecretBackend } from '@prisma/client'; +import { SecretBackendRotatorLoop } from '../src/services/secret-backend-rotator-loop.js'; +import type { SecretBackendService } from '../src/services/secret-backend.service.js'; +import type { SecretBackendRotator } from '../src/services/secret-backend-rotator.service.js'; + +function backend(overrides: Partial = {}): SecretBackend { + return { + id: 'b1', name: 'bao', type: 'openbao', + config: { url: 'http://bao.example:8200', rotation: { enabled: true, tokenRole: 'r', intervalHours: 24 } }, + isDefault: true, description: '', version: 1, + createdAt: new Date(), updatedAt: new Date(), + ...overrides, + } as SecretBackend; +} + +interface Harness { + loop: SecretBackendRotatorLoop; + rotator: { isRotatable: ReturnType; isOverdue: ReturnType; healthCheck: ReturnType; rotateOne: ReturnType }; + logs: { info: string[]; warn: string[]; error: Array<{ obj: Record; msg: string }> }; + timers: Array<{ cb: () => void; ms: number }>; + cleared: number; +} + +function harness(opts: { + rows?: SecretBackend[]; + rotatable?: boolean; + overdue?: boolean; + health?: { ok: boolean; message?: string } | Error; +} = {}): Harness { + const rows = opts.rows ?? [backend()]; + const logs: Harness['logs'] = { info: [], warn: [], error: [] }; + const timers: Harness['timers'] = []; + const state = { cleared: 0 }; + + const rotator = { + isRotatable: vi.fn(() => opts.rotatable ?? true), + isOverdue: vi.fn(() => opts.overdue ?? false), + healthCheck: vi.fn(async () => { + if (opts.health instanceof Error) throw opts.health; + return opts.health ?? { ok: true }; + }), + rotateOne: vi.fn(async () => ({})), + }; + + const loop = new SecretBackendRotatorLoop({ + backends: { + list: async () => rows, + getById: async (id: string) => rows.find((r) => r.id === id) ?? rows[0]!, + } as unknown as SecretBackendService, + rotator: rotator as unknown as SecretBackendRotator, + setTimeout: ((cb: () => void, ms: number) => { timers.push({ cb, ms }); return { id: timers.length } as unknown as NodeJS.Timeout; }), + clearTimeout: (() => { state.cleared++; }), + log: { + info: (m) => { logs.info.push(m); }, + warn: (m) => { logs.warn.push(m); }, + error: (obj, msg) => { logs.error.push({ obj, msg }); }, + }, + }); + + return { loop, rotator, logs, timers, get cleared() { return state.cleared; } } as Harness; +} + +/** The boot health check is fire-and-forget; let its microtasks settle. */ +const settle = async (): Promise => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; + +describe('SecretBackendRotatorLoop', () => { + it('stays idle when nothing is rotatable', async () => { + const h = harness({ rotatable: false }); + await h.loop.start(); + expect(h.logs.info.join(' ')).toMatch(/no rotatable backends/); + expect(h.timers).toHaveLength(0); + expect(h.rotator.healthCheck).not.toHaveBeenCalled(); + }); + + it('runs a boot-time health check for every rotatable backend', async () => { + const h = harness({ rows: [backend(), backend({ id: 'b2', name: 'bao2' })] }); + await h.loop.start(); + await settle(); + expect(h.rotator.healthCheck).toHaveBeenCalledTimes(2); + }); + + it('emits BACKEND_TOKEN_DEAD through the logger, not console', async () => { + // The regression that made `mcpctl errors` blind to it: this used to be a + // bare console.error, which bypasses the pino stream feeding ErrorLogBuffer. + const h = harness({ health: { ok: false, message: 'token rejected' } }); + await h.loop.start(); + await settle(); + expect(h.logs.error).toHaveLength(1); + expect(h.logs.error[0]?.obj).toMatchObject({ kind: 'BACKEND_TOKEN_DEAD', backend: 'bao' }); + expect(h.logs.error[0]?.msg).toBe('token rejected'); + }); + + it('does not log a fatal when the backend is healthy', async () => { + const h = harness({ health: { ok: true } }); + await h.loop.start(); + await settle(); + expect(h.logs.error).toHaveLength(0); + }); + + it('survives a health check that throws', async () => { + const h = harness({ health: new Error('network down') }); + await expect(h.loop.start()).resolves.toBeUndefined(); + await settle(); + expect(h.logs.warn.join(' ')).toMatch(/health check threw: network down/); + }); + + it('rotates immediately when a backend is overdue, and still schedules', async () => { + const h = harness({ overdue: true }); + await h.loop.start(); + await settle(); + expect(h.rotator.rotateOne).toHaveBeenCalledWith('b1'); + expect(h.timers).toHaveLength(1); + }); + + it('does not rotate on boot when not overdue', async () => { + const h = harness({ overdue: false }); + await h.loop.start(); + await settle(); + expect(h.rotator.rotateOne).not.toHaveBeenCalled(); + expect(h.timers).toHaveLength(1); + }); + + it('never schedules sooner than the 60s floor, even with adversarial jitter', async () => { + // intervalHours tiny + default jitter would otherwise produce a negative delay. + const rows = [backend({ config: { url: 'u', rotation: { enabled: true, tokenRole: 'r', intervalHours: 0.0001 } } } as Partial)]; + for (let i = 0; i < 50; i++) { + const h = harness({ rows }); + await h.loop.start(); + expect(h.timers[0]?.ms).toBeGreaterThanOrEqual(60_000); + } + }); + + it('stop() clears timers and suppresses further scheduling', async () => { + const h = harness(); + await h.loop.start(); + expect(h.timers).toHaveLength(1); + + h.loop.stop(); + expect(h.cleared).toBeGreaterThan(0); + + // The `stopped` guard has never been exercised: a firing timer must not + // reschedule after stop(). + const before = h.timers.length; + await h.loop.rotateNow('b1').catch(() => undefined); + expect(h.timers).toHaveLength(before); + }); +}); From 7b5136491f031c8122c0a7fdcde23a9dd29532e3 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 22:54:42 +0100 Subject: [PATCH 52/77] =?UTF-8?q?fix(secrets):=20list=20via=20GET=20=3Flis?= =?UTF-8?q?t=3Dtrue=20=E2=80=94=20the=20LIST=20verb=20dies=20at=20the=20pr?= =?UTF-8?q?oxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the readiness probe added in the previous commit, on its first run against production: `mcpctl status` reported Secrets: bao-k8s* ✗ auth failed: OpenBao list: HTTP 400 Bad Request That is a real bug, not a probe artifact. `bao-k8s` is configured with the public ingress URL, and Cilium's ingress Envoy rejects the non-standard LIST HTTP method outright. Verified live from the mcpd pod: LIST https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/ -> 400 Bad Request GET https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/?list=true -> 403 permission denied (bogus token, i.e. reached bao) LIST http://openbao.openbao.svc:8200/... (ClusterIP, no Envoy) -> 403 permission denied So the verb was fine against bao and fatal through the ingress. OpenBao accepts both forms and documents the GET form for exactly this reason. This was never noticed because nothing called list() in production — it backs `mcpctl migrate secrets`, which would have failed with an opaque HTTP 400 against any ingress-fronted backend. Also adds the per-server scoping primitives Phase 2 needs, with tests: buildServerSecretPolicyHcl (no wildcards, read-only, stable under reordering), buildServerProvisioningPolicyHcl (prefix-confined so mcpd cannot grant itself more than it holds), ensureKubernetesAuthRole / delete helpers, the driver-level ensureServerIdentity/removeServerIdentity capability, and ServerIdentityService. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- .../src/services/secret-backends/caching.ts | 17 +++ .../src/services/secret-backends/openbao.ts | 66 +++++++- .../src/services/secret-backends/types.ts | 25 +++ .../src/services/server-identity.service.ts | 142 ++++++++++++++++++ src/mcpd/tests/secret-backends.test.ts | 8 +- src/shared/src/vault/client.ts | 80 ++++++++++ src/shared/src/vault/policy.ts | 67 +++++++++ src/shared/tests/vault-server-scoping.test.ts | 138 +++++++++++++++++ 8 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 src/mcpd/src/services/server-identity.service.ts create mode 100644 src/shared/tests/vault-server-scoping.test.ts diff --git a/src/mcpd/src/services/secret-backends/caching.ts b/src/mcpd/src/services/secret-backends/caching.ts index 22b4276..bdc87c4 100644 --- a/src/mcpd/src/services/secret-backends/caching.ts +++ b/src/mcpd/src/services/secret-backends/caching.ts @@ -164,6 +164,23 @@ export class CachingSecretBackendDriver implements SecretBackendDriver { return this.inner.healthCheck?.() ?? { ok: true, detail: 'no probe' }; } + /** + * Identity provisioning is a control-plane operation on the backend itself, + * not a value read — nothing to cache, so pass straight through. Throwing + * when unsupported (rather than silently succeeding) keeps a misconfigured + * backend from looking like it granted access it never did. + */ + async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise { + if (this.inner.ensureServerIdentity === undefined) { + throw new Error(`backend '${this.backendName}' (${this.inner.kind}) does not support per-server identities`); + } + await this.inner.ensureServerIdentity(input); + } + + async removeServerIdentity(input: { name: string }): Promise { + await this.inner.removeServerIdentity?.(input); + } + async authCheck(): Promise<{ ok: boolean; detail?: string }> { return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' }; } diff --git a/src/mcpd/src/services/secret-backends/openbao.ts b/src/mcpd/src/services/secret-backends/openbao.ts index 34ddedc..9bee647 100644 --- a/src/mcpd/src/services/secret-backends/openbao.ts +++ b/src/mcpd/src/services/secret-backends/openbao.ts @@ -7,7 +7,7 @@ * POST /v1//data/ -- write * GET /v1//data/ -- read latest * DELETE /v1//metadata/ -- full delete (all versions) - * LIST /v1//metadata/ -- for migration + * GET /v1//metadata/?list=true -- for migration (see list()) * POST /v1/auth//login -- kubernetes auth * * Auth strategies (`config.auth`): @@ -29,6 +29,13 @@ import { readFile } from 'node:fs/promises'; import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js'; import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js'; +import { + buildServerSecretPolicyHcl, + writePolicy, + deletePolicy, + ensureKubernetesAuthRole, + deleteKubernetesAuthRole, +} from '@mcpctl/shared'; /** Best-effort read of a response body for error messages. Empty on parse failure. */ async function bodyText(res: Response): Promise { @@ -185,7 +192,13 @@ export class OpenBaoDriver implements SecretBackendDriver { async list(): Promise> { const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`; - const res = await this.request('LIST', `/v1/${this.mount}/metadata/${listPath}`); + // `GET ?list=true`, not the LIST verb. OpenBao accepts both, but LIST is a + // non-standard HTTP method and proxies drop it: through Cilium's ingress + // Envoy (which is how `bao-k8s` is reached) LIST returns a bare + // `400 Bad Request` while the GET form returns normally. Verified live + // 2026-08-20 — this silently broke `mcpctl migrate secrets` against any + // ingress-fronted backend. + const res = await this.request('GET', `/v1/${this.mount}/metadata/${listPath}?list=true`); if (res.status === 404) return []; if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`); const body = await res.json() as { data?: { keys?: string[] } }; @@ -247,6 +260,55 @@ export class OpenBaoDriver implements SecretBackendDriver { } } + /** + * Create/refresh a per-server OpenBao identity: a policy naming exactly this + * server's secrets, and a Kubernetes auth role bound to exactly its + * ServiceAccount. + * + * Requires mcpd's own token to hold the provisioning grant (see + * `buildServerProvisioningPolicyHcl`) — deliberately confined to the + * `mcpctl-server-` name prefix, so this cannot mint an identity that reads + * anything mcpd itself could not already read. + * + * Only meaningful with `auth: 'kubernetes'`: the role has to live on the same + * auth mount that validates this cluster's ServiceAccount tokens. + */ + async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise { + if (this.authStrategy !== 'kubernetes') { + throw new Error( + `OpenBao: per-server identities require auth: 'kubernetes' (this backend uses '${this.authStrategy}') — ` + + 'a token-auth backend has no ServiceAccount auth mount to bind a role on', + ); + } + const token = await this.getToken(); + const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) }; + + // Policy first, then the role that references it. The reverse order would + // briefly leave a role pointing at a non-existent policy, which OpenBao + // resolves as "no capabilities" — a confusing transient 403 for the pod. + await writePolicy( + this.url, + token, + input.name, + buildServerSecretPolicyHcl({ mount: this.mount, pathPrefix: this.pathPrefix, secretNames: input.secretNames }), + deps, + ); + await ensureKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, { + boundServiceAccountNames: [input.name], + boundServiceAccountNamespaces: [input.namespace], + tokenPolicies: [input.name], + }, deps); + } + + /** Role before policy: revoke the ability to log in before the grants vanish. */ + async removeServerIdentity(input: { name: string }): Promise { + if (this.authStrategy !== 'kubernetes') return; + const token = await this.getToken(); + const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) }; + await deleteKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, deps); + await deletePolicy(this.url, token, input.name, deps); + } + private pathFor(name: string): string { const safe = encodeURIComponent(name); return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`; diff --git a/src/mcpd/src/services/secret-backends/types.ts b/src/mcpd/src/services/secret-backends/types.ts index 8d91ce9..74a423b 100644 --- a/src/mcpd/src/services/secret-backends/types.ts +++ b/src/mcpd/src/services/secret-backends/types.ts @@ -55,6 +55,31 @@ export interface SecretBackendDriver { */ healthCheck?(): Promise<{ ok: boolean; detail?: string }>; + /** + * Optional: provision a per-server identity in the backend, so an MCP server + * pod can fetch its OWN secrets directly (Agent Injector) without mcpd ever + * materialising the value into the pod spec. + * + * Scoping is the entire point. Each server gets a policy naming only the + * secrets it declares, bound to its own ServiceAccount. A single shared role + * would let any opted-in pod — including third-party images we do not + * control — read every secret under the prefix, which is a worse position + * than leaving values in the pod spec. + * + * Idempotent: called on every reconcile, must converge rather than conflict. + */ + ensureServerIdentity?(input: { + /** Identity name — also the policy name and the k8s ServiceAccount name. */ + name: string; + /** Namespace the bound ServiceAccount lives in. */ + namespace: string; + /** Secrets this server may read. Empty means "revoke everything". */ + secretNames: string[]; + }): Promise; + + /** Optional: tear down what `ensureServerIdentity` created. Idempotent. */ + removeServerIdentity?(input: { name: string }): Promise; + /** * Optional READINESS probe: can we actually read through this backend with * the credentials we hold? diff --git a/src/mcpd/src/services/server-identity.service.ts b/src/mcpd/src/services/server-identity.service.ts new file mode 100644 index 0000000..f6f8d30 --- /dev/null +++ b/src/mcpd/src/services/server-identity.service.ts @@ -0,0 +1,142 @@ +/** + * Per-server OpenBao identities, so an MCP server pod can fetch its own secrets + * without mcpd ever writing the value into the pod spec. + * + * ## Why per-server, and not one shared role + * + * The obvious implementation gives every injected pod one shared ServiceAccount + * and one role granting `secret/data/mcpctl/*`. That would be a downgrade, not + * an improvement: today's exposure is "4 credentials readable by anyone with + * `get pod` in this namespace", and the shared-role version is "10 credentials + * readable by any process inside a third-party MCP server image". We do not + * control `gitea-mcp-server` or `ha-mcp`; they should not be able to read the + * Grafana token. + * + * So each server gets: + * - its own Kubernetes ServiceAccount `mcpctl-server-` + * - its own OpenBao policy `mcpctl-server-` (names only + * the secrets that server declares — no wildcards) + * - its own OpenBao k8s auth role `mcpctl-server-` (bound to + * that one ServiceAccount in that one namespace) + * + * ## Convergence + * + * `ensureFor` is idempotent and is safe to call on every start. The generated + * policy is sorted and deduped, so re-writing an unchanged server produces a + * byte-identical body — an unstable policy would churn the OpenBao audit log + * and make real changes invisible. + * + * Removing a secret from a server's env and restarting it narrows the policy on + * the next reconcile. Removing the server entirely revokes the identity. + */ +import type { McpServer } from '@prisma/client'; +import type { SecretBackendService } from './secret-backend.service.js'; +import type { ServerEnvEntry } from '../validation/mcp-server.schema.js'; + +/** Minimal Kubernetes surface this needs — keeps the service testable. */ +export interface ServiceAccountPort { + /** Namespace MCP server pods run in. */ + readonly namespace: string; + /** Create the ServiceAccount if absent. Must tolerate "already exists". */ + ensure(name: string): Promise; + /** Delete it. Must tolerate "not found". */ + remove(name: string): Promise; +} + +export interface ServerIdentityLog { + info(obj: Record, msg: string): void; + warn(obj: Record, msg: string): void; +} + +const NOOP_LOG: ServerIdentityLog = { info: () => undefined, warn: () => undefined }; + +/** Shared prefix. mcpd's OpenBao grant is confined to exactly this prefix. */ +export const IDENTITY_PREFIX = 'mcpctl-server-'; + +export class ServerIdentityService { + private readonly log: ServerIdentityLog; + + constructor( + private readonly backends: SecretBackendService, + private readonly serviceAccounts: ServiceAccountPort, + log?: ServerIdentityLog, + ) { + this.log = log ?? NOOP_LOG; + } + + /** Identity name for a server — also the SA, policy and role name. */ + identityNameFor(serverName: string): string { + return `${IDENTITY_PREFIX}${serverName}`; + } + + /** + * The distinct secrets a server declares. Inline `value` entries are not + * secrets and must not widen the policy. + */ + secretNamesFor(server: Pick): string[] { + const entries = (server.env ?? []) as ServerEnvEntry[]; + const names = new Set(); + for (const entry of entries) { + const ref = entry.valueFrom?.secretRef; + if (ref !== undefined) names.add(ref.name); + } + return [...names].sort((a, b) => a.localeCompare(b)); + } + + /** + * Converge the identity for one server. Returns the identity name so the + * caller can stamp it onto the pod spec. + * + * Order matters: the ServiceAccount must exist before the role that binds it, + * or the pod can start, fail to log in, and crashloop while the role is still + * being written. + */ + async ensureFor(server: Pick): Promise { + const name = this.identityNameFor(server.name); + const secretNames = this.secretNamesFor(server); + + const backend = await this.backends.getDefault(); + const driver = this.backends.driverFor(backend); + if (driver.ensureServerIdentity === undefined) { + throw new Error( + `secret backend '${backend.name}' (${backend.type}) cannot provision per-server identities — ` + + 'per-server secret delivery requires an openbao backend using kubernetes auth', + ); + } + + await this.serviceAccounts.ensure(name); + await driver.ensureServerIdentity({ + name, + namespace: this.serviceAccounts.namespace, + secretNames, + }); + + this.log.info( + { identity: name, namespace: this.serviceAccounts.namespace, secrets: secretNames }, + `provisioned scoped OpenBao identity for server '${server.name}' (${String(secretNames.length)} secret(s))`, + ); + return name; + } + + /** + * Revoke a server's identity. Best-effort per step: a half-removed identity + * grants nothing useful, and failing the whole teardown because a + * ServiceAccount was already gone would leave the OpenBao side orphaned. + */ + async removeFor(serverName: string): Promise { + const name = this.identityNameFor(serverName); + const backend = await this.backends.getDefault(); + const driver = this.backends.driverFor(backend); + + try { + await driver.removeServerIdentity?.({ name }); + } catch (err) { + this.log.warn({ identity: name, err: String(err) }, `could not remove OpenBao identity '${name}'`); + } + try { + await this.serviceAccounts.remove(name); + } catch (err) { + this.log.warn({ identity: name, err: String(err) }, `could not remove ServiceAccount '${name}'`); + } + } +} diff --git a/src/mcpd/tests/secret-backends.test.ts b/src/mcpd/tests/secret-backends.test.ts index 43785a2..d43cb20 100644 --- a/src/mcpd/tests/secret-backends.test.ts +++ b/src/mcpd/tests/secret-backends.test.ts @@ -87,9 +87,9 @@ describe('OpenBaoDriver', () => { await expect(driver.delete({ name: 'gone', externalRef: '' })).resolves.toBeUndefined(); }); - it('list returns names from the metadata LIST call', async () => { + it('list returns names from the metadata listing', async () => { const fetchFn = makeFetch([{ - url: /\/v1\/secret\/metadata\/mcpctl\/$/, + url: /\/v1\/secret\/metadata\/mcpctl\/\?list=true$/, status: 200, body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } }, }]); @@ -98,6 +98,10 @@ describe('OpenBaoDriver', () => { { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, ); const result = await driver.list(); + // GET ?list=true rather than the LIST verb: proxies (Cilium's ingress + // Envoy among them) answer a bare 400 to the non-standard method. + const [, listInit] = fetchFn.mock.calls[0] as [unknown, RequestInit]; + expect(listInit.method).toBe('GET'); // Sub-folders (trailing slash) are excluded; only leaf keys are returned. expect(result).toEqual([ { name: 'token1', externalRef: 'secret/mcpctl/token1' }, diff --git a/src/shared/src/vault/client.ts b/src/shared/src/vault/client.ts index ef79e9c..9e3c218 100644 --- a/src/shared/src/vault/client.ts +++ b/src/shared/src/vault/client.ts @@ -306,3 +306,83 @@ export async function testWriteReadDelete( throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`); } } + +export interface KubernetesAuthRoleConfig { + /** ServiceAccount names permitted to log in as this role. */ + boundServiceAccountNames: string[]; + /** Namespaces those ServiceAccounts must live in. */ + boundServiceAccountNamespaces: string[]; + /** Policies attached to tokens issued for this role. */ + tokenPolicies: string[]; + /** Token TTL in seconds. Default 3600 — pods only need it at startup. */ + tokenTtlSeconds?: number; +} + +/** + * POST /v1/auth//role/. Idempotent: upserts the role. + * + * Used to give each MCP server pod its own OpenBao identity, bound to its own + * ServiceAccount, so the injector can fetch only that server's secrets. Note + * `bound_service_account_names` is a list but we pass exactly one — a role + * shared between ServiceAccounts would defeat the point. + */ +export async function ensureKubernetesAuthRole( + url: string, + token: string, + authMount: string, + role: string, + cfg: KubernetesAuthRoleConfig, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const mount = authMount.replace(/^\/|\/$/g, ''); + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/${mount}/role/${encodeURIComponent(role)}`, { + method: 'POST', + headers: headers(token, deps.namespace, true), + body: JSON.stringify({ + bound_service_account_names: cfg.boundServiceAccountNames, + bound_service_account_namespaces: cfg.boundServiceAccountNamespaces, + token_policies: cfg.tokenPolicies, + token_ttl: cfg.tokenTtlSeconds ?? 3600, + }), + }); + if (!res.ok) { + throw new Error(`vault ensure k8s auth role ${role}: HTTP ${String(res.status)} ${await readError(res)}`); + } +} + +/** DELETE /v1/auth//role/. Idempotent — 404 is success. */ +export async function deleteKubernetesAuthRole( + url: string, + token: string, + authMount: string, + role: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const mount = authMount.replace(/^\/|\/$/g, ''); + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/${mount}/role/${encodeURIComponent(role)}`, { + method: 'DELETE', + headers: headers(token, deps.namespace, false), + }); + if (!res.ok && res.status !== 404) { + throw new Error(`vault delete k8s auth role ${role}: HTTP ${String(res.status)} ${await readError(res)}`); + } +} + +/** DELETE /v1/sys/policies/acl/. Idempotent — 404 is success. */ +export async function deletePolicy( + url: string, + token: string, + name: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const res = await fetchImpl(`${baseUrl(url)}/v1/sys/policies/acl/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: headers(token, deps.namespace, false), + }); + if (!res.ok && res.status !== 404) { + throw new Error(`vault delete policy ${name}: HTTP ${String(res.status)} ${await readError(res)}`); + } +} diff --git a/src/shared/src/vault/policy.ts b/src/shared/src/vault/policy.ts index 65b30cb..d28d535 100644 --- a/src/shared/src/vault/policy.ts +++ b/src/shared/src/vault/policy.ts @@ -33,3 +33,70 @@ export function buildAppMcpdPolicyHcl(cfg: AppMcpdPolicyConfig): string { '', ].join('\n'); } + +/** + * Per-server read policy for the OpenBao Agent Injector. + * + * Each MCP server pod authenticates to OpenBao as its OWN ServiceAccount and + * gets a policy naming only the secrets that server actually declares. That is + * the difference between "the injector moved the credential out of the pod + * spec" and "the injector made things worse": with one shared role, every + * opted-in pod — including third-party images we do not control — could read + * every secret under the prefix. Here, `gitea` can read `gitea-creds` and + * nothing else. + * + * No wildcards. Each secret is named explicitly; adding a secret to a server + * means regenerating and re-writing this policy, which is exactly the audit + * trail we want. + */ +export interface ServerSecretPolicyConfig { + /** KV v2 mount name, e.g. 'secret'. */ + mount: string; + /** Path prefix under the mount, e.g. 'mcpctl'. */ + pathPrefix: string; + /** Secret names this server may read. Order-insensitive; deduped + sorted. */ + secretNames: string[]; +} + +export function buildServerSecretPolicyHcl(cfg: ServerSecretPolicyConfig): string { + const { mount } = cfg; + const prefix = cfg.pathPrefix.replace(/^\/|\/$/g, ''); + // Sort + dedupe so the generated HCL is stable: an unstable policy body would + // rewrite on every reconcile and make real changes invisible in the audit log. + const names = [...new Set(cfg.secretNames)].sort((a, b) => a.localeCompare(b)); + const lines: string[] = []; + for (const name of names) { + const path = prefix === '' ? name : `${prefix}/${name}`; + lines.push(`path "${mount}/data/${path}" { capabilities = ["read"] }`); + lines.push(`path "${mount}/metadata/${path}" { capabilities = ["read"] }`); + } + lines.push(''); + return lines.join('\n'); +} + +/** + * Grants mcpd needs in order to provision the per-server identities above: + * write its own scoped policies and the matching Kubernetes auth roles. + * + * Deliberately confined by name prefix. This is a real privilege increase for + * mcpd, but a much narrower one than the obvious alternative of granting it + * Kubernetes `secrets` verbs — that would let it read every Secret in the + * cluster, not just the ones it already owns. + */ +export interface ServerProvisioningPolicyConfig { + /** Kubernetes auth mount, e.g. 'kubernetes-worker0'. */ + authMount: string; + /** Shared name prefix for generated policies + roles, e.g. 'mcpctl-server-'. */ + namePrefix: string; +} + +export function buildServerProvisioningPolicyHcl(cfg: ServerProvisioningPolicyConfig): string { + const authMount = cfg.authMount.replace(/^\/|\/$/g, ''); + const prefix = cfg.namePrefix; + return [ + `path "sys/policies/acl/${prefix}*" { capabilities = ["create", "read", "update", "delete"] }`, + `path "auth/${authMount}/role/${prefix}*" { capabilities = ["create", "read", "update", "delete"] }`, + `path "auth/${authMount}/role" { capabilities = ["list"] }`, + '', + ].join('\n'); +} diff --git a/src/shared/tests/vault-server-scoping.test.ts b/src/shared/tests/vault-server-scoping.test.ts new file mode 100644 index 0000000..e870100 --- /dev/null +++ b/src/shared/tests/vault-server-scoping.test.ts @@ -0,0 +1,138 @@ +/** + * Per-server OpenBao scoping: each MCP server pod gets its own identity and a + * policy naming only its own secrets. + * + * The property under test is containment. A shared role would let any opted-in + * pod — including third-party images we don't control — read every secret under + * the prefix, which is a worse position than leaving values in the pod spec. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + buildServerSecretPolicyHcl, + buildServerProvisioningPolicyHcl, + ensureKubernetesAuthRole, + deleteKubernetesAuthRole, + deletePolicy, +} from '../src/vault/index.js'; + +describe('buildServerSecretPolicyHcl', () => { + const cfg = { mount: 'secret', pathPrefix: 'mcpctl' }; + + it('grants read on exactly the named secrets and nothing else', () => { + const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds'] }); + expect(hcl).toContain('path "secret/data/mcpctl/gitea-creds"'); + expect(hcl).toContain('path "secret/metadata/mcpctl/gitea-creds"'); + expect(hcl).toContain('capabilities = ["read"]'); + }); + + it('never emits a wildcard — that is the whole point', () => { + const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds', 'unifi-creds'] }); + expect(hcl).not.toContain('*'); + }); + + it('grants no write capability anywhere', () => { + const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['a', 'b'] }); + for (const verb of ['create', 'update', 'delete', 'list', 'sudo']) { + expect(hcl, `must not grant ${verb}`).not.toContain(verb); + } + }); + + it('does not reach other servers\' secrets', () => { + const gitea = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds'] }); + expect(gitea).not.toContain('unifi-creds'); + expect(gitea).not.toContain('anthropic-key'); + expect(gitea).not.toContain('litellm-key'); + }); + + it('is stable under reordering and duplication', () => { + // An unstable body would rewrite on every reconcile, drowning real changes + // in the OpenBao audit log. + const a = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['b', 'a', 'b'] }); + const b = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['a', 'b'] }); + expect(a).toBe(b); + }); + + it('handles an empty prefix without producing a double slash', () => { + const hcl = buildServerSecretPolicyHcl({ mount: 'secret', pathPrefix: '', secretNames: ['x'] }); + expect(hcl).toContain('path "secret/data/x"'); + expect(hcl).not.toContain('//'); + }); + + it('emits nothing but a trailing newline for a server with no secrets', () => { + expect(buildServerSecretPolicyHcl({ ...cfg, secretNames: [] })).toBe(''); + }); +}); + +describe('buildServerProvisioningPolicyHcl', () => { + const cfg = { authMount: 'kubernetes-worker0', namePrefix: 'mcpctl-server-' }; + + it('confines mcpd to the generated name prefix', () => { + const hcl = buildServerProvisioningPolicyHcl(cfg); + expect(hcl).toContain('path "sys/policies/acl/mcpctl-server-*"'); + expect(hcl).toContain('path "auth/kubernetes-worker0/role/mcpctl-server-*"'); + }); + + it('does not grant blanket policy or auth administration', () => { + const hcl = buildServerProvisioningPolicyHcl(cfg); + expect(hcl).not.toContain('path "sys/policies/acl/*"'); + expect(hcl).not.toContain('path "auth/*"'); + expect(hcl).not.toContain('path "sys/*"'); + }); + + it('grants no access to secret data at all', () => { + // mcpd reads secrets through its OWN policy; the provisioning grant must + // not widen that surface. + expect(buildServerProvisioningPolicyHcl(cfg)).not.toContain('secret/data'); + }); +}); + +describe('ensureKubernetesAuthRole', () => { + it('binds exactly one ServiceAccount in one namespace', async () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 204 })); + await ensureKubernetesAuthRole( + 'http://bao.example:8200', 'tok', 'kubernetes-worker0', 'mcpctl-server-gitea', + { + boundServiceAccountNames: ['mcpctl-server-gitea'], + boundServiceAccountNamespaces: ['mcpctl-servers'], + tokenPolicies: ['mcpctl-server-gitea'], + }, + { fetch: fetchFn as unknown as typeof fetch }, + ); + const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://bao.example:8200/v1/auth/kubernetes-worker0/role/mcpctl-server-gitea'); + const body = JSON.parse(init.body as string) as Record; + expect(body.bound_service_account_names).toEqual(['mcpctl-server-gitea']); + expect(body.bound_service_account_namespaces).toEqual(['mcpctl-servers']); + expect(body.token_policies).toEqual(['mcpctl-server-gitea']); + expect(body.token_ttl).toBe(3600); + }); + + it('surfaces the OpenBao error body on failure', async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ errors: ['permission denied'] }), { status: 403 })); + await expect(ensureKubernetesAuthRole( + 'http://bao.example:8200', 'tok', 'kubernetes-worker0', 'r', + { boundServiceAccountNames: ['a'], boundServiceAccountNamespaces: ['n'], tokenPolicies: ['p'] }, + { fetch: fetchFn as unknown as typeof fetch }, + )).rejects.toThrow(/permission denied/); + }); +}); + +describe('cleanup helpers are idempotent', () => { + it('treats a missing role as already deleted', async () => { + const fetchFn = vi.fn(async () => new Response('', { status: 404 })); + await expect(deleteKubernetesAuthRole('http://b', 't', 'kubernetes-worker0', 'gone', { fetch: fetchFn as unknown as typeof fetch })) + .resolves.toBeUndefined(); + }); + + it('treats a missing policy as already deleted', async () => { + const fetchFn = vi.fn(async () => new Response('', { status: 404 })); + await expect(deletePolicy('http://b', 't', 'gone', { fetch: fetchFn as unknown as typeof fetch })) + .resolves.toBeUndefined(); + }); + + it('still raises a real failure', async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ errors: ['denied'] }), { status: 403 })); + await expect(deletePolicy('http://b', 't', 'p', { fetch: fetchFn as unknown as typeof fetch })) + .rejects.toThrow(/denied/); + }); +}); From f8427959e58a3fefabc2eb7c3772cf41aa37dec8 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 23:13:49 +0100 Subject: [PATCH 53/77] test(secrets): cover per-server identity containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerIdentityService shipped without tests. Containment is the whole point of the design, so it needs assertions rather than trust: a shared role would let third-party MCP images (gitea-mcp, ha-mcp) read every secret under secret/mcpctl/*, which is worse than the pod-spec exposure it replaces. Eight cases, all about what a server must NOT get: the grant covers only the secrets that server declares; a secret referenced twice is one grant, not two; inline env values never widen it; another server's secrets never appear. Plus the ordering invariant (ServiceAccount before the role that binds it — the reverse lets a pod start, fail to log in and crashloop while the role is still being written), and that a backend which cannot scope identities REFUSES rather than silently succeeding, which would leave a pod believing it held access it never got. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki --- .../tests/server-identity-service.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/mcpd/tests/server-identity-service.test.ts diff --git a/src/mcpd/tests/server-identity-service.test.ts b/src/mcpd/tests/server-identity-service.test.ts new file mode 100644 index 0000000..f72d818 --- /dev/null +++ b/src/mcpd/tests/server-identity-service.test.ts @@ -0,0 +1,117 @@ +/** + * Per-server identity provisioning. The property under test throughout is + * CONTAINMENT: a server's identity must grant exactly its own secrets, because + * the alternative (one shared role) would let third-party MCP images read every + * secret under the prefix — worse than the pod-spec exposure it replaces. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { SecretBackend } from '@prisma/client'; +import { ServerIdentityService, type ServiceAccountPort } from '../src/services/server-identity.service.js'; +import type { SecretBackendService } from '../src/services/secret-backend.service.js'; +import type { SecretBackendDriver } from '../src/services/secret-backends/types.js'; + +const NS = 'mcpctl-servers'; + +function envRef(name: string, secret: string, key: string): unknown { + return { name, valueFrom: { secretRef: { name: secret, key } } }; +} + +function harness(driverOverrides: Partial = {}, backendType = 'openbao') { + const calls = { sa: [] as string[], saRemoved: [] as string[] }; + const ensureServerIdentity = vi.fn(async () => undefined); + const removeServerIdentity = vi.fn(async () => undefined); + const driver = { kind: backendType, ensureServerIdentity, removeServerIdentity, ...driverOverrides } as unknown as SecretBackendDriver; + + const backends = { + getDefault: async (): Promise => ({ id: 'b1', name: 'bao-k8s', type: backendType } as SecretBackend), + driverFor: () => driver, + } as unknown as SecretBackendService; + + const serviceAccounts: ServiceAccountPort = { + namespace: NS, + ensure: async (n) => { calls.sa.push(n); }, + remove: async (n) => { calls.saRemoved.push(n); }, + }; + + return { svc: new ServerIdentityService(backends, serviceAccounts), calls, ensureServerIdentity, removeServerIdentity }; +} + +describe('ServerIdentityService', () => { + it('grants a server exactly the secrets it declares', async () => { + const h = harness(); + await h.svc.ensureFor({ + name: 'gitea', + env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN'), envRef('GITEA_HOST', 'gitea-creds', 'GITEA_HOST')], + } as never); + + expect(h.ensureServerIdentity).toHaveBeenCalledWith({ + name: 'mcpctl-server-gitea', + namespace: NS, + // One secret, not two: the same secret referenced twice is one grant. + secretNames: ['gitea-creds'], + }); + }); + + it('does not leak another server\'s secrets into the grant', async () => { + const h = harness(); + await h.svc.ensureFor({ name: 'gitea', env: [envRef('T', 'gitea-creds', 'K')] } as never); + const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; + expect(granted.secretNames).not.toContain('unifi-creds'); + expect(granted.secretNames).not.toContain('anthropic-key'); + }); + + it('ignores inline env values — they are not secrets', async () => { + const h = harness(); + await h.svc.ensureFor({ + name: 's', + env: [{ name: 'PLAIN', value: 'not-a-secret' }, envRef('T', 'real', 'K')], + } as never); + const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; + expect(granted.secretNames).toEqual(['real']); + }); + + it('creates the ServiceAccount BEFORE binding the role to it', async () => { + // Reverse order lets the pod start, fail to log in, and crashloop while the + // role is still being written. + const order: string[] = []; + const h = harness(); + const svc = new ServerIdentityService( + { getDefault: async () => ({ id: 'b1', name: 'bao', type: 'openbao' } as SecretBackend), + driverFor: () => ({ kind: 'openbao', ensureServerIdentity: async () => { order.push('role'); } }) } as unknown as SecretBackendService, + { namespace: NS, ensure: async () => { order.push('sa'); }, remove: async () => undefined }, + ); + await svc.ensureFor({ name: 'x', env: [envRef('T', 's', 'K')] } as never); + expect(order).toEqual(['sa', 'role']); + expect(h).toBeDefined(); + }); + + it('produces an empty grant for a server with no secret refs', async () => { + const h = harness(); + await h.svc.ensureFor({ name: 'plain', env: [] } as never); + const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; + expect(granted.secretNames).toEqual([]); + }); + + it('refuses on a backend that cannot scope identities, rather than silently succeeding', async () => { + // A plaintext backend has no auth mount to bind a role on. Succeeding here + // would leave a pod believing it had been granted access it never got. + const h = harness({ ensureServerIdentity: undefined }, 'plaintext'); + await expect(h.svc.ensureFor({ name: 's', env: [] } as never)) + .rejects.toThrow(/cannot provision per-server identities/); + }); + + it('revokes the OpenBao identity and the ServiceAccount on removal', async () => { + const h = harness(); + await h.svc.removeFor('gitea'); + expect(h.removeServerIdentity).toHaveBeenCalledWith({ name: 'mcpctl-server-gitea' }); + expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']); + }); + + it('still removes the ServiceAccount when OpenBao teardown fails', async () => { + // A half-removed identity grants nothing useful; abandoning the rest would + // strand the k8s side. + const h = harness({ removeServerIdentity: vi.fn(async () => { throw new Error('bao down'); }) as never }); + await expect(h.svc.removeFor('gitea')).resolves.toBeUndefined(); + expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']); + }); +}); From 370fd0a03406c1e2fdf7705389d95f5be08911b4 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 20 Aug 2026 23:33:13 +0100 Subject: [PATCH 54/77] feat(secrets): opt-in injected secret delivery, scoped per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the path that stops mcpd writing secret VALUES into MCP server pod specs. With `secretDelivery: injector`, the pod fetches its own secrets from OpenBao through the agent injector, under a ServiceAccount and role scoped to just that server's secrets — so the value never enters etcd, and gitea-mcp cannot read the Grafana token. Opt-in per server, defaulting to `env`. Every existing server is bit-for-bit unchanged, and migrating is one reversible decision at a time rather than a flag day. The two invariants under most risk, both tested: - **Opted-out servers produce an identical manifest.** No annotations, no serviceAccountName, automountServiceAccountToken still false. - **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops reading a server's secrets, the check e6cd735 added no longer fires for it, and a typo'd secretRef would degrade into a vault-agent-init crashloop that mcpd reports as a generic pod failure — the same class of bug that had gitea-mcp running for weeks on an empty token while reporting healthy. `validateServerEnvRefs` resolves every ref and throws the value away, purely to keep that error. After the value cache it is a cache hit and costs nothing. Shell quoting is the other silent-failure trap and is treated as part of the contract: the agent renders `export NAME='value'` and the container command sources it, so a value containing a space, `$`, a quote or a newline would truncate and yield an empty token. `shellSingleQuote` is tested by executing a real /bin/sh over nine adversarial values including `'; export PWNED=1; '` — and those tests fail against naive quoting, confirmed before keeping them. `sh -c