Merge remote-tracking branch 'origin/main' into feat/web-search-templates

# Conflicts:
#	completions/mcpctl.bash
#	completions/mcpctl.fish
#	src/cli/src/commands/create.ts
#	src/db/src/seed/index.ts
This commit is contained in:
Michal
2026-08-12 23:11:54 +01:00
83 changed files with 7354 additions and 318 deletions

View File

@@ -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),

View File

@@ -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<string, unknown> {
export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
const o: Record<string, unknown> = { ...overrides };
if (subject.kind === 'project') {
delete o.personality;
if (subject.allowSecrets) o.allowSecrets = true;
}
const body: Record<string, unknown> = { 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<string> {
): Promise<string | undefined> {
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<string>((resolve, reject) => {
return new Promise<string | undefined>((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) => {

View File

@@ -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';
@@ -34,6 +34,34 @@ 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,
mergeUserScopeServer,
userScopeProject,
activeProjectIn,
canonicalProjectIn,
legacyEntriesIn,
claudeJsonPath,
type McpJson,
type ClaudeJson,
} from '../config/claude-mcp.js';
import {
opencodeConfigDir,
opencodeStatePath,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
readOpencodeState,
writeOpencodeState,
storedToken,
} from '../utils/opencode-settings.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/**
@@ -44,8 +72,76 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
*/
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
/** Same, for the tokens `config opencode` mints. */
const OPENCODE_TOKEN_PREFIX = 'opencode';
/**
* 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 readJsonFile<T>(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 {
return JSON.parse(readFileSync(path, 'utf-8')) as McpJson;
} catch {
return 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 {
@@ -53,6 +149,8 @@ export interface ConfigCommandDeps {
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 {
@@ -68,6 +166,7 @@ const defaultDeps: ConfigCommandDeps = {
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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
@@ -76,6 +175,118 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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<ProjectToken[] | null> {
if (!skillsClient) return null;
try {
const list = await skillsClient.get<unknown>(`/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<boolean> {
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 `<agent>` 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<void> {
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 <name> --project ${project}\`.`);
}
}
/**
* Mint a fresh mcptoken for `project`.
*
* A unique `<agent>-<stamp>` 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<string | null> {
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')
@@ -132,67 +343,136 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.command(name)
.description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--scope <scope>', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user')
.option('-o, --output <path>', '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+)')
.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('--skip-ui', 'Do not install the status line or the /mcpctl slash command')
.option('--claude-dir <path>', '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; 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
// 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;
return;
}
const servers: McpConfig['mcpServers'] = {};
if (opts.project) {
servers[opts.project] = {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
};
// 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;
}
if (opts.inspect) {
servers['mcpctl-inspect'] = {
command: 'mcpctl',
args: ['console', '--stdin-mcp'],
};
const userScope = scope === 'user';
const outputPath = userScope ? claudeJsonPath() : resolve(opts.output);
const existing = userScope
? (readJsonFile<ClaudeJson>(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) {
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: {
scope,
output: outputPath,
previousProject: userScope
? userScopeProject(existing as ClaudeJson)
: activeProjectIn(existing as McpJson),
server: MCPCTL_SERVER_NAME,
entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '<unchanged>',
retiredLegacyEntries: retired,
marker: opts.skipMarker === true || opts.project === undefined
? '<skipped>'
: join(dirname(outputPath), '.mcpctl-project'),
skills: opts.skipSkills === true ? '<skipped>' : 'sync + SessionStart hook',
statusLine: opts.skipUi === true ? '<skipped>' : `${claudeSettings} (${STATUSLINE_COMMAND})`,
slashCommand: opts.skipUi === true ? '<skipped>' : claudeCommand,
},
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))`);
// 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.
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.`);
}
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
// 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 (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 {
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) {
@@ -211,12 +491,34 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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
@@ -299,7 +601,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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 +611,12 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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 +713,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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<boolean> {
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<void> {
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 <name> --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<ProjectToken[] | null> {
if (!skillsClient) return null;
try {
const list = await skillsClient.get<unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`);
return Array.isArray(list) ? list as ProjectToken[] : null;
} catch {
return null;
}
}
}
registerClaudeCommand('claude', false);
@@ -511,7 +727,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.option('--skip-skills', 'Skip the initial skills sync')
.option('--settings <path>', 'pi settings.json path (default: ~/.pi/agent/settings.json)')
.option('--pi-dir <path>', '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;
@@ -527,6 +744,20 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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 ? '<skipped>' : skillsInstall,
source: opts.extensionDir ?? '<embedded>',
},
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
@@ -590,6 +821,190 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, 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 <name>', 'Project name to make active')
.option('--gateway-url <url>', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL)
.option('--token <pat>', 'mcpctl project bearer token to use (skips auto-minting)')
.option('--opencode-dir <path>', '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 ? '<skipped>' : paths.serverPluginPath(),
tuiPlugin: opts.skipPlugin === true ? '<skipped>' : paths.tuiPluginPath(),
tuiJson: opts.skipPlugin === true ? '<skipped>' : paths.tuiJsonPath(),
statePath,
mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`,
skillsDir: opts.skipSkills === true ? '<skipped>' : paths.skillsDir(),
marker: opts.skipMarker === true ? '<skipped>' : 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 <pat>');
}
}
} 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<typeof console.error>)) },
);
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 }));

View File

@@ -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<string, unknown>;
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<string, unknown>;
}
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.
*
@@ -184,6 +247,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--volume <spec>', 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)', collect, [])
.option('--health-check-tool <tool>', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")')
.option('--health-check-args <json>', 'Readiness probe: JSON object of arguments for the probe tool')
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
.option('--force', 'Update if already exists')
@@ -266,6 +334,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);
@@ -959,7 +1033,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;

View File

@@ -106,6 +106,12 @@ function formatInstanceDetail(instance: Record<string, unknown>, 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<string, unknown> | undefined;

View File

@@ -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<Syn
// 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 isPi = target === 'pi';
// prime-agent and pi share the same skill semantics: their own flat skill
// tree + separate state file, no SessionStart hooks / postInstall, no
// prime-agent, pi and opencode share the same skill semantics: their own flat
// skill tree + separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour.
const isSharedTree = isPrimeAgent || isPi;
const isSharedTree = target !== 'claude';
// Canonical ownership scope for a *skill*: null when the skill is global
// (globals are visible from every project, so pinning one to whichever
// project happened to sync it would lock every other project out of ever
// updating it), otherwise the project that installed it.
const ownerOf = (s: VisibleSkill): string | null => (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 <agent>` 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 <name>', 'Project to sync (overrides .mcpctl-project marker)')
.option('--agent <name>', 'Sync target: claude (default), prime-agent, or pi', 'claude')
.option('--agent <name>', '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 },

View File

@@ -0,0 +1,163 @@
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
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';
/**
* `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 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.
*/
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<StatusLineInput> {
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;
}
/** Claude Code's user-scope config, or null if it is missing or unreadable. */
export function readClaudeJson(path: string): ClaudeJson | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson;
} catch {
return 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 {
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>): 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>): 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}` : '';
}
export interface StatuslineDeps {
log: (line: string) => void;
cwd: () => string;
homeDir: () => string;
}
export function createStatuslineCommand(deps?: Partial<StatuslineDeps>): 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 <path>', 'Directory to resolve the project for (default: from stdin, then cwd)')
.option('--prefix <text>', '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());
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) {
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);
});
}

View File

@@ -0,0 +1,248 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `.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.
*/
/**
* 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`). */
export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect';
export interface McpServerEntry {
command?: string;
args?: string[];
url?: string;
env?: Record<string, string>;
[key: string]: unknown;
}
export interface McpJson {
mcpServers: Record<string, McpServerEntry>;
[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 the canonical `mcpctl` entry mounts, or null if there isn't one. */
export function canonicalProjectIn(config: Pick<McpJson, 'mcpServers'> | 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<McpJson, 'mcpServers'> | 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<McpJson, 'mcpServers'> | null | undefined): string | null {
return canonicalProjectIn(config) ?? legacyEntriesIn(config)[0]?.project ?? 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<McpJson> | null | undefined,
opts: { project?: string; inspect?: boolean },
): MergeResult {
const servers: Record<string, McpServerEntry> = { ...(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<McpJson>;
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');
}
/** 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<string, McpServerEntry>;
projects?: Record<string, ClaudeProjectEntry>;
[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<string> {
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.
*
* `.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<string, McpServerEntry> = { ...(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 ?? {} });
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 <name>', 'Target project for project commands');
program.addCommand(createStatusCommand());
program.addCommand(createStatuslineCommand());
program.addCommand(createLoginCommand());
program.addCommand(createLogoutCommand());

View File

@@ -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<Settings> {
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<void> {
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<StatusLineOutcome> {
const settings = await readSettings(settingsPath);
const current = settings.statusLine;
if (current !== undefined && current !== null) {
if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '<unknown>') };
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<boolean> {
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 <name> --skip-marker\`
4. Report the switch as "now on <project>". 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<string> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8');
return path;
}

View File

@@ -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<string[]> {
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<TuiConfig> {
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<void> {
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<string, string>;
}
/** Read the state file; missing or corrupt yields an empty state. */
export async function readOpencodeState(path: string = opencodeStatePath()): Promise<OpencodeState> {
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<string> {
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;
}

View File

@@ -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<Settings> {
@@ -95,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 }],

View File

@@ -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<void>((r) => server!.close(() => r()));
server = null;
}
});
/** Serve one SSE response body for any POST, return the base URL. */
async function serveSse(frames: string[]): Promise<string> {
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<void>((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');
});
});

View File

@@ -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, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
@@ -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 () => {
@@ -46,7 +61,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 +76,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 +96,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 +128,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 +142,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);
});
});
@@ -223,3 +278,127 @@ 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'");
});
// 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<string> => {
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:');
});
});
});

View File

@@ -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);
}
});
});

View File

@@ -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<string, unknown> => ({
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<string> {
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');
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
projectOfEntry,
isLegacyMcpctlEntry,
activeProjectIn,
canonicalProjectIn,
legacyEntriesIn,
disabledServersFor,
} from '../../src/config/claude-mcp.js';
const bridge = (project: string): Record<string, unknown> => ({
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('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' });
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']);
});
});

View File

@@ -0,0 +1,103 @@
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 opencodes own tree, never Claudes', () => {
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: '<leader>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'");
});
});
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;/);
}
});
});

View File

@@ -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 callers 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');
});
});

View File

@@ -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.

View File

@@ -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'");
});
});

View File

@@ -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/);
});
});

View File

@@ -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`');
});
});

View File

@@ -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 users 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');
});
});

View File

@@ -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<Record<string, unknown>> }> };
};
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');
});
});

View File

@@ -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'],

View File

@@ -29,9 +29,10 @@ export interface SeedTemplate {
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.
* 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;

View File

@@ -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

View File

@@ -24,7 +24,13 @@ export const VolumeSpecSchema = z.object({
export type VolumeSpecInput = z.infer<typeof VolumeSpecSchema>;
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),

View File

@@ -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({

View File

@@ -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');
});
});

View File

@@ -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';

View File

@@ -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<Response> {
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
const headers: Record<string, string> = {
...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<T>(method: string, path: string, body?: unknown): Promise<T> {
const result = await this.forward(method, path, '', body);

View File

@@ -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]) ?? '/';
@@ -19,25 +71,78 @@ 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);
}
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<void> {
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<string, string> = {};
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<void>((resolve, reject) => {
const upstream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[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();
}
}

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -0,0 +1,255 @@
import http from 'node:http';
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<string> {
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<FastifyInstance> {
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('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<boolean>((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<string>((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) => {
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/);
});
});

View File

@@ -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<void>((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.

View File

@@ -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

View File

@@ -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<T>(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<string, string> = { 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<string, unknown>;
}
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<Server[]>('GET', '/api/v1/servers');
expect(s.status, `GET /api/v1/servers returned ${s.status}`).toBe(200);
servers = s.data;
const i = await mcpdRequest<Instance[]>('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<ProxyResult>('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<ProxyResult>('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([]);
});
});

View File

@@ -32,6 +32,18 @@ function httpRequest(opts: {
headers?: Record<string, string>;
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)

View File

@@ -0,0 +1,323 @@
/** @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:<project>` 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<string, string>;
}
interface ProjectInfo {
name: string;
description?: string;
}
function statePath(): string {
return join(homedir(), '.mcpctl', 'opencode-state.json');
}
async function readState(): Promise<OpencodeState> {
try {
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 {};
}
}
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<string> {
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<ProjectInfo[]> {
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<void> => {
/** Re-read the state file and publish the footer label. */
async function refreshIndicator(): Promise<string | null> {
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<void> {
const gatewayUrl = state.gatewayUrl;
if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
const token = state.tokens?.[project] ?? '';
const headers: Record<string, string> = {};
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: {
type: 'remote',
url: projectUrl(gatewayUrl, project),
headers,
enabled: true,
timeout: 120_000,
},
});
}
async function switchTo(project: string): Promise<void> {
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 projects MCP servers and skills',
category: 'mcpctl',
namespace: 'palette',
slashName: 'mcpctl',
async run(): Promise<void> {
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(() => (
<DialogSelect
title={active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project'}
placeholder="type to filter…"
current={active ?? undefined}
options={orderProjects(projects, active).map((p) => ({
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<void> {
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 projects mcpctl skills into opencodes skill directory',
category: 'mcpctl',
namespace: 'palette',
slashName: 'mcpctl-skills',
async run(): Promise<void> {
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' });
}
},
},
],
// 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: '<leader>m', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },
],
});
// 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.)
// 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 => (
<text fg={api.theme.current.textMuted} wrapMode="none" truncate>
{api.kv.get(KV_LABEL, indicatorLabel(null))}
</text>
);
api.slots.register({
order: 100,
slots: {
session_prompt_right: () => <Indicator />,
home_prompt_right: () => <Indicator />,
},
});
await refreshIndicator();
};
function errText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export default {
id: 'mcpctl',
tui,
} satisfies TuiPluginModule & { id: string };

View File

@@ -0,0 +1,170 @@
/**
* 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 <mcpctl PAT>` 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<string, string>;
}
function statePath(): string {
return join(homedir(), '.mcpctl', 'opencode-state.json');
}
async function readState(): Promise<OpencodeState> {
try {
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 {};
}
}
/** 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<void> {
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<string, string> = {};
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<boolean> {
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<void> | null = null;
function ensureMounted(): Promise<void> {
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<void> => {
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<void> => {
await ensureMounted();
},
};
};
export default {
id: 'mcpctl',
server,
} satisfies PluginModule & { id: string };

View File

@@ -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"]
}

View File

@@ -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<string
}
// ── JSON Schema → TypeBox ────────────────────────────────────────────────────
/**
* `{ type: "string", enum: [...] }` rather than a union of literals: Google's
* API (and other providers that reject anyOf/const) only accept the flat form.
*
* Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in
* `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the
* whole extension then failed to load. See the import note at the top.
*/
function stringEnum(values: string[], description?: string): TSchema {
return Type.Unsafe<string>({
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));

View File

@@ -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<string> {
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<ProjectInfo[]> {
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:<project>`). */
async function credentialedProjects(): Promise<Set<string>> {
const out = new Set<string>();
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<string, { key?: unknown } | null>;
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/<name>/mcp` proxy URL *and* auth.json holds an `mcp:<name>` mcpctl
* PAT. A hand-configured server has no such credential and is never mistaken
* for the active project.
*/
async function activeProject(): Promise<string | null> {
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<string, Record<string, unknown>> };
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<void> {
// 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<string | undefined>; input(title: string, placeholder?: string): Promise<string | undefined>; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },
projects: ProjectInfo[],
active: string | null,
): Promise<string | undefined> {
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');
},
});
}

View File

@@ -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"]
}