Merge origin/main into feat/agentic-teams
124 commits of main against 33 of agentic-teams. 22 files overlapped, 7 conflicted. Three needed real reconciliation rather than a side-pick: **The `volumes` collision.** Both branches independently added a `volumes` field to ContainerSpec meaning different things: main's is persistent storage keyed by a server-derived `claimName` that deliberately outlives an instance; mine is emptyDir scratch shared between an init container and the main container. Worse, each emitted the pod-level `volumes` list from its own helper, so spreading both would have silently kept only the last — a server with a PVC would have lost it the moment a session scratch volume appeared. Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now owns the whole pod-level list and emits both kinds; `buildPodExtras` is init containers only. fsGroup/securityContext stays tied to the PVC case, because an emptyDir is created writable by the container's own user and chowning it is work with no purpose. Five tests pin the coexistence so a later change cannot drop one kind again. **The extracted permission mapper.** I moved `mapUrlToPermission` out of main.ts into middleware/url-permission.ts so it could be tested; main kept it inline and kept editing it. Kept the extraction and ported main's one change across — the `/api/v1/audit/events/purge` mapping onto the existing `audit-purge` operation. Verified rule-by-rule that the extracted version is a superset: all 15 of main's rules plus the 5 agentic-teams ones. **The NUL byte in create.ts.** Both branches fixed the same defect independently, mine as `''` and main's as `'\0'`. Took main's. The rest were one-liners: .gitignore comment wording, and an add/add on proxy-long-running.test.ts where main's copy carries a progressive-delivery test mine lacked — took main's in full. Not changed, but worth flagging: main's generateDeploymentSpec hardcodes `automountServiceAccountToken: false` while generatePodSpec honours `spec.automountServiceAccountToken`. That asymmetry is main's own and predates this merge, so it is left exactly as-is rather than altered inside a merge commit. Verified: 3138 tests pass (237 files), build clean, prisma schema valid. Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file touched during conflict resolution was checked and introduces no new errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -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),
|
||||
@@ -20,6 +21,14 @@ const ServerEnvEntrySchema = z.object({
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const VolumeSpecSchema = z.object({
|
||||
name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
|
||||
mountPath: z.string().min(1).regex(/^\//, 'mountPath must be absolute'),
|
||||
sizeGb: z.number().int().min(1).max(1024).default(10),
|
||||
storageClass: z.string().optional(),
|
||||
fsGroup: z.number().int().min(0).max(65535).optional(),
|
||||
});
|
||||
|
||||
const ServerSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
@@ -34,6 +43,12 @@ const ServerSpecSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
// nullable: `get server -o yaml` emits null for servers with no override,
|
||||
// and that YAML must apply back unchanged.
|
||||
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(),
|
||||
});
|
||||
|
||||
const SecretSpecSchema = z.object({
|
||||
@@ -125,6 +140,12 @@ const TemplateSpecSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: z.enum(['env', 'injector']).optional(),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
// nullable: `get server -o yaml` emits null for servers with no override,
|
||||
// and that YAML must apply back unchanged.
|
||||
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(),
|
||||
});
|
||||
|
||||
const UserSpecSchema = z.object({
|
||||
|
||||
@@ -72,7 +72,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). */
|
||||
@@ -111,7 +111,7 @@ 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;
|
||||
@@ -122,7 +122,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde
|
||||
o.project = subject.project;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -223,7 +226,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();
|
||||
}
|
||||
@@ -280,7 +287,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) {
|
||||
@@ -524,15 +533,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));
|
||||
|
||||
@@ -553,7 +568,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,
|
||||
@@ -574,7 +589,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) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from 'commander';
|
||||
import { writeFileSync, readFileSync, existsSync } 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';
|
||||
@@ -9,12 +9,139 @@ import { saveCredentials, loadCredentials } from '../auth/index.js';
|
||||
import { createConfigSetupCommand } from './config-setup.js';
|
||||
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { writeProjectMarker } from '../utils/project-marker.js';
|
||||
import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js';
|
||||
import { installManagedSessionHook } from '../utils/sessionhook.js';
|
||||
import {
|
||||
installExtensionFiles,
|
||||
installEmbeddedExtension,
|
||||
registerWithPi,
|
||||
piSkillsDir,
|
||||
piSettingsPath,
|
||||
piExtensionDir,
|
||||
piExtensionMainPath,
|
||||
piStatePath,
|
||||
withPiAgentDir,
|
||||
writePiProjectState,
|
||||
} from '../utils/pi-settings.js';
|
||||
import { runSkillsSync } from './skills.js';
|
||||
import {
|
||||
registerPrimeAgentMcp,
|
||||
primeAgentSettingsPath,
|
||||
DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
writePrimeAgentAuth,
|
||||
readPrimeAgentAuthKey,
|
||||
mcpTokenPrefixOf,
|
||||
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';
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||
/**
|
||||
* Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint
|
||||
* gets a unique `<prefix>-<stamp>` name because `McpToken` is unique on
|
||||
* (name, projectId) and revoke is a soft delete — a fixed name could only ever
|
||||
* be minted once per project.
|
||||
*/
|
||||
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
|
||||
|
||||
/** 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 {
|
||||
@@ -22,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 {
|
||||
@@ -37,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
|
||||
@@ -45,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')
|
||||
@@ -101,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) {
|
||||
@@ -180,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
|
||||
@@ -193,9 +526,486 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
}
|
||||
}
|
||||
|
||||
// prime-agent: register our proxy MCP gateway in prime-agent's settings.json
|
||||
// + sync the project's skills into prime-agent's skills tree. Mirror of the
|
||||
// claude command above, but targeting ~/.prime/agent/ instead of .mcp.json.
|
||||
function registerPrimeAgentCommand(name: string, hidden: boolean): void {
|
||||
const cmd = config
|
||||
.command(name)
|
||||
.description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)')
|
||||
.option('-p, --project <name>', 'Project name')
|
||||
.option('-o, --output <path>', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)')
|
||||
.option('--gateway-url <url>', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL)
|
||||
.option('--token <pat>', 'mcpctl project bearer token to store in auth.json (skips auto-minting)')
|
||||
.option('--skip-skills', 'Skip the skills sync step')
|
||||
.option('--skip-extension', 'Do not install the /mcpctl project-switcher extension')
|
||||
.option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory')
|
||||
.option('--dry-run', 'Print what would change without writing or syncing')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
output?: string;
|
||||
gatewayUrl: string;
|
||||
token?: string;
|
||||
skipSkills?: boolean;
|
||||
skipExtension?: boolean;
|
||||
skipMarker?: boolean;
|
||||
dryRun?: boolean;
|
||||
}) => {
|
||||
if (opts.project === undefined || opts.project === '') {
|
||||
log('Error: --project is required');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsPath = resolve(opts.output ?? primeAgentSettingsPath());
|
||||
const agentDir = dirname(settingsPath);
|
||||
const authPath = join(agentDir, 'auth.json');
|
||||
const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME);
|
||||
const gatewayBase = opts.gatewayUrl.replace(/\/+$/, '');
|
||||
const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`;
|
||||
|
||||
if (opts.dryRun === true) {
|
||||
const dry = JSON.stringify({
|
||||
primeAgent: {
|
||||
settingsPath,
|
||||
authPath,
|
||||
mcpServers: { [opts.project]: { type: 'http', url } },
|
||||
extension: opts.skipExtension === true ? '<skipped>' : extPath,
|
||||
marker: opts.skipMarker === true ? '<skipped>' : join(process.cwd(), '.mcpctl-project'),
|
||||
},
|
||||
action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
|
||||
}, null, 2);
|
||||
log(dry);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Provision the bearer credential prime-agent needs for this project.
|
||||
// mcpctl's stdio bridge supplied auth implicitly; over HTTP we must
|
||||
// store an mcp:<project> token in auth.json. Use --token if given,
|
||||
// keep a still-valid existing one, otherwise mint it via the API.
|
||||
//
|
||||
// This runs BEFORE settings.json is touched: registering the new
|
||||
// project unmounts the previously active one, so a mint failure must
|
||||
// not be able to leave prime-agent with no working project at all.
|
||||
// A switch with no usable credential is a FAILURE (exit != 0) so the
|
||||
// /mcpctl extension does not report success over a bare project.
|
||||
let provisioned = false;
|
||||
try {
|
||||
// Whatever this auth.json held before we touched it — the only token
|
||||
// this run is entitled to retire once it has a replacement.
|
||||
const staleKey = await readPrimeAgentAuthKey(opts.project, authPath);
|
||||
if (opts.token !== undefined && opts.token !== '') {
|
||||
await writePrimeAgentAuth(opts.project, opts.token, authPath);
|
||||
log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
|
||||
provisioned = true;
|
||||
// Only when we actually replaced something: `--token` with a fresh
|
||||
// auth.json must stay entirely offline, as documented.
|
||||
if (staleKey !== null) {
|
||||
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}`);
|
||||
provisioned = true;
|
||||
} else if (skillsClient) {
|
||||
// Mint under a fresh, unique name. `McpToken` is unique on
|
||||
// (name, projectId) and revoke is a soft delete, so reusing a fixed
|
||||
// name would collide with the revoked row forever. Retire the old
|
||||
// tokens only *after* the replacement is safely on disk.
|
||||
const 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(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted);
|
||||
} else {
|
||||
log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`);
|
||||
}
|
||||
} else {
|
||||
log('Error: no API client available to mint a project token — pass --token <pat> to provision auth.json');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
if (!provisioned) {
|
||||
process.exitCode = 1;
|
||||
log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Register the proxy MCP gateway (merge; never destroy settings).
|
||||
try {
|
||||
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath });
|
||||
log(reg.created
|
||||
? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})`
|
||||
: `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`);
|
||||
if (reg.removed.length > 0) {
|
||||
log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Write the .mcpctl-project marker so later `skills sync` calls can
|
||||
// resolve the project. An explicit -p is authoritative: it updates a
|
||||
// differing up-tree marker (so the scope doesn't silently revert on
|
||||
// the next sync), is a no-op when it already matches, and never
|
||||
// scopes $HOME itself. `--skip-marker` opts out entirely: the
|
||||
// /mcpctl switcher runs this command from whatever directory
|
||||
// prime-agent happens to be started in, and must not silently
|
||||
// re-scope an unrelated repo that Claude Code's own sync reads.
|
||||
try {
|
||||
if (opts.skipMarker === true) {
|
||||
log('Skipped .mcpctl-project marker (--skip-marker)');
|
||||
} else if (process.cwd() !== homedir()) {
|
||||
const existing = await findProjectMarker(process.cwd(), homedir());
|
||||
if (existing !== null && existing.project === opts.project) {
|
||||
log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`);
|
||||
} else {
|
||||
const markerPath = await writeProjectMarker(process.cwd(), opts.project);
|
||||
log(existing !== null
|
||||
? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')`
|
||||
: `Wrote ${markerPath}`);
|
||||
}
|
||||
} else {
|
||||
log('Skipped .mcpctl-project marker (running from $HOME)');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 4. Sync skills into prime-agent's skills tree (skippable).
|
||||
// Best-effort: settings + auth (steps 1–2) determine whether the
|
||||
// switch succeeded. A skills error is reported but must not flip the
|
||||
// /mcpctl switch to "failed" when MCP access is already provisioned.
|
||||
if (opts.skipSkills !== true) {
|
||||
if (skillsClient) {
|
||||
try {
|
||||
const result = await runPrimeAgentSkillsSync(
|
||||
{ project: opts.project },
|
||||
{ client: skillsClient, log: (...a: unknown[]) => 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;
|
||||
if (total > 0 || result.errors.length > 0) {
|
||||
log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else {
|
||||
log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Install the /mcpctl project-switcher extension (skippable).
|
||||
if (opts.skipExtension !== true) {
|
||||
try {
|
||||
mkdirSync(dirname(extPath), { recursive: true });
|
||||
writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8');
|
||||
log(`Installed /mcpctl switcher extension: ${extPath}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (hidden) {
|
||||
void cmd;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
registerClaudeCommand('claude', false);
|
||||
registerClaudeCommand('claude-generate', true); // backward compat
|
||||
|
||||
// ── pi: wire the mcpctl pi extension (no MCP, no Claude) ──
|
||||
config
|
||||
.command('pi')
|
||||
.description('Install the pi extension + sync skills (native, no MCP client, no Claude)')
|
||||
.option('-p, --project <name>', 'Project name to make active')
|
||||
.option('--extension-dir <path>', 'Source-tree src/pi-ext dir (dev only; default: embedded sources)')
|
||||
.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)')
|
||||
.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;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve target paths, honouring an optional --pi-dir override.
|
||||
const piHome = opts.piDir ? withPiAgentDir(opts.piDir) : undefined;
|
||||
const settingsPath = opts.settings ?? piHome?.settingsPath() ?? piSettingsPath();
|
||||
const extDest = piHome?.extensionDir() ?? piExtensionDir();
|
||||
const skillsInstall = piHome?.skillsDir() ?? piSkillsDir();
|
||||
// Isolate the persisted active-project state under the pi home when a
|
||||
// custom --pi-dir is used (also keeps tests off the real ~/.mcpctl).
|
||||
const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath();
|
||||
|
||||
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
|
||||
// checkout for dev iteration.
|
||||
try {
|
||||
const written = opts.extensionDir
|
||||
? await installExtensionFiles(opts.extensionDir, extDest)
|
||||
: await installEmbeddedExtension(extDest);
|
||||
log(`Installed extension files:`);
|
||||
for (const w of written) log(` ${w}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Error: could not install extension: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Register with pi settings (extensions + skills dir).
|
||||
try {
|
||||
const extMain = piHome?.extensionMainPath() ?? piExtensionMainPath();
|
||||
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extMain, skillsInstall);
|
||||
log(`Updated ${settingsPath}`);
|
||||
log(` extensions: ${addedExtensions.length ? addedExtensions.join(', ') : 'already registered'}`);
|
||||
log(` skills: ${addedSkills.length ? addedSkills.join(', ') : 'already registered'}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: could not update pi settings: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 3. Persist the active project (survives across sessions/cwd).
|
||||
try {
|
||||
const p = await writePiProjectState(opts.project, statePath);
|
||||
log(`Wrote active project to ${p}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: could not write pi state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 4. Optional initial skills sync into pi's skills dir.
|
||||
if (!opts.skipSkills && skillsClient) {
|
||||
try {
|
||||
const result = await runSkillsSync(
|
||||
{ project: opts.project, target: 'pi', installRoot: skillsInstall },
|
||||
{ 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;
|
||||
if (total > 0) {
|
||||
log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`);
|
||||
} else {
|
||||
log('Skills: no changes (already up to date)');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else if (!opts.skipSkills) {
|
||||
log('Warning: skipping initial skills sync (no API client provided)');
|
||||
}
|
||||
|
||||
log('');
|
||||
log('Next: restart pi (or start a new session) — the mcpctl tools for the active');
|
||||
log(`project will be registered automatically. Use /mcpctl to switch projects.`);
|
||||
});
|
||||
|
||||
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 }));
|
||||
|
||||
if (apiDeps) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -103,6 +166,53 @@ function parseServerEnv(entries: string[]): ServerEnvEntry[] {
|
||||
});
|
||||
}
|
||||
|
||||
export interface ServerVolume {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
sizeGb: number;
|
||||
storageClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`.
|
||||
*
|
||||
* Split on ':' rather than a regex so a Windows-style or otherwise odd mount
|
||||
* path fails loudly on the leading-slash check instead of being silently
|
||||
* mis-parsed into the wrong field.
|
||||
*/
|
||||
export function parseServerVolumes(entries: string[]): ServerVolume[] {
|
||||
return entries.map((entry) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2 || parts.length > 4) {
|
||||
throw new Error(
|
||||
`Invalid volume format '${entry}'. Expected NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`,
|
||||
);
|
||||
}
|
||||
const [name, mountPath, sizeRaw, storageClass] = parts;
|
||||
if (name === undefined || name === '') {
|
||||
throw new Error(`Invalid volume '${entry}': name is empty`);
|
||||
}
|
||||
if (mountPath === undefined || !mountPath.startsWith('/')) {
|
||||
throw new Error(`Invalid volume '${entry}': mount path must be absolute (start with '/')`);
|
||||
}
|
||||
|
||||
let sizeGb = 10;
|
||||
if (sizeRaw !== undefined && sizeRaw !== '') {
|
||||
sizeGb = Number(sizeRaw);
|
||||
if (!Number.isInteger(sizeGb) || sizeGb < 1) {
|
||||
throw new Error(`Invalid volume '${entry}': size must be a positive whole number of GiB`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
mountPath,
|
||||
sizeGb,
|
||||
...(storageClass !== undefined && storageClass !== '' ? { storageClass } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseEnvEntries(entries: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
@@ -259,6 +369,15 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--container-port <port>', 'Container port number')
|
||||
.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('--tool-call-timeout <seconds>', 'Per-server tool-call deadline in seconds (default: mcplocal\'s global deadline)')
|
||||
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
|
||||
.option('--secret-delivery <mode>', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)')
|
||||
.option('--entrypoint <argv>', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect')
|
||||
.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')
|
||||
@@ -334,6 +453,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.transport) body.transport = opts.transport;
|
||||
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
|
||||
if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery;
|
||||
if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean);
|
||||
if (opts.toolCallTimeout !== undefined) {
|
||||
body.toolCallTimeoutSeconds = parsePositiveInt('--tool-call-timeout', opts.toolCallTimeout as string);
|
||||
}
|
||||
if (opts.packageName) body.packageName = opts.packageName;
|
||||
if (opts.runtime) body.runtime = opts.runtime;
|
||||
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
|
||||
@@ -341,6 +465,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);
|
||||
@@ -356,6 +486,19 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
}
|
||||
body.env = merged;
|
||||
}
|
||||
if (opts.volume.length > 0) {
|
||||
// Merge by volume name, same as --env, so a CLI volume can retarget one
|
||||
// the template declared without duplicating the mount.
|
||||
const cliVolumes = parseServerVolumes(opts.volume as string[]);
|
||||
const existing = (body.volumes as ServerVolume[] | undefined) ?? [];
|
||||
const merged = [...existing];
|
||||
for (const vol of cliVolumes) {
|
||||
const idx = merged.findIndex((v) => v.name === vol.name);
|
||||
if (idx >= 0) merged[idx] = vol;
|
||||
else merged.push(vol);
|
||||
}
|
||||
body.volumes = merged;
|
||||
}
|
||||
|
||||
// Defaults when no template
|
||||
if (!opts.fromTemplate) {
|
||||
@@ -1021,7 +1164,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('\u0000')) {
|
||||
if (text.includes('\0')) {
|
||||
throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`);
|
||||
}
|
||||
files[rel] = text;
|
||||
|
||||
@@ -21,6 +21,20 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
lines.push(`${pad('Name:')}${server.name}`);
|
||||
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
|
||||
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
|
||||
{
|
||||
// Surface the remaining exposure rather than leaving it silent: a server
|
||||
// still on `env` with secret refs has those VALUES written into its pod
|
||||
// spec, i.e. cleartext in etcd for anyone with `get pod`.
|
||||
const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env';
|
||||
const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>;
|
||||
const hasSecretRefs = env.some((e) => e.valueFrom !== undefined);
|
||||
const note = delivery === 'injector'
|
||||
? ' (pod fetches its own secrets under a scoped OpenBao identity)'
|
||||
: hasSecretRefs
|
||||
? ' ⚠ secret values are inlined into the pod spec'
|
||||
: '';
|
||||
lines.push(`${pad('Secret Delivery:')}${delivery}${note}`);
|
||||
}
|
||||
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
|
||||
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
|
||||
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
|
||||
@@ -51,6 +65,22 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
const volumes = server.volumes as Array<{ name: string; mountPath: string; sizeGb?: number; storageClass?: string }> | undefined;
|
||||
if (volumes && volumes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Volumes:');
|
||||
const volW = Math.max(6, ...volumes.map((v) => v.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(volW)}MOUNT PATH${' '.repeat(12)}SIZE CLAIM`);
|
||||
for (const v of volumes) {
|
||||
const size = `${v.sizeGb ?? 10}Gi`;
|
||||
const cls = v.storageClass !== undefined && v.storageClass !== '' ? ` (${v.storageClass})` : '';
|
||||
lines.push(
|
||||
` ${v.name.padEnd(volW)}${pad(v.mountPath, 22)}${pad(size, 7)}`
|
||||
+ `mcpctl-${server.name as string}-${v.name}${cls}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
@@ -90,6 +120,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;
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
detectModifiedFiles,
|
||||
type SkillState,
|
||||
defaultStatePath,
|
||||
pathExists,
|
||||
} from '../utils/skills-state.js';
|
||||
import {
|
||||
installSkillAtomic,
|
||||
ensureSkillFrontmatter,
|
||||
removeSkillAtomic,
|
||||
type SkillBody,
|
||||
} from '../utils/skills-disk.js';
|
||||
@@ -31,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
|
||||
@@ -87,10 +104,24 @@ export interface SyncOpts {
|
||||
keepOrphans?: boolean;
|
||||
/** For tests: override cwd start for the marker walk-up. */
|
||||
cwd?: string;
|
||||
/** For tests: override skills install root (default: ~/.claude/skills). */
|
||||
/** For tests: override skills install root (default depends on target). */
|
||||
installRoot?: string;
|
||||
/** For tests: override state file path. */
|
||||
/** For tests: override state file path (default depends on target). */
|
||||
statePath?: string;
|
||||
/** Override $HOME used for default paths (tests). */
|
||||
homeDir?: string;
|
||||
/**
|
||||
* Which agent's skill tree to sync into:
|
||||
* 'claude' (default) — ~/.claude/skills, with hooks + postInstall.
|
||||
* 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall,
|
||||
* shared flat tree with per-project ownership so
|
||||
* configuring a second project never deletes the
|
||||
* first project's skills, and pre-existing
|
||||
* (untracked) skill dirs are preserved.
|
||||
* 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in
|
||||
* ~/.pi/agent/skills and opencode's `skill` dir.
|
||||
*/
|
||||
target?: SyncTarget;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
@@ -120,6 +151,8 @@ export interface SyncDeps {
|
||||
*/
|
||||
export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<SyncResult> {
|
||||
const { client, log, warn } = deps;
|
||||
const target = opts.target ?? 'claude';
|
||||
const homeDir = opts.homeDir ?? homedir();
|
||||
const result: SyncResult = {
|
||||
installed: [],
|
||||
updated: [],
|
||||
@@ -173,10 +206,26 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
// becomes a concept.
|
||||
visible = visible.filter((s) => s.scope !== 'agent');
|
||||
|
||||
// 3. Load state.
|
||||
const statePath = opts.statePath ?? defaultStatePath();
|
||||
// 3. Load state. Defaults depend on the sync target: Claude Code gets
|
||||
// ~/.claude/skills + the shared state file; prime-agent gets its own
|
||||
// tree + separate state file so the two never collide.
|
||||
// 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 = 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
|
||||
?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`));
|
||||
const state = await loadState(statePath);
|
||||
const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills');
|
||||
// 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 ?? agentInstallRoot(target, homeDir);
|
||||
|
||||
// 4. Diff.
|
||||
const visibleByName = new Map(visible.map((s) => [s.name, s]));
|
||||
@@ -190,7 +239,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
toFetch.push(v);
|
||||
continue;
|
||||
}
|
||||
if (prior.contentHash === v.contentHash) {
|
||||
// --force re-fetches even when the server content is unchanged. Without
|
||||
// this, a skill already on disk can never be repaired by a client-side fix
|
||||
// (e.g. frontmatter the earlier sync failed to write) because the hash
|
||||
// still matches and the skill is skipped forever.
|
||||
if (prior.contentHash === v.contentHash && !opts.force) {
|
||||
result.skipped.push(v.name);
|
||||
continue;
|
||||
}
|
||||
@@ -205,12 +258,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
await Promise.all(batch.map((v) => applyOne(v)));
|
||||
}
|
||||
|
||||
// 6. Orphan removal: skills in state but not in server's visible set.
|
||||
// 6. Orphan removal: skills in state but not in the server's visible set.
|
||||
if (!opts.keepOrphans) {
|
||||
for (const name of stateNames) {
|
||||
if (visibleByName.has(name)) continue;
|
||||
const prior = state.skills[name];
|
||||
if (!prior) continue;
|
||||
// prime-agent shares one flat skill tree across projects while
|
||||
// settings.json accumulates one MCP server per project. Never delete a
|
||||
// skill that belongs to a *different* project (or the user would lose
|
||||
// their first project just by configuring a second one). Only remove
|
||||
// skills this project (or globals) previously installed and that have
|
||||
// since left the visible set.
|
||||
if (isSharedTree && !ownsOrphan(prior)) continue;
|
||||
try {
|
||||
// Preserve user-modified skills — warn + skip.
|
||||
const modified = await detectModifiedFiles(prior.installDir, prior.files);
|
||||
@@ -224,8 +284,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
continue;
|
||||
}
|
||||
await removeSkillAtomic(prior.installDir);
|
||||
// Drop any hook entries this skill registered.
|
||||
try { await removeManagedHooks(name); } catch { /* best-effort */ }
|
||||
// Drop any hook entries this skill registered (Claude only — the
|
||||
// shared-tree paths never touch ~/.claude/settings.json).
|
||||
if (!isSharedTree) {
|
||||
try { await removeManagedHooks(name); } catch { /* best-effort */ }
|
||||
}
|
||||
delete state.skills[name];
|
||||
result.removed.push(name);
|
||||
} catch (err: unknown) {
|
||||
@@ -268,7 +331,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
|
||||
if (parts.length === 0) parts.push('no changes');
|
||||
if (!opts.quiet) {
|
||||
log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
|
||||
log(`mcpctl skills sync (${target})${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
|
||||
} else if (anythingHappened) {
|
||||
// Quiet mode: only emit a single line if something actually happened.
|
||||
warn(`mcpctl: ${parts.join(', ')}`);
|
||||
@@ -277,6 +340,27 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
|
||||
return result;
|
||||
|
||||
/**
|
||||
* May this sync delete `prior` from the shared prime-agent tree now that it
|
||||
* has left the visible set? The tree is flat and shared across projects while
|
||||
* each project syncs only its own slice, so "not visible to me" is never on
|
||||
* its own a reason to delete.
|
||||
*
|
||||
* - global (`null`) — globals are visible from every project and on a
|
||||
* global-only sync, so gone here means gone. Removable.
|
||||
* - a project name — removable only while syncing that same project.
|
||||
* - `undefined` — written before ownership tracking existed. The only
|
||||
* evidence of the owner is which project last wrote
|
||||
* the state file; when that isn't the project syncing
|
||||
* now, leave it alone rather than delete another
|
||||
* project's skills on the first post-upgrade sync.
|
||||
*/
|
||||
function ownsOrphan(prior: SkillState): boolean {
|
||||
if (prior.project === null) return true;
|
||||
if (typeof prior.project === 'string') return prior.project === projectName;
|
||||
return priorSyncProject !== null && priorSyncProject === projectName;
|
||||
}
|
||||
|
||||
async function applyOne(v: VisibleSkill): Promise<void> {
|
||||
try {
|
||||
// If on-disk files were locally modified, preserve unless --force.
|
||||
@@ -290,6 +374,36 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared-tree (prime-agent / pi): never clobber an untracked,
|
||||
// pre-existing directory (e.g. a hand-authored skill like `sre`, or a
|
||||
// skill another project owns). The shared, hand-editable tree must never
|
||||
// be rm -rf'd just because our state file is fresh (empty) on first sync.
|
||||
if (isSharedTree && !prior) {
|
||||
const dirExists = await pathExists(targetDir);
|
||||
if (dirExists && !opts.force) {
|
||||
warn(`mcpctl: '${v.name}' already exists at ${targetDir} but is not tracked by this agent's sync — leaving it untouched. Re-run with --force to overwrite.`);
|
||||
result.preserved.push(v.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// prime-agent/pi: if this skill name is *tracked* to a specific project
|
||||
// in the shared flat tree, don't silently overwrite it with a different
|
||||
// project's (or a global) skill of the same name — that's the exact
|
||||
// cross-project data loss ownership tracking was added to prevent.
|
||||
// Same-owner updates, globals (owner null) and untracked legacy entries
|
||||
// (owner undefined, adopted on write) all proceed normally.
|
||||
const owner = ownerOf(v);
|
||||
if (isSharedTree && prior !== undefined &&
|
||||
typeof prior.project === 'string' && prior.project !== owner) {
|
||||
if (!opts.force) {
|
||||
warn(`mcpctl: '${v.name}' is owned by project '${prior.project}' — leaving it untouched while syncing ${owner === null ? 'globals' : `'${owner}'`}. Re-run with --force to overwrite.`);
|
||||
result.preserved.push(v.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
if (prior) result.updated.push(v.name);
|
||||
else result.installed.push(v.name);
|
||||
@@ -298,27 +412,34 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
|
||||
const full = await client.get<FullSkill>(`/api/v1/skills/${encodeURIComponent(v.id)}`);
|
||||
const body: SkillBody = {
|
||||
content: full.content,
|
||||
// mcpd keeps name/description as columns, so a skill whose content is
|
||||
// just the body would land on disk without the frontmatter every host
|
||||
// requires and be silently rejected.
|
||||
content: ensureSkillFrontmatter(full.content, full.name || v.name, full.description || v.description || ''),
|
||||
...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}),
|
||||
};
|
||||
const fileStates = await installSkillAtomic(targetDir, body);
|
||||
|
||||
// ── hooks: register metadata.hooks in ~/.claude/settings.json ──
|
||||
// Tagged with _mcpctl_source: <skill-name> so each skill's hooks
|
||||
// can be cleanly added/updated/removed without trampling other
|
||||
// skills or user-added hooks. No-op when the field is absent or
|
||||
// empty.
|
||||
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
|
||||
if (meta.hooks && typeof meta.hooks === 'object') {
|
||||
try {
|
||||
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
|
||||
if (hookRes.updated) result.hooksApplied.push(v.name);
|
||||
} catch (err: unknown) {
|
||||
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
// ── hooks (Claude only) ──
|
||||
// prime-agent/pi have no SessionStart-hook equivalent and must never
|
||||
// touch ~/.claude/settings.json. Tagged with _mcpctl_source:
|
||||
// <skill-name> so each skill's hooks can be cleanly added/updated/
|
||||
// removed without trampling other skills or user-added hooks. No-op when
|
||||
// absent.
|
||||
if (!isSharedTree) {
|
||||
if (meta.hooks && typeof meta.hooks === 'object') {
|
||||
try {
|
||||
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
|
||||
if (hookRes.updated) result.hooksApplied.push(v.name);
|
||||
} catch (err: unknown) {
|
||||
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else if (prior !== undefined) {
|
||||
// Skill no longer declares hooks but used to — clean up.
|
||||
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
|
||||
}
|
||||
} else if (prior !== undefined) {
|
||||
// Skill no longer declares hooks but used to — clean up.
|
||||
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ── mcpServers: auto-attach declared deps to the active project ──
|
||||
@@ -327,7 +448,16 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
// servers (warn + skip). Idempotent — re-syncing a skill whose
|
||||
// deps are already attached is a no-op.
|
||||
const mcpServerDeps = parseMcpServerDeps(meta.mcpServers);
|
||||
if (mcpServerDeps.length > 0 && projectName) {
|
||||
if (isSharedTree) {
|
||||
// prime-agent/pi talk to the gateway over HTTP (or directly);
|
||||
// auto-attaching a skill's declared server deps would mutate the
|
||||
// *shared* mcpd project attachments (and a /mcpctl switch would
|
||||
// re-trigger it). Deliberately never attach for shared-tree agents,
|
||||
// but say so instead of being silent.
|
||||
if (mcpServerDeps.length > 0) {
|
||||
warn(`mcpctl: skill '${v.name}' declares mcpServers but this agent's sync does not attach project servers; skipping attach`);
|
||||
}
|
||||
} else if (mcpServerDeps.length > 0 && projectName) {
|
||||
try {
|
||||
const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn);
|
||||
for (const srv of att.attached) {
|
||||
@@ -351,7 +481,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
// what state recorded. Failures DO NOT update the recorded hash so
|
||||
// the next sync retries. Other skills continue regardless.
|
||||
let postInstallHash: string | null = prior?.postInstallHash ?? null;
|
||||
// postInstall scripts assume a Claude-esque shell and are skipped for
|
||||
// prime-agent/pi.
|
||||
if (
|
||||
!isSharedTree &&
|
||||
!opts.skipPostInstall &&
|
||||
typeof meta.postInstall === 'string' &&
|
||||
meta.postInstall.length > 0
|
||||
@@ -418,6 +551,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
files: fileStates,
|
||||
postInstallHash,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
...(isSharedTree ? { project: owner } : {}),
|
||||
};
|
||||
state.skills[v.name] = newState;
|
||||
if (prior) result.updated.push(v.name);
|
||||
@@ -435,30 +569,62 @@ export interface SkillsCommandDeps {
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent target to its on-disk skills install root.
|
||||
* "claude" → ~/.claude/skills
|
||||
* "prime-agent" → ~/.prime/agent/skills
|
||||
* "pi" → ~/.pi/agent/skills
|
||||
* "opencode" → ~/.config/opencode/skill (XDG-aware)
|
||||
* Kept here so `mcpctl skills sync --agent` and `mcpctl config <agent>` agree.
|
||||
*/
|
||||
export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string {
|
||||
switch (agent) {
|
||||
case 'prime-agent':
|
||||
return join(homeDir, '.prime', 'agent', 'skills');
|
||||
case 'pi':
|
||||
return join(homeDir, '.pi', 'agent', 'skills');
|
||||
case 'opencode':
|
||||
return opencodeSkillsDir(process.env, homeDir);
|
||||
case 'claude':
|
||||
default:
|
||||
return join(homeDir, '.claude', 'skills');
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkillsCommand(deps: SkillsCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
const warn = (...args: unknown[]): void => {
|
||||
console.error(...(args as Parameters<typeof console.error>));
|
||||
};
|
||||
|
||||
const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd');
|
||||
const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)');
|
||||
|
||||
cmd.command('sync')
|
||||
.description('Sync skills from mcpd onto disk under ~/.claude/skills/')
|
||||
.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, 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 SessionStart hook)')
|
||||
.option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)')
|
||||
.option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)')
|
||||
.option('--keep-orphans', 'Do not remove skills that are no longer in the server set')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
agent?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
quiet?: boolean;
|
||||
skipPostinstall?: boolean;
|
||||
keepOrphans?: boolean;
|
||||
}) => {
|
||||
// Validate --agent so an unknown value fails loudly instead of silently
|
||||
// running the default (Claude) sync.
|
||||
const agent = opts.agent ?? 'claude';
|
||||
if (!isSyncTarget(agent)) {
|
||||
warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const result = await runSkillsSync(
|
||||
{
|
||||
...(opts.project !== undefined ? { project: opts.project } : {}),
|
||||
@@ -467,6 +633,8 @@ 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,
|
||||
installRoot: agentInstallRoot(agent),
|
||||
},
|
||||
{ client, log, warn },
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ServerLlm {
|
||||
* of the last credential-rotation failure (e.g. a dead OpenBao token).
|
||||
*/
|
||||
interface SecretBackendInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
isDefault?: boolean;
|
||||
@@ -60,6 +61,22 @@ interface SecretBackendInfo {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live probe result from GET /api/v1/secretbackends/:id/health.
|
||||
*
|
||||
* `live` and `ready` are deliberately separate: a backend that is reachable but
|
||||
* whose credentials no longer grant anything is the failure mode that hid an
|
||||
* OpenBao re-init for four days. `null` means the probe itself failed, which we
|
||||
* report as unknown rather than pretending it means healthy.
|
||||
*/
|
||||
interface SecretBackendHealth {
|
||||
live: boolean;
|
||||
liveDetail?: string;
|
||||
ready: boolean;
|
||||
readyDetail?: string;
|
||||
cache?: { entries: number; servingStale: number; oldestStaleSince?: number | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a live "say hi" probe against a server LLM. `ok` says we got a
|
||||
* 200 + non-empty content back; `say` is the trimmed first 16 chars of the
|
||||
@@ -99,6 +116,7 @@ export interface StatusCommandDeps {
|
||||
probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise<ServerLlmHealth>;
|
||||
/** Fetch SecretBackends from mcpd to surface backend health. Null on error. */
|
||||
fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise<SecretBackendInfo[] | null>;
|
||||
probeSecretBackend: (mcpdUrl: string, id: string, token: string | null) => Promise<SecretBackendHealth | null>;
|
||||
isTTY: boolean;
|
||||
}
|
||||
|
||||
@@ -275,6 +293,38 @@ function defaultFetchSecretBackends(mcpdUrl: string, token: string | null): Prom
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-probe one SecretBackend. Resolves to null on any unhappy path — same
|
||||
* never-throw discipline as the other probes here, and `null` renders as
|
||||
* "unknown", never as healthy.
|
||||
*/
|
||||
function defaultProbeSecretBackend(mcpdUrl: string, id: string, token: string | null): Promise<SecretBackendHealth | null> {
|
||||
return new Promise((resolve) => {
|
||||
let req: http.ClientRequest;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (token !== null) headers['Authorization'] = `Bearer ${token}`;
|
||||
try {
|
||||
req = httpDriverFor(mcpdUrl).get(`${mcpdUrl}/api/v1/secretbackends/${id}/health`, { timeout: 5000, headers }, (res) => {
|
||||
if (res.statusCode !== 200) { resolve(null); res.resume(); return; }
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SecretBackendHealth);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
req.on('error', () => resolve(null));
|
||||
req.on('timeout', () => { req.destroy(); resolve(null); });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a tiny "say hi" prompt to /api/v1/llms/<name>/infer and decide if
|
||||
* the LLM actually serves inference. Returns ok=true when the response is
|
||||
@@ -386,6 +436,7 @@ const defaultDeps: StatusCommandDeps = {
|
||||
fetchProviders: defaultFetchProviders,
|
||||
fetchServerLlms: defaultFetchServerLlms,
|
||||
fetchSecretBackends: defaultFetchSecretBackends,
|
||||
probeSecretBackend: defaultProbeSecretBackend,
|
||||
probeServerLlm: defaultProbeServerLlm,
|
||||
isTTY: process.stdout.isTTY ?? false,
|
||||
};
|
||||
@@ -448,7 +499,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean):
|
||||
}
|
||||
|
||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
||||
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, isTTY } = { ...defaultDeps, ...deps };
|
||||
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, probeSecretBackend, isTTY } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('status')
|
||||
.description('Show mcpctl status and connectivity')
|
||||
@@ -482,6 +533,30 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
})))
|
||||
: null;
|
||||
|
||||
// Same live probe the table view uses. `healthy` is derived from the
|
||||
// probe, NOT from tokenMeta.lastRotationError — that field is only ever
|
||||
// written for token-auth backends, so scripts consuming it were told
|
||||
// every kubernetes-auth backend was healthy unconditionally.
|
||||
const secretBackendsWithHealth = secretBackends !== null
|
||||
? await Promise.all(secretBackends.map(async (b) => {
|
||||
const health = await probeSecretBackend(config.mcpdUrl, b.id, token);
|
||||
return {
|
||||
name: b.name,
|
||||
type: b.type,
|
||||
healthy: health !== null && health.live && health.ready,
|
||||
live: health?.live ?? null,
|
||||
ready: health?.ready ?? null,
|
||||
servingStale: health?.cache?.servingStale ?? 0,
|
||||
error: health === null
|
||||
? 'health probe failed'
|
||||
: !health.live ? (health.liveDetail ?? 'unreachable')
|
||||
: !health.ready ? (health.readyDetail ?? 'auth failed')
|
||||
: null,
|
||||
rotationError: b.tokenMeta?.lastRotationError ?? null,
|
||||
};
|
||||
}))
|
||||
: null;
|
||||
|
||||
const llm = llmLabel
|
||||
? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})`
|
||||
: null;
|
||||
@@ -499,7 +574,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
llmStatus,
|
||||
...(providersInfo ? { providers: providersInfo } : {}),
|
||||
...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}),
|
||||
...(secretBackends !== null ? { secretBackends: secretBackends.map((b) => ({ name: b.name, type: b.type, healthy: !b.tokenMeta?.lastRotationError, error: b.tokenMeta?.lastRotationError ?? null })) } : {}),
|
||||
...(secretBackends !== null ? { secretBackends: secretBackendsWithHealth } : {}),
|
||||
};
|
||||
|
||||
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
|
||||
@@ -530,7 +605,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
|
||||
if (!llmLabel) {
|
||||
log(`LLM: not configured (run 'mcpctl config setup')`);
|
||||
await renderSecretBackendsSection(secretBackendsPromise, isTTY);
|
||||
await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token);
|
||||
await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY);
|
||||
return;
|
||||
}
|
||||
@@ -595,7 +670,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
}
|
||||
}
|
||||
|
||||
await renderSecretBackendsSection(secretBackendsPromise, isTTY);
|
||||
await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token);
|
||||
await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY);
|
||||
});
|
||||
|
||||
@@ -609,21 +684,50 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
async function renderSecretBackendsSection(
|
||||
backendsPromise: Promise<SecretBackendInfo[] | null>,
|
||||
ansi: boolean,
|
||||
mcpdUrl: string,
|
||||
token: string | null,
|
||||
): Promise<void> {
|
||||
const backends = await backendsPromise;
|
||||
if (backends === null || backends.length === 0) return;
|
||||
const parts = backends.map((b) => {
|
||||
const err = b.tokenMeta?.lastRotationError;
|
||||
const tag = b.isDefault ? `${b.name}*` : b.name;
|
||||
if (err) {
|
||||
const short = err.split('\n')[0]?.slice(0, 80) ?? 'error';
|
||||
return ansi ? `${tag} ${RED}✗ ${short}${RESET}` : `${tag} ✗ ${short}`;
|
||||
}
|
||||
return ansi ? `${tag} ${GREEN}✓${RESET}` : `${tag} ✓`;
|
||||
});
|
||||
const healths = await Promise.all(backends.map((b) => probeSecretBackend(mcpdUrl, b.id, token)));
|
||||
const parts = backends.map((b, i) => renderOneBackend(b, healths[i] ?? null, ansi));
|
||||
log(`Secrets: ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one backend's status line.
|
||||
*
|
||||
* This used to be `tokenMeta.lastRotationError ? red : green`, which was a
|
||||
* hard-coded green tick for every `auth: kubernetes` backend — the rotator
|
||||
* only writes that field for token-auth backends, so it was never set and
|
||||
* `mcpctl status` reported OpenBao healthy even when it was unreachable.
|
||||
* Rotation state is now one clause among several, not the only signal.
|
||||
*/
|
||||
function renderOneBackend(b: SecretBackendInfo, health: SecretBackendHealth | null, ansi: boolean): string {
|
||||
const tag = b.isDefault === true ? `${b.name}*` : b.name;
|
||||
const paint = (colour: string, text: string): string => (ansi ? `${colour}${text}${RESET}` : text);
|
||||
const rotationErr = b.tokenMeta?.lastRotationError ?? '';
|
||||
const rotationClause = rotationErr === ''
|
||||
? ''
|
||||
: ` (rotation: ${rotationErr.split('\n')[0]?.slice(0, 60) ?? 'error'})`;
|
||||
|
||||
if (health === null) {
|
||||
// The probe itself failed. Unknown is not healthy — say so.
|
||||
return `${tag} ${paint(YELLOW, '? unknown')}${rotationClause}`;
|
||||
}
|
||||
if (!health.live) {
|
||||
return `${tag} ${paint(RED, `✗ unreachable: ${health.liveDetail ?? 'no detail'}`)}${rotationClause}`;
|
||||
}
|
||||
if (!health.ready) {
|
||||
return `${tag} ${paint(RED, `✗ auth failed: ${(health.readyDetail ?? 'no detail').slice(0, 60)}`)}${rotationClause}`;
|
||||
}
|
||||
const stale = health.cache?.servingStale ?? 0;
|
||||
if (stale > 0) {
|
||||
return `${tag} ${paint(YELLOW, `⚠ degraded — serving ${String(stale)} cached secret(s)`)}${rotationClause}`;
|
||||
}
|
||||
return `${tag} ${paint(GREEN, '✓ reachable')}${rotationClause}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a "Server LLMs:" section listing mcpd-managed Llm rows by tier
|
||||
* with a per-LLM "say hi" liveness probe. Distinct from the mcplocal-side
|
||||
|
||||
163
src/cli/src/commands/statusline.ts
Normal file
163
src/cli/src/commands/statusline.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
176
src/cli/src/commands/trace.ts
Normal file
176
src/cli/src/commands/trace.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface TraceCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
/** Mirrors mcplocal's AuditEvent rows as stored by mcpd. */
|
||||
interface AuditEvent {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
eventKind: string;
|
||||
source: string;
|
||||
serverName?: string | null;
|
||||
correlationId?: string | null;
|
||||
userName?: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === 'string' && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
function clock(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '--:--:--';
|
||||
const p = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function bytes(n: number): string {
|
||||
if (n < 1024) return `${String(n)}B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}kB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/** One line of detail per event kind — what you actually want to see. */
|
||||
function detailOf(e: AuditEvent): string {
|
||||
const p = e.payload;
|
||||
switch (e.eventKind) {
|
||||
case 'stage_execution': {
|
||||
const parts = [str(p['stage']) ?? 'stage'];
|
||||
const ms = num(p['durationMs']);
|
||||
if (ms !== null) parts.push(`${String(ms)}ms`);
|
||||
const inSize = num(p['inputSize']);
|
||||
const outSize = num(p['outputSize']);
|
||||
if (inSize !== null && outSize !== null) parts.push(`${bytes(inSize)} → ${bytes(outSize)}`);
|
||||
const sections = num(p['sectionCount']);
|
||||
if (sections !== null && sections > 0) parts.push(`${String(sections)} sections`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
case 'pipeline_execution': {
|
||||
const ms = num(p['totalDurationMs']);
|
||||
const stages = num(p['stageCount']);
|
||||
return `${String(stages ?? 0)} stages${ms === null ? '' : `, ${String(ms)}ms total`}`;
|
||||
}
|
||||
case 'tool_call_trace': {
|
||||
const parts = [str(p['toolName']) ?? '(tool)'];
|
||||
const ms = num(p['durationMs']);
|
||||
if (ms !== null) parts.push(`${String(ms)}ms`);
|
||||
const size = num(p['resultSizeBytes']);
|
||||
if (size !== null) parts.push(bytes(size));
|
||||
return parts.join(' ');
|
||||
}
|
||||
case 'gate_decision': {
|
||||
const trigger = str(p['trigger']) ?? 'gate';
|
||||
const matched = Array.isArray(p['matchedPrompts']) ? (p['matchedPrompts']).length : 0;
|
||||
return `${trigger} · ${String(matched)} prompts`;
|
||||
}
|
||||
default:
|
||||
return e.eventKind;
|
||||
}
|
||||
}
|
||||
|
||||
function degradationOf(e: AuditEvent): string | null {
|
||||
if (e.payload['degraded'] !== true) return null;
|
||||
return str(e.payload['degradedReason']) ?? 'degraded';
|
||||
}
|
||||
|
||||
function errorOf(e: AuditEvent): string | null {
|
||||
return str(e.payload['error']);
|
||||
}
|
||||
|
||||
export function createTraceCommand(deps?: Partial<TraceCommandDeps>): Command {
|
||||
const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); });
|
||||
|
||||
return new Command('trace')
|
||||
.argument('<code>', 'trace code from an error message or degradation notice')
|
||||
.description('Show the stage-by-stage timeline for one MCP request')
|
||||
.option('-o, --output <format>', 'table (default) or json')
|
||||
.option('--strict', 'exit non-zero if the trace contains an error or a degraded step')
|
||||
.action(async (code: string, opts: { output?: string; strict?: boolean }) => {
|
||||
const client = deps?.client;
|
||||
if (!client) throw new Error('trace: no API client configured');
|
||||
|
||||
const res = await client.get<{ events?: AuditEvent[]; total?: number }>(
|
||||
`/api/v1/audit/events?correlationId=${encodeURIComponent(code)}&limit=500`,
|
||||
);
|
||||
const events = (res.events ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
if (events.length === 0) {
|
||||
log(`No trace found for '${code}'.`);
|
||||
log('');
|
||||
log('Traces are written by mcplocal and stored by mcpd. If the request is very');
|
||||
log('recent the batch may not have flushed yet (up to 5s); if it is very old it');
|
||||
log('may predate trace codes. Check the code was copied exactly — they are 8');
|
||||
log('characters, no I/L/O/U.');
|
||||
if (opts.strict === true) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(events, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const first = events[0]!;
|
||||
const start = new Date(first.timestamp).getTime();
|
||||
const call = events.find((e) => e.eventKind === 'tool_call_trace');
|
||||
const headline = call ? detailOf(call) : first.eventKind;
|
||||
|
||||
log(`Trace ${code} project ${first.projectName} session ${first.sessionId.slice(0, 8)}…`);
|
||||
if (first.userName) log(`user ${first.userName}`);
|
||||
log(`${headline}`);
|
||||
log('');
|
||||
log(' TIME Δms EVENT DETAIL');
|
||||
|
||||
let degradedCount = 0;
|
||||
let errorCount = 0;
|
||||
let slowest: { name: string; ms: number } | null = null;
|
||||
|
||||
for (const e of events) {
|
||||
const delta = new Date(e.timestamp).getTime() - start;
|
||||
const degraded = degradationOf(e);
|
||||
const err = errorOf(e);
|
||||
if (degraded !== null) degradedCount++;
|
||||
if (err !== null) errorCount++;
|
||||
|
||||
// Only real steps compete for "slowest" — tool_call_trace and
|
||||
// pipeline_execution are aggregates OF those steps, so including them
|
||||
// would always name the total and tell you nothing.
|
||||
const ms = num(e.payload['durationMs']);
|
||||
const label = str(e.payload['stage']);
|
||||
if (ms !== null && label !== null && (slowest === null || ms > slowest.ms)) {
|
||||
slowest = { name: label, ms };
|
||||
}
|
||||
|
||||
const marks = [
|
||||
degraded !== null ? `⚠ ${degraded}` : null,
|
||||
err !== null ? `✗ ${err}` : null,
|
||||
].filter((x): x is string => x !== null).join(' ');
|
||||
|
||||
log(
|
||||
` ${clock(e.timestamp)} ${String(delta).padStart(6)} `
|
||||
+ `${e.eventKind.padEnd(19)} ${detailOf(e)}${marks ? ` ${marks}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
log('');
|
||||
const summary: string[] = [`${String(events.length)} events`];
|
||||
if (degradedCount > 0) summary.push(`⚠ ${String(degradedCount)} degraded`);
|
||||
if (errorCount > 0) summary.push(`✗ ${String(errorCount)} error${errorCount === 1 ? '' : 's'}`);
|
||||
if (slowest !== null) summary.push(`slowest: ${slowest.name} (${String(slowest.ms)}ms)`);
|
||||
log(summary.join(' · '));
|
||||
|
||||
if (opts.strict === true && (degradedCount > 0 || errorCount > 0)) process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
248
src/cli/src/config/claude-mcp.ts
Normal file
248
src/cli/src/config/claude-mcp.ts
Normal 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 ?? {} });
|
||||
}
|
||||
27
src/cli/src/config/opencode-extension.ts
Normal file
27
src/cli/src/config/opencode-extension.ts
Normal file
File diff suppressed because one or more lines are too long
15
src/cli/src/config/pi-extension.ts
Normal file
15
src/cli/src/config/pi-extension.ts
Normal file
File diff suppressed because one or more lines are too long
13
src/cli/src/config/prime-agent-extension.ts
Normal file
13
src/cli/src/config/prime-agent-extension.ts
Normal file
File diff suppressed because one or more lines are too long
277
src/cli/src/config/prime-agent.ts
Normal file
277
src/cli/src/config/prime-agent.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Read/merge/write helpers for prime-agent's own configuration files, used
|
||||
* by `mcpctl config prime-agent`.
|
||||
*
|
||||
* prime-agent keeps two user-editable files under `~/.prime/agent/`:
|
||||
* - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus
|
||||
* model/provider preferences. `mcpctl config prime-agent` registers our
|
||||
* proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`.
|
||||
* - `auth.json` — per-server bearer tokens keyed as `mcp:<server>`.
|
||||
*
|
||||
* Safety invariants:
|
||||
* - We only ever *merge* the `mcpServers` map, preserving every other key
|
||||
* and any servers the user already configured.
|
||||
* - If `settings.json` exists but is corrupt, we fail loudly instead of
|
||||
* swallowing the parse error and rewriting (which would destroy every
|
||||
* non-mcpServers setting). Untouched corrupt files are never overwritten.
|
||||
* - A project's existing `mcpServers` entry is merged (user-added fields are
|
||||
* kept), never replaced wholesale.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Base URL of the deployed mcpctl HTTP MCP gateway. */
|
||||
export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu';
|
||||
|
||||
/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */
|
||||
const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_';
|
||||
|
||||
/** Resolve the prime-agent settings.json path. */
|
||||
export function primeAgentSettingsPath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'settings.json');
|
||||
}
|
||||
|
||||
/** Resolve the prime-agent auth.json path. */
|
||||
export function primeAgentAuthPath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'auth.json');
|
||||
}
|
||||
|
||||
/** Resolve the prime-agent extensions directory (auto-discovered by the app). */
|
||||
export function primeAgentExtensionsDir(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'extensions');
|
||||
}
|
||||
|
||||
/** Proxy MCP URL for a given project on the gateway. */
|
||||
export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string {
|
||||
const base = gatewayUrl.replace(/\/+$/, '');
|
||||
return `${base}/projects/${encodeURIComponent(project)}/mcp`;
|
||||
}
|
||||
|
||||
export interface PrimeAgentSettings {
|
||||
mcpServers?: Record<string, Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load prime-agent settings.
|
||||
* - Missing file → returns `{}` (a brand-new file about to be created).
|
||||
* - Unreadable/corrupt → throws, so the caller refuses to overwrite it.
|
||||
*/
|
||||
export async function loadPrimeAgentSettings(path: string): Promise<PrimeAgentSettings> {
|
||||
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 PrimeAgentSettings;
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (err: unknown) {
|
||||
throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the `mcpServers` entry named `name` one that mcpctl installed?
|
||||
*
|
||||
* Two shapes count:
|
||||
* - `mcpctlManaged: true` — written by this CLI (current releases tag every
|
||||
* entry they write).
|
||||
* - *untagged*, but the URL is exactly the canonical `/projects/<name>/mcp`
|
||||
* proxy URL **and** auth.json holds an `mcp:<name>` mcpctl PAT. Older CLIs
|
||||
* wrote the entry + credential pair but no tag; without adopting them a
|
||||
* project switch would leave two gateways mounted at once and the `/mcpctl`
|
||||
* switcher would report no active project.
|
||||
*
|
||||
* A hand-configured server never has an mcpctl PAT stored under `mcp:<name>`,
|
||||
* so it is never adopted — that pairing is what makes the legacy match safe.
|
||||
*/
|
||||
export function isMcpctlManagedEntry(
|
||||
name: string,
|
||||
entry: unknown,
|
||||
authKeys: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (entry === null || typeof entry !== 'object') return false;
|
||||
const rec = entry as Record<string, unknown>;
|
||||
if (rec['mcpctlManaged'] === true) return true;
|
||||
const url = rec['url'];
|
||||
if (typeof url !== 'string') return false;
|
||||
// Host-agnostic: adopt regardless of which gateway the old entry pointed at.
|
||||
const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`);
|
||||
return canonical.test(url) && authKeys.has(name);
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the projects auth.json holds an mcpctl PAT for (`mcp:<project>`).
|
||||
* Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt
|
||||
* auth.json yields an empty set — adoption then simply doesn't happen.
|
||||
*/
|
||||
export async function primeAgentAuthProjects(authPath: string): Promise<Set<string>> {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = await loadPrimeAgentAuth(authPath);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (!k.startsWith('mcp:')) continue;
|
||||
const key = (v as { key?: unknown } | null)?.key;
|
||||
if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RegisterMcpResult {
|
||||
settingsPath: string;
|
||||
created: boolean; // true if the settings file did not previously exist
|
||||
addedServer: string;
|
||||
newServer: boolean; // true if the project's MCP entry was not already present
|
||||
url: string;
|
||||
totalServers: number;
|
||||
/** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a proxy MCP `{ type: "http", url }` entry for `project` into the
|
||||
* prime-agent settings file. Preserves all other fields and servers, and
|
||||
* merges into an existing `mcpServers[project]` entry (keeping any user-added
|
||||
* keys like `headers`) rather than replacing it wholesale.
|
||||
*/
|
||||
export async function registerPrimeAgentMcp(
|
||||
project: string,
|
||||
settingsPath: string,
|
||||
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
opts: { authPath?: string } = {},
|
||||
): Promise<RegisterMcpResult> {
|
||||
const existed = await pathExists(settingsPath);
|
||||
const settings = await loadPrimeAgentSettings(settingsPath);
|
||||
if (settings.mcpServers !== undefined && (typeof settings.mcpServers !== 'object' || settings.mcpServers === null)) {
|
||||
throw new Error(`invalid mcpServers block in ${settingsPath} — refusing to overwrite it`);
|
||||
}
|
||||
|
||||
settings.mcpServers = settings.mcpServers ?? {};
|
||||
const url = projectMcpUrl(project, gatewayUrl);
|
||||
const existing = settings.mcpServers[project];
|
||||
const newServer = existing === undefined;
|
||||
// Merge: keep any user-added fields on the project's entry (e.g. headers),
|
||||
// and tag it so the /mcpctl switcher can find the single *active* project.
|
||||
settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url, mcpctlManaged: true };
|
||||
|
||||
// prime-agent loads every mcpServers entry, so only ONE mcpctl project should
|
||||
// be active at a time. Remove any *other* mcpctl-managed project entries we
|
||||
// previously installed — including the untagged ones older CLIs wrote (see
|
||||
// isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke
|
||||
// `sre`, websearch, etc) so switching never nukes unrelated integrations.
|
||||
const authKeys = opts.authPath !== undefined
|
||||
? await primeAgentAuthProjects(opts.authPath)
|
||||
: new Set<string>();
|
||||
const removed: string[] = [];
|
||||
for (const k of Object.keys(settings.mcpServers)) {
|
||||
if (k === project) continue;
|
||||
if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) {
|
||||
delete settings.mcpServers[k];
|
||||
removed.push(k);
|
||||
}
|
||||
}
|
||||
const totalServers = Object.keys(settings.mcpServers).length;
|
||||
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
|
||||
return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `mcp:<project>` carries `{ type: "api_key", key }` in
|
||||
* `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz`
|
||||
* provider credential, other `mcp:*` servers, etc).
|
||||
*
|
||||
* auth.json holds never-expiring bearer tokens, so it always ends up 0600 —
|
||||
* never the default umask. `writeFile`'s `mode` only applies when the file is
|
||||
* created, and prime-agent itself creates auth.json 0644, so we chmod after
|
||||
* writing rather than trusting the open flags.
|
||||
*/
|
||||
export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise<void> {
|
||||
const current = await loadPrimeAgentAuth(authPath);
|
||||
current[`mcp:${project}`] = { type: 'api_key', key };
|
||||
await mkdir(dirname(authPath), { recursive: true });
|
||||
await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
await chmod(authPath, 0o600);
|
||||
} catch { /* best-effort: a credential written is better than one refused */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load auth.json.
|
||||
* - Missing/empty → `{}` (a brand-new file about to be created).
|
||||
* - Corrupt JSON → throws, so the caller refuses to overwrite it (the same
|
||||
* guarantee as settings.json — one syntax error must not destroy every
|
||||
* credential, including the provider API key).
|
||||
*/
|
||||
async function loadPrimeAgentAuth(path: string): Promise<Record<string, unknown>> {
|
||||
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 Record<string, unknown>;
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (err: unknown) {
|
||||
throw new Error(`auth file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential currently stored for `project`, or null if there is none.
|
||||
* Throws on corrupt JSON (the caller must refuse to overwrite the file).
|
||||
*/
|
||||
export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise<string | null> {
|
||||
const parsed = await loadPrimeAgentAuth(authPath) as Record<string, { type?: string; key?: string } | undefined>;
|
||||
const entry = parsed[`mcp:${project}`];
|
||||
if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) {
|
||||
return entry.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */
|
||||
export async function hasPrimeAgentAuth(project: string, authPath: string): Promise<boolean> {
|
||||
return (await readPrimeAgentAuthKey(project, authPath)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The displayable prefix mcpd records for a raw token (`tokenPrefix` on
|
||||
* McpToken): the first 16 characters. Lets us match a stored credential against
|
||||
* the server's token list without ever sending the secret.
|
||||
*/
|
||||
export function mcpTokenPrefixOf(raw: string): string {
|
||||
return raw.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */
|
||||
export function isMcpctlToken(raw: string): boolean {
|
||||
return raw.startsWith(MCPCTL_TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@ 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 { createTraceCommand } from './commands/trace.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -52,6 +54,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());
|
||||
|
||||
@@ -319,6 +322,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createTraceCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createBackupCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
|
||||
201
src/cli/src/utils/claude-ui.ts
Normal file
201
src/cli/src/utils/claude-ui.ts
Normal 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;
|
||||
}
|
||||
204
src/cli/src/utils/opencode-settings.ts
Normal file
204
src/cli/src/utils/opencode-settings.ts
Normal 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;
|
||||
}
|
||||
174
src/cli/src/utils/pi-settings.ts
Normal file
174
src/cli/src/utils/pi-settings.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Wiring helpers for the pi extension.
|
||||
*
|
||||
* `mcpctl config pi --project X`:
|
||||
* 1. writes the embedded extension sources (`mcpctl-pi.ts` + `mcp-http.ts`)
|
||||
* into `~/.pi/agent/extensions/mcpctl/`,
|
||||
* 2. adds the extension path and the pi skills dir to
|
||||
* `~/.pi/agent/settings.json` (`extensions`, `skills` arrays),
|
||||
* 3. writes `~/.mcpctl/pi-state.json` so the active project survives across
|
||||
* sessions regardless of the shell's cwd.
|
||||
*
|
||||
* The extension sources are embedded in the CLI (see
|
||||
* `config/pi-extension.ts`) so an installed binary with no source tree can
|
||||
* still provision a working pi integration.
|
||||
*
|
||||
* Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join, basename, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { PI_EXTENSION_FILES } from '../config/pi-extension.js';
|
||||
|
||||
export function piAgentDir(): string {
|
||||
return join(homedir(), '.pi', 'agent');
|
||||
}
|
||||
|
||||
/** Override the pi agent home (for tests / custom installs). */
|
||||
export function withPiAgentDir(base: string): {
|
||||
settingsPath: () => string;
|
||||
extensionDir: () => string;
|
||||
skillsDir: () => string;
|
||||
extensionMainPath: () => string;
|
||||
} {
|
||||
return {
|
||||
settingsPath: () => join(base, 'settings.json'),
|
||||
extensionDir: () => join(base, 'extensions', 'mcpctl'),
|
||||
skillsDir: () => join(base, 'skills'),
|
||||
extensionMainPath: () => join(base, 'extensions', 'mcpctl', 'mcpctl-pi.ts'),
|
||||
};
|
||||
}
|
||||
|
||||
export function piSettingsPath(): string {
|
||||
return join(piAgentDir(), 'settings.json');
|
||||
}
|
||||
|
||||
export function piExtensionDir(): string {
|
||||
return join(piAgentDir(), 'extensions', 'mcpctl');
|
||||
}
|
||||
|
||||
export function piSkillsDir(): string {
|
||||
return join(piAgentDir(), 'skills');
|
||||
}
|
||||
|
||||
export function piExtensionMainPath(): string {
|
||||
return join(piExtensionDir(), 'mcpctl-pi.ts');
|
||||
}
|
||||
|
||||
export function piStatePath(): string {
|
||||
return join(homedir(), '.mcpctl', 'pi-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the embedded extension sources into the pi extensions dir. This is the
|
||||
* production path used by `mcpctl config pi` — it needs no source tree, so it
|
||||
* works from an installed binary. The extension imports its http client via a
|
||||
* sibling `./mcp-http.js`, so both files must land together.
|
||||
*/
|
||||
export async function installEmbeddedExtension(dest: string = piExtensionDir()): Promise<string[]> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
const written: string[] = [];
|
||||
for (const [name, src] of Object.entries(PI_EXTENSION_FILES)) {
|
||||
const dst = join(dest, name);
|
||||
await writeFile(dst, src, 'utf-8');
|
||||
written.push(dst);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the bundled extension sources (relative to this source tree's
|
||||
* `src/pi-ext/`) into the pi extensions dir. Kept for tests / dev; production
|
||||
* uses `installEmbeddedExtension` so installed binaries are self-contained.
|
||||
*/
|
||||
export async function installExtensionFiles(
|
||||
srcPiExtDir: string,
|
||||
dest: string = piExtensionDir(),
|
||||
): Promise<string[]> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
const files = [
|
||||
['mcpctl-pi.ts', 'mcpctl-pi.ts'],
|
||||
['mcp-http.ts', 'mcp-http.ts'],
|
||||
] as const;
|
||||
const written: string[] = [];
|
||||
for (const [from, to] of files) {
|
||||
const src = resolve(srcPiExtDir, from);
|
||||
const dst = join(dest, to);
|
||||
await copyFile(src, dst);
|
||||
written.push(dst);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
interface PiSettings {
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
async function readSettings(path: string): Promise<PiSettings> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8');
|
||||
if (raw.trim().length === 0) return {};
|
||||
return JSON.parse(raw) as PiSettings;
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
// settings.json may contain line comments; strip them defensively.
|
||||
const stripped = (await readFile(path, 'utf-8')).replace(/^\s*\/\/.*$/gm, '');
|
||||
return JSON.parse(stripped) as PiSettings;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettings(path: string, settings: PiSettings): Promise<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge our extension path + skills dir into `~/.pi/agent/settings.json`.
|
||||
* Idempotent — re-running doesn't duplicate entries.
|
||||
* Returns the paths that were added.
|
||||
*/
|
||||
export async function registerWithPi(
|
||||
settingsPath = piSettingsPath(),
|
||||
extensionPath = piExtensionMainPath(),
|
||||
skillsDir = piSkillsDir(),
|
||||
): Promise<{ addedExtensions: string[]; addedSkills: string[] }> {
|
||||
const settings = await readSettings(settingsPath);
|
||||
if (!Array.isArray(settings.extensions)) settings.extensions = [];
|
||||
if (!Array.isArray(settings.skills)) settings.skills = [];
|
||||
|
||||
const addedExtensions: string[] = [];
|
||||
if (!settings.extensions.includes(extensionPath)) {
|
||||
settings.extensions.push(extensionPath);
|
||||
addedExtensions.push(extensionPath);
|
||||
}
|
||||
const addedSkills: string[] = [];
|
||||
if (!settings.skills.includes(skillsDir)) {
|
||||
settings.skills.push(skillsDir);
|
||||
addedSkills.push(skillsDir);
|
||||
}
|
||||
|
||||
// Only rewrite when something actually changed. Re-serialising drops any
|
||||
// comments the file had (readSettings strips them to parse), so a no-op run
|
||||
// must not silently reformat a hand-maintained settings.json.
|
||||
if (addedExtensions.length > 0 || addedSkills.length > 0) {
|
||||
await writeSettings(settingsPath, settings);
|
||||
}
|
||||
return { addedExtensions, addedSkills };
|
||||
}
|
||||
|
||||
/** Write the persisted active-project state. */
|
||||
export async function writePiProjectState(project: string, path: string = piStatePath()): Promise<string> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8');
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Debug helper: the on-disk file name of an extension path. */
|
||||
export function _base(path: string): string {
|
||||
return basename(path);
|
||||
}
|
||||
54
src/cli/src/utils/prime-agent-skills.ts
Normal file
54
src/cli/src/utils/prime-agent-skills.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`.
|
||||
*
|
||||
* This is a thin convenience wrapper around the shared [`runSkillsSync`]
|
||||
* implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`.
|
||||
* All diffing, atomic install, preservation and orphan logic lives there; this
|
||||
* module only:
|
||||
* - resolves the prime-agent install root and state file paths, and
|
||||
* - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified
|
||||
* sync so callers and tests keep a stable, intent-revealing name.
|
||||
*
|
||||
* Target-specific behaviour (handled by the shared implementation):
|
||||
* - installs markdown skills under `~/.prime/agent/skills/<name>/`
|
||||
* - never touches `~/.claude/settings.json` (no hooks / postInstall)
|
||||
* - still auto-attaches skill-declared `mcpServers` deps to the project
|
||||
* - records per-project ownership and preserves untracked / cross-project
|
||||
* skill dirs so a shared, hand-editable tree is never silently wiped
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js';
|
||||
|
||||
/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */
|
||||
export function primeAgentSkillsRoot(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'skills');
|
||||
}
|
||||
|
||||
/** prime-agent keeps its own state file so it never collides with Claude's. */
|
||||
export function primeAgentStatePath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json');
|
||||
}
|
||||
|
||||
export type PrimeAgentSyncOpts = Pick<
|
||||
SyncOpts,
|
||||
'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir'
|
||||
>;
|
||||
export type PrimeAgentSyncResult = SyncResult;
|
||||
export type PrimeAgentSyncDeps = SyncDeps;
|
||||
|
||||
/**
|
||||
* Sync the active project's skills into prime-agent's markdown skills tree.
|
||||
* Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2
|
||||
* disk/state error.
|
||||
*/
|
||||
export async function runPrimeAgentSkillsSync(
|
||||
opts: PrimeAgentSyncOpts,
|
||||
deps: PrimeAgentSyncDeps,
|
||||
): Promise<PrimeAgentSyncResult> {
|
||||
return runSkillsSync({ ...opts, target: 'prime-agent' }, deps);
|
||||
}
|
||||
|
||||
// Re-export for callers that prefer to use the shared function directly.
|
||||
export { runSkillsSync };
|
||||
@@ -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 }],
|
||||
|
||||
@@ -18,6 +18,40 @@ export interface SkillBody {
|
||||
files?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee the YAML frontmatter every Agent Skills host requires.
|
||||
*
|
||||
* mcpd stores `name` and `description` as columns, and a skill's `content` is
|
||||
* frequently just the body — `propose-learnings` starts straight at its `#`
|
||||
* heading. Written verbatim, such a skill is rejected by the agent that loads
|
||||
* it (prime-agent logs `description is required` and never loads it; Claude
|
||||
* Code and pi need the same keys), so the sync has to put the frontmatter back.
|
||||
*
|
||||
* Content that already carries a block is left alone apart from filling in a
|
||||
* missing `name`/`description` — we never rewrite a skill author's own header.
|
||||
*/
|
||||
export function ensureSkillFrontmatter(content: string, name: string, description: string): string {
|
||||
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(content);
|
||||
if (match === null) {
|
||||
return `---\nname: ${yamlScalar(name)}\ndescription: ${yamlScalar(description)}\n---\n\n${content}`;
|
||||
}
|
||||
const block = match[1] ?? '';
|
||||
const additions: string[] = [];
|
||||
if (!/^name[ \t]*:/m.test(block)) additions.push(`name: ${yamlScalar(name)}`);
|
||||
if (!/^description[ \t]*:/m.test(block)) additions.push(`description: ${yamlScalar(description)}`);
|
||||
if (additions.length === 0) return content;
|
||||
return `---\n${[block, ...additions].join('\n')}\n---\n${content.slice(match[0].length)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a YAML scalar. Descriptions routinely contain `:` and `#`, which break a
|
||||
* bare scalar — a JSON string is a valid YAML double-quoted scalar and escapes
|
||||
* quotes and newlines correctly.
|
||||
*/
|
||||
function yamlScalar(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a skill atomically into `targetDir`. If a previous install exists,
|
||||
* it's renamed to `<targetDir>.mcpctl-trash-<pid>` and rmtree'd after the
|
||||
|
||||
@@ -30,6 +30,13 @@ export interface SkillState {
|
||||
/** sha256 of the postInstall script if any; null if none. */
|
||||
postInstallHash: string | null;
|
||||
lastSyncedAt: string;
|
||||
/**
|
||||
* Owning project name, used by the prime-agent sync to avoid cross-project
|
||||
* orphan deletion in the shared ~/.prime/agent/skills tree. Globals record
|
||||
* null; project-scoped skills record the project that installed them.
|
||||
* Unset for the Claude Code path.
|
||||
*/
|
||||
project?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillsStateFile {
|
||||
|
||||
116
src/cli/tests/commands/chat-thread-brick.test.ts
Normal file
116
src/cli/tests/commands/chat-thread-brick.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
210
src/cli/tests/commands/config-opencode.test.ts
Normal file
210
src/cli/tests/commands/config-opencode.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
83
src/cli/tests/commands/config-pi.test.ts
Normal file
83
src/cli/tests/commands/config-pi.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(visibleSkills: unknown[] = []): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path.endsWith('/skills/visible')) return visibleSkills;
|
||||
if (path.startsWith('/api/v1/skills/')) return {
|
||||
id: 's1', name: 'demo', description: 'd', semver: '1.0.0',
|
||||
content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null,
|
||||
};
|
||||
return {};
|
||||
}),
|
||||
post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('config pi', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: string[]) => output.push(args.join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('installs the extension + registers with a custom pi dir (skips skills)', async () => {
|
||||
// Stage a src/pi-ext source tree to install from.
|
||||
const srcPiExt = join(tmpDir, 'src-pi-ext');
|
||||
mkdirSync(srcPiExt, { recursive: true });
|
||||
writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n');
|
||||
writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n');
|
||||
|
||||
const piDir = join(tmpDir, 'pi-agent');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
|
||||
await cmd.parseAsync(
|
||||
['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
|
||||
// Extension files landed in the pi dir.
|
||||
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true);
|
||||
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true);
|
||||
|
||||
// settings.json updated.
|
||||
const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8'));
|
||||
expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'));
|
||||
expect(settings.skills).toContain(join(piDir, 'skills'));
|
||||
|
||||
// Active project persisted under the pi dir (fully isolated).
|
||||
const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8'));
|
||||
expect(state.project).toBe('docmost');
|
||||
|
||||
// Output mentions the write.
|
||||
expect(output.join('\n')).toContain('Wrote active project');
|
||||
});
|
||||
|
||||
it('requires a project', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('--project is required');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createCreateCommand } from '../../src/commands/create.js';
|
||||
import { createCreateCommand, parseServerVolumes } from '../../src/commands/create.js';
|
||||
import { type ApiClient, ApiError } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
@@ -556,3 +556,31 @@ describe('create command', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServerVolumes', () => {
|
||||
it('parses name, path, size and storage class', () => {
|
||||
expect(parseServerVolumes(['data:/data:20:longhorn'])).toEqual([
|
||||
{ name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults size to 10Gi and omits storageClass', () => {
|
||||
expect(parseServerVolumes(['data:/data'])).toEqual([
|
||||
{ name: 'data', mountPath: '/data', sizeGb: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a relative mount path', () => {
|
||||
expect(() => parseServerVolumes(['data:data'])).toThrow(/must be absolute/);
|
||||
});
|
||||
|
||||
it('rejects a non-integer or zero size', () => {
|
||||
expect(() => parseServerVolumes(['data:/data:abc'])).toThrow(/positive whole number/);
|
||||
expect(() => parseServerVolumes(['data:/data:0'])).toThrow(/positive whole number/);
|
||||
});
|
||||
|
||||
it('rejects a malformed spec', () => {
|
||||
expect(() => parseServerVolumes(['data'])).toThrow(/Invalid volume format/);
|
||||
expect(() => parseServerVolumes(['a:/b:1:c:d'])).toThrow(/Invalid volume format/);
|
||||
});
|
||||
});
|
||||
|
||||
607
src/cli/tests/commands/prime-agent.test.ts
Normal file
607
src/cli/tests/commands/prime-agent.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('config prime-agent', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: string[]) => output.push(args.join(' '));
|
||||
|
||||
let prevCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-'));
|
||||
// config prime-agent writes the .mcpctl-project marker into cwd, so run
|
||||
// every test from an isolated temp dir to avoid polluting the repo.
|
||||
prevCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
process.exitCode = 0;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('requires --project', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('--project is required');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('writes proxy MCP entry into prime-agent settings.json', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['homeautomation']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`,
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
expect(output.join('\n')).toContain('homeautomation');
|
||||
});
|
||||
|
||||
it('merges with existing servers and preserves other settings', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
defaultProvider: 'itaz',
|
||||
mcpServers: {
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` },
|
||||
},
|
||||
}));
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.defaultProvider).toBe('itaz'); // untouched
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // preserved
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`,
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a project marker for later skills sync', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const markerPath = join(tmpDir, '.mcpctl-project');
|
||||
expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre');
|
||||
});
|
||||
|
||||
it('--dry-run prints the change without writing', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('proj-2');
|
||||
// No file should have been created.
|
||||
expect(exceptionSafeRead(settingsPath)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not call the API when --skip-skills and --token are given', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' });
|
||||
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('backward compat: prime-agent-generate still works', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['proj-1']).toBeDefined();
|
||||
});
|
||||
|
||||
it('provisions auth.json by minting a project token', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' }));
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' });
|
||||
});
|
||||
|
||||
it('uses --token without calling the API', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' });
|
||||
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' });
|
||||
});
|
||||
|
||||
it('keeps an existing credential and does not re-mint', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } }));
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:labctl'].key).toBe('existing');
|
||||
});
|
||||
|
||||
it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts');
|
||||
expect(existsSync(extPath)).toBe(true);
|
||||
expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'");
|
||||
|
||||
output.length = 0;
|
||||
const cmd2 = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
expect(output.join('\n')).not.toContain('switcher extension');
|
||||
});
|
||||
|
||||
it('does not write a .mcpctl-project marker when run from $HOME', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const prevCwd = process.cwd();
|
||||
process.chdir(homedir());
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
try {
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
expect(output.join('\n')).toContain('Skipped .mcpctl-project marker');
|
||||
expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('refusing to overwrite');
|
||||
expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('exits non-zero when a credential cannot be provisioned', async () => {
|
||||
// mockClient post returns { token: ... } by default; override to no token.
|
||||
const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client;
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(1);
|
||||
// body of provisioning error surfaced
|
||||
expect(output.join('\n')).toContain('no token returned');
|
||||
});
|
||||
|
||||
it('writes auth.json with mode 0600', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token
|
||||
const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('refuses to overwrite a corrupt settings.json', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, '{ this is not valid json !!!');
|
||||
const prevCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
try {
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
expect(output.join('\n')).toContain('refusing to overwrite');
|
||||
// The corrupt file is untouched.
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!');
|
||||
});
|
||||
|
||||
it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
mcpServers: {
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set
|
||||
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
|
||||
},
|
||||
}));
|
||||
// Active project is homeautomation (tagged). Switch to labctl.
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active
|
||||
expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved
|
||||
});
|
||||
|
||||
it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => {
|
||||
// Written by a CLI that predates `mcpctlManaged`: an untagged entry whose
|
||||
// URL is canonical AND a matching mcp:<project> PAT in auth.json.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
mcpServers: {
|
||||
legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` },
|
||||
websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential
|
||||
},
|
||||
}));
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
itaz: { type: 'api_key', key: 'sk-provider' },
|
||||
'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' },
|
||||
}));
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted
|
||||
expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved
|
||||
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true);
|
||||
});
|
||||
|
||||
it('mints each credential under a unique name (never a fixed one)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string };
|
||||
// A fixed name can only ever be minted once: McpToken is unique on
|
||||
// (name, projectId) and revoke is a soft delete.
|
||||
expect(body.name).not.toBe('prime-agent');
|
||||
expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it('revokes the token it replaced, only after the replacement is stored', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const authPath = join(tmpDir, 'auth.json');
|
||||
writeFileSync(authPath, JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' },
|
||||
}));
|
||||
const order: string[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async (url: string) => {
|
||||
order.push(`get ${url}`);
|
||||
return [
|
||||
{ id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' },
|
||||
{ id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' },
|
||||
];
|
||||
}),
|
||||
post: vi.fn(async (url: string) => {
|
||||
order.push(`post ${url}`);
|
||||
return {};
|
||||
}),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
// Explicitly replace the stored credential.
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' });
|
||||
|
||||
// The new credential landed on disk...
|
||||
expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000');
|
||||
// ...before the token it replaced was revoked — never the other way round.
|
||||
const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke');
|
||||
expect(revokeAt).toBeGreaterThanOrEqual(0);
|
||||
expect(statSync(authPath).mtimeMs).toBeGreaterThan(0);
|
||||
// Tokens this auth.json never held are reported, never revoked.
|
||||
expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke');
|
||||
});
|
||||
|
||||
it('never revokes a token this auth.json did not hold', async () => {
|
||||
// A run against a custom --output (or a second machine) must not touch the
|
||||
// credential the real install is using.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
{ id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' },
|
||||
]),
|
||||
post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke'));
|
||||
expect(revokes).toEqual([]);
|
||||
// ...but the user is told about it rather than left guessing.
|
||||
expect(output.join('\n')).toContain('prime-agent-abc');
|
||||
});
|
||||
|
||||
it('leaves settings.json untouched when the credential cannot be provisioned', async () => {
|
||||
// The active project must keep working when a switch fails: registering the
|
||||
// new project unmounts the old one, so it may not run before the mint.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const before = JSON.stringify({
|
||||
mcpServers: {
|
||||
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
|
||||
},
|
||||
});
|
||||
writeFileSync(settingsPath, before);
|
||||
const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('re-mints when the stored credential is no longer active', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' },
|
||||
}));
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
{ id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' },
|
||||
]),
|
||||
post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a stored credential that is still active', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' },
|
||||
}));
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
// mcpd records the first 16 chars of the raw token as tokenPrefix.
|
||||
{ id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' },
|
||||
]),
|
||||
post: vi.fn(async () => ({ token: 'should-not-be-minted' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa');
|
||||
});
|
||||
|
||||
it('tightens a pre-existing 0644 auth.json to 0600', async () => {
|
||||
// prime-agent creates auth.json itself with the default umask; writeFile's
|
||||
// `mode` is ignored for an existing file, so the write must chmod.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const authPath = join(tmpDir, 'auth.json');
|
||||
writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 });
|
||||
chmodSync(authPath, 0o644);
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(statSync(authPath).mode & 0o777).toBe(0o600);
|
||||
// The provider credential is still there.
|
||||
expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x');
|
||||
});
|
||||
|
||||
it('--skip-marker leaves the current directory alone', async () => {
|
||||
// The /mcpctl switcher runs from whatever directory prime-agent started in.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull();
|
||||
});
|
||||
|
||||
it('the installed switcher extension publishes the active project to the footer', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
|
||||
// Footer status, refreshed on startup and on every reload (which is what
|
||||
// the switch itself triggers) — the mcpctl equivalent of the model name.
|
||||
expect(ext).toContain("pi.on('session_start'");
|
||||
expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY');
|
||||
expect(ext).toContain('`mcpctl:${active}`');
|
||||
// notify() only accepts info|warning|error — 'success' is not a valid type.
|
||||
expect(ext).not.toContain("'success'");
|
||||
});
|
||||
|
||||
it('the installed switcher shows the active project via a widget, not just setStatus', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
|
||||
// Both hosts render statuses next to the model name (pi in its footer,
|
||||
// prime-agent in the tray — the latter via prime-agent-extension-status.patch).
|
||||
expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY');
|
||||
// The widget was a workaround for the unpatched tray; it scrolled away with
|
||||
// the transcript, so it is not a status line and must not come back.
|
||||
expect(ext).not.toContain('setWidget');
|
||||
// prime-agent emits session_start only from reload(), never at startup, so
|
||||
// a session_start-only indicator stays blank until the first switch.
|
||||
expect(ext).toContain("pi.on('turn_start'");
|
||||
});
|
||||
|
||||
it('the installed switcher filters the project list before showing it', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
|
||||
// Hundreds of projects in a plain arrow-key list is unusable.
|
||||
expect(ext).toContain('export function filterProjects');
|
||||
expect(ext).toContain('ctx.ui.input(');
|
||||
expect(ext).toContain('FILTER_THRESHOLD');
|
||||
});
|
||||
|
||||
it('the installed switcher extension passes --skip-marker', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
|
||||
expect(ext).toContain("'--skip-extension', '--skip-marker'");
|
||||
});
|
||||
|
||||
it('merges a re-configured project entry, preserving user-added fields', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
mcpServers: {
|
||||
ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } },
|
||||
},
|
||||
}));
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['ha']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`,
|
||||
headers: { Authorization: 'Bearer u' }, // user-added field preserved
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function exceptionSafeRead(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
56
src/cli/tests/commands/skills.test.ts
Normal file
56
src/cli/tests/commands/skills.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createSkillsCommand } from '../../src/commands/skills.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('skills sync --agent', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-skills-agent-'));
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('defaults to claude and runs the normal sync', async () => {
|
||||
const cmd = createSkillsCommand({ client, log });
|
||||
await cmd.parseAsync(['sync', '--project', 'proj', '--skip-postinstall'], { from: 'user' });
|
||||
// claude path calls the project visible endpoint.
|
||||
expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible');
|
||||
});
|
||||
|
||||
it('routes --agent prime-agent to the prime-agent target', async () => {
|
||||
const cmd = createSkillsCommand({ client, log });
|
||||
await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'prime-agent'], { from: 'user' });
|
||||
// prime-agent path also hits the project visible endpoint, and the summary
|
||||
// line should mention the target.
|
||||
expect(output.join('\n')).toContain('prime-agent');
|
||||
});
|
||||
|
||||
it('rejects an unknown --agent value with a non-zero exit', async () => {
|
||||
const cmd = createSkillsCommand({ client, log });
|
||||
await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommand
|
||||
fetchServerLlms: async () => null,
|
||||
probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }),
|
||||
fetchSecretBackends: async () => null,
|
||||
probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 0, servingStale: 0 } }),
|
||||
isTTY: false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -46,33 +47,73 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
const BAO = { id: 'b1', name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } };
|
||||
|
||||
it('shows a healthy secret backend in the Secrets line', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [
|
||||
{ name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } },
|
||||
{ name: 'default', type: 'plaintext' },
|
||||
],
|
||||
fetchSecretBackends: async () => [BAO, { id: 'b2', name: 'default', type: 'plaintext' }],
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
const out = output.join('\n');
|
||||
expect(out).toContain('Secrets:');
|
||||
expect(out).toContain('bao* ✓');
|
||||
expect(out).toContain('default ✓');
|
||||
expect(out).toContain('bao* ✓ reachable');
|
||||
expect(out).toContain('default ✓ reachable');
|
||||
});
|
||||
|
||||
it('flags a dead secret-backend token in the Secrets line', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [
|
||||
{ name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } },
|
||||
{ ...BAO, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } },
|
||||
],
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
const out = output.join('\n');
|
||||
expect(out).toContain('bao* ✗');
|
||||
expect(out).toContain('BACKEND_TOKEN_DEAD');
|
||||
expect(out).not.toContain('more detail'); // only first line, truncated
|
||||
});
|
||||
|
||||
it('reports an unreachable backend even when rotation never errored', async () => {
|
||||
// THE bug: a kubernetes-auth backend never writes tokenMeta.lastRotationError,
|
||||
// so this line used to render a green tick with OpenBao completely down.
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [BAO],
|
||||
probeSecretBackend: async () => ({ live: false, liveDetail: 'sealed', ready: false }),
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
const out = output.join('\n');
|
||||
expect(out).toContain('bao* ✗ unreachable: sealed');
|
||||
expect(out).not.toContain('✓');
|
||||
});
|
||||
|
||||
it('distinguishes reachable-but-unusable from unreachable', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [BAO],
|
||||
probeSecretBackend: async () => ({ live: true, ready: false, readyDetail: 'HTTP 403 permission denied' }),
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('bao* ✗ auth failed: HTTP 403 permission denied');
|
||||
});
|
||||
|
||||
it('reports degraded while serving cached secrets', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [BAO],
|
||||
probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 9, servingStale: 7 } }),
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('bao* ⚠ degraded — serving 7 cached secret(s)');
|
||||
});
|
||||
|
||||
it('reports unknown — never healthy — when the probe itself fails', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
fetchSecretBackends: async () => [BAO],
|
||||
probeSecretBackend: async () => null,
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
const out = output.join('\n');
|
||||
expect(out).toContain('bao* ? unknown');
|
||||
expect(out).not.toContain('✓');
|
||||
});
|
||||
|
||||
it('omits the Secrets line when mcpd returns no backends', async () => {
|
||||
const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => null }));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
121
src/cli/tests/commands/statusline.test.ts
Normal file
121
src/cli/tests/commands/statusline.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -234,7 +234,7 @@ describe('agent + chat completions', () => {
|
||||
});
|
||||
|
||||
it('bash dispatches `create agent` with the correct flags', () => {
|
||||
const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? '';
|
||||
const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? '';
|
||||
expect(createBlock).toContain('--llm');
|
||||
expect(createBlock).toContain('--system-prompt');
|
||||
expect(createBlock).toContain('--default-temperature');
|
||||
|
||||
157
src/cli/tests/config/claude-mcp.test.ts
Normal file
157
src/cli/tests/config/claude-mcp.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
103
src/cli/tests/config/opencode-extension-embed.test.ts
Normal file
103
src/cli/tests/config/opencode-extension-embed.test.ts
Normal 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 opencode’s own tree, never Claude’s', () => {
|
||||
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;/);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
src/cli/tests/config/opencode-project-order.test.ts
Normal file
67
src/cli/tests/config/opencode-project-order.test.ts
Normal 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 caller’s list', () => {
|
||||
const input = [...PROJECTS];
|
||||
orderProjects(input, 'sre');
|
||||
expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name));
|
||||
});
|
||||
|
||||
it('keeps descriptions, which the dialog shows under each row', () => {
|
||||
expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indicatorLabel', () => {
|
||||
it('names the active project', () => {
|
||||
expect(indicatorLabel('docmost')).toBe('mcpctl:docmost');
|
||||
});
|
||||
|
||||
it('says so when there is none, rather than rendering a bare prefix', () => {
|
||||
expect(indicatorLabel(null)).toBe('mcpctl:none');
|
||||
expect(indicatorLabel('')).toBe('mcpctl:none');
|
||||
});
|
||||
});
|
||||
73
src/cli/tests/config/pi-extension-embed.test.ts
Normal file
73
src/cli/tests/config/pi-extension-embed.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PI_EXTENSION_FILES, PI_EXTENSION_FILENAMES } from '../../src/config/pi-extension.js';
|
||||
|
||||
/**
|
||||
* `mcpctl config pi` installs the *embedded* copy of the extension, not the
|
||||
* files in src/pi-ext/. Editing the sources without re-running the generator
|
||||
* therefore ships stale code to users while the repo looks correct — and the
|
||||
* embedded copy is the one thing no typecheck covers. Same guarantee the
|
||||
* completions check gives.
|
||||
*/
|
||||
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
|
||||
const piExtDir = join(repoRoot, 'src', 'pi-ext');
|
||||
|
||||
describe('embedded pi extension', () => {
|
||||
it('matches the sources in src/pi-ext (re-run scripts/generate-pi-extension.ts)', () => {
|
||||
for (const name of PI_EXTENSION_FILENAMES) {
|
||||
const onDisk = readFileSync(join(piExtDir, name), 'utf-8');
|
||||
expect(PI_EXTENSION_FILES[name], `${name} is embedded`).toBeDefined();
|
||||
expect(PI_EXTENSION_FILES[name], `${name} is stale — regenerate the embed`).toBe(onDisk);
|
||||
}
|
||||
});
|
||||
|
||||
it('embeds every file the extension needs to load', () => {
|
||||
// mcpctl-pi.ts imports ./mcp-http.js — installing one without the other
|
||||
// yields an extension pi cannot load.
|
||||
expect(Object.keys(PI_EXTENSION_FILES).sort()).toEqual(['mcp-http.ts', 'mcpctl-pi.ts']);
|
||||
expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js');
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
expect(main).not.toMatch(/select\([^)]*\[\s*\{\s*value:/);
|
||||
// Skills must land in pi's tree, never ~/.claude: the sync passes
|
||||
// --agent pi and no longer tells the user it wrote to Claude's tree.
|
||||
expect(main).toContain('"--agent", "pi"');
|
||||
expect(main).not.toContain('(into ~/.claude/skills)');
|
||||
// Spawned without a shell, so the project name is never interpolated into
|
||||
// a command string. Matches the call, not prose mentioning it.
|
||||
expect(main).toContain('execFile("mcpctl", args');
|
||||
expect(main).not.toMatch(/execSync\s*\(/);
|
||||
});
|
||||
});
|
||||
103
src/cli/tests/config/pi-project-filter.test.ts
Normal file
103
src/cli/tests/config/pi-project-filter.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterProjects, toolChangeAnnouncement } from '../../../pi-ext/mcpctl-pi.js';
|
||||
|
||||
/**
|
||||
* pi's selector is a plain arrow-key list — no search — so the project picker
|
||||
* filters before handing the list over. Real installs run to hundreds of
|
||||
* projects, where scrolling is hopeless.
|
||||
*/
|
||||
const PROJECTS = [
|
||||
'smoke-proj-none-mohimh46',
|
||||
'homeautomation',
|
||||
'docmost',
|
||||
'copy-homeautomation',
|
||||
'labctl',
|
||||
'mcpctl-development',
|
||||
'sre',
|
||||
];
|
||||
|
||||
describe('filterProjects', () => {
|
||||
it('puts the active project first, then sorts alphabetically', () => {
|
||||
expect(filterProjects(PROJECTS, '', 'labctl')).toEqual([
|
||||
'labctl',
|
||||
'copy-homeautomation',
|
||||
'docmost',
|
||||
'homeautomation',
|
||||
'mcpctl-development',
|
||||
'smoke-proj-none-mohimh46',
|
||||
'sre',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically when nothing is active', () => {
|
||||
expect(filterProjects(PROJECTS, '', null)[0]).toBe('copy-homeautomation');
|
||||
});
|
||||
|
||||
it('keeps everything for a blank or whitespace query', () => {
|
||||
expect(filterProjects(PROJECTS, '', null)).toHaveLength(PROJECTS.length);
|
||||
expect(filterProjects(PROJECTS, ' ', null)).toHaveLength(PROJECTS.length);
|
||||
});
|
||||
|
||||
it('matches case-insensitive substrings', () => {
|
||||
expect(filterProjects(PROJECTS, 'HOMEAUTO', null)).toEqual([
|
||||
'copy-homeautomation',
|
||||
'homeautomation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires every space-separated term to match', () => {
|
||||
// "home auto" finds homeautomation even though the terms aren't adjacent...
|
||||
expect(filterProjects(PROJECTS, 'home auto', null)).toEqual([
|
||||
'copy-homeautomation',
|
||||
'homeautomation',
|
||||
]);
|
||||
// ...and a term that matches nothing eliminates the row.
|
||||
expect(filterProjects(PROJECTS, 'home zzz', null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('narrows the smoke-test noise that motivated this', () => {
|
||||
const many = [...PROJECTS, ...Array.from({ length: 300 }, (_, i) => `smoke-proj-none-x${String(i)}`)];
|
||||
expect(filterProjects(many, 'smoke', null)).toHaveLength(301);
|
||||
expect(filterProjects(many, 'ctl', null)).toEqual(['labctl', 'mcpctl-development']);
|
||||
});
|
||||
|
||||
it('leaves the caller with an empty list rather than a bad match', () => {
|
||||
expect(filterProjects(PROJECTS, 'nosuchproject', null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [...PROJECTS];
|
||||
filterProjects(input, 'home', 'sre');
|
||||
expect(input).toEqual(PROJECTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolChangeAnnouncement', () => {
|
||||
it('names the active project and the live tools', () => {
|
||||
const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']);
|
||||
expect(msg).toContain("active project is now 'sre'");
|
||||
expect(msg).toContain('mc_sre_begin_session');
|
||||
});
|
||||
|
||||
it('tells the model to ignore the previous project\'s tool names', () => {
|
||||
// pi cannot unregister tools, so stale names stay in the conversation and
|
||||
// the model keeps calling them until told otherwise.
|
||||
expect(toolChangeAnnouncement('sre', ['mc_sre_x'])).toMatch(/no longer callable|ignore them/);
|
||||
});
|
||||
|
||||
it('calls out gating so the model knows to unlock the rest', () => {
|
||||
const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']);
|
||||
expect(msg).toContain('gated');
|
||||
expect(msg).toMatch(/call mc_sre_begin_session first/);
|
||||
});
|
||||
|
||||
it('omits the gate hint when the project is not gated', () => {
|
||||
const msg = toolChangeAnnouncement('docmost', ['mc_docmost_search', 'mc_docmost_get_page']);
|
||||
expect(msg).not.toContain('gated');
|
||||
expect(msg).toContain('mc_docmost_search, mc_docmost_get_page');
|
||||
});
|
||||
|
||||
it('is explicit when a project exposes nothing', () => {
|
||||
expect(toolChangeAnnouncement('empty', [])).toContain('No mcpctl tools are currently available');
|
||||
});
|
||||
});
|
||||
48
src/cli/tests/config/prime-agent-extension-embed.test.ts
Normal file
48
src/cli/tests/config/prime-agent-extension-embed.test.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
59
src/cli/tests/health-check-opts.test.ts
Normal file
59
src/cli/tests/health-check-opts.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
75
src/cli/tests/trace.test.ts
Normal file
75
src/cli/tests/trace.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createTraceCommand } from '../src/commands/trace.js';
|
||||
import type { ApiClient } from '../src/api-client.js';
|
||||
|
||||
function clientReturning(events: unknown[]): ApiClient {
|
||||
return { get: vi.fn(async () => ({ events, total: events.length })) } as unknown as ApiClient;
|
||||
}
|
||||
|
||||
function run(client: ApiClient, args: string[]): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
const cmd = createTraceCommand({ client, log: (...a: string[]) => { lines.push(a.join(' ')); } });
|
||||
return cmd.parseAsync(['node', 'trace', ...args]).then(() => lines);
|
||||
}
|
||||
|
||||
const base = {
|
||||
sessionId: 'abcdef1234567890', projectName: 'sre', source: 'mcplocal',
|
||||
correlationId: 'K3P7QW2M', userName: 'michal@itaz.eu',
|
||||
};
|
||||
|
||||
describe('mcpctl trace', () => {
|
||||
it('renders a waterfall ordered by time', async () => {
|
||||
const lines = await run(clientReturning([
|
||||
{ ...base, timestamp: '2026-08-25T22:00:02.000Z', eventKind: 'tool_call_trace',
|
||||
payload: { toolName: 'docmost/search', durationMs: 461, resultSizeBytes: 14091, error: null } },
|
||||
{ ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution',
|
||||
payload: { stage: 'paginate', durationMs: 12, inputSize: 14091, outputSize: 5632, sectionCount: 2 } },
|
||||
]), ['K3P7QW2M']);
|
||||
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain('Trace K3P7QW2M');
|
||||
expect(out).toContain('project sre');
|
||||
expect(out).toContain('docmost/search');
|
||||
// Sorted: the stage at :00 must precede the tool_call at :02 even though
|
||||
// the API returned them newest-first.
|
||||
expect(out.indexOf('paginate')).toBeLessThan(out.indexOf('tool_call_trace'));
|
||||
});
|
||||
|
||||
it('flags degraded steps and names the slowest', async () => {
|
||||
const lines = await run(clientReturning([
|
||||
{ ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution',
|
||||
payload: { stage: 'summarize-tree', durationMs: 30001, inputSize: 100, outputSize: 50,
|
||||
degraded: true, degradedReason: 'stage LLM budget of 30000ms exhausted' } },
|
||||
]), ['K3P7QW2M']);
|
||||
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain('⚠ stage LLM budget of 30000ms exhausted');
|
||||
expect(out).toContain('1 degraded');
|
||||
expect(out).toContain('slowest: summarize-tree (30001ms)');
|
||||
});
|
||||
|
||||
it('explains an empty result instead of printing nothing', async () => {
|
||||
const lines = await run(clientReturning([]), ['ZZZZZZZZ']);
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain("No trace found for 'ZZZZZZZZ'");
|
||||
expect(out).toContain('no I/L/O/U');
|
||||
});
|
||||
|
||||
it('--strict exits non-zero on a degraded trace', async () => {
|
||||
process.exitCode = 0;
|
||||
await run(clientReturning([
|
||||
{ ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution',
|
||||
payload: { stage: 'paginate', durationMs: 1, degraded: true, degradedReason: 'timed out' } },
|
||||
]), ['K3P7QW2M', '--strict']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('queries mcpd by correlationId', async () => {
|
||||
const client = clientReturning([]);
|
||||
await run(client, ['K3P7QW2M']);
|
||||
expect(client.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/audit/events?correlationId=K3P7QW2M'),
|
||||
);
|
||||
});
|
||||
});
|
||||
133
src/cli/tests/utils/claude-ui.test.ts
Normal file
133
src/cli/tests/utils/claude-ui.test.ts
Normal 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`');
|
||||
});
|
||||
});
|
||||
169
src/cli/tests/utils/opencode-settings.test.ts
Normal file
169
src/cli/tests/utils/opencode-settings.test.ts
Normal 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 user’s plugins', async () => {
|
||||
writeFileSync(tuiJson, '{ this is not json');
|
||||
await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/);
|
||||
expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json');
|
||||
});
|
||||
|
||||
it('treats an empty file as a fresh start', async () => {
|
||||
writeFileSync(tuiJson, ' \n');
|
||||
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode state file', () => {
|
||||
let dir: string;
|
||||
let statePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-'));
|
||||
statePath = join(dir, 'nested', 'opencode-state.json');
|
||||
});
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('creates the directory and writes project + gateway + token', async () => {
|
||||
await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath);
|
||||
expect(await readOpencodeState(statePath)).toEqual({
|
||||
project: 'docmost',
|
||||
gatewayUrl: 'https://gw',
|
||||
tokens: { docmost: 'mcpctl_pat_a' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is written 0600 — it holds bearer tokens', async () => {
|
||||
await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath);
|
||||
expect(statSync(statePath).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps other projects’ tokens, so switching back needs no new mint', async () => {
|
||||
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
|
||||
await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath);
|
||||
const state = await readOpencodeState(statePath);
|
||||
expect(state.project).toBe('b');
|
||||
expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' });
|
||||
});
|
||||
|
||||
it('switching without a new token leaves the stored one alone', async () => {
|
||||
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
|
||||
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath);
|
||||
const state = await readOpencodeState(statePath);
|
||||
expect(state.gatewayUrl).toBe('https://gw2');
|
||||
expect(storedToken(state, 'a')).toBe('tok-a');
|
||||
});
|
||||
|
||||
it('reads a missing or corrupt state as empty rather than throwing', async () => {
|
||||
expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({});
|
||||
mkdirSync(join(dir, 'x'), { recursive: true });
|
||||
writeFileSync(join(dir, 'x', 's.json'), 'not json');
|
||||
expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({});
|
||||
});
|
||||
|
||||
it('storedToken ignores an empty or missing entry', () => {
|
||||
expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull();
|
||||
expect(storedToken({}, 'a')).toBeNull();
|
||||
expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x');
|
||||
});
|
||||
});
|
||||
109
src/cli/tests/utils/pi-settings.test.ts
Normal file
109
src/cli/tests/utils/pi-settings.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { agentInstallRoot } from '../../src/commands/skills.js';
|
||||
import {
|
||||
registerWithPi,
|
||||
installExtensionFiles,
|
||||
installEmbeddedExtension,
|
||||
} from '../../src/utils/pi-settings.js';
|
||||
|
||||
describe('skills agentInstallRoot', () => {
|
||||
it('maps claude (default) to ~/.claude/skills', () => {
|
||||
expect(agentInstallRoot(undefined)).toBe(join(homedir(), '.claude', 'skills'));
|
||||
expect(agentInstallRoot('claude')).toBe(join(homedir(), '.claude', 'skills'));
|
||||
});
|
||||
it('maps pi to ~/.pi/agent/skills', () => {
|
||||
expect(agentInstallRoot('pi')).toBe(join(homedir(), '.pi', 'agent', 'skills'));
|
||||
});
|
||||
it('maps prime-agent to ~/.prime/agent/skills', () => {
|
||||
expect(agentInstallRoot('prime-agent')).toBe(join(homedir(), '.prime', 'agent', 'skills'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('pi-settings util', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mcpctl-pi-settings-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('registers extension + skills dir into an empty settings file', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
|
||||
const skillsDir = join(tmp, 'skills');
|
||||
|
||||
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
|
||||
expect(addedExtensions).toEqual([extPath]);
|
||||
expect(addedSkills).toEqual([skillsDir]);
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.extensions).toEqual([extPath]);
|
||||
expect(written.skills).toEqual([skillsDir]);
|
||||
});
|
||||
|
||||
it('is idempotent — running twice does not duplicate entries', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
|
||||
const skillsDir = join(tmp, 'skills');
|
||||
|
||||
await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
const second = await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
|
||||
expect(second.addedExtensions).toEqual([]);
|
||||
expect(second.addedSkills).toEqual([]);
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.extensions).toEqual([extPath]);
|
||||
expect(written.skills).toEqual([skillsDir]);
|
||||
});
|
||||
|
||||
it('preserves existing settings keys', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: 'dark', packages: ['git:foo/bar'] }, null, 2));
|
||||
|
||||
await registerWithPi(settingsPath, '/abs/ext.ts', '/abs/skills');
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.theme).toBe('dark');
|
||||
expect(written.packages).toEqual(['git:foo/bar']);
|
||||
expect(written.extensions).toEqual(['/abs/ext.ts']);
|
||||
expect(written.skills).toEqual(['/abs/skills']);
|
||||
});
|
||||
|
||||
it('copies the extension files into the pi extension dir', async () => {
|
||||
const srcDir = join(tmp, 'src-pi-ext');
|
||||
const destDir = join(tmp, 'dest-ext');
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
writeFileSync(join(srcDir, 'mcpctl-pi.ts'), '// extension\n');
|
||||
writeFileSync(join(srcDir, 'mcp-http.ts'), '// client\n');
|
||||
|
||||
const written = await installExtensionFiles(srcDir, destDir);
|
||||
|
||||
expect(written).toHaveLength(2);
|
||||
expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n');
|
||||
expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n');
|
||||
});
|
||||
|
||||
it('writes the embedded extension sources (self-contained, no source tree)', async () => {
|
||||
const destDir = join(tmp, 'embedded-ext');
|
||||
|
||||
const written = await installEmbeddedExtension(destDir);
|
||||
|
||||
expect(written).toHaveLength(2);
|
||||
// Both filenames must be written.
|
||||
expect(written.map((w) => w.replace(destDir + '/', ''))).toEqual(['mcpctl-pi.ts', 'mcp-http.ts']);
|
||||
// The main file must be non-empty and import the sibling client.
|
||||
const main = readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8');
|
||||
expect(main.length).toBeGreaterThan(1000);
|
||||
expect(main).toContain('./mcp-http.js');
|
||||
const http = readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8');
|
||||
expect(http).toContain('McpHttpSession');
|
||||
});
|
||||
});
|
||||
326
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
326
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js';
|
||||
import { loadState } from '../../src/utils/skills-state.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(overrides: Record<string, unknown> = {}): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async (url: string) => {
|
||||
if (url.includes('/skills/visible')) {
|
||||
return overrides['visible'] ?? [];
|
||||
}
|
||||
if (url.startsWith('/api/v1/skills/')) {
|
||||
const id = url.split('/').pop() as string;
|
||||
const full = (overrides['full'] as Record<string, unknown>)?.[id];
|
||||
if (!full) throw new Error(`no full skill for ${id}`);
|
||||
return full;
|
||||
}
|
||||
if (url.endsWith('/skills?scope=global')) {
|
||||
return overrides['visible'] ?? [];
|
||||
}
|
||||
throw new Error(`unexpected get: ${url}`);
|
||||
}),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
const SKILL_MD = `---
|
||||
name: sample-skill
|
||||
description: A test skill synced into prime-agent.
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
Body text.
|
||||
`;
|
||||
|
||||
describe('runPrimeAgentSkillsSync', () => {
|
||||
let tmpDir: string;
|
||||
let installRoot: string;
|
||||
let statePath: string;
|
||||
const log = (..._a: unknown[]) => {};
|
||||
const warn = (..._a: unknown[]) => {};
|
||||
|
||||
function deps(client: ApiClient) {
|
||||
return { client, log, warn };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-pa-sync-'));
|
||||
installRoot = join(tmpDir, 'skills');
|
||||
statePath = join(tmpDir, 'skills-state.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('installs a new markdown skill into the prime-agent skills root', async () => {
|
||||
const visible = [
|
||||
{ id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const full = {
|
||||
'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} },
|
||||
};
|
||||
const client = mockClient({ visible, full });
|
||||
|
||||
const result = await runPrimeAgentSkillsSync(
|
||||
{ project: 'proj', installRoot, statePath },
|
||||
deps(client),
|
||||
);
|
||||
|
||||
expect(result.installed).toEqual(['sample-skill']);
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
const skillDir = join(installRoot, 'sample-skill');
|
||||
expect(existsSync(skillDir)).toBe(true);
|
||||
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD);
|
||||
|
||||
// State persisted so a re-sync is a no-op.
|
||||
const state = await loadState(statePath);
|
||||
expect(state.skills['sample-skill'].contentHash).toBe('sha256:h1');
|
||||
});
|
||||
|
||||
it('skips unchanged skills on re-sync', async () => {
|
||||
const visible = [
|
||||
{ id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const full = {
|
||||
'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} },
|
||||
};
|
||||
const client = mockClient({ visible, full });
|
||||
|
||||
await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client));
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.skipped).toEqual(['sample-skill']);
|
||||
expect(result.installed).toEqual([]);
|
||||
});
|
||||
|
||||
it('syncs the global set when no project is provided', async () => {
|
||||
const visible = [
|
||||
{ id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', metadata: {}, scope: 'global' },
|
||||
];
|
||||
const full = {
|
||||
'skill-2': { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', content: '# global\n', files: {} },
|
||||
};
|
||||
const client = mockClient({ visible, full });
|
||||
|
||||
// Isolate cwd so no stray .mcpctl-project marker is discovered.
|
||||
const empty = join(tmpDir, 'empty');
|
||||
mkdirSync(empty, { recursive: true });
|
||||
|
||||
const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.installed).toEqual(['global-skill']);
|
||||
const getCalls = (client.get as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
|
||||
expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves an untracked pre-existing skill dir on first sync (no rm -rf)', async () => {
|
||||
const existing = join(installRoot, 'sample-skill');
|
||||
mkdirSync(existing, { recursive: true });
|
||||
writeFileSync(join(existing, 'SKILL.md'), '# hand-authored\n', 'utf-8');
|
||||
|
||||
const visible = [
|
||||
{ id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const full = {
|
||||
'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: '# server content\n', files: {} },
|
||||
};
|
||||
const client = mockClient({ visible, full });
|
||||
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.preserved).toContain('sample-skill');
|
||||
expect(result.installed).toEqual([]);
|
||||
// The hand-authored content is untouched.
|
||||
expect(readFileSync(join(existing, 'SKILL.md'), 'utf-8')).toBe('# hand-authored\n');
|
||||
});
|
||||
|
||||
it('does not delete another project\'s skills when configuring a second project', async () => {
|
||||
// Project A installs a skill.
|
||||
const av = [
|
||||
{ id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const af = { 'a-1': { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# a\n', files: {} } };
|
||||
const clientA = mockClient({ visible: av, full: af });
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
|
||||
expect(existsSync(join(installRoot, 'a-skill'))).toBe(true);
|
||||
|
||||
// Project B syncs with a totally different skill set.
|
||||
const bv = [
|
||||
{ id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const bf = { 'b-1': { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# b\n', files: {} } };
|
||||
const clientB = mockClient({ visible: bv, full: bf });
|
||||
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
|
||||
|
||||
// B should neither remove A\'s skill nor claim it was removed.
|
||||
expect(resultB.removed).toEqual([]);
|
||||
expect(existsSync(join(installRoot, 'a-skill'))).toBe(true);
|
||||
expect(existsSync(join(installRoot, 'b-skill'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes an orphaned skill that belongs to the same project', async () => {
|
||||
const v = [
|
||||
{ id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const f = { 'x-1': { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', content: '# old\n', files: {} } };
|
||||
const client1 = mockClient({ visible: v, full: f });
|
||||
await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client1));
|
||||
expect(existsSync(join(installRoot, 'old-skill'))).toBe(true);
|
||||
|
||||
// Next sync for the same project: the skill is gone from the visible set.
|
||||
const client2 = mockClient({ visible: [], full: {} });
|
||||
const result2 = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client2));
|
||||
|
||||
expect(result2.removed).toContain('old-skill');
|
||||
expect(existsSync(join(installRoot, 'old-skill'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not overwrite a same-named skill owned by a different project', async () => {
|
||||
// Project A installs skill X.
|
||||
const av = [
|
||||
{ id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } };
|
||||
const clientA = mockClient({ visible: av, full: af });
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
|
||||
// Frontmatter is synthesised on write, so assert the body, not exact bytes.
|
||||
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a');
|
||||
|
||||
// Project B also has a skill named X with different content.
|
||||
const bv = [
|
||||
{ id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const bf = { 'b-1': { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# version-b\n', files: {} } };
|
||||
const clientB = mockClient({ visible: bv, full: bf });
|
||||
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
|
||||
|
||||
expect(resultB.preserved).toContain('x-skill');
|
||||
// A's version is untouched (not clobbered by B's).
|
||||
// Frontmatter is synthesised on write, so assert the body, not exact bytes.
|
||||
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a');
|
||||
});
|
||||
|
||||
it('does not delete legacy, ownership-less state belonging to another project', async () => {
|
||||
// State written by a CLI that predates the `project` field: the skill has
|
||||
// no recorded owner and the file records projA as the last syncing project.
|
||||
const legacyDir = join(installRoot, 'legacy-skill');
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
lastSync: '2026-01-01T00:00:00.000Z',
|
||||
lastSyncProject: 'projA',
|
||||
skills: {
|
||||
'legacy-skill': {
|
||||
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
|
||||
installDir: legacyDir, files: {}, postInstallHash: null,
|
||||
lastSyncedAt: '2026-01-01T00:00:00.000Z',
|
||||
// note: no `project` field
|
||||
},
|
||||
},
|
||||
}), 'utf-8');
|
||||
|
||||
// First sync after upgrading, for a *different* project.
|
||||
const client = mockClient({ visible: [], full: {} });
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(existsSync(legacyDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up legacy state once the owning project syncs again', async () => {
|
||||
const legacyDir = join(installRoot, 'legacy-skill');
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
lastSync: '2026-01-01T00:00:00.000Z',
|
||||
lastSyncProject: 'projA',
|
||||
skills: {
|
||||
'legacy-skill': {
|
||||
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
|
||||
installDir: legacyDir, files: {}, postInstallHash: null,
|
||||
lastSyncedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}), 'utf-8');
|
||||
|
||||
const client = mockClient({ visible: [], full: {} });
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.removed).toContain('legacy-skill');
|
||||
expect(existsSync(legacyDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps global skills updatable after switching projects', async () => {
|
||||
// A global installed while projA was active must not be pinned to projA —
|
||||
// globals are visible from every project.
|
||||
const gv = (hash: string) => [
|
||||
{ id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' },
|
||||
];
|
||||
const gf = (hash: string, body: string) => ({
|
||||
'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} },
|
||||
});
|
||||
|
||||
const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') });
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
|
||||
expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull();
|
||||
|
||||
// Switch to projB; the global has been updated server-side.
|
||||
const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') });
|
||||
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
|
||||
|
||||
expect(resultB.updated).toContain('shared-global');
|
||||
expect(resultB.preserved).toEqual([]);
|
||||
expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toContain('# v2');
|
||||
});
|
||||
|
||||
it('does not let a global-only sync clobber a project-owned skill', async () => {
|
||||
const av = [
|
||||
{ id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } };
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af })));
|
||||
|
||||
// A global of the same name shows up on a global-only sync.
|
||||
const gv = [
|
||||
{ id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' },
|
||||
];
|
||||
const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } };
|
||||
const empty = join(tmpDir, 'empty3');
|
||||
mkdirSync(empty, { recursive: true });
|
||||
const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf })));
|
||||
|
||||
expect(result.preserved).toContain('x-skill');
|
||||
// Frontmatter is synthesised on write, so assert the body, not exact bytes.
|
||||
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a');
|
||||
});
|
||||
|
||||
it('removes global orphans on a global-only sync', async () => {
|
||||
// First sync a global skill.
|
||||
const v = [
|
||||
{ id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' },
|
||||
];
|
||||
const f = { 'g-1': { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# g\n', files: {} } };
|
||||
const client1 = mockClient({ visible: v, full: f });
|
||||
const empty = join(tmpDir, 'empty2');
|
||||
mkdirSync(empty, { recursive: true });
|
||||
await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client1));
|
||||
expect(existsSync(join(installRoot, 'gone-global'))).toBe(true);
|
||||
|
||||
// Next global-only sync: the global is gone from the visible set.
|
||||
const client2 = mockClient({ visible: [], full: {} });
|
||||
const result2 = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client2));
|
||||
expect(result2.removed).toContain('gone-global');
|
||||
expect(existsSync(join(installRoot, 'gone-global'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
53
src/cli/tests/utils/skill-frontmatter.test.ts
Normal file
53
src/cli/tests/utils/skill-frontmatter.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ensureSkillFrontmatter } from '../../src/utils/skills-disk.js';
|
||||
|
||||
/**
|
||||
* mcpd stores name/description as columns; a skill's `content` is often just
|
||||
* the body. Written verbatim it lands on disk without the frontmatter every
|
||||
* Agent Skills host requires — prime-agent logs "description is required" and
|
||||
* refuses to load it. Observed live with the `propose-learnings` skill.
|
||||
*/
|
||||
describe('ensureSkillFrontmatter', () => {
|
||||
it('adds frontmatter to a body that has none', () => {
|
||||
const out = ensureSkillFrontmatter('# propose-learnings\n\nBody text.\n', 'propose-learnings', 'How to capture knowledge.');
|
||||
expect(out).toBe(
|
||||
'---\nname: "propose-learnings"\ndescription: "How to capture knowledge."\n---\n\n# propose-learnings\n\nBody text.\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an existing complete header untouched', () => {
|
||||
const original = '---\nname: commit\ndescription: Commit changes\n---\n\n# commit\n';
|
||||
expect(ensureSkillFrontmatter(original, 'commit', 'something else')).toBe(original);
|
||||
});
|
||||
|
||||
it('fills in only the missing key, preserving the author\'s other fields', () => {
|
||||
const out = ensureSkillFrontmatter('---\nname: x\nallowed-tools: Bash\n---\n\nBody\n', 'x', 'The description');
|
||||
expect(out).toContain('allowed-tools: Bash');
|
||||
expect(out).toContain('description: "The description"');
|
||||
// name was already there — not duplicated
|
||||
expect(out.match(/name:/g)).toHaveLength(1);
|
||||
expect(out).toContain('\n\nBody\n');
|
||||
});
|
||||
|
||||
it('quotes descriptions containing YAML metacharacters', () => {
|
||||
// A bare scalar breaks on ':' and '#'; these are common in descriptions.
|
||||
const out = ensureSkillFrontmatter('body', 'n', 'Use when: debugging #hard problems');
|
||||
expect(out).toContain('description: "Use when: debugging #hard problems"');
|
||||
});
|
||||
|
||||
it('escapes quotes and newlines rather than emitting invalid YAML', () => {
|
||||
const out = ensureSkillFrontmatter('body', 'n', 'He said "hi"\nthen left');
|
||||
expect(out).toContain('description: "He said \\"hi\\"\\nthen left"');
|
||||
});
|
||||
|
||||
it('handles CRLF frontmatter delimiters', () => {
|
||||
const out = ensureSkillFrontmatter('---\r\nname: x\r\n---\r\nBody', 'x', 'D');
|
||||
expect(out).toContain('description: "D"');
|
||||
expect(out).toContain('Body');
|
||||
});
|
||||
|
||||
it('still emits the key when the description is empty', () => {
|
||||
// Can't invent one, but the key must exist for the host to parse it.
|
||||
expect(ensureSkillFrontmatter('body', 'n', '')).toContain('description: ""');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Persistent volumes for MCP servers.
|
||||
--
|
||||
-- Instances are immutable and get recreated on any server edit, so anything an
|
||||
-- MCP server writes to its container filesystem was previously lost at that
|
||||
-- point. A volume is declared on the *server* and backed by a Docker named
|
||||
-- volume or a Kubernetes PVC whose identity is derived from the server name,
|
||||
-- so it outlives the instances that mount it.
|
||||
--
|
||||
-- Shape: [{ "name": "data", "mountPath": "/data", "sizeGb": 10,
|
||||
-- "storageClass": "longhorn" }]
|
||||
--
|
||||
-- Defaults to an empty array, so existing rows keep today's behaviour.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "McpTemplate" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Per-server secret delivery mode.
|
||||
--
|
||||
-- 'env' preserves today's behaviour exactly: mcpd resolves secret refs and
|
||||
-- writes the VALUES into the pod spec. Defaulting to it means every existing
|
||||
-- server is unchanged by this migration, and migration to 'injector' is an
|
||||
-- explicit, reversible, per-server decision.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "secretDelivery" TEXT NOT NULL DEFAULT 'env';
|
||||
|
||||
-- argv to exec after the injector's rendered secrets are sourced. Only needed
|
||||
-- for dockerImage servers, where the image ENTRYPOINT is what would otherwise
|
||||
-- run and mcpd cannot introspect it.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "entrypoint" JSONB;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Per-server override for mcplocal's tool-call deadline, in seconds.
|
||||
--
|
||||
-- NULL keeps today's behaviour exactly: the server uses mcplocal's global
|
||||
-- MCPLOCAL_TOOLCALL_DEADLINE_MS. Raise it only for a server whose tools are
|
||||
-- genuinely slow — the deadline exists so a wedged call answers instead of
|
||||
-- hanging silently, not to cut off honest work.
|
||||
ALTER TABLE "McpServer" ADD COLUMN "toolCallTimeoutSeconds" INTEGER;
|
||||
@@ -77,9 +77,30 @@ model McpServer {
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
volumes Json @default("[]")
|
||||
|
||||
/// How secret-backed env reaches the container.
|
||||
/// env — mcpd resolves values and writes them into the pod spec.
|
||||
/// Cleartext in etcd, readable by anyone with `get pod`.
|
||||
/// injector — the pod fetches its own secrets from OpenBao via the agent
|
||||
/// injector, using a ServiceAccount + role scoped to just this
|
||||
/// server's secrets. The value never touches the pod spec.
|
||||
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
|
||||
secretDelivery String @default("env")
|
||||
|
||||
/// argv the injector wrapper must exec after sourcing the rendered secrets.
|
||||
/// Only needed for dockerImage servers using `injector`, where the image's
|
||||
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
|
||||
entrypoint Json?
|
||||
|
||||
/// Per-server override for mcplocal's tool-call deadline, in seconds.
|
||||
/// Null = use MCPLOCAL_TOOLCALL_DEADLINE_MS. Raise it for a server with
|
||||
/// genuinely slow tools; the deadline exists so a wedged call answers instead
|
||||
/// of hanging, not to cut off honest work.
|
||||
toolCallTimeoutSeconds Int?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
templateName String?
|
||||
templateVersion String?
|
||||
@@ -114,8 +135,23 @@ model McpTemplate {
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
volumes Json @default("[]")
|
||||
|
||||
/// How secret-backed env reaches the container.
|
||||
/// env — mcpd resolves values and writes them into the pod spec.
|
||||
/// Cleartext in etcd, readable by anyone with `get pod`.
|
||||
/// injector — the pod fetches its own secrets from OpenBao via the agent
|
||||
/// injector, using a ServiceAccount + role scoped to just this
|
||||
/// server's secrets. The value never touches the pod spec.
|
||||
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
|
||||
secretDelivery String @default("env")
|
||||
|
||||
/// argv the injector wrapper must exec after sourcing the rendered secrets.
|
||||
/// Only needed for dockerImage servers using `injector`, where the image's
|
||||
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
|
||||
entrypoint Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
@@ -16,11 +16,25 @@ export interface HealthCheckSpec {
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SeedVolumeSpec {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
sizeGb?: number;
|
||||
storageClass?: string;
|
||||
}
|
||||
|
||||
export interface SeedTemplate {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
packageName?: string;
|
||||
/**
|
||||
* Package runner: 'node' (npx) or 'python' (uvx). McpTemplate has had this
|
||||
* column all along, but the upsert below never wrote it, so a template
|
||||
* declaring `runtime: python` seeded as null and every server created from
|
||||
* it silently got the node runner.
|
||||
*/
|
||||
runtime?: string;
|
||||
dockerImage?: string;
|
||||
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
|
||||
repositoryUrl?: string;
|
||||
@@ -30,6 +44,7 @@ export interface SeedTemplate {
|
||||
replicas?: number;
|
||||
env?: TemplateEnvEntry[];
|
||||
healthCheck?: HealthCheckSpec;
|
||||
volumes?: SeedVolumeSpec[];
|
||||
}
|
||||
|
||||
export async function seedTemplates(
|
||||
@@ -45,6 +60,7 @@ export async function seedTemplates(
|
||||
version: tpl.version,
|
||||
description: tpl.description,
|
||||
packageName: tpl.packageName ?? null,
|
||||
runtime: tpl.runtime ?? null,
|
||||
dockerImage: tpl.dockerImage ?? null,
|
||||
transport: tpl.transport,
|
||||
repositoryUrl: tpl.repositoryUrl ?? null,
|
||||
@@ -54,12 +70,14 @@ export async function seedTemplates(
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
name: tpl.name,
|
||||
version: tpl.version,
|
||||
description: tpl.description,
|
||||
packageName: tpl.packageName ?? null,
|
||||
runtime: tpl.runtime ?? null,
|
||||
dockerImage: tpl.dockerImage ?? null,
|
||||
transport: tpl.transport,
|
||||
repositoryUrl: tpl.repositoryUrl ?? null,
|
||||
@@ -69,6 +87,7 @@ export async function seedTemplates(
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
upserted++;
|
||||
|
||||
67
src/mcpd/src/bootstrap/warm-secret-cache.ts
Normal file
67
src/mcpd/src/bootstrap/warm-secret-cache.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* One-shot: resolve every secret that a running server depends on, so the
|
||||
* value cache holds a last-known-good copy before anything needs it.
|
||||
*
|
||||
* The caching driver absorbs a backend outage by serving the last value it saw
|
||||
* — but only for secrets it has actually seen. Without this, a cold mcpd (fresh
|
||||
* deploy, pod reschedule, crash-restart) has an empty cache, and if the backend
|
||||
* is unreachable at that moment every secret-bearing server fails to start.
|
||||
*
|
||||
* This is the honest mitigation, and it is deliberately partial: if the backend
|
||||
* is ALSO down at boot, this changes nothing and instances fail loudly, which
|
||||
* is correct. The alternatives — persisting last-known-good to Postgres or to
|
||||
* disk — are just "plaintext secrets at rest" wearing a hat, which is the thing
|
||||
* we are trying to move away from.
|
||||
*
|
||||
* Best-effort by construction: a failure here must never block startup, and the
|
||||
* warm is per-secret so one bad reference doesn't abandon the rest.
|
||||
*/
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { SecretService } from '../services/secret.service.js';
|
||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||
|
||||
export interface WarmLog {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
}
|
||||
|
||||
export async function warmSecretCache(
|
||||
prisma: PrismaClient,
|
||||
secrets: SecretService,
|
||||
log: WarmLog,
|
||||
): Promise<{ warmed: number; failed: number }> {
|
||||
const servers = await prisma.mcpServer.findMany({
|
||||
where: { replicas: { gt: 0 } },
|
||||
select: { name: true, env: true },
|
||||
});
|
||||
|
||||
// Distinct (secret, key) pairs — several servers commonly share one secret,
|
||||
// and there is no point paying for the same read more than once.
|
||||
const refs = new Map<string, { name: string; key: string }>();
|
||||
for (const server of servers) {
|
||||
for (const entry of (server.env ?? []) as ServerEnvEntry[]) {
|
||||
const ref = entry.valueFrom?.secretRef;
|
||||
if (ref === undefined) continue;
|
||||
refs.set(`${ref.name}/${ref.key}`, { name: ref.name, key: ref.key });
|
||||
}
|
||||
}
|
||||
if (refs.size === 0) return { warmed: 0, failed: 0 };
|
||||
|
||||
let warmed = 0;
|
||||
let failed = 0;
|
||||
for (const ref of refs.values()) {
|
||||
try {
|
||||
// Value deliberately discarded — we only want it in the cache.
|
||||
await secrets.resolve(ref.name, ref.key);
|
||||
warmed++;
|
||||
} catch {
|
||||
// Expected when the backend is down, or when a server references a
|
||||
// secret that no longer exists. Neither should block startup, and both
|
||||
// surface loudly at instance-start time anyway.
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`secret cache warm: ${String(warmed)} resolved, ${String(failed)} unavailable`);
|
||||
return { warmed, failed };
|
||||
}
|
||||
@@ -26,11 +26,16 @@ import { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||
import { backfillInferenceTaskBindings } from './bootstrap/rbac-tasks-backfill.js';
|
||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
|
||||
import { ServerIdentityService } from './services/server-identity.service.js';
|
||||
import { K8sServiceAccountPort } from './services/k8s/service-account-port.js';
|
||||
import { K8sOfficialClient } from './services/k8s/k8s-client-official.js';
|
||||
import { warmSecretCache } from './bootstrap/warm-secret-cache.js';
|
||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
||||
import { SecretBackendRotator } from './services/secret-backend-rotator.service.js';
|
||||
import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop.js';
|
||||
import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js';
|
||||
import { registerSecretBackendHealthRoutes } from './routes/secret-backend-health.js';
|
||||
import { LlmRepository } from './repositories/llm.repository.js';
|
||||
import { LlmService } from './services/llm.service.js';
|
||||
import { InferenceTaskRepository } from './repositories/inference-task.repository.js';
|
||||
@@ -338,16 +343,32 @@ async function main(): Promise<void> {
|
||||
},
|
||||
},
|
||||
secretRefResolver: secretResolverBridge,
|
||||
}, {
|
||||
// Cache-transition events go through pino so BACKEND_UNREACHABLE /
|
||||
// BACKEND_RECOVERED land in ErrorLogBuffer and `mcpctl errors`.
|
||||
log: {
|
||||
warn: (obj: Record<string, unknown>, msg: string): void => { app.log.warn(obj, msg); },
|
||||
info: (obj: Record<string, unknown>, msg: string): void => { app.log.info(obj, msg); },
|
||||
},
|
||||
});
|
||||
const secretService = new SecretService(secretRepo, secretBackendService);
|
||||
const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService);
|
||||
const secretBackendRotator = new SecretBackendRotator({
|
||||
backends: secretBackendService,
|
||||
secrets: secretService,
|
||||
log: {
|
||||
error: (obj: Record<string, unknown>, msg: string): void => { app.log.error(obj, msg); },
|
||||
warn: (msg: string): void => { app.log.warn(msg); },
|
||||
},
|
||||
});
|
||||
const secretBackendRotatorLoop = new SecretBackendRotatorLoop({
|
||||
backends: secretBackendService,
|
||||
rotator: secretBackendRotator,
|
||||
log: {
|
||||
info: (msg: string): void => { app.log.info(`[rotator] ${msg}`); },
|
||||
warn: (msg: string): void => { app.log.warn(`[rotator] ${msg}`); },
|
||||
error: (obj: Record<string, unknown>, msg: string): void => { app.log.error(obj, msg); },
|
||||
},
|
||||
});
|
||||
const llmAdapters = new LlmAdapterRegistry();
|
||||
// LlmService takes the adapter registry so create()/update() can run an
|
||||
@@ -365,8 +386,38 @@ async function main(): Promise<void> {
|
||||
// AgentService + ChatService get fully wired below once projectService and
|
||||
// mcpProxyService are constructed (ChatService needs them via the
|
||||
// ChatToolDispatcher bridge).
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
|
||||
// Per-server OpenBao identities, for servers with `secretDelivery: injector`.
|
||||
// Kubernetes-only: it needs a ServiceAccount per server and an orchestrator
|
||||
// that can honour secret references. Left undefined elsewhere, which makes
|
||||
// InstanceService fall back to inline resolution — so Docker/Podman local
|
||||
// development is unaffected.
|
||||
const serverIdentityService = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
|
||||
? (() => {
|
||||
const k8s = new K8sOfficialClient();
|
||||
return new ServerIdentityService(
|
||||
secretBackendService,
|
||||
new K8sServiceAccountPort(k8s.core, k8s.serversNamespace),
|
||||
{
|
||||
info: (obj: Record<string, unknown>, msg: string): void => { app.log.info(obj, msg); },
|
||||
warn: (obj: Record<string, unknown>, msg: string): void => { app.log.warn(obj, msg); },
|
||||
},
|
||||
{
|
||||
// Must be the mount that validates THIS cluster's SA tokens; the
|
||||
// chart default (`auth/kubernetes`) belongs to a different cluster
|
||||
// and holds none of our roles.
|
||||
authPath: process.env['MCPD_SECRET_INJECTOR_AUTH_PATH'] ?? 'auth/kubernetes',
|
||||
mount: process.env['MCPD_SECRET_INJECTOR_MOUNT'] ?? 'secret',
|
||||
pathPrefix: process.env['MCPD_SECRET_INJECTOR_PREFIX'] ?? 'mcpctl',
|
||||
},
|
||||
);
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const instanceService = new InstanceService(
|
||||
instanceRepo, serverRepo, orchestrator, secretService, serverIdentityService,
|
||||
);
|
||||
serverService.setInstanceService(instanceService);
|
||||
if (serverIdentityService !== undefined) serverService.setServerIdentity(serverIdentityService);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const auditEventService = new AuditEventService(auditEventRepo);
|
||||
@@ -375,6 +426,10 @@ async function main(): Promise<void> {
|
||||
const authService = new AuthService(prisma);
|
||||
const templateService = new TemplateService(templateRepo);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||
// When syncStatus observes a container restart/replacement, the cached
|
||||
// STDIO pipe under that containerId is dead — evict it so the next call
|
||||
// redials (mcpctl#114). Setter injection, same as setInstanceService above.
|
||||
instanceService.setStdioInvalidator((cid) => mcpProxyService.removeClient(cid));
|
||||
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
||||
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
||||
const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService);
|
||||
@@ -566,6 +621,7 @@ async function main(): Promise<void> {
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerSecretBackendRoutes(app, secretBackendService);
|
||||
registerSecretBackendRotateRoutes(app, secretBackendRotator);
|
||||
registerSecretBackendHealthRoutes(app, secretBackendService);
|
||||
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||
registerLlmRoutes(app, llmService);
|
||||
registerAgentRoutes(app, agentService);
|
||||
@@ -885,6 +941,18 @@ async function main(): Promise<void> {
|
||||
app.log.error({ err }, 'secret keyNames backfill failed');
|
||||
});
|
||||
|
||||
// One-shot: pre-populate the secret value cache so a later backend outage is
|
||||
// absorbed rather than cascading into instance ERROR loops. Best-effort — if
|
||||
// the backend is already down at boot this is a no-op and instances fail
|
||||
// honestly. See bootstrap/warm-secret-cache.ts.
|
||||
warmSecretCache(
|
||||
prisma,
|
||||
secretService,
|
||||
{ info: (m: string): void => { app.log.info(m); }, warn: (m: string): void => { app.log.warn(m); } },
|
||||
).catch((err: unknown) => {
|
||||
app.log.warn({ err }, 'secret cache warm failed (non-fatal)');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
setupGracefulShutdown(app, {
|
||||
disconnectDb: async () => {
|
||||
@@ -894,6 +962,7 @@ async function main(): Promise<void> {
|
||||
healthProbeRunner.stop();
|
||||
secretBackendRotatorLoop.stop();
|
||||
gitBackup.stop();
|
||||
mcpProxyService.closeAll();
|
||||
await prisma.$disconnect();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck
|
||||
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
||||
// Same operation guards the trace-event purge — both are bulk deletes of
|
||||
// audit history and should not be separately grantable by accident.
|
||||
if (url.startsWith('/api/v1/audit/events/purge')) return { kind: 'operation', operation: 'audit-purge' };
|
||||
// /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
|
||||
if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
|
||||
// /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so
|
||||
|
||||
@@ -170,6 +170,17 @@ export class AuditEventRepository implements IAuditEventRepository {
|
||||
});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old events. AuditEvent had no retention at all while the table was
|
||||
* write-only; now that `mcpctl trace` reads it, it is worth bounding.
|
||||
*/
|
||||
async deleteOlderThan(cutoff: Date): Promise<number> {
|
||||
const result = await this.prisma.auditEvent.deleteMany({
|
||||
where: { timestamp: { lt: cutoff } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
|
||||
|
||||
@@ -108,6 +108,8 @@ export interface IAuditEventRepository {
|
||||
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
|
||||
/** Rank tools by invocation count for a project (from tool_call_trace events). */
|
||||
toolUsage(projectName: string, from: Date, sampleLimit?: number): Promise<ToolUsageEntry[]>;
|
||||
/** Delete events older than `cutoff`; returns the number removed. */
|
||||
deleteOlderThan(cutoff: Date): Promise<number>;
|
||||
}
|
||||
|
||||
// ── MCP Tokens ──
|
||||
|
||||
@@ -33,6 +33,10 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
replicas: data.replicas,
|
||||
env: data.env,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
volumes: data.volumes,
|
||||
secretDelivery: data.secretDelivery,
|
||||
entrypoint: (data.entrypoint ?? Prisma.DbNull) as Prisma.InputJsonValue,
|
||||
toolCallTimeoutSeconds: data.toolCallTimeoutSeconds ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,6 +55,10 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||
if (data.env !== undefined) updateData['env'] = data.env;
|
||||
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.volumes !== undefined) updateData['volumes'] = data.volumes;
|
||||
if (data.secretDelivery !== undefined) updateData['secretDelivery'] = data.secretDelivery;
|
||||
if (data.entrypoint !== undefined) updateData['entrypoint'] = (data.entrypoint ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.toolCallTimeoutSeconds !== undefined) updateData['toolCallTimeoutSeconds'] = data.toolCallTimeoutSeconds;
|
||||
|
||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
replicas: data.replicas,
|
||||
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
volumes: (data.volumes ?? []) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -71,6 +72,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
if (data.replicas !== undefined) updateData.replicas = data.replicas;
|
||||
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
|
||||
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.volumes !== undefined) updateData.volumes = (data.volumes ?? []) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpTemplate.update({
|
||||
where: { id },
|
||||
|
||||
@@ -58,6 +58,15 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
// POST /api/v1/audit/events/purge — drop events past the retention window.
|
||||
// Mirrors /api/v1/audit-logs/purge: triggered, not scheduled, so an operator
|
||||
// (or a cron) decides when a potentially large delete runs.
|
||||
app.post('/api/v1/audit/events/purge', async (_request, reply) => {
|
||||
const deleted = await service.purgeExpired();
|
||||
reply.code(200);
|
||||
return { deleted };
|
||||
});
|
||||
|
||||
// GET /api/v1/audit/tool-usage — rank tools by invocation count (for favourites)
|
||||
app.get<{ Querystring: { projectName?: string; window?: string; limit?: string } }>('/api/v1/audit/tool-usage', async (request, reply) => {
|
||||
const q = request.query;
|
||||
|
||||
76
src/mcpd/src/routes/secret-backend-health.ts
Normal file
76
src/mcpd/src/routes/secret-backend-health.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* GET /api/v1/secretbackends/:id/health — a live probe of a secret backend.
|
||||
*
|
||||
* Exists because the only health signal we had was `tokenMeta.lastRotationError`,
|
||||
* and the rotator writes that field ONLY for `auth: 'token'` backends
|
||||
* (`SecretBackendRotator.isRotatable()`). A `kubernetes`-auth backend therefore
|
||||
* never wrote it and rendered a hard-coded green tick in `mcpctl status` — even
|
||||
* with OpenBao completely unreachable.
|
||||
*
|
||||
* Two signals, deliberately separate, mirroring the liveness/readiness split
|
||||
* that instance health probes already use:
|
||||
*
|
||||
* live — is the backend reachable at all? (unauthenticated)
|
||||
* ready — can we actually read through it? (uses our credentials)
|
||||
*
|
||||
* A backend that is `live` but not `ready` is the exact shape of a re-initialised
|
||||
* OpenBao that left us holding valid-looking tokens granting nothing. Collapsing
|
||||
* the two into one boolean is what hid that for four days.
|
||||
*
|
||||
* RBAC: no special mapping needed — `mapUrlToPermission` falls through to the
|
||||
* generic `secretbackends` resource, so a GET requires `view:secretbackends`.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { SecretBackendService } from '../services/secret-backend.service.js';
|
||||
import { NotFoundError } from '../services/mcp-server.service.js';
|
||||
|
||||
interface TokenMetaShape {
|
||||
lastRotationAt?: string;
|
||||
lastRotationError?: string | null;
|
||||
rotatable?: boolean;
|
||||
}
|
||||
|
||||
export function registerSecretBackendHealthRoutes(
|
||||
app: FastifyInstance,
|
||||
backends: SecretBackendService,
|
||||
): void {
|
||||
app.get<{ Params: { id: string } }>('/api/v1/secretbackends/:id/health', async (request, reply) => {
|
||||
try {
|
||||
const backend = await backends.getById(request.params.id);
|
||||
const driver = backends.driverFor(backend);
|
||||
|
||||
const live = await driver.healthCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||
// Only probe readiness if the backend answered at all — otherwise the
|
||||
// auth check just re-reports the same outage with a confusing message.
|
||||
const ready = live.ok
|
||||
? await driver.authCheck?.() ?? { ok: true, detail: 'no probe' }
|
||||
: { ok: false, detail: 'not probed (backend unreachable)' };
|
||||
|
||||
const meta = (backend.tokenMeta ?? {}) as TokenMetaShape;
|
||||
const cache = backends.cacheStatsFor(backend);
|
||||
|
||||
return {
|
||||
backend: backend.name,
|
||||
type: backend.type,
|
||||
live: live.ok,
|
||||
liveDetail: live.detail,
|
||||
ready: ready.ok,
|
||||
readyDetail: ready.detail,
|
||||
// Present only for cached (remote) backends; plaintext has no cache.
|
||||
cache: cache ?? null,
|
||||
rotation: {
|
||||
rotatable: meta.rotatable ?? false,
|
||||
lastRotationAt: meta.lastRotationAt ?? null,
|
||||
lastRotationError: meta.lastRotationError ?? null,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
reply.code(502);
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -17,8 +17,31 @@ export interface AuditEventQueryParams {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retention for trace/audit events.
|
||||
*
|
||||
* Shorter than AuditLog's 90 days: these are high-volume per-request telemetry
|
||||
* (several rows per MCP call), not a record of administrative mutations.
|
||||
*/
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
export class AuditEventService {
|
||||
constructor(private readonly repo: IAuditEventRepository) {}
|
||||
constructor(
|
||||
private readonly repo: IAuditEventRepository,
|
||||
private readonly retentionDays: number =
|
||||
Number(process.env['MCPD_AUDIT_EVENT_RETENTION_DAYS']) || DEFAULT_RETENTION_DAYS,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Drop events past the retention window. The table previously had no prune
|
||||
* at all and grew unbounded; `mcpctl trace` now reads it, so bounding it
|
||||
* matters more than it did when nothing consumed it.
|
||||
*/
|
||||
async purgeExpired(): Promise<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - this.retentionDays);
|
||||
return this.repo.deleteOlderThan(cutoff);
|
||||
}
|
||||
|
||||
async list(params?: AuditEventQueryParams): Promise<{ events: AuditEvent[]; total: number }> {
|
||||
const filter = this.buildFilter(params);
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface BackupServer {
|
||||
replicas: number;
|
||||
env: unknown;
|
||||
healthCheck: unknown;
|
||||
volumes: unknown;
|
||||
/** Optional so backups taken before the field remain restorable. */
|
||||
secretDelivery?: string;
|
||||
entrypoint?: unknown;
|
||||
}
|
||||
|
||||
export interface BackupSecret {
|
||||
@@ -140,6 +144,9 @@ export class BackupService {
|
||||
replicas: s.replicas,
|
||||
env: s.env,
|
||||
healthCheck: s.healthCheck,
|
||||
volumes: s.volumes,
|
||||
secretDelivery: s.secretDelivery,
|
||||
entrypoint: s.entrypoint,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,16 @@ export class RestoreService {
|
||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
replicas: server.replicas ?? 1,
|
||||
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||
// Restores the volume *declaration* only. The backing PVC / named
|
||||
// volume is not part of the backup — a restored server re-attaches to
|
||||
// a claim of the same name if one survived, and otherwise starts on a
|
||||
// fresh empty volume.
|
||||
volumes: (server.volumes ?? []) as Parameters<IMcpServerRepository['create']>[0]['volumes'],
|
||||
// Preserve the delivery mode across a backup/restore round-trip.
|
||||
// Falling back to 'env' keeps pre-field backups restorable, and 'env'
|
||||
// is the safe default: it works without the per-server OpenBao
|
||||
// identity, which a restore cannot assume still exists.
|
||||
secretDelivery: (server.secretDelivery === 'injector' ? 'injector' : 'env'),
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.runtime) createData.runtime = server.runtime;
|
||||
|
||||
@@ -45,6 +45,14 @@ function mapState(state: string | undefined): ContainerInfo['state'] {
|
||||
}
|
||||
|
||||
export class DockerContainerManager implements McpOrchestrator {
|
||||
/**
|
||||
* No injector equivalent exists for plain Docker/Podman containers, so
|
||||
* `envFromSecret` cannot be honoured here — mcpd resolves those refs and
|
||||
* merges them into `env` instead. Local development therefore keeps today's
|
||||
* behaviour exactly.
|
||||
*/
|
||||
readonly capabilities = { secretRefs: false } as const;
|
||||
|
||||
private docker: Docker;
|
||||
|
||||
constructor(opts?: Docker.DockerOptions) {
|
||||
@@ -96,7 +104,7 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
// Named Docker volumes stand in for k8s emptyDir: same lifetime intent
|
||||
// (scratch space shared across the pod), removed by removeContainer's `v`.
|
||||
const volumeName = (specName: string, vol: string): string => `${specName}-${vol}`;
|
||||
const mounts: Docker.MountSettings[] = (spec.volumes ?? []).map((v) => ({
|
||||
const mounts: Docker.MountSettings[] = (spec.scratchVolumes ?? []).map((v) => ({
|
||||
Type: 'volume' as const,
|
||||
Source: volumeName(spec.name, v.name),
|
||||
Target: v.mountPath,
|
||||
@@ -116,8 +124,16 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
Memory: memoryLimit,
|
||||
...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
// Scratch space shared with init containers; reaped with the container.
|
||||
...(mounts.length > 0 ? { Mounts: mounts } : {}),
|
||||
...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}),
|
||||
// Persistent named volumes, created on demand by the engine. Named (not
|
||||
// anonymous) matters: `removeContainer` passes `v: true`, which reaps
|
||||
// anonymous volumes but leaves named ones — which is what lets the
|
||||
// data outlive the instance.
|
||||
...(spec.volumes && spec.volumes.length > 0
|
||||
? { Binds: spec.volumes.map((v) => `${v.claimName}:${v.mountPath}`) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
if (spec.command) {
|
||||
@@ -339,6 +355,16 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
const stderr = new PassThrough();
|
||||
this.docker.modem.demuxStream(stream, stdout, stderr);
|
||||
|
||||
// demuxStream never propagates end/close/error to the demuxed streams, so
|
||||
// a dead container left consumers waiting on a silent pipe — same funnel
|
||||
// as the one-shot execInContainer path uses (mcpctl#114).
|
||||
const endStdout = () => {
|
||||
if (!stdout.destroyed && !stdout.writableEnded) stdout.end();
|
||||
};
|
||||
stream.on('end', endStdout);
|
||||
stream.on('close', endStdout);
|
||||
stream.on('error', endStdout);
|
||||
|
||||
return {
|
||||
stdout,
|
||||
write(data: string) {
|
||||
|
||||
@@ -44,3 +44,63 @@ export async function resolveServerEnv(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a server's env into inline values and secret references, WITHOUT
|
||||
* resolving the references.
|
||||
*
|
||||
* Used for injector-delivered servers, where the pod fetches its own values and
|
||||
* mcpd must never materialise them. `resolveServerEnv` above stays as-is: it is
|
||||
* still the path for Docker, for opted-out servers, and for the generated
|
||||
* client `.mcp.json`.
|
||||
*/
|
||||
export function partitionServerEnv(server: McpServer): {
|
||||
inline: Record<string, string>;
|
||||
refs: Array<{ name: string; secretName: string; key: string }>;
|
||||
} {
|
||||
const entries = (server.env ?? []) as ServerEnvEntry[];
|
||||
const inline: Record<string, string> = {};
|
||||
const refs: Array<{ name: string; secretName: string; key: string }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
inline[entry.name] = entry.value;
|
||||
} else if (entry.valueFrom?.secretRef) {
|
||||
refs.push({
|
||||
name: entry.name,
|
||||
secretName: entry.valueFrom.secretRef.name,
|
||||
key: entry.valueFrom.secretRef.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { inline, refs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every reference and DISCARD the values, purely to make a missing
|
||||
* secret or key fail loudly at instance start.
|
||||
*
|
||||
* Once a server is injector-delivered, mcpd no longer reads its secrets, so the
|
||||
* check that commit e6cd735 bought stops firing for it: a bad secretRef would
|
||||
* degrade from a clear `secret resolution failed: …` into a vault-agent-init
|
||||
* crashloop that mcpd reports as a generic pod failure. That is the exact class
|
||||
* of bug that had gitea-mcp running for weeks with an empty token while
|
||||
* reporting healthy — so we keep paying for the check. After the value cache it
|
||||
* is a cache hit and costs essentially nothing.
|
||||
*/
|
||||
export async function validateServerEnvRefs(
|
||||
server: McpServer,
|
||||
resolver: SecretResolver,
|
||||
): Promise<void> {
|
||||
const { refs } = partitionServerEnv(server);
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
await resolver.resolve(ref.secretName, ref.key);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Cannot resolve secret for server '${server.name}' env '${ref.name}': ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,35 @@ 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';
|
||||
|
||||
// Crossing the threshold means every probe rode the same cached STDIO
|
||||
// pipe — evict it once so the next probe/call redials instead of failing
|
||||
// forever against a connection the other side already dropped.
|
||||
if (!result.healthy && state.consecutiveFailures === failureThreshold && instance.containerId) {
|
||||
this.mcpProxyService?.removeClient(instance.containerId);
|
||||
}
|
||||
|
||||
// Build event
|
||||
const probeLabel = probeKind === 'readiness'
|
||||
? `Readiness check (${healthCheck.tool})`
|
||||
: '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
|
||||
|
||||
@@ -2,7 +2,28 @@ import type { McpInstance, McpServer } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import { resolveServerEnv, type SecretResolver } from './env-resolver.js';
|
||||
import {
|
||||
resolveServerEnv,
|
||||
partitionServerEnv,
|
||||
validateServerEnvRefs,
|
||||
type SecretResolver,
|
||||
} from './env-resolver.js';
|
||||
|
||||
/**
|
||||
* What InstanceService needs from the per-server identity layer. Kept narrow
|
||||
* and local so the orchestration stays testable without an OpenBao backend.
|
||||
*/
|
||||
export interface ServerIdentityProvisioner {
|
||||
/** Converge the server's identity; resolves to the ServiceAccount/role name. */
|
||||
ensureFor(server: McpServer): Promise<string>;
|
||||
/** Injector pod annotations for these refs under that identity. */
|
||||
annotationsFor(
|
||||
identity: string,
|
||||
refs: Array<{ name: string; secretName: string; key: string }>,
|
||||
): Record<string, string>;
|
||||
/** Rewrite argv to source the rendered secrets before exec'ing the server. */
|
||||
wrapCommand(server: McpServer, command: string[] | undefined): string[] | undefined;
|
||||
}
|
||||
|
||||
/** Runner images for package-based MCP servers, keyed by runtime name. */
|
||||
const RUNNER_IMAGES: Record<string, string> = {
|
||||
@@ -32,6 +53,9 @@ interface RetryMetadata {
|
||||
attemptCount?: number;
|
||||
lastAttemptAt?: string;
|
||||
nextRetryAt?: string;
|
||||
/** containerStatuses[0].restartCount at last sync — a bump means every
|
||||
* cached STDIO pipe to this (unchanged) containerId is dead. */
|
||||
lastRestartCount?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -52,13 +76,36 @@ export class InvalidStateError extends Error {
|
||||
}
|
||||
|
||||
export class InstanceService {
|
||||
private stdioInvalidator?: (containerId: string) => void;
|
||||
|
||||
constructor(
|
||||
private instanceRepo: IMcpInstanceRepository,
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private orchestrator: McpOrchestrator,
|
||||
private secretResolver?: SecretResolver,
|
||||
/** Provisions per-server OpenBao identities. Absent = injector unavailable. */
|
||||
private serverIdentity?: ServerIdentityProvisioner,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Hook for evicting cached STDIO clients (McpProxyService.removeClient).
|
||||
* Setter injection, matching serverService.setInstanceService in main.ts —
|
||||
* McpProxyService is constructed after this service and already imports
|
||||
* from this file, so a constructor arg would be a circular import.
|
||||
*/
|
||||
setStdioInvalidator(fn: (containerId: string) => void): void {
|
||||
this.stdioInvalidator = fn;
|
||||
}
|
||||
|
||||
private invalidateStdio(containerId: string | null | undefined): void {
|
||||
if (!containerId) return;
|
||||
try {
|
||||
this.stdioInvalidator?.(containerId);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async list(serverId?: string): Promise<McpInstance[]> {
|
||||
return this.instanceRepo.findAll(serverId);
|
||||
}
|
||||
@@ -71,37 +118,96 @@ export class InstanceService {
|
||||
|
||||
/**
|
||||
* Sync instance statuses with actual container state.
|
||||
* Detects crashed/stopped containers and marks them ERROR.
|
||||
*
|
||||
* Beyond marking crashed containers ERROR, this is the recovery path for
|
||||
* mcpctl#114: ERROR rows whose pod came back are re-adopted (the pod has
|
||||
* restartPolicy Always, so kubelet restarts the container in place and the
|
||||
* row must follow it back), and an in-place restart under an unchanged
|
||||
* containerId — visible only as a restartCount bump — invalidates any
|
||||
* cached STDIO pipe, which is dead by definition.
|
||||
*
|
||||
* Every metadata write here MERGES via readRetryMeta: updateStatus replaces
|
||||
* the JSON column wholesale, and clobbering nextRetryAt is what used to
|
||||
* make ERROR rows instantly dueForRetry and hot-loop against a 409.
|
||||
*/
|
||||
async syncStatus(): Promise<void> {
|
||||
const instances = await this.instanceRepo.findAll();
|
||||
for (const inst of instances) {
|
||||
if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) {
|
||||
try {
|
||||
const info = await this.orchestrator.inspectContainer(inst.containerId);
|
||||
if (!inst.containerId) continue;
|
||||
if (inst.status !== 'RUNNING' && inst.status !== 'STARTING' && inst.status !== 'ERROR') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.state === 'stopped' || info.state === 'error') {
|
||||
// Container died — get last logs for error context
|
||||
let errorMsg = `Container ${info.state}`;
|
||||
try {
|
||||
const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 });
|
||||
const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop();
|
||||
if (lastLog) errorMsg = lastLog;
|
||||
} catch { /* best-effort */ }
|
||||
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
|
||||
metadata: { error: errorMsg },
|
||||
});
|
||||
} else if (info.state === 'starting' && inst.status === 'RUNNING') {
|
||||
// Pod went back to starting (e.g. CrashLoopBackOff restart)
|
||||
await this.instanceRepo.updateStatus(inst.id, 'STARTING', {});
|
||||
} else if (info.state === 'running' && inst.status === 'STARTING') {
|
||||
// Pod became ready — promote to RUNNING
|
||||
await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {});
|
||||
}
|
||||
} catch {
|
||||
// Container gone entirely
|
||||
let info: ContainerInfo;
|
||||
try {
|
||||
info = await this.orchestrator.inspectContainer(inst.containerId);
|
||||
} catch {
|
||||
// Container gone entirely. ERROR rows with a missing pod stay as they
|
||||
// are — the retry/backoff path owns recreating them.
|
||||
if (inst.status !== 'ERROR') {
|
||||
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
|
||||
metadata: { error: 'Container not found' },
|
||||
metadata: { ...readRetryMeta(inst), error: 'Container not found' },
|
||||
});
|
||||
this.invalidateStdio(inst.containerId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = readRetryMeta(inst);
|
||||
|
||||
if (inst.status === 'ERROR') {
|
||||
// The pod outlived the ERROR verdict (kubelet restarted the container
|
||||
// in place). Re-adopt instead of leaving the row stuck forever.
|
||||
if (info.state === 'running') {
|
||||
const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta;
|
||||
await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {
|
||||
metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 },
|
||||
});
|
||||
this.invalidateStdio(inst.containerId);
|
||||
} else if (info.state === 'starting') {
|
||||
// Keep retry metadata until it is actually running.
|
||||
await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta });
|
||||
this.invalidateStdio(inst.containerId);
|
||||
}
|
||||
// stopped/error: leave for the backoff/retry path.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.state === 'stopped' || info.state === 'error') {
|
||||
// Container died — get last logs for error context
|
||||
let errorMsg = `Container ${info.state}`;
|
||||
try {
|
||||
const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 });
|
||||
const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop();
|
||||
if (lastLog) errorMsg = lastLog;
|
||||
} catch { /* best-effort */ }
|
||||
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
|
||||
metadata: { ...meta, error: errorMsg },
|
||||
});
|
||||
this.invalidateStdio(inst.containerId);
|
||||
} else if (info.state === 'starting' && inst.status === 'RUNNING') {
|
||||
// Pod went back to starting (e.g. CrashLoopBackOff restart)
|
||||
await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta });
|
||||
this.invalidateStdio(inst.containerId);
|
||||
} else if (info.state === 'running' && inst.status === 'STARTING') {
|
||||
// Pod became ready — promote to RUNNING and clear retry state.
|
||||
const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta;
|
||||
await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {
|
||||
metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 },
|
||||
});
|
||||
// A fresh/restarted pod under this name invalidates any cached pipe.
|
||||
this.invalidateStdio(inst.containerId);
|
||||
} else if (info.state === 'running' && info.restartCount !== undefined) {
|
||||
if (typeof meta.lastRestartCount === 'number' && info.restartCount > meta.lastRestartCount) {
|
||||
// In-place restart between polls: same pod name, dead pipes.
|
||||
await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {
|
||||
metadata: { ...meta, lastRestartCount: info.restartCount },
|
||||
});
|
||||
this.invalidateStdio(inst.containerId);
|
||||
} else if (meta.lastRestartCount === undefined) {
|
||||
// First sighting: record the baseline without invalidating.
|
||||
await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {
|
||||
metadata: { ...meta, lastRestartCount: info.restartCount },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -232,6 +338,7 @@ export class InstanceService {
|
||||
} catch {
|
||||
// Container may already be gone
|
||||
}
|
||||
this.invalidateStdio(instance.containerId);
|
||||
}
|
||||
|
||||
await this.instanceRepo.delete(id);
|
||||
@@ -256,6 +363,7 @@ export class InstanceService {
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
this.invalidateStdio(inst.containerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,6 +480,28 @@ export class InstanceService {
|
||||
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
||||
spec.containerPort = server.containerPort ?? 3000;
|
||||
}
|
||||
|
||||
// Volumes are keyed on the server, not this instance: `spec.name` carries
|
||||
// the instance id and a claim named after it would be torn down on the
|
||||
// next server edit — exactly when the data has to survive.
|
||||
const volumes = (server.volumes ?? []) as Array<{
|
||||
name: string;
|
||||
mountPath: string;
|
||||
sizeGb?: number;
|
||||
storageClass?: string;
|
||||
fsGroup?: number;
|
||||
}>;
|
||||
if (volumes.length > 0) {
|
||||
spec.volumes = volumes.map((v) => ({
|
||||
claimName: `mcpctl-${server.name}-${v.name}`,
|
||||
mountPath: v.mountPath,
|
||||
sizeGb: v.sizeGb ?? 10,
|
||||
...(v.storageClass !== undefined && v.storageClass !== ''
|
||||
? { storageClass: v.storageClass }
|
||||
: {}),
|
||||
...(v.fsGroup !== undefined ? { fsGroup: v.fsGroup } : {}),
|
||||
}));
|
||||
}
|
||||
// Package-based servers: command = [packageName, ...args] (entrypoint handles execution)
|
||||
// Docker-image servers: use explicit command if provided
|
||||
if (pkgCommand) {
|
||||
@@ -394,11 +524,31 @@ export class InstanceService {
|
||||
// will retry it in-place on the next tick whose nextRetryAt has
|
||||
// elapsed. Optional/missing env vars should be modeled as `value: ""`
|
||||
// entries on the server, not as silent secret-resolution failures.
|
||||
// Injected delivery needs BOTH an orchestrator that can honour references
|
||||
// (Docker cannot) and a provisioner for the per-server identity. Falling
|
||||
// back to inline resolution when either is missing keeps local dev and
|
||||
// plaintext-backend setups working unchanged.
|
||||
const useInjector = server.secretDelivery === 'injector'
|
||||
&& this.orchestrator.capabilities?.secretRefs === true
|
||||
&& this.serverIdentity !== undefined;
|
||||
|
||||
if (this.secretResolver) {
|
||||
try {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
if (useInjector) {
|
||||
// Resolve and DISCARD, purely so a missing secret still fails here
|
||||
// with a clear message instead of becoming a vault-agent-init
|
||||
// crashloop that mcpd reports as a generic pod failure. See
|
||||
// validateServerEnvRefs.
|
||||
await validateServerEnvRefs(server, this.secretResolver);
|
||||
|
||||
const { inline, refs } = partitionServerEnv(server);
|
||||
if (Object.keys(inline).length > 0) spec.env = inline;
|
||||
spec.envFromSecret = refs;
|
||||
} else {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
}
|
||||
}
|
||||
} catch (envErr) {
|
||||
const msg = envErr instanceof Error ? envErr.message : String(envErr);
|
||||
@@ -406,6 +556,27 @@ export class InstanceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (useInjector && spec.envFromSecret !== undefined && spec.envFromSecret.length > 0) {
|
||||
try {
|
||||
// Converge the identity BEFORE the pod exists: a pod whose role is
|
||||
// not yet written logs in, gets no capabilities, and crashloops.
|
||||
const identity = await this.serverIdentity!.ensureFor(server);
|
||||
spec.serviceAccountName = identity;
|
||||
spec.automountServiceAccountToken = true;
|
||||
spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret);
|
||||
// Replaces the image entrypoint (see wrapCommand); the original argv
|
||||
// is folded in, so spec.command must not also be emitted as args.
|
||||
const wrapped = this.serverIdentity!.wrapCommand(server, spec.command);
|
||||
if (wrapped !== undefined) {
|
||||
spec.entrypoint = wrapped;
|
||||
delete spec.command;
|
||||
}
|
||||
} catch (idErr) {
|
||||
const msg = idErr instanceof Error ? idErr.message : String(idErr);
|
||||
return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull image if not available locally
|
||||
try {
|
||||
await this.orchestrator.pullImage(image);
|
||||
@@ -467,6 +638,7 @@ export class InstanceService {
|
||||
try {
|
||||
await this.orchestrator.removeContainer(instance.containerId, true);
|
||||
} catch { /* best-effort */ }
|
||||
this.invalidateStdio(instance.containerId);
|
||||
}
|
||||
await this.instanceRepo.delete(instance.id);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,40 @@ import type {
|
||||
ContainerSpec,
|
||||
ContainerInfo,
|
||||
ContainerLogs,
|
||||
ContainerVolume,
|
||||
ExecResult,
|
||||
InteractiveExec,
|
||||
} from '../orchestrator.js';
|
||||
import { K8sOfficialClient } from './k8s-client-official.js';
|
||||
import type { K8sOfficialClientConfig } from './k8s-client-official.js';
|
||||
import { generatePodSpec } from './manifest-generator.js';
|
||||
import type { V1Pod } from '@kubernetes/client-node';
|
||||
import { generatePodSpec, generatePvcSpec, sanitizeName } from './manifest-generator.js';
|
||||
import type { V1Pod, V1PersistentVolumeClaim } from '@kubernetes/client-node';
|
||||
|
||||
/**
|
||||
* HTTP status from a client-node error, across the shapes it actually throws.
|
||||
*
|
||||
* @kubernetes/client-node v1 raises `ApiException`, which carries the status on
|
||||
* `.code` — not `.statusCode` as the pre-1.0 `HttpError` did. Reading only
|
||||
* `.statusCode` yields undefined and turns every "expected" 404/409 into a
|
||||
* rethrow, so a missing pod fails a delete and a missing PVC fails a create.
|
||||
* The message-parsing fallback covers wrappers that preserve neither field.
|
||||
*/
|
||||
export function httpStatusOf(err: unknown): number | undefined {
|
||||
const e = err as {
|
||||
code?: unknown;
|
||||
statusCode?: unknown;
|
||||
response?: { statusCode?: unknown };
|
||||
message?: unknown;
|
||||
};
|
||||
for (const candidate of [e.code, e.statusCode, e.response?.statusCode]) {
|
||||
if (typeof candidate === 'number') return candidate;
|
||||
}
|
||||
if (typeof e.message === 'string') {
|
||||
const match = /HTTP-Code:\s*(\d{3})/.exec(e.message);
|
||||
if (match?.[1] !== undefined) return Number(match[1]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapPodState(pod: V1Pod): ContainerInfo['state'] {
|
||||
const cs = pod.status?.containerStatuses?.[0];
|
||||
@@ -54,10 +81,25 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo {
|
||||
info.port = ports[0].containerPort;
|
||||
}
|
||||
|
||||
// Restart visibility: a container that crashes and restarts IN PLACE keeps
|
||||
// the same pod name and reports state=running again — restartCount is the
|
||||
// only durable evidence, and syncStatus uses it to invalidate stale STDIO
|
||||
// pipes (mcpctl#114).
|
||||
const cs = pod.status?.containerStatuses?.[0];
|
||||
if (cs?.restartCount !== undefined) {
|
||||
info.restartCount = cs.restartCount;
|
||||
}
|
||||
if (cs?.state?.running?.startedAt) {
|
||||
info.startedAt = new Date(cs.state.running.startedAt as unknown as string);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
/** Pods can fetch their own secrets via the OpenBao agent injector. */
|
||||
readonly capabilities = { secretRefs: true } as const;
|
||||
|
||||
private client: K8sOfficialClient;
|
||||
private namespace: string;
|
||||
|
||||
@@ -82,11 +124,46 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
|
||||
await this.ensureNamespace(this.namespace);
|
||||
|
||||
// PVCs must exist before the pod references them, or the pod stays Pending
|
||||
// on an unbound claim.
|
||||
for (const volume of spec.volumes ?? []) {
|
||||
await this.ensurePvc(volume, spec.labels);
|
||||
}
|
||||
|
||||
const manifest = generatePodSpec(spec, this.namespace);
|
||||
const pod = await this.client.core.createNamespacedPod({
|
||||
namespace: this.namespace,
|
||||
body: manifest as V1Pod,
|
||||
});
|
||||
let pod;
|
||||
try {
|
||||
pod = await this.client.core.createNamespacedPod({
|
||||
namespace: this.namespace,
|
||||
body: manifest as V1Pod,
|
||||
});
|
||||
} catch (err) {
|
||||
if (httpStatusOf(err) !== 409) throw err;
|
||||
// AlreadyExists: a retry raced a pod that is still there. If it is
|
||||
// alive, ADOPT it — recreating under the same name can never succeed
|
||||
// and used to loop the instance in ERROR forever (mcpctl#114). Only a
|
||||
// genuinely dead pod is replaced.
|
||||
const podName = (manifest as V1Pod).metadata!.name!;
|
||||
const existing = await this.inspectContainer(podName);
|
||||
if (existing.state === 'running' || existing.state === 'starting') {
|
||||
return existing;
|
||||
}
|
||||
await this.removeContainer(podName, true);
|
||||
// deleteNamespacedPod returns before the pod is gone (grace period);
|
||||
// recreating while it is Terminating would 409 again. Bounded wait.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
try {
|
||||
await this.inspectContainer(podName);
|
||||
} catch {
|
||||
break; // 404 — pod is gone
|
||||
}
|
||||
}
|
||||
pod = await this.client.core.createNamespacedPod({
|
||||
namespace: this.namespace,
|
||||
body: manifest as V1Pod,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait briefly for pod to start scheduling
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
@@ -106,8 +183,7 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
gracePeriodSeconds: 5,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode
|
||||
?? (err as { response?: { statusCode?: number } }).response?.statusCode;
|
||||
const status = httpStatusOf(err);
|
||||
if (status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
@@ -244,6 +320,17 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
// Wait for WebSocket connection to establish
|
||||
const ws = await wsPromise;
|
||||
|
||||
// client-node's WebSocketHandler only ends stdout on an explicit
|
||||
// CloseStream/Status frame from the apiserver. An abnormal socket death
|
||||
// (container OOMKilled, node reboot) emits neither — without these
|
||||
// handlers the PassThrough stays open and the consumer never learns the
|
||||
// pipe is dead (mcpctl#114).
|
||||
const endStdout = () => {
|
||||
if (!stdout.destroyed && !stdout.writableEnded) stdout.end();
|
||||
};
|
||||
ws.on('close', endStdout);
|
||||
ws.on('error', endStdout);
|
||||
|
||||
return {
|
||||
stdout,
|
||||
write(data: string) {
|
||||
@@ -284,6 +371,13 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
false, // tty
|
||||
);
|
||||
|
||||
// Same abnormal-death funnel as execInteractive — see comment there.
|
||||
const endStdout = () => {
|
||||
if (!stdout.destroyed && !stdout.writableEnded) stdout.end();
|
||||
};
|
||||
ws.on('close', endStdout);
|
||||
ws.on('error', endStdout);
|
||||
|
||||
return {
|
||||
stdout,
|
||||
write(data: string) {
|
||||
@@ -316,13 +410,47 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
body: { apiVersion: 'v1', kind: 'Namespace', metadata: { name } },
|
||||
});
|
||||
} catch (createErr: unknown) {
|
||||
const status = (createErr as { statusCode?: number }).statusCode
|
||||
?? (createErr as { response?: { statusCode?: number } }).response?.statusCode;
|
||||
const status = httpStatusOf(createErr);
|
||||
if (status !== 409) throw createErr; // Already exists is fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the PVC backing a server volume if it is not already there.
|
||||
*
|
||||
* Deliberately never updates an existing claim: most PVC spec fields are
|
||||
* immutable after binding, and silently resizing someone's storage is not a
|
||||
* side effect a container start should have. Growing a volume is an explicit
|
||||
* `kubectl edit pvc` (the class must allow expansion).
|
||||
*/
|
||||
private async ensurePvc(
|
||||
volume: ContainerVolume,
|
||||
labels?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const name = sanitizeName(volume.claimName);
|
||||
try {
|
||||
await this.client.core.readNamespacedPersistentVolumeClaim({
|
||||
name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
return; // Already there — reuse it, data and all.
|
||||
} catch (err: unknown) {
|
||||
const status = httpStatusOf(err);
|
||||
if (status !== 404) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.core.createNamespacedPersistentVolumeClaim({
|
||||
namespace: this.namespace,
|
||||
body: generatePvcSpec(volume, this.namespace, labels) as V1PersistentVolumeClaim,
|
||||
});
|
||||
} catch (createErr: unknown) {
|
||||
const status = httpStatusOf(createErr);
|
||||
if (status !== 409) throw createErr; // Lost a create race — fine.
|
||||
}
|
||||
}
|
||||
|
||||
getNamespace(): string {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContainerSpec } from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
|
||||
import type { ContainerSpec, ContainerVolume } from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS, DEFAULT_VOLUME_FS_GROUP } from '../orchestrator.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
|
||||
@@ -10,8 +10,10 @@ export interface K8sPodManifest {
|
||||
name: string;
|
||||
namespace: string;
|
||||
labels: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
spec: {
|
||||
serviceAccountName?: string;
|
||||
containers: Array<{
|
||||
name: string;
|
||||
image: string;
|
||||
@@ -41,10 +43,133 @@ export interface K8sPodManifest {
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string }>;
|
||||
}>;
|
||||
volumes?: Array<{ name: string; emptyDir: Record<string, never> }>;
|
||||
restartPolicy: 'Always' | 'Never' | 'OnFailure';
|
||||
automountServiceAccountToken: boolean;
|
||||
nodeSelector?: Record<string, string>;
|
||||
// One pod-level list carrying both kinds: PVC-backed volumes that outlive
|
||||
// the instance, and emptyDir scratch shared with init containers.
|
||||
volumes?: Array<
|
||||
| { name: string; persistentVolumeClaim: { claimName: string } }
|
||||
| { name: string; emptyDir: Record<string, never> }
|
||||
>;
|
||||
securityContext?: { fsGroup: number; fsGroupChangePolicy: string };
|
||||
};
|
||||
}
|
||||
|
||||
/** Directory the OpenBao agent renders this pod's secrets into. */
|
||||
export const INJECTED_SECRET_DIR = '/vault/secrets';
|
||||
|
||||
/**
|
||||
* Quote a shell value for a file that will be `.`-sourced: single quotes, with
|
||||
* any embedded `'` closed-escaped-reopened.
|
||||
*
|
||||
* Not cosmetic. The rendered file is sourced by /bin/sh, so a value containing
|
||||
* a space, `$`, a quote or a newline would otherwise truncate or mangle the
|
||||
* variable — *silently*, producing an empty token and a server that starts,
|
||||
* answers tools/list and reports healthy while every authenticated call fails.
|
||||
* That is precisely the failure this change exists to prevent, so the escaping
|
||||
* is part of the contract, not a detail.
|
||||
*/
|
||||
export function shellSingleQuote(inner: string): string {
|
||||
return "'" + inner.split("'").join("'\\''") + "'";
|
||||
}
|
||||
|
||||
export interface InjectorEnvRef {
|
||||
/** Env var name inside the container. */
|
||||
name: string;
|
||||
/** mcpctl Secret name. */
|
||||
secretName: string;
|
||||
/** Key within that secret. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface InjectorConfig {
|
||||
/** OpenBao role to log in as — one per server, scoped to its own secrets. */
|
||||
role: string;
|
||||
/** Auth mount that validates THIS cluster's ServiceAccount tokens. */
|
||||
authPath: string;
|
||||
/** KV v2 mount, e.g. `secret`. */
|
||||
mount: string;
|
||||
/** Path prefix under the mount, e.g. `mcpctl`. */
|
||||
pathPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pod annotations that drive the OpenBao Agent Injector.
|
||||
*
|
||||
* One rendered file per referenced Secret (the agent keys files by the
|
||||
* annotation suffix), each holding `export NAME='value'` lines for every env
|
||||
* var drawn from that Secret.
|
||||
*
|
||||
* `agent-pre-populate-only` is deliberate: the agent runs as an init container
|
||||
* and exits. MCP servers cannot reload env anyway, so a long-lived sidecar
|
||||
* would buy nothing and cost memory in every pod — and these pods run at a flat
|
||||
* 512Mi where Node-based servers have already been OOMKilled.
|
||||
*/
|
||||
export function buildInjectorAnnotations(
|
||||
refs: InjectorEnvRef[],
|
||||
cfg: InjectorConfig,
|
||||
): Record<string, string> {
|
||||
const bySecret = new Map<string, InjectorEnvRef[]>();
|
||||
for (const ref of refs) {
|
||||
const list = bySecret.get(ref.secretName) ?? [];
|
||||
list.push(ref);
|
||||
bySecret.set(ref.secretName, list);
|
||||
}
|
||||
|
||||
const annotations: Record<string, string> = {
|
||||
'vault.hashicorp.com/agent-inject': 'true',
|
||||
'vault.hashicorp.com/agent-pre-populate-only': 'true',
|
||||
'vault.hashicorp.com/role': cfg.role,
|
||||
// Without this the agent logs in at the default `auth/kubernetes`, which
|
||||
// validates a DIFFERENT cluster's ServiceAccounts and holds none of our
|
||||
// roles — every login would fail with a confusing permission error.
|
||||
'vault.hashicorp.com/auth-path': cfg.authPath,
|
||||
};
|
||||
|
||||
const prefix = cfg.pathPrefix === '' ? '' : `${cfg.pathPrefix}/`;
|
||||
for (const [secretName, entries] of [...bySecret.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
const path = `${cfg.mount}/data/${prefix}${secretName}`;
|
||||
annotations[`vault.hashicorp.com/agent-inject-secret-${secretName}`] = path;
|
||||
const lines = entries
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((e) => `export ${e.name}=${shellSingleQuote(`{{ .Data.data.${e.key} }}`)}`);
|
||||
annotations[`vault.hashicorp.com/agent-inject-template-${secretName}`] =
|
||||
`{{- with secret "${path}" -}}\n${lines.join('\n')}\n{{- end -}}`;
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the container command to source the rendered secrets, then exec the
|
||||
* real process.
|
||||
*
|
||||
* `sh -c <script> arg0 arg1 …` binds `$0`/`$@`, which is how the server's own
|
||||
* argv survives intact. `exec` matters just as much: mcpd attaches to PID 1's
|
||||
* stdin/stdout for STDIO servers, so the real process must REPLACE the shell
|
||||
* rather than run forked beneath it.
|
||||
*/
|
||||
export function wrapCommandForInjector(argv: string[], secretNames: string[]): string[] {
|
||||
const sources = [...secretNames]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((n) => `. ${INJECTED_SECRET_DIR}/${n}`)
|
||||
.join('; ');
|
||||
return ['/bin/sh', '-c', `${sources}; exec "$0" "$@"`, ...argv];
|
||||
}
|
||||
|
||||
export interface K8sPvcManifest {
|
||||
apiVersion: 'v1';
|
||||
kind: 'PersistentVolumeClaim';
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
spec: {
|
||||
accessModes: string[];
|
||||
resources: { requests: { storage: string } };
|
||||
storageClassName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +213,40 @@ function sanitizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63);
|
||||
}
|
||||
|
||||
function buildPodVolumes(
|
||||
spec: ContainerSpec,
|
||||
): Pick<K8sPodManifest['spec'], 'volumes' | 'securityContext'> {
|
||||
// Both kinds land in the same pod-level list, so they are built together —
|
||||
// emitting them from two places would have the second spread silently
|
||||
// overwrite the first.
|
||||
const persistent = (spec.volumes ?? []).map((v) => ({
|
||||
name: sanitizeName(v.claimName),
|
||||
persistentVolumeClaim: { claimName: sanitizeName(v.claimName) },
|
||||
}));
|
||||
const scratch = (spec.scratchVolumes ?? []).map((v) => ({ name: v.name, emptyDir: {} }));
|
||||
const volumes = [...persistent, ...scratch];
|
||||
if (volumes.length === 0) return {};
|
||||
|
||||
return {
|
||||
volumes,
|
||||
// fsGroup exists for the PVC case only; an emptyDir is created writable by
|
||||
// the container's own user, so scratch-only pods need no securityContext.
|
||||
...(spec.volumes && spec.volumes.length > 0
|
||||
? {
|
||||
securityContext: {
|
||||
// Without this a fresh PVC mounts root:root and any image that dropped
|
||||
// privileges cannot write to it. First declared fsGroup wins — a pod has
|
||||
// exactly one, so per-volume groups are not expressible here.
|
||||
fsGroup: spec.volumes[0]?.fsGroup ?? DEFAULT_VOLUME_FS_GROUP,
|
||||
// Only chown when the top-level ownership is already wrong; the default
|
||||
// ("Always") walks the whole volume on every single start.
|
||||
fsGroupChangePolicy: 'OnRootMismatch' as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLabels(spec: ContainerSpec): Record<string, string> {
|
||||
return {
|
||||
[MCPCTL_LABEL]: 'true',
|
||||
@@ -126,7 +285,12 @@ function buildContainerSpec(spec: ContainerSpec) {
|
||||
// In Docker, spec.command maps to Cmd (args to entrypoint).
|
||||
// In k8s, we use `args` to pass arguments to the image's entrypoint,
|
||||
// preserving the runner image's entrypoint (uvx, npx -y, etc.)
|
||||
if (spec.command && spec.command.length > 0) {
|
||||
//
|
||||
// `entrypoint` is the exception: it REPLACES the entrypoint (k8s `command`),
|
||||
// which injected secret delivery needs so the sourcing shell can be PID 1.
|
||||
if (spec.entrypoint && spec.entrypoint.length > 0) {
|
||||
container.command = spec.entrypoint;
|
||||
} else if (spec.command && spec.command.length > 0) {
|
||||
container.args = spec.command;
|
||||
}
|
||||
|
||||
@@ -142,23 +306,32 @@ function buildContainerSpec(spec: ContainerSpec) {
|
||||
container.workingDir = spec.workdir;
|
||||
}
|
||||
|
||||
// The main container sees every declared volume; init containers pick the
|
||||
// subset they need via their own volumeMounts.
|
||||
if (spec.volumes && spec.volumes.length > 0) {
|
||||
container.volumeMounts = spec.volumes.map((v) => ({ name: v.name, mountPath: v.mountPath }));
|
||||
// Both kinds mount into the main container: persistent volumes keyed by
|
||||
// claimName, and scratch volumes it shares with its init containers. Init
|
||||
// containers pick the subset they need via their own volumeMounts.
|
||||
const volumeMounts = [
|
||||
...(spec.volumes ?? []).map((v) => ({
|
||||
name: sanitizeName(v.claimName),
|
||||
mountPath: v.mountPath,
|
||||
})),
|
||||
...(spec.scratchVolumes ?? []).map((v) => ({ name: v.name, mountPath: v.mountPath })),
|
||||
];
|
||||
if (volumeMounts.length > 0) {
|
||||
container.volumeMounts = volumeMounts;
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/** Translate ContainerSpec volumes/initContainers into pod-spec fragments. */
|
||||
/**
|
||||
* Init containers for the pod spec.
|
||||
*
|
||||
* Volumes are deliberately NOT emitted here — `buildPodVolumes` owns the whole
|
||||
* pod-level list so the two kinds cannot clobber one another.
|
||||
*/
|
||||
function buildPodExtras(spec: ContainerSpec): Partial<K8sPodManifest['spec']> {
|
||||
const extras: Partial<K8sPodManifest['spec']> = {};
|
||||
|
||||
if (spec.volumes && spec.volumes.length > 0) {
|
||||
extras.volumes = spec.volumes.map((v) => ({ name: v.name, emptyDir: {} }));
|
||||
}
|
||||
|
||||
// Kubernetes runs initContainers to completion, in declaration order, before
|
||||
// any app container starts — so "clone the repos, then work them" needs no
|
||||
// sequencing logic on our side.
|
||||
@@ -177,6 +350,45 @@ function buildPodExtras(spec: ContainerSpec): Partial<K8sPodManifest['spec']> {
|
||||
return extras;
|
||||
}
|
||||
|
||||
/**
|
||||
* PVC for a server volume. Created once per server and left in place when
|
||||
* instances come and go — that outliving is the entire point of the resource.
|
||||
*
|
||||
* ReadWriteOnce because the backing class is typically a block volume
|
||||
* (Longhorn, EBS). A server asking for >1 replica plus a volume cannot have
|
||||
* both; `instance.service` rejects that combination rather than silently
|
||||
* leaving replicas unschedulable.
|
||||
*/
|
||||
export function generatePvcSpec(
|
||||
volume: ContainerVolume,
|
||||
namespace: string,
|
||||
labels: Record<string, string> = {},
|
||||
): K8sPvcManifest {
|
||||
const storageClass = volume.storageClass ?? process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: {
|
||||
name: sanitizeName(volume.claimName),
|
||||
namespace,
|
||||
labels: {
|
||||
[MCPCTL_LABEL]: 'true',
|
||||
'app.kubernetes.io/managed-by': 'mcpctl',
|
||||
...labels,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: `${volume.sizeGb}Gi` } },
|
||||
// Only set when known: an empty string means "no class" to Kubernetes,
|
||||
// which is not the same as omitting the field (use the default class).
|
||||
...(storageClass !== undefined && storageClass !== ''
|
||||
? { storageClassName: storageClass }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest {
|
||||
const labels = buildLabels(spec);
|
||||
return {
|
||||
@@ -186,15 +398,23 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
|
||||
name: sanitizeName(spec.name),
|
||||
namespace,
|
||||
labels,
|
||||
...(spec.annotations && Object.keys(spec.annotations).length > 0
|
||||
? { annotations: spec.annotations }
|
||||
: {}),
|
||||
},
|
||||
spec: {
|
||||
...(spec.serviceAccountName !== undefined
|
||||
? { serviceAccountName: spec.serviceAccountName }
|
||||
: {}),
|
||||
containers: [buildContainerSpec(spec)],
|
||||
...buildPodExtras(spec),
|
||||
// Long-lived MCP servers restart forever; ephemeral session pods pass
|
||||
// 'Never' so a finished or crashed run stays finished.
|
||||
restartPolicy: spec.restartPolicy ?? 'Always',
|
||||
// MCP server pods don't need k8s API access
|
||||
automountServiceAccountToken: false,
|
||||
// MCP server pods don't need k8s API access — EXCEPT when the OpenBao
|
||||
// agent is injected, which logs in with the projected SA token.
|
||||
automountServiceAccountToken: spec.automountServiceAccountToken ?? false,
|
||||
...buildPodVolumes(spec),
|
||||
// On mixed-arch clusters, constrain to the same arch as mcpd
|
||||
// (runner images are typically single-arch)
|
||||
...(process.env['MCPD_NODE_SELECTOR']
|
||||
@@ -231,6 +451,7 @@ export function generateDeploymentSpec(spec: ContainerSpec, namespace: string, r
|
||||
// anything else — so spec.restartPolicy is deliberately ignored here.
|
||||
restartPolicy: 'Always',
|
||||
automountServiceAccountToken: false,
|
||||
...buildPodVolumes(spec),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
52
src/mcpd/src/services/k8s/service-account-port.ts
Normal file
52
src/mcpd/src/services/k8s/service-account-port.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Kubernetes adapter for `ServiceAccountPort` — one ServiceAccount per MCP
|
||||
* server, so each pod authenticates to OpenBao as itself.
|
||||
*
|
||||
* Both operations are idempotent by design: `ensureFor` runs on every instance
|
||||
* start and `removeFor` on every server delete, so "already exists" and "not
|
||||
* found" are normal outcomes, not errors.
|
||||
*/
|
||||
import type { CoreV1Api } from '@kubernetes/client-node';
|
||||
import type { ServiceAccountPort } from '../server-identity.service.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
|
||||
function statusOf(err: unknown): number | undefined {
|
||||
const e = err as { code?: number; statusCode?: number; body?: { code?: number } };
|
||||
return e?.code ?? e?.statusCode ?? e?.body?.code;
|
||||
}
|
||||
|
||||
export class K8sServiceAccountPort implements ServiceAccountPort {
|
||||
constructor(private readonly core: CoreV1Api, readonly namespace: string) {}
|
||||
|
||||
async ensure(name: string): Promise<void> {
|
||||
try {
|
||||
await this.core.createNamespacedServiceAccount({
|
||||
namespace: this.namespace,
|
||||
body: {
|
||||
metadata: {
|
||||
name,
|
||||
namespace: this.namespace,
|
||||
labels: { [MCPCTL_LABEL]: 'true' },
|
||||
},
|
||||
// No k8s API power is wanted here — this SA exists purely as an
|
||||
// OpenBao identity. The pod still needs its token projected, which
|
||||
// is set on the pod spec, not here.
|
||||
automountServiceAccountToken: false,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (statusOf(err) === 409) return; // already exists — the normal path
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(name: string): Promise<void> {
|
||||
try {
|
||||
await this.core.deleteNamespacedServiceAccount({ name, namespace: this.namespace });
|
||||
} catch (err) {
|
||||
if (statusOf(err) === 404) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,59 @@ function parseStreamableResponse(body: string): McpProxyResponse {
|
||||
return JSON.parse(body) as McpProxyResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how mcpd opens a STDIO session against a running container.
|
||||
*
|
||||
* attach → connect to PID 1's stdin/stdout
|
||||
* exec → spawn a NEW process inside the container
|
||||
*
|
||||
* Pure and exported so the choice is testable: it is subtle, and getting it
|
||||
* wrong fails silently rather than loudly (see the injector case below).
|
||||
*/
|
||||
export function chooseStdioMode(server: {
|
||||
name: string;
|
||||
id: string;
|
||||
secretDelivery?: string | null;
|
||||
command?: string[] | null;
|
||||
packageName?: string | null;
|
||||
dockerImage?: string | null;
|
||||
runtime?: string | null;
|
||||
}): StdioMode {
|
||||
// Injected delivery MUST attach, whatever the server type.
|
||||
//
|
||||
// The secrets exist only in PID 1's environment: the container command is a
|
||||
// shell that sources /vault/secrets/<name> and execs the real server, so the
|
||||
// values live in that process and nowhere else — not in the pod spec, which
|
||||
// is the entire point of the feature.
|
||||
//
|
||||
// `exec` spawns a NEW process, which never sourced the file and so starts
|
||||
// with empty credentials. The server comes up, answers tools/list, and fails
|
||||
// every authenticated call — the exact silent-empty-token failure this
|
||||
// feature exists to prevent. Observed as
|
||||
// `Readiness check (list_datasources) failed: process exited 1` on a pod
|
||||
// whose PID 1 demonstrably held the token.
|
||||
//
|
||||
// Safe because wrapCommandForInjector execs rather than forks, so PID 1 IS
|
||||
// the server process.
|
||||
if (server.secretDelivery === 'injector') return { kind: 'attach' };
|
||||
|
||||
if (server.command !== null && server.command !== undefined && server.command.length > 0) {
|
||||
return { kind: 'exec', command: server.command };
|
||||
}
|
||||
if (server.packageName !== null && server.packageName !== undefined && server.packageName !== '') {
|
||||
return { kind: 'exec', command: buildRuntimeSpawnCmd(server.runtime ?? 'node', server.packageName) };
|
||||
}
|
||||
// Image entrypoint IS the MCP server.
|
||||
if (server.dockerImage !== null && server.dockerImage !== undefined && server.dockerImage !== '') {
|
||||
return { kind: 'attach' };
|
||||
}
|
||||
|
||||
throw new InvalidStateError(
|
||||
`Server '${server.name}' (${server.id}) uses STDIO transport but has no ` +
|
||||
`packageName, command, or dockerImage. Configure one of these.`,
|
||||
);
|
||||
}
|
||||
|
||||
export class McpProxyService {
|
||||
/** Session IDs per server for streamable-http protocol */
|
||||
private sessions = new Map<string, string>();
|
||||
@@ -159,20 +212,15 @@ export class McpProxyService {
|
||||
// - command set → exec the given command in the container.
|
||||
// - dockerImage only → attach to PID 1 (image entrypoint IS the MCP server).
|
||||
// - nothing → unreachable, reject.
|
||||
const runtime = (server.runtime as string | null) ?? 'node';
|
||||
let mode: StdioMode;
|
||||
if (command && command.length > 0) {
|
||||
mode = { kind: 'exec', command };
|
||||
} else if (packageName) {
|
||||
mode = { kind: 'exec', command: buildRuntimeSpawnCmd(runtime, packageName) };
|
||||
} else if (dockerImage) {
|
||||
mode = { kind: 'attach' };
|
||||
} else {
|
||||
throw new InvalidStateError(
|
||||
`Server '${server.name}' (${server.id}) uses STDIO transport but has no ` +
|
||||
`packageName, command, or dockerImage. Configure one of these.`,
|
||||
);
|
||||
}
|
||||
const mode = chooseStdioMode({
|
||||
name: server.name as string,
|
||||
id: server.id as string,
|
||||
secretDelivery: server.secretDelivery as string | null,
|
||||
command,
|
||||
packageName,
|
||||
dockerImage,
|
||||
runtime: server.runtime as string | null,
|
||||
});
|
||||
|
||||
// Try persistent connection first
|
||||
try {
|
||||
@@ -180,20 +228,28 @@ export class McpProxyService {
|
||||
} catch (err) {
|
||||
this.removeClient(instance.containerId);
|
||||
// Fall back to one-shot exec when we have a command to run.
|
||||
// Attach mode has no equivalent one-shot fallback — surface the error.
|
||||
if (mode.kind === 'exec') {
|
||||
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command, runtime);
|
||||
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command, (server.runtime as string | null) ?? 'node');
|
||||
}
|
||||
// Attach mode has no one-shot equivalent, but the failure is usually
|
||||
// a stale pipe from an in-place container restart — retry once
|
||||
// through a fresh client (which redials) before surfacing the error,
|
||||
// so the FIRST call after a detected death succeeds (mcpctl#114).
|
||||
try {
|
||||
return await this.sendViaPersistentStdio(instance.containerId, mode, method, params);
|
||||
} catch (retryErr) {
|
||||
this.removeClient(instance.containerId);
|
||||
const detail = formatError(retryErr);
|
||||
console.error(`[mcp-proxy] attach to ${instance.containerId} failed (after retry):`, retryErr);
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `STDIO attach to '${instance.containerId}' failed: ${detail}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const detail = formatError(err);
|
||||
console.error(`[mcp-proxy] attach to ${instance.containerId} failed:`, err);
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `STDIO attach to '${instance.containerId}' failed: ${detail}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { InstanceService } from './instance.service.js';
|
||||
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
|
||||
|
||||
export class McpServerService {
|
||||
private serverIdentity?: { removeFor(serverName: string): Promise<void> };
|
||||
|
||||
private instanceService: InstanceService | null = null;
|
||||
|
||||
constructor(private readonly repo: IMcpServerRepository) {}
|
||||
@@ -55,14 +57,27 @@ export class McpServerService {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
const server = await this.getById(id);
|
||||
// Stop all containers before DB cascade
|
||||
if (this.instanceService) {
|
||||
await this.instanceService.removeAllForServer(id);
|
||||
}
|
||||
// Revoke the per-server OpenBao identity, if it had one. Best-effort and
|
||||
// AFTER the pods are gone: a leftover role that no pod can authenticate as
|
||||
// grants nothing, whereas failing the delete over it would strand the
|
||||
// server row. Unconditional rather than gated on secretDelivery, so a
|
||||
// server flipped back to `env` before deletion is still cleaned up.
|
||||
if (this.serverIdentity) {
|
||||
await this.serverIdentity.removeFor(server.name);
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
/** Setter injection, matching setInstanceService — constructed later in main. */
|
||||
setServerIdentity(identity: { removeFor(serverName: string): Promise<void> }): void {
|
||||
this.serverIdentity = identity;
|
||||
}
|
||||
|
||||
// ── Backup/restore helpers ──
|
||||
|
||||
async upsertByName(data: Record<string, unknown>): Promise<McpServer> {
|
||||
|
||||
@@ -2,15 +2,77 @@
|
||||
* Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A persistent volume mounted into an MCP server container.
|
||||
*
|
||||
* `claimName` is deliberately caller-supplied and derived from the *server*,
|
||||
* not the instance: instances are immutable and get recreated on any server
|
||||
* edit, so an instance-scoped volume would be destroyed exactly when the data
|
||||
* needs to survive. Backed by a Docker named volume or a Kubernetes PVC, and
|
||||
* never removed when an instance goes away.
|
||||
*/
|
||||
export interface ContainerVolume {
|
||||
/** Stable backing-volume name, shared by every instance of a server. */
|
||||
claimName: string;
|
||||
/** Absolute path to mount at inside the container. */
|
||||
mountPath: string;
|
||||
/** Requested size in GiB. Kubernetes only — Docker named volumes are unbounded. */
|
||||
sizeGb: number;
|
||||
/**
|
||||
* StorageClass for the PVC, defaulting to MCPD_VOLUME_STORAGE_CLASS.
|
||||
* Worth setting explicitly on clusters with more than one default class,
|
||||
* where an omitted class binds nondeterministically.
|
||||
*/
|
||||
storageClass?: string;
|
||||
/**
|
||||
* Group that owns the mounted volume (Kubernetes `fsGroup`).
|
||||
*
|
||||
* A fresh PVC mounts as root:root, so any image that drops privileges cannot
|
||||
* write to it — docs-mcp-server fails with SQLITE_CANTOPEN, and its own
|
||||
* Dockerfile tells you to `chown 1000:1000` the volume. Defaults to
|
||||
* DEFAULT_VOLUME_FS_GROUP, which covers images using the conventional uid
|
||||
* 1000; containers still running as root are unaffected either way.
|
||||
*/
|
||||
fsGroup?: number;
|
||||
}
|
||||
|
||||
export interface ContainerSpec {
|
||||
/** Docker/OCI image reference */
|
||||
image: string;
|
||||
/** Human-readable name (used as container name prefix) */
|
||||
name: string;
|
||||
/** Persistent volumes to mount (survive instance recreation) */
|
||||
volumes?: ContainerVolume[];
|
||||
/** Custom command to run (overrides image CMD) */
|
||||
command?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Env the ORCHESTRATOR must arrange for the container to obtain itself,
|
||||
* carrying the reference and never the value.
|
||||
*
|
||||
* Kept separate from `env` rather than replacing it: `env` is the plaintext
|
||||
* shape every existing server still uses, and the two must coexist while
|
||||
* servers migrate one at a time.
|
||||
*/
|
||||
envFromSecret?: Array<{ name: string; secretName: string; key: string }>;
|
||||
/** Orchestrator hints. On Kubernetes these become pod annotations. */
|
||||
annotations?: Record<string, string>;
|
||||
/** Identity the pod runs as — how the backend knows which secrets it may read. */
|
||||
serviceAccountName?: string;
|
||||
/** Injected agents need the projected SA token; plain servers do not. */
|
||||
automountServiceAccountToken?: boolean;
|
||||
/**
|
||||
* REPLACES the image's ENTRYPOINT (k8s `command`), unlike `command`, which is
|
||||
* appended to it as `args`.
|
||||
*
|
||||
* Needed only for injected secret delivery: the container has to run a shell
|
||||
* that sources the rendered file before exec'ing the real process, and that
|
||||
* shell must BE the entrypoint. Because it replaces the entrypoint, the argv
|
||||
* here has to include whatever the image's entrypoint would have contributed
|
||||
* (`npx -y`, `uvx`, ...).
|
||||
*/
|
||||
entrypoint?: string[];
|
||||
/** Host port to bind (null = auto-assign) */
|
||||
hostPort?: number | null;
|
||||
/** Container port to expose */
|
||||
@@ -36,8 +98,15 @@ export interface ContainerSpec {
|
||||
restartPolicy?: 'Always' | 'Never' | 'OnFailure';
|
||||
/** Working directory for the main container. */
|
||||
workdir?: string;
|
||||
/** Scratch volumes, e.g. the shared workspace an init container clones into. */
|
||||
volumes?: VolumeSpec[];
|
||||
/**
|
||||
* Scratch volumes, e.g. the shared workspace an init container clones into.
|
||||
*
|
||||
* Distinct from `volumes` above, which is persistent storage keyed by a
|
||||
* server-derived `claimName` and deliberately survives instance recreation.
|
||||
* These are the opposite: the pod's lifetime bounds them, and they exist so
|
||||
* an init container and the main container can share a filesystem.
|
||||
*/
|
||||
scratchVolumes?: VolumeSpec[];
|
||||
/**
|
||||
* Containers that must run to completion, in order, before the main container
|
||||
* starts. Used to clone the project's repositories into the workspace volume.
|
||||
@@ -68,6 +137,14 @@ export interface ContainerInfo {
|
||||
/** Container IP on the first non-default network (for internal communication) */
|
||||
ip?: string;
|
||||
createdAt: Date;
|
||||
/**
|
||||
* Times the container restarted in place (k8s containerStatuses[0]).
|
||||
* A bump with an unchanged containerId means every cached STDIO pipe to it
|
||||
* is dead — syncStatus uses this to invalidate them (mcpctl#114).
|
||||
*/
|
||||
restartCount?: number;
|
||||
/** When the current container process started (k8s state.running.startedAt). */
|
||||
startedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ContainerLogs {
|
||||
@@ -82,6 +159,17 @@ export interface ExecResult {
|
||||
}
|
||||
|
||||
export interface McpOrchestrator {
|
||||
/**
|
||||
* What this backend can do beyond running containers.
|
||||
*
|
||||
* `secretRefs` means the orchestrator can honour `ContainerSpec.envFromSecret`
|
||||
* — i.e. arrange for the container to fetch its own secret values. Docker and
|
||||
* Podman cannot, so mcpd resolves those refs itself and merges them into
|
||||
* `env`. Declaring the difference here keeps it one visible decision at the
|
||||
* call site instead of a silent divergence inside each implementation.
|
||||
*/
|
||||
readonly capabilities?: { secretRefs: boolean };
|
||||
|
||||
/** Pull an image if not present locally */
|
||||
pullImage(image: string): Promise<void>;
|
||||
|
||||
@@ -126,3 +214,10 @@ export interface InteractiveExec {
|
||||
/** Default resource limits */
|
||||
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
|
||||
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU
|
||||
|
||||
/**
|
||||
* Group applied to mounted volumes when a server does not specify one.
|
||||
* 1000 is the conventional non-root uid/gid in node, python and distro base
|
||||
* images, which is what the images that drop privileges actually use.
|
||||
*/
|
||||
export const DEFAULT_VOLUME_FS_GROUP = 1000;
|
||||
|
||||
@@ -26,7 +26,11 @@ export interface SecretBackendRotatorLoopDeps {
|
||||
/** Override in tests. */
|
||||
setTimeout?: (cb: () => void, ms: number) => NodeJS.Timeout;
|
||||
clearTimeout?: (t: NodeJS.Timeout) => void;
|
||||
log?: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (obj: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 24 * 3600 * 1000;
|
||||
@@ -36,7 +40,7 @@ export class SecretBackendRotatorLoop {
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly setT: (cb: () => void, ms: number) => NodeJS.Timeout;
|
||||
private readonly clearT: (t: NodeJS.Timeout) => void;
|
||||
private readonly log: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
private readonly log: NonNullable<SecretBackendRotatorLoopDeps['log']>;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly deps: SecretBackendRotatorLoopDeps) {
|
||||
@@ -44,9 +48,11 @@ export class SecretBackendRotatorLoop {
|
||||
this.clearT = deps.clearTimeout ?? ((t) => global.clearTimeout(t));
|
||||
this.log = deps.log ?? {
|
||||
// eslint-disable-next-line no-console
|
||||
info: (m) => console.log(`[rotator] ${m}`),
|
||||
info: (m: string): void => { console.log(`[rotator] ${m}`); },
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (m) => console.warn(`[rotator] ${m}`),
|
||||
warn: (m: string): void => { console.warn(`[rotator] ${m}`); },
|
||||
// eslint-disable-next-line no-console
|
||||
error: (obj: Record<string, unknown>, m: string): void => { console.error(JSON.stringify({ level: 'fatal', ...obj, message: m })); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,13 +76,10 @@ export class SecretBackendRotatorLoop {
|
||||
this.deps.rotator.healthCheck(b.id)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(JSON.stringify({
|
||||
level: 'fatal',
|
||||
kind: 'BACKEND_TOKEN_DEAD',
|
||||
backend: b.name,
|
||||
message: res.message ?? 'unknown',
|
||||
}));
|
||||
this.log.error(
|
||||
{ kind: 'BACKEND_TOKEN_DEAD', backend: b.name },
|
||||
res.message ?? 'unknown',
|
||||
);
|
||||
this.log.warn(`backend '${b.name}' health check failed: ${res.message ?? 'unknown'}`);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,18 +53,37 @@ export interface TokenMeta {
|
||||
rotatable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured logger. Must be a real pino-shaped logger in production: the
|
||||
* `BACKEND_TOKEN_DEAD` fatals below used to go out via bare `console.error`,
|
||||
* which bypasses the pino multistream feeding `ErrorLogBuffer` — so the one
|
||||
* failure `mcpctl errors` exists to surface was the one it never saw.
|
||||
*/
|
||||
export interface RotatorLog {
|
||||
error(obj: Record<string, unknown>, msg: string): void;
|
||||
warn(msg: string): void;
|
||||
}
|
||||
|
||||
export interface SecretBackendRotatorDeps {
|
||||
backends: SecretBackendService;
|
||||
secrets: SecretService;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
now?: () => Date;
|
||||
log?: RotatorLog;
|
||||
}
|
||||
|
||||
export class SecretBackendRotator {
|
||||
private readonly now: () => Date;
|
||||
private readonly log: RotatorLog;
|
||||
|
||||
constructor(private readonly deps: SecretBackendRotatorDeps) {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.log = deps.log ?? {
|
||||
// eslint-disable-next-line no-console
|
||||
error: (obj: Record<string, unknown>, msg: string): void => { console.error(JSON.stringify({ level: 'fatal', ...obj, message: msg })); },
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (msg: string): void => { console.warn(msg); },
|
||||
};
|
||||
}
|
||||
|
||||
/** True iff this backend is a wizard-provisioned token-auth openbao with rotation enabled. */
|
||||
@@ -144,15 +163,16 @@ export class SecretBackendRotator {
|
||||
: err;
|
||||
const wrappedMsg = wrapped instanceof Error ? wrapped.message : String(wrapped);
|
||||
await this.recordError(backendId, meta, wrappedMsg);
|
||||
// Loud, structured log so the operator sees it in `kubectl logs deploy/mcpd`.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(JSON.stringify({
|
||||
level: 'fatal',
|
||||
kind: tokenDead ? 'BACKEND_TOKEN_DEAD' : 'BACKEND_ROTATION_FAILED',
|
||||
backend: backend.name,
|
||||
url: cfg.url,
|
||||
message: wrappedMsg,
|
||||
}));
|
||||
// Loud and structured, through pino so it also lands in ErrorLogBuffer
|
||||
// and therefore in `mcpctl errors` — not just in `kubectl logs`.
|
||||
this.log.error(
|
||||
{
|
||||
kind: tokenDead ? 'BACKEND_TOKEN_DEAD' : 'BACKEND_ROTATION_FAILED',
|
||||
backend: backend.name,
|
||||
url: cfg.url,
|
||||
},
|
||||
wrappedMsg,
|
||||
);
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
@@ -164,7 +184,7 @@ export class SecretBackendRotator {
|
||||
// Log but don't fail the rotation — the new token is already live.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`);
|
||||
this.log.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +269,7 @@ export class SecretBackendRotator {
|
||||
} catch (inner) {
|
||||
// Don't mask the original error — just log the DB failure.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`);
|
||||
this.log.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SecretBackend } from '@prisma/client';
|
||||
import type { ISecretBackendRepository } from '../repositories/secret-backend.repository.js';
|
||||
import type { SecretBackendDriver } from './secret-backends/types.js';
|
||||
import { createDriver, type DriverFactoryDeps } from './secret-backends/factory.js';
|
||||
import { CachingSecretBackendDriver, type CachingDriverOptions, type CacheStats } from './secret-backends/caching.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
|
||||
export class SecretBackendInUseError extends Error {
|
||||
@@ -17,6 +18,7 @@ export class SecretBackendService {
|
||||
constructor(
|
||||
private readonly repo: ISecretBackendRepository,
|
||||
private readonly driverDeps: DriverFactoryDeps,
|
||||
private readonly cacheOpts: CachingDriverOptions = {},
|
||||
) {}
|
||||
|
||||
async list(): Promise<SecretBackend[]> {
|
||||
@@ -87,12 +89,34 @@ export class SecretBackendService {
|
||||
this.driverCache.delete(id);
|
||||
}
|
||||
|
||||
/** Get the driver for a given backend id, creating + caching on first call. */
|
||||
/**
|
||||
* Get the driver for a given backend id, creating + caching on first call.
|
||||
*
|
||||
* Remote backends are wrapped in `CachingSecretBackendDriver` so a backend
|
||||
* outage degrades to "serving last known-good" instead of failing every
|
||||
* caller. `plaintext` is deliberately NOT wrapped: its `read()` is an
|
||||
* identity function over the DB row passed in by the caller, so a value cache
|
||||
* there would serve pre-update data with nothing to invalidate it.
|
||||
*
|
||||
* Config changes invalidate for free — `update()` and `delete()` drop this
|
||||
* map, and the value cache lives inside the driver instance, which is right:
|
||||
* if `url`/`mount`/`pathPrefix` change, the cached names now mean something
|
||||
* different.
|
||||
*/
|
||||
driverFor(backend: SecretBackend): SecretBackendDriver {
|
||||
const cached = this.driverCache.get(backend.id);
|
||||
if (cached) return cached;
|
||||
const driver = createDriver(backend, this.driverDeps);
|
||||
const base = createDriver(backend, this.driverDeps);
|
||||
const driver = backend.type === 'plaintext'
|
||||
? base
|
||||
: new CachingSecretBackendDriver(base, { ...this.cacheOpts, backendName: backend.name });
|
||||
this.driverCache.set(backend.id, driver);
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** Cache state for a backend, for the health endpoint. Never exposes values. */
|
||||
cacheStatsFor(backend: SecretBackend): CacheStats | undefined {
|
||||
const driver = this.driverFor(backend);
|
||||
return driver instanceof CachingSecretBackendDriver ? driver.stats() : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
215
src/mcpd/src/services/secret-backends/caching.ts
Normal file
215
src/mcpd/src/services/secret-backends/caching.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Caching + stale-while-error decorator for any `SecretBackendDriver`.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `SecretService.resolveData()` calls `driver.read()` on *every* use, and every
|
||||
* consumer funnels through it: server env resolution, LLM api keys, chat, git
|
||||
* providers, code repos, webhooks. With a remote backend that means one network
|
||||
* round-trip per secret per call, and — worse — any OpenBao blip propagates
|
||||
* straight through. An instance that restarts during a blip fails env
|
||||
* resolution, gets marked ERROR, and enters a 30s×5-then-5min backoff
|
||||
* (`instance.service.ts`), so a few seconds of backend unavailability turns
|
||||
* into minutes of degraded service.
|
||||
*
|
||||
* ## Semantics
|
||||
*
|
||||
* - **Fresh** (age < ttlMs): served from memory, no network.
|
||||
* - **Stale-while-error**: past the TTL we always try the backend first. If it
|
||||
* answers, we refresh. If it fails *as a transport failure*
|
||||
* (`SecretBackendUnavailableError`), we serve the last known-good value
|
||||
* instead of throwing. This is the part that actually stops the ERROR storm.
|
||||
* - **`SecretNotFoundError` evicts and rethrows.** Never served stale — that
|
||||
* would resurrect a deliberately deleted or revoked credential, which is
|
||||
* strictly worse than an outage.
|
||||
* - **Any other error rethrows, without stale.** A 403 that survives a token
|
||||
* refresh means our grants were revoked; papering over it with cached data is
|
||||
* exactly how an upstream OpenBao re-init once went unnoticed for four days.
|
||||
* - **No negative caching.** A miss must re-check; retry/backoff already lives
|
||||
* in the driver.
|
||||
*
|
||||
* The stale window is deliberately unbounded. A cap would mean a long outage
|
||||
* eventually takes mcpd down anyway, which defeats the purpose, and the
|
||||
* revoked-credential case is already handled definitively by `SecretNotFound`.
|
||||
*
|
||||
* ## What this does NOT fix
|
||||
*
|
||||
* A cold cache during an outage. If mcpd restarts while the backend is
|
||||
* unreachable, nothing has a last-known-good value and secret-bearing servers
|
||||
* fail to start — honestly, with a loud error. That is the correct behaviour:
|
||||
* booting a server with an empty credential is the failure mode that had
|
||||
* gitea-mcp reporting healthy while every authed call failed. The mitigation is
|
||||
* to warm this cache at boot, not to invent a value.
|
||||
*
|
||||
* Values live in heap in cleartext for the TTL, so the map is bounded (LRU) and
|
||||
* values are never logged.
|
||||
*/
|
||||
import type { SecretBackendDriver, SecretData, ExternalRef } from './types.js';
|
||||
import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js';
|
||||
|
||||
export interface CachingDriverLog {
|
||||
warn(obj: Record<string, unknown>, msg: string): void;
|
||||
info(obj: Record<string, unknown>, msg: string): void;
|
||||
}
|
||||
|
||||
export interface CachingDriverOptions {
|
||||
/** How long a value is served without consulting the backend. */
|
||||
ttlMs?: number;
|
||||
/** LRU bound — these are plaintext credentials held in memory. */
|
||||
maxEntries?: number;
|
||||
/** Backend name, for log context only. */
|
||||
backendName?: string;
|
||||
now?: () => number;
|
||||
log?: CachingDriverLog;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: SecretData;
|
||||
fetchedAt: number;
|
||||
/** Set when we last served this past its TTL because the backend was down. */
|
||||
staleSince: number | undefined;
|
||||
}
|
||||
|
||||
export const DEFAULT_CACHE_TTL_MS = 300_000;
|
||||
export const DEFAULT_CACHE_MAX_ENTRIES = 500;
|
||||
|
||||
const NOOP_LOG: CachingDriverLog = { warn: () => undefined, info: () => undefined };
|
||||
|
||||
export interface CacheStats {
|
||||
entries: number;
|
||||
servingStale: number;
|
||||
oldestStaleSince: number | undefined;
|
||||
}
|
||||
|
||||
export class CachingSecretBackendDriver implements SecretBackendDriver {
|
||||
readonly kind: string;
|
||||
|
||||
private readonly entries = new Map<string, CacheEntry>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly backendName: string;
|
||||
private readonly nowFn: () => number;
|
||||
private readonly log: CachingDriverLog;
|
||||
|
||||
constructor(private readonly inner: SecretBackendDriver, opts: CachingDriverOptions = {}) {
|
||||
this.kind = `cached:${inner.kind}`;
|
||||
this.ttlMs = opts.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
this.maxEntries = opts.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
||||
this.backendName = opts.backendName ?? inner.kind;
|
||||
this.nowFn = opts.now ?? ((): number => Date.now());
|
||||
this.log = opts.log ?? NOOP_LOG;
|
||||
}
|
||||
|
||||
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
|
||||
const now = this.nowFn();
|
||||
const cached = this.entries.get(input.name);
|
||||
|
||||
if (cached !== undefined && now - cached.fetchedAt < this.ttlMs) {
|
||||
this.touch(input.name, cached);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.inner.read(input);
|
||||
if (cached?.staleSince !== undefined) {
|
||||
// Edge-triggered: only on the transition back to healthy.
|
||||
this.log.info(
|
||||
{ kind: 'BACKEND_RECOVERED', backend: this.backendName, secret: input.name,
|
||||
staleForMs: now - cached.staleSince },
|
||||
`secret backend '${this.backendName}' recovered; '${input.name}' is live again`,
|
||||
);
|
||||
}
|
||||
this.store(input.name, { data, fetchedAt: now, staleSince: undefined });
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err instanceof SecretNotFoundError) {
|
||||
// Definitive. Drop the stale copy so we can never hand it out later.
|
||||
this.entries.delete(input.name);
|
||||
throw err;
|
||||
}
|
||||
if (!(err instanceof SecretBackendUnavailableError) || cached === undefined) {
|
||||
throw err;
|
||||
}
|
||||
if (cached.staleSince === undefined) {
|
||||
cached.staleSince = now;
|
||||
this.log.warn(
|
||||
{ kind: 'BACKEND_UNREACHABLE', backend: this.backendName, secret: input.name,
|
||||
ageMs: now - cached.fetchedAt, reason: err.message },
|
||||
`secret backend '${this.backendName}' unreachable; serving cached '${input.name}'`,
|
||||
);
|
||||
}
|
||||
this.touch(input.name, cached);
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
async write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }> {
|
||||
const result = await this.inner.write(input);
|
||||
// Cache what a subsequent read() would return — the values just written —
|
||||
// not `storedData`, which remote drivers deliberately leave empty.
|
||||
this.store(input.name, { data: input.data, fetchedAt: this.nowFn(), staleSince: undefined });
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(input: { name: string; externalRef: ExternalRef }): Promise<void> {
|
||||
await this.inner.delete(input);
|
||||
this.entries.delete(input.name);
|
||||
}
|
||||
|
||||
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||
return this.inner.list();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
return this.inner.healthCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity provisioning is a control-plane operation on the backend itself,
|
||||
* not a value read — nothing to cache, so pass straight through. Throwing
|
||||
* when unsupported (rather than silently succeeding) keeps a misconfigured
|
||||
* backend from looking like it granted access it never did.
|
||||
*/
|
||||
async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise<void> {
|
||||
if (this.inner.ensureServerIdentity === undefined) {
|
||||
throw new Error(`backend '${this.backendName}' (${this.inner.kind}) does not support per-server identities`);
|
||||
}
|
||||
await this.inner.ensureServerIdentity(input);
|
||||
}
|
||||
|
||||
async removeServerIdentity(input: { name: string }): Promise<void> {
|
||||
await this.inner.removeServerIdentity?.(input);
|
||||
}
|
||||
|
||||
async authCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||
}
|
||||
|
||||
/** Cache state for the backend health endpoint. Never exposes values. */
|
||||
stats(): CacheStats {
|
||||
let servingStale = 0;
|
||||
let oldestStaleSince: number | undefined;
|
||||
for (const e of this.entries.values()) {
|
||||
if (e.staleSince === undefined) continue;
|
||||
servingStale++;
|
||||
if (oldestStaleSince === undefined || e.staleSince < oldestStaleSince) oldestStaleSince = e.staleSince;
|
||||
}
|
||||
return { entries: this.entries.size, servingStale, oldestStaleSince };
|
||||
}
|
||||
|
||||
/** Move an entry to the MRU end of the insertion-ordered Map. */
|
||||
private touch(name: string, entry: CacheEntry): void {
|
||||
this.entries.delete(name);
|
||||
this.entries.set(name, entry);
|
||||
}
|
||||
|
||||
private store(name: string, entry: CacheEntry): void {
|
||||
this.entries.delete(name);
|
||||
this.entries.set(name, entry);
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldest = this.entries.keys().next();
|
||||
if (oldest.done === true) break;
|
||||
this.entries.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* POST <url>/v1/<mount>/data/<path> -- write
|
||||
* GET <url>/v1/<mount>/data/<path> -- read latest
|
||||
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
|
||||
* LIST <url>/v1/<mount>/metadata/ -- for migration
|
||||
* GET <url>/v1/<mount>/metadata/?list=true -- for migration (see list())
|
||||
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
|
||||
*
|
||||
* Auth strategies (`config.auth`):
|
||||
@@ -28,6 +28,14 @@
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
|
||||
import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js';
|
||||
import {
|
||||
buildServerSecretPolicyHcl,
|
||||
writePolicy,
|
||||
deletePolicy,
|
||||
ensureKubernetesAuthRole,
|
||||
deleteKubernetesAuthRole,
|
||||
} from '@mcpctl/shared';
|
||||
|
||||
/** Best-effort read of a response body for error messages. Empty on parse failure. */
|
||||
async function bodyText(res: Response): Promise<string> {
|
||||
@@ -77,10 +85,23 @@ export interface OpenBaoDriverDeps {
|
||||
readServiceAccountToken?: (path: string) => Promise<string>;
|
||||
/** Clock for cache TTL — overridable in tests. */
|
||||
now?: () => number;
|
||||
/** Per-request timeout. Without one, an unreachable OpenBao hangs every caller. */
|
||||
timeoutMs?: number;
|
||||
/** Total attempts for retryable failures (network / 5xx / 429). 1 disables retry. */
|
||||
maxAttempts?: number;
|
||||
/** Base for exponential backoff between retries; full jitter is applied. */
|
||||
backoffBaseMs?: number;
|
||||
/** Test seam — real sleeps would make the retry tests take seconds. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
|
||||
const TOKEN_RENEW_GRACE_MS = 60_000;
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_BACKOFF_BASE_MS = 200;
|
||||
/** Statuses worth retrying: the backend is up but cannot answer right now. */
|
||||
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
||||
|
||||
export class OpenBaoDriver implements SecretBackendDriver {
|
||||
readonly kind = 'openbao';
|
||||
@@ -98,6 +119,10 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
private readonly resolver: SecretRefResolver | undefined;
|
||||
private readonly readSaToken: (path: string) => Promise<string>;
|
||||
private readonly nowFn: () => number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly backoffBaseMs: number;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
|
||||
// Cached vault token + when (epoch ms) it should be considered expired and refetched.
|
||||
private cachedToken: string | undefined;
|
||||
@@ -131,13 +156,19 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver;
|
||||
this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim()));
|
||||
this.nowFn = deps.now ?? (() => Date.now());
|
||||
this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.maxAttempts = deps.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
this.backoffBaseMs = deps.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
||||
this.sleep = deps.sleep ?? ((ms: number): Promise<void> => new Promise((r) => { setTimeout(r, ms); }));
|
||||
}
|
||||
|
||||
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
|
||||
const path = this.pathFor(input.name);
|
||||
const res = await this.request('GET', `/v1/${this.mount}/data/${path}`);
|
||||
if (res.status === 404) {
|
||||
throw new Error(`OpenBao: secret '${input.name}' not found at ${path}`);
|
||||
// Definitive answer, not a transport failure — the caching decorator
|
||||
// must evict rather than serve a stale value here.
|
||||
throw new SecretNotFoundError(`OpenBao: secret '${input.name}' not found at ${path}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`OpenBao read ${path}: HTTP ${res.status} ${await bodyText(res)}`);
|
||||
const body = await res.json() as { data?: { data?: SecretData } };
|
||||
@@ -161,7 +192,13 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
|
||||
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||
const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`;
|
||||
const res = await this.request('LIST', `/v1/${this.mount}/metadata/${listPath}`);
|
||||
// `GET ?list=true`, not the LIST verb. OpenBao accepts both, but LIST is a
|
||||
// non-standard HTTP method and proxies drop it: through Cilium's ingress
|
||||
// Envoy (which is how `bao-k8s` is reached) LIST returns a bare
|
||||
// `400 Bad Request` while the GET form returns normally. Verified live
|
||||
// 2026-08-20 — this silently broke `mcpctl migrate secrets` against any
|
||||
// ingress-fronted backend.
|
||||
const res = await this.request('GET', `/v1/${this.mount}/metadata/${listPath}?list=true`);
|
||||
if (res.status === 404) return [];
|
||||
if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`);
|
||||
const body = await res.json() as { data?: { keys?: string[] } };
|
||||
@@ -174,15 +211,104 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVENESS. Deliberately unauthenticated: `sys/health` needs no token, and
|
||||
* routing it through `request()` (as this used to) took a login first — so an
|
||||
* expired role reported as "OpenBao is down", and every probe cost a login.
|
||||
*
|
||||
* OpenBao encodes its state in the status code, so map it rather than
|
||||
* collapsing everything to ok/not-ok.
|
||||
*/
|
||||
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const res = await this.request('GET', '/v1/sys/health');
|
||||
return { ok: res.ok, detail: `HTTP ${res.status}` };
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
|
||||
const res = await this.fetchImpl(`${this.url}/v1/sys/health`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
switch (res.status) {
|
||||
case 200: return { ok: true, detail: 'active' };
|
||||
case 429: return { ok: true, detail: 'standby' };
|
||||
case 472: case 473: return { ok: true, detail: 'replication secondary' };
|
||||
case 501: return { ok: false, detail: 'not initialized' };
|
||||
case 503: return { ok: false, detail: 'sealed' };
|
||||
default: return { ok: res.ok, detail: `HTTP ${String(res.status)}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* READINESS. Exercises the capability we actually depend on — read/list under
|
||||
* `<mount>/<pathPrefix>/` — using the credentials we hold.
|
||||
*
|
||||
* `list()` rather than `auth/token/lookup-self` on purpose: lookup-self only
|
||||
* proves the token exists, not that its policy still grants anything. The
|
||||
* four-day outage in e51b924 was exactly a live token whose grants had been
|
||||
* dropped by an upstream re-init. The existing read policy already permits
|
||||
* this call, so it needs no bao-side change.
|
||||
*/
|
||||
async authCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
await this.list();
|
||||
return { ok: true, detail: `readable at ${this.mount}/${this.pathPrefix}` };
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/refresh a per-server OpenBao identity: a policy naming exactly this
|
||||
* server's secrets, and a Kubernetes auth role bound to exactly its
|
||||
* ServiceAccount.
|
||||
*
|
||||
* Requires mcpd's own token to hold the provisioning grant (see
|
||||
* `buildServerProvisioningPolicyHcl`) — deliberately confined to the
|
||||
* `mcpctl-server-` name prefix, so this cannot mint an identity that reads
|
||||
* anything mcpd itself could not already read.
|
||||
*
|
||||
* Only meaningful with `auth: 'kubernetes'`: the role has to live on the same
|
||||
* auth mount that validates this cluster's ServiceAccount tokens.
|
||||
*/
|
||||
async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise<void> {
|
||||
if (this.authStrategy !== 'kubernetes') {
|
||||
throw new Error(
|
||||
`OpenBao: per-server identities require auth: 'kubernetes' (this backend uses '${this.authStrategy}') — ` +
|
||||
'a token-auth backend has no ServiceAccount auth mount to bind a role on',
|
||||
);
|
||||
}
|
||||
const token = await this.getToken();
|
||||
const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) };
|
||||
|
||||
// Policy first, then the role that references it. The reverse order would
|
||||
// briefly leave a role pointing at a non-existent policy, which OpenBao
|
||||
// resolves as "no capabilities" — a confusing transient 403 for the pod.
|
||||
await writePolicy(
|
||||
this.url,
|
||||
token,
|
||||
input.name,
|
||||
buildServerSecretPolicyHcl({ mount: this.mount, pathPrefix: this.pathPrefix, secretNames: input.secretNames }),
|
||||
deps,
|
||||
);
|
||||
await ensureKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, {
|
||||
boundServiceAccountNames: [input.name],
|
||||
boundServiceAccountNamespaces: [input.namespace],
|
||||
tokenPolicies: [input.name],
|
||||
}, deps);
|
||||
}
|
||||
|
||||
/** Role before policy: revoke the ability to log in before the grants vanish. */
|
||||
async removeServerIdentity(input: { name: string }): Promise<void> {
|
||||
if (this.authStrategy !== 'kubernetes') return;
|
||||
const token = await this.getToken();
|
||||
const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) };
|
||||
await deleteKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, deps);
|
||||
await deletePolicy(this.url, token, input.name, deps);
|
||||
}
|
||||
|
||||
private pathFor(name: string): string {
|
||||
const safe = encodeURIComponent(name);
|
||||
return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`;
|
||||
@@ -206,11 +332,28 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
|
||||
const res = await this.fetchImpl(loginUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ role: this.k8sRole, jwt }),
|
||||
});
|
||||
// Bounded like every other call: a hung login is indistinguishable from a
|
||||
// hung read to the caller, and this one used to have no timeout at all.
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(loginUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ role: this.k8sRole, jwt }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SecretBackendUnavailableError(
|
||||
`OpenBao kubernetes login (role=${this.k8sRole!}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (RETRYABLE_STATUS.has(res.status)) {
|
||||
throw new SecretBackendUnavailableError(
|
||||
`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)}`,
|
||||
{ lastStatus: res.status },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`);
|
||||
@@ -229,30 +372,77 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
return clientToken;
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
const token = await this.getToken();
|
||||
/** Build a fresh RequestInit — headers must not be shared across attempts. */
|
||||
private buildInit(method: string, token: string, body?: unknown): RequestInit {
|
||||
const headers: Record<string, string> = { 'X-Vault-Token': token };
|
||||
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return init;
|
||||
}
|
||||
|
||||
const res = await this.fetchImpl(`${this.url}${path}`, init);
|
||||
/** Full-jitter exponential backoff, so concurrent callers don't resonate. */
|
||||
private backoffFor(attempt: number): number {
|
||||
return Math.random() * this.backoffBaseMs * Math.pow(2, attempt - 1);
|
||||
}
|
||||
|
||||
// If the cached token expired between cache-check and request (k8s clock
|
||||
// skew, server-side revocation, etc.), purge cache and retry once.
|
||||
if (res.status === 403 && this.cachedToken !== undefined) {
|
||||
this.cachedToken = undefined;
|
||||
this.cachedTokenExpiresAt = 0;
|
||||
const fresh = await this.getToken();
|
||||
const retryHeaders: Record<string, string> = { 'X-Vault-Token': fresh };
|
||||
if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace;
|
||||
if (body !== undefined) retryHeaders['Content-Type'] = 'application/json';
|
||||
const retryInit: RequestInit = { method, headers: retryHeaders };
|
||||
if (body !== undefined) retryInit.body = JSON.stringify(body);
|
||||
return this.fetchImpl(`${this.url}${path}`, retryInit);
|
||||
private async request(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
const url = `${this.url}${path}`;
|
||||
let lastStatus: number | undefined;
|
||||
let lastErr: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||
let res: Response;
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
res = await this.fetchImpl(url, this.buildInit(method, token, body));
|
||||
} catch (err) {
|
||||
// Network failure, DNS failure, or our own AbortSignal firing.
|
||||
lastErr = err;
|
||||
if (attempt < this.maxAttempts) {
|
||||
await this.sleep(this.backoffFor(attempt));
|
||||
continue;
|
||||
}
|
||||
throw new SecretBackendUnavailableError(
|
||||
`OpenBao ${method} ${path}: ${err instanceof Error ? err.message : String(err)} (after ${String(attempt)} attempt(s))`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
// If the cached token expired between cache-check and request (k8s clock
|
||||
// skew, server-side revocation, etc.), purge cache and retry once. This
|
||||
// is deliberately OUTSIDE the retry budget: it is a credential refresh,
|
||||
// not a backend-unavailable condition, and it must stay single-shot so a
|
||||
// genuinely revoked grant fails loudly instead of looping.
|
||||
if (res.status === 403 && this.cachedToken !== undefined) {
|
||||
this.cachedToken = undefined;
|
||||
this.cachedTokenExpiresAt = 0;
|
||||
const fresh = await this.getToken();
|
||||
return this.fetchImpl(url, this.buildInit(method, fresh, body));
|
||||
}
|
||||
|
||||
// The backend is up but cannot answer right now — 503 is also what a
|
||||
// sealed OpenBao returns, which used to be an immediate hard failure.
|
||||
if (RETRYABLE_STATUS.has(res.status) && attempt < this.maxAttempts) {
|
||||
lastStatus = res.status;
|
||||
await this.sleep(this.backoffFor(attempt));
|
||||
continue;
|
||||
}
|
||||
if (RETRYABLE_STATUS.has(res.status)) {
|
||||
throw new SecretBackendUnavailableError(
|
||||
`OpenBao ${method} ${path}: HTTP ${String(res.status)} after ${String(attempt)} attempt(s)`,
|
||||
{ lastStatus: res.status },
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
|
||||
/* c8 ignore next 5 -- unreachable: every loop exit above returns or throws */
|
||||
throw new SecretBackendUnavailableError(
|
||||
`OpenBao ${method} ${path}: exhausted ${String(this.maxAttempts)} attempt(s)`,
|
||||
lastErr !== undefined ? { cause: lastErr, ...(lastStatus !== undefined ? { lastStatus } : {}) } : (lastStatus !== undefined ? { lastStatus } : {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,49 @@ export interface SecretBackendDriver {
|
||||
/** List everything the backend knows about. Used for migration + drift detection. */
|
||||
list(): Promise<Array<{ name: string; externalRef: ExternalRef }>>;
|
||||
|
||||
/** Optional: health probe. Used by `mcpctl describe secretbackend`. */
|
||||
/**
|
||||
* Optional LIVENESS probe: is the backend reachable at all?
|
||||
*
|
||||
* Must NOT require authentication — the whole point is to separate "the
|
||||
* backend is down/sealed" from "our credentials stopped working". Compare
|
||||
* `authCheck()`, which is the readiness half.
|
||||
*/
|
||||
healthCheck?(): Promise<{ ok: boolean; detail?: string }>;
|
||||
|
||||
/**
|
||||
* Optional: provision a per-server identity in the backend, so an MCP server
|
||||
* pod can fetch its OWN secrets directly (Agent Injector) without mcpd ever
|
||||
* materialising the value into the pod spec.
|
||||
*
|
||||
* Scoping is the entire point. Each server gets a policy naming only the
|
||||
* secrets it declares, bound to its own ServiceAccount. A single shared role
|
||||
* would let any opted-in pod — including third-party images we do not
|
||||
* control — read every secret under the prefix, which is a worse position
|
||||
* than leaving values in the pod spec.
|
||||
*
|
||||
* Idempotent: called on every reconcile, must converge rather than conflict.
|
||||
*/
|
||||
ensureServerIdentity?(input: {
|
||||
/** Identity name — also the policy name and the k8s ServiceAccount name. */
|
||||
name: string;
|
||||
/** Namespace the bound ServiceAccount lives in. */
|
||||
namespace: string;
|
||||
/** Secrets this server may read. Empty means "revoke everything". */
|
||||
secretNames: string[];
|
||||
}): Promise<void>;
|
||||
|
||||
/** Optional: tear down what `ensureServerIdentity` created. Idempotent. */
|
||||
removeServerIdentity?(input: { name: string }): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional READINESS probe: can we actually read through this backend with
|
||||
* the credentials we hold?
|
||||
*
|
||||
* A backend that answers `healthCheck()` but fails here is the exact shape of
|
||||
* the incident where a re-initialised OpenBao left mcpd holding valid-looking
|
||||
* tokens that granted nothing. Reporting one signal for both hides it.
|
||||
*/
|
||||
authCheck?(): Promise<{ ok: boolean; detail?: string }>;
|
||||
}
|
||||
|
||||
/** Stored config for a SecretBackend row; dispatched on `type`. */
|
||||
@@ -66,3 +107,40 @@ export interface BackendRow {
|
||||
export interface SecretRefResolver {
|
||||
resolve(secretName: string, key: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend gave a definitive answer: this secret (or key) does not exist.
|
||||
*
|
||||
* Callers may treat this as final. The caching decorator EVICTS on this and
|
||||
* never serves a stale value for it — serving stale here would resurrect a
|
||||
* deliberately deleted or revoked credential, which is strictly worse than an
|
||||
* outage.
|
||||
*/
|
||||
export class SecretNotFoundError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = 'SecretNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend could not be reached or did not answer: DNS/TCP failure, request
|
||||
* timeout, or an exhausted retry budget against 5xx/429.
|
||||
*
|
||||
* This is the ONLY error the caching decorator will serve a stale value for.
|
||||
* The distinction has to be typed rather than string-matched: a mis-classified
|
||||
* "not found" would resurrect deleted secrets, and a mis-classified auth
|
||||
* failure would silently paper over a backend whose grants were revoked — the
|
||||
* failure mode that let an OpenBao re-init break every secret write for four
|
||||
* days (commit e51b924).
|
||||
*/
|
||||
export class SecretBackendUnavailableError extends Error {
|
||||
/** HTTP status of the last attempt, when the failure was an HTTP response. */
|
||||
readonly lastStatus: number | undefined;
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown; lastStatus?: number }) {
|
||||
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
||||
this.name = 'SecretBackendUnavailableError';
|
||||
this.lastStatus = options?.lastStatus;
|
||||
}
|
||||
}
|
||||
|
||||
219
src/mcpd/src/services/server-identity.service.ts
Normal file
219
src/mcpd/src/services/server-identity.service.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Per-server OpenBao identities, so an MCP server pod can fetch its own secrets
|
||||
* without mcpd ever writing the value into the pod spec.
|
||||
*
|
||||
* ## Why per-server, and not one shared role
|
||||
*
|
||||
* The obvious implementation gives every injected pod one shared ServiceAccount
|
||||
* and one role granting `secret/data/mcpctl/*`. That would be a downgrade, not
|
||||
* an improvement: today's exposure is "4 credentials readable by anyone with
|
||||
* `get pod` in this namespace", and the shared-role version is "10 credentials
|
||||
* readable by any process inside a third-party MCP server image". We do not
|
||||
* control `gitea-mcp-server` or `ha-mcp`; they should not be able to read the
|
||||
* Grafana token.
|
||||
*
|
||||
* So each server gets:
|
||||
* - its own Kubernetes ServiceAccount `mcpctl-server-<server>`
|
||||
* - its own OpenBao policy `mcpctl-server-<server>` (names only
|
||||
* the secrets that server declares — no wildcards)
|
||||
* - its own OpenBao k8s auth role `mcpctl-server-<server>` (bound to
|
||||
* that one ServiceAccount in that one namespace)
|
||||
*
|
||||
* ## Convergence
|
||||
*
|
||||
* `ensureFor` is idempotent and is safe to call on every start. The generated
|
||||
* policy is sorted and deduped, so re-writing an unchanged server produces a
|
||||
* byte-identical body — an unstable policy would churn the OpenBao audit log
|
||||
* and make real changes invisible.
|
||||
*
|
||||
* Removing a secret from a server's env and restarting it narrows the policy on
|
||||
* the next reconcile. Removing the server entirely revokes the identity.
|
||||
*/
|
||||
import type { McpServer } from '@prisma/client';
|
||||
import type { SecretBackendService } from './secret-backend.service.js';
|
||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||
import {
|
||||
buildInjectorAnnotations,
|
||||
wrapCommandForInjector,
|
||||
type InjectorEnvRef,
|
||||
} from './k8s/manifest-generator.js';
|
||||
|
||||
/** Minimal Kubernetes surface this needs — keeps the service testable. */
|
||||
export interface ServiceAccountPort {
|
||||
/** Namespace MCP server pods run in. */
|
||||
readonly namespace: string;
|
||||
/** Create the ServiceAccount if absent. Must tolerate "already exists". */
|
||||
ensure(name: string): Promise<void>;
|
||||
/** Delete it. Must tolerate "not found". */
|
||||
remove(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ServerIdentityLog {
|
||||
info(obj: Record<string, unknown>, msg: string): void;
|
||||
warn(obj: Record<string, unknown>, msg: string): void;
|
||||
}
|
||||
|
||||
const NOOP_LOG: ServerIdentityLog = { info: () => undefined, warn: () => undefined };
|
||||
|
||||
/** Shared prefix. mcpd's OpenBao grant is confined to exactly this prefix. */
|
||||
export const IDENTITY_PREFIX = 'mcpctl-server-';
|
||||
|
||||
/** Where the injector should authenticate and look for secrets. */
|
||||
export interface InjectorSettings {
|
||||
/** Auth mount validating THIS cluster's ServiceAccount tokens. */
|
||||
authPath: string;
|
||||
/** KV v2 mount. */
|
||||
mount: string;
|
||||
/** Path prefix under the mount. */
|
||||
pathPrefix: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_INJECTOR_SETTINGS: InjectorSettings = {
|
||||
authPath: 'auth/kubernetes',
|
||||
mount: 'secret',
|
||||
pathPrefix: 'mcpctl',
|
||||
};
|
||||
|
||||
export class ServerIdentityService {
|
||||
private readonly log: ServerIdentityLog;
|
||||
|
||||
constructor(
|
||||
private readonly backends: SecretBackendService,
|
||||
private readonly serviceAccounts: ServiceAccountPort,
|
||||
log?: ServerIdentityLog,
|
||||
private readonly injector: InjectorSettings = DEFAULT_INJECTOR_SETTINGS,
|
||||
) {
|
||||
this.log = log ?? NOOP_LOG;
|
||||
}
|
||||
|
||||
/** Injector pod annotations for these refs, under the given identity. */
|
||||
annotationsFor(identity: string, refs: InjectorEnvRef[]): Record<string, string> {
|
||||
return buildInjectorAnnotations(refs, {
|
||||
role: identity,
|
||||
authPath: this.injector.authPath,
|
||||
mount: this.injector.mount,
|
||||
pathPrefix: this.injector.pathPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/** Identity name for a server — also the SA, policy and role name. */
|
||||
identityNameFor(serverName: string): string {
|
||||
return `${IDENTITY_PREFIX}${serverName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct secrets a server declares. Inline `value` entries are not
|
||||
* secrets and must not widen the policy.
|
||||
*/
|
||||
secretNamesFor(server: Pick<McpServer, 'env'>): string[] {
|
||||
const entries = (server.env ?? []) as ServerEnvEntry[];
|
||||
const names = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const ref = entry.valueFrom?.secretRef;
|
||||
if (ref !== undefined) names.add(ref.name);
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the container ENTRYPOINT that sources the rendered secrets and then
|
||||
* execs the real server.
|
||||
*
|
||||
* This must REPLACE the image's entrypoint, not extend it. mcpd normally puts
|
||||
* a package server's argv into k8s `args` so the runner image's `npx -y` /
|
||||
* `uvx` entrypoint still runs; a wrapper placed there would be executed BY
|
||||
* npx (`npx -y /bin/sh -c ...`) and fail. So the argv returned here folds in
|
||||
* whatever the image's entrypoint would have contributed.
|
||||
*
|
||||
* Returns undefined when there is nothing to wrap, leaving the pod on the
|
||||
* normal entrypoint+args path.
|
||||
*/
|
||||
wrapCommand(
|
||||
server: Pick<McpServer, 'env' | 'entrypoint' | 'packageName' | 'runtime'>,
|
||||
command: string[] | undefined,
|
||||
): string[] | undefined {
|
||||
const secretNames = this.secretNamesFor(server);
|
||||
if (secretNames.length === 0) return undefined;
|
||||
|
||||
// Build the COMPLETE argv the container should run. It differs by shape:
|
||||
//
|
||||
// package server — mcpd owns the runner image, whose ENTRYPOINT
|
||||
// (`npx -y` / `uvx`) is lost once we take over
|
||||
// `command`, so it must be prepended here.
|
||||
// image + command — `command` is already a full command line; mcpd would
|
||||
// have run exactly it. Prepending anything breaks it.
|
||||
// image only — the image's own ENTRYPOINT would run and mcpd cannot
|
||||
// introspect it, so the row must declare `entrypoint`.
|
||||
//
|
||||
// Getting this wrong returns undefined and SILENTLY skips the wrapper: the
|
||||
// agent still renders the file, nothing sources it, and the server starts
|
||||
// with empty credentials. Observed on docmost and my-home-assistant, which
|
||||
// carry a `command` but no `entrypoint`.
|
||||
const hasPackage = server.packageName !== null && server.packageName !== undefined && server.packageName !== '';
|
||||
const argv = hasPackage
|
||||
? [...(server.runtime === 'python' ? ['uvx'] : ['npx', '-y']), ...(command ?? [server.packageName as string])]
|
||||
: command !== undefined && command.length > 0
|
||||
? command
|
||||
: ((server.entrypoint as string[] | null) ?? undefined);
|
||||
|
||||
if (argv === undefined || argv.length === 0) return undefined;
|
||||
return wrapCommandForInjector(argv, secretNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converge the identity for one server. Returns the identity name so the
|
||||
* caller can stamp it onto the pod spec.
|
||||
*
|
||||
* Order matters: the ServiceAccount must exist before the role that binds it,
|
||||
* or the pod can start, fail to log in, and crashloop while the role is still
|
||||
* being written.
|
||||
*/
|
||||
async ensureFor(server: Pick<McpServer, 'name' | 'env'>): Promise<string> {
|
||||
const name = this.identityNameFor(server.name);
|
||||
const secretNames = this.secretNamesFor(server);
|
||||
|
||||
const backend = await this.backends.getDefault();
|
||||
const driver = this.backends.driverFor(backend);
|
||||
if (driver.ensureServerIdentity === undefined) {
|
||||
throw new Error(
|
||||
`secret backend '${backend.name}' (${backend.type}) cannot provision per-server identities — ` +
|
||||
'per-server secret delivery requires an openbao backend using kubernetes auth',
|
||||
);
|
||||
}
|
||||
|
||||
await this.serviceAccounts.ensure(name);
|
||||
await driver.ensureServerIdentity({
|
||||
name,
|
||||
namespace: this.serviceAccounts.namespace,
|
||||
secretNames,
|
||||
});
|
||||
|
||||
this.log.info(
|
||||
{ identity: name, namespace: this.serviceAccounts.namespace, secrets: secretNames },
|
||||
`provisioned scoped OpenBao identity for server '${server.name}' (${String(secretNames.length)} secret(s))`,
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a server's identity. Best-effort per step: a half-removed identity
|
||||
* grants nothing useful, and failing the whole teardown because a
|
||||
* ServiceAccount was already gone would leave the OpenBao side orphaned.
|
||||
*/
|
||||
async removeFor(serverName: string): Promise<void> {
|
||||
const name = this.identityNameFor(serverName);
|
||||
const backend = await this.backends.getDefault();
|
||||
const driver = this.backends.driverFor(backend);
|
||||
|
||||
try {
|
||||
await driver.removeServerIdentity?.({ name });
|
||||
} catch (err) {
|
||||
this.log.warn({ identity: name, err: String(err) }, `could not remove OpenBao identity '${name}'`);
|
||||
}
|
||||
try {
|
||||
await this.serviceAccounts.remove(name);
|
||||
} catch (err) {
|
||||
this.log.warn({ identity: name, err: String(err) }, `could not remove ServiceAccount '${name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,15 +121,15 @@ export class PersistentStdioClient {
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
exec.stdout.on('end', () => {
|
||||
this.initialized = false;
|
||||
this.exec = null;
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('STDIO process exited'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
// All three events funnel into the same teardown: 'end' is the graceful
|
||||
// path, but an abnormal websocket death may only surface as 'close' or
|
||||
// 'error' on the stream — before this, isConnected stayed true and every
|
||||
// request rode out the full timeout against a dead pipe (mcpctl#114).
|
||||
exec.stdout.on('end', () => this.teardown(exec, 'STDIO process exited'));
|
||||
exec.stdout.on('close', () => this.teardown(exec, 'STDIO stream closed'));
|
||||
exec.stdout.on('error', (err: Error) =>
|
||||
this.teardown(exec, `STDIO stream error: ${err.message}`),
|
||||
);
|
||||
|
||||
// Run MCP init handshake
|
||||
const initId = this.nextId++;
|
||||
@@ -174,6 +174,23 @@ export class PersistentStdioClient {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a dead session: reject in-flight requests and mark the client
|
||||
* disconnected so the next send() redials via ensureReady(). The identity
|
||||
* guard is load-bearing: after a reconnect, a LATE 'close'/'end' from the
|
||||
* previous session's stream must not clobber the new session.
|
||||
*/
|
||||
private teardown(exec: InteractiveExec, reason: string): void {
|
||||
if (this.exec !== exec) return;
|
||||
this.initialized = false;
|
||||
this.exec = null;
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
private write(msg: Record<string, unknown>): void {
|
||||
if (!this.exec) throw new Error('Not connected');
|
||||
this.exec.write(JSON.stringify(msg) + '\n');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HealthCheckSchema } from './template.schema.js';
|
||||
import { HealthCheckSchema, VolumeSpecSchema } from './template.schema.js';
|
||||
|
||||
const SecretRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -19,6 +19,20 @@ export const ServerEnvEntrySchema = z.object({
|
||||
|
||||
export type ServerEnvEntry = z.infer<typeof ServerEnvEntrySchema>;
|
||||
|
||||
/**
|
||||
* How secret-backed env reaches the container.
|
||||
*
|
||||
* `env` (default) resolves values in mcpd and writes them into the pod spec —
|
||||
* cleartext in etcd, readable by anyone with `get pod`. `injector` has the pod
|
||||
* fetch its own secrets from OpenBao, under an identity scoped to just that
|
||||
* server's secrets, so the value never touches the pod spec.
|
||||
*
|
||||
* Defaulting to `env` keeps every existing server bit-for-bit unchanged;
|
||||
* migrating is an explicit, reversible, per-server decision.
|
||||
*/
|
||||
export const SecretDeliverySchema = z.enum(['env', 'injector']);
|
||||
export type SecretDelivery = z.infer<typeof SecretDeliverySchema>;
|
||||
|
||||
export const CreateMcpServerSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
description: z.string().max(1000).default(''),
|
||||
@@ -33,7 +47,32 @@ export const CreateMcpServerSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
secretDelivery: SecretDeliverySchema.default('env'),
|
||||
entrypoint: z.array(z.string()).optional(),
|
||||
// Per-server override for mcplocal's tool-call deadline. Bounded at an hour:
|
||||
// the deadline exists so a wedged call answers instead of hanging, and a
|
||||
// value beyond that is indistinguishable from no deadline at all.
|
||||
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).optional(),
|
||||
}).refine(
|
||||
(s) => s.volumes.length === 0 || s.replicas <= 1,
|
||||
{
|
||||
message:
|
||||
'A server with volumes cannot have replicas > 1: the backing claim is ReadWriteOnce, '
|
||||
+ 'so the extra replicas would sit unschedulable on a volume they cannot mount.',
|
||||
path: ['replicas'],
|
||||
},
|
||||
).refine(
|
||||
(s) => s.secretDelivery !== 'injector' || s.dockerImage === undefined || s.entrypoint !== undefined,
|
||||
{
|
||||
message:
|
||||
'secretDelivery: injector on a dockerImage server needs an explicit entrypoint. '
|
||||
+ 'The injector renders secrets to a file, so the container command is rewritten to source '
|
||||
+ 'it and exec the real process — and mcpd cannot read the image\'s own ENTRYPOINT to know '
|
||||
+ 'what that process is. Package-based servers do not need this (mcpd owns their entrypoint).',
|
||||
path: ['entrypoint'],
|
||||
},
|
||||
);
|
||||
|
||||
export const UpdateMcpServerSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
@@ -48,6 +87,10 @@ export const UpdateMcpServerSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).optional(),
|
||||
env: z.array(ServerEnvEntrySchema).optional(),
|
||||
healthCheck: HealthCheckSchema.nullable().optional(),
|
||||
volumes: z.array(VolumeSpecSchema).optional(),
|
||||
secretDelivery: SecretDeliverySchema.optional(),
|
||||
entrypoint: z.array(z.string()).nullable().optional(),
|
||||
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||
|
||||
@@ -7,8 +7,30 @@ const TemplateEnvEntrySchema = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A persistent volume declared on a server (or template).
|
||||
*
|
||||
* `name` is scoped to the server; the backing Docker volume / Kubernetes PVC is
|
||||
* named `mcpctl-<server>-<name>` so it is stable across instance recreation.
|
||||
*/
|
||||
export const VolumeSpecSchema = z.object({
|
||||
name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, 'Volume name must be lowercase alphanumeric with hyphens'),
|
||||
mountPath: z.string().min(1).max(200).regex(/^\//, 'mountPath must be absolute'),
|
||||
sizeGb: z.number().int().min(1).max(1024).default(10),
|
||||
storageClass: z.string().max(100).optional(),
|
||||
fsGroup: z.number().int().min(0).max(65535).optional(),
|
||||
});
|
||||
|
||||
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),
|
||||
@@ -32,6 +54,7 @@ export const CreateTemplateSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('DockerContainerManager — ephemeral session containers', () => {
|
||||
});
|
||||
|
||||
it('translates volumes into namespaced Docker mounts', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'sess', volumes: [WORKSPACE] });
|
||||
await manager.createContainer({ image: 'x:latest', name: 'sess', scratchVolumes: [WORKSPACE] });
|
||||
expect(hostConfig(0)['Mounts']).toEqual([
|
||||
{ Type: 'volume', Source: 'sess-workspace', Target: '/workspace' },
|
||||
]);
|
||||
@@ -99,7 +99,7 @@ describe('DockerContainerManager — ephemeral session containers', () => {
|
||||
const spec: ContainerSpec = {
|
||||
image: 'agent:latest',
|
||||
name: 'sess',
|
||||
volumes: [WORKSPACE],
|
||||
scratchVolumes: [WORKSPACE],
|
||||
initContainers: [
|
||||
{
|
||||
name: 'clone-api',
|
||||
@@ -138,7 +138,7 @@ describe('DockerContainerManager — ephemeral session containers', () => {
|
||||
manager.createContainer({
|
||||
image: 'agent:latest',
|
||||
name: 'sess',
|
||||
volumes: [WORKSPACE],
|
||||
scratchVolumes: [WORKSPACE],
|
||||
initContainers: [
|
||||
{ name: 'clone', image: 'alpine/git', command: ['git', 'clone', 'bad'] },
|
||||
{ name: 'never-runs', image: 'alpine/git', command: ['true'] },
|
||||
|
||||
58
src/mcpd/tests/injector-argv.test.ts
Normal file
58
src/mcpd/tests/injector-argv.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Which argv the injector wrapper wraps, by server shape.
|
||||
*
|
||||
* Getting this wrong is SILENT: wrapCommand returns undefined, the wrapper is
|
||||
* skipped, the agent still renders /vault/secrets/<name>, nothing sources it,
|
||||
* and the server starts with empty credentials. Observed live on docmost and
|
||||
* my-home-assistant, which carry a `command` but no `entrypoint`.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ServerIdentityService } from '../src/services/server-identity.service.js';
|
||||
import type { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
|
||||
const svc = new ServerIdentityService(
|
||||
{} as unknown as SecretBackendService,
|
||||
{ namespace: 'mcpctl-servers', ensure: async () => undefined, remove: async () => undefined },
|
||||
);
|
||||
|
||||
const withSecret = { env: [{ name: 'T', valueFrom: { secretRef: { name: 'creds', key: 'K' } } }] };
|
||||
/** The wrapper is `sh -c <script> arg0 arg1...`; argv starts at index 3. */
|
||||
const argvOf = (r: string[] | undefined): string[] | undefined => r?.slice(3);
|
||||
|
||||
describe('wrapCommand argv by server shape', () => {
|
||||
it('prepends the node runner entrypoint for a package server', () => {
|
||||
const r = svc.wrapCommand({ ...withSecret, packageName: '@leval/mcp-grafana', runtime: 'node', entrypoint: null } as never, ['@leval/mcp-grafana']);
|
||||
expect(argvOf(r)).toEqual(['npx', '-y', '@leval/mcp-grafana']);
|
||||
});
|
||||
|
||||
it('prepends uvx for a python package server', () => {
|
||||
const r = svc.wrapCommand({ ...withSecret, packageName: 'mcp-searxng', runtime: 'python', entrypoint: null } as never, ['mcp-searxng']);
|
||||
expect(argvOf(r)).toEqual(['uvx', 'mcp-searxng']);
|
||||
});
|
||||
|
||||
it('uses an image server\'s command verbatim — prepending anything breaks it', () => {
|
||||
// The docmost/home-assistant regression: this used to return undefined.
|
||||
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'build/index.js']);
|
||||
expect(argvOf(r)).toEqual(['node', 'build/index.js']);
|
||||
});
|
||||
|
||||
it('falls back to the declared entrypoint for an image server with no command', () => {
|
||||
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: ['/usr/local/bin/gitea-mcp'] } as never, undefined);
|
||||
expect(argvOf(r)).toEqual(['/usr/local/bin/gitea-mcp']);
|
||||
});
|
||||
|
||||
it('returns undefined when there is genuinely nothing to run', () => {
|
||||
expect(svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a server with no secret refs', () => {
|
||||
expect(svc.wrapCommand({ env: [], packageName: 'p', runtime: 'node', entrypoint: null } as never, ['p'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('always sources before exec, whatever the shape', () => {
|
||||
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'x.js']);
|
||||
expect(r?.[0]).toBe('/bin/sh');
|
||||
expect(r?.[2]).toContain('. /vault/secrets/creds');
|
||||
expect(r?.[2]).toContain('exec "$0" "$@"');
|
||||
});
|
||||
});
|
||||
67
src/mcpd/tests/injector-instance-wiring.test.ts
Normal file
67
src/mcpd/tests/injector-instance-wiring.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Instance-start wiring for injector delivery.
|
||||
*
|
||||
* Two invariants dominate: an opted-OUT server behaves exactly as before, and
|
||||
* an opted-IN server still fails LOUDLY on a bad secret ref. The second is the
|
||||
* one at risk — once mcpd stops reading a server's secrets, the fail-loud check
|
||||
* from e6cd735 no longer fires for it, and a typo'd ref degrades into a
|
||||
* vault-agent-init crashloop reported as a generic pod failure.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { partitionServerEnv, validateServerEnvRefs } from '../src/services/env-resolver.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function server(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
name: 'gitea',
|
||||
env: [
|
||||
{ name: 'GITEA_HOST', value: 'https://example' },
|
||||
{ name: 'GITEA_ACCESS_TOKEN', valueFrom: { secretRef: { name: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' } } },
|
||||
],
|
||||
...overrides,
|
||||
} as McpServer;
|
||||
}
|
||||
|
||||
describe('partitionServerEnv', () => {
|
||||
it('separates inline values from references without resolving anything', () => {
|
||||
const { inline, refs } = partitionServerEnv(server());
|
||||
expect(inline).toEqual({ GITEA_HOST: 'https://example' });
|
||||
expect(refs).toEqual([
|
||||
{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never carries a secret value — that is the whole point', () => {
|
||||
const { inline } = partitionServerEnv(server());
|
||||
expect(JSON.stringify(inline)).not.toContain('TOKEN_VALUE');
|
||||
expect(Object.keys(inline)).not.toContain('GITEA_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('handles a server with no env at all', () => {
|
||||
expect(partitionServerEnv(server({ env: [] }))).toEqual({ inline: {}, refs: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateServerEnvRefs — preserves the fail-loud invariant', () => {
|
||||
it('resolves every ref and discards the value', async () => {
|
||||
const resolve = vi.fn(async () => 'super-secret');
|
||||
await validateServerEnvRefs(server(), { resolve });
|
||||
expect(resolve).toHaveBeenCalledWith('gitea-creds', 'GITEA_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('throws the SAME message shape as inline resolution on a bad ref', async () => {
|
||||
// instance.service turns this into markInstanceError("secret resolution
|
||||
// failed: …"), which is what an operator greps for.
|
||||
const resolve = vi.fn(async () => { throw new Error("Secret 'gitea-creds' has no key 'NOPE'"); });
|
||||
await expect(validateServerEnvRefs(
|
||||
server({ env: [{ name: 'T', valueFrom: { secretRef: { name: 'gitea-creds', key: 'NOPE' } } }] as never }),
|
||||
{ resolve },
|
||||
)).rejects.toThrow(/Cannot resolve secret for server 'gitea' env 'T'/);
|
||||
});
|
||||
|
||||
it('does nothing for a server with no refs', async () => {
|
||||
const resolve = vi.fn();
|
||||
await validateServerEnvRefs(server({ env: [{ name: 'X', value: 'y' }] as never }), { resolve });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
158
src/mcpd/tests/injector-manifest.test.ts
Normal file
158
src/mcpd/tests/injector-manifest.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Injector manifest generation.
|
||||
*
|
||||
* The two things that must not break: a server NOT opted in produces a
|
||||
* byte-identical manifest to before, and a rendered secret value survives the
|
||||
* shell intact whatever it contains. The second is the subtle one — bad quoting
|
||||
* fails silently, yielding an empty token and a server that reports healthy
|
||||
* while every authenticated call fails.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
generatePodSpec,
|
||||
buildInjectorAnnotations,
|
||||
wrapCommandForInjector,
|
||||
shellSingleQuote,
|
||||
} from '../src/services/k8s/manifest-generator.js';
|
||||
import type { ContainerSpec } from '../src/services/orchestrator.js';
|
||||
|
||||
const BASE: ContainerSpec = { name: 'gitea', image: 'gitea/mcp:latest' } as ContainerSpec;
|
||||
const CFG = { role: 'mcpctl-server-gitea', authPath: 'auth/kubernetes-worker0', mount: 'secret', pathPrefix: 'mcpctl' };
|
||||
|
||||
describe('generatePodSpec — opted-out servers are untouched', () => {
|
||||
it('emits no annotations, no serviceAccountName, automount still false', () => {
|
||||
const pod = generatePodSpec({ ...BASE, env: { TOKEN: 'plain' } } as ContainerSpec, 'mcpctl-servers');
|
||||
expect(pod.metadata.annotations).toBeUndefined();
|
||||
expect(pod.spec.serviceAccountName).toBeUndefined();
|
||||
expect(pod.spec.automountServiceAccountToken).toBe(false);
|
||||
expect(pod.spec.containers[0]?.env).toEqual([{ name: 'TOKEN', value: 'plain' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePodSpec — opted-in servers', () => {
|
||||
const spec = {
|
||||
...BASE,
|
||||
annotations: buildInjectorAnnotations(
|
||||
[{ name: 'GITEA_ACCESS_TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' }],
|
||||
CFG,
|
||||
),
|
||||
serviceAccountName: 'mcpctl-server-gitea',
|
||||
automountServiceAccountToken: true,
|
||||
} as ContainerSpec;
|
||||
|
||||
it('carries no secret VALUE anywhere in the manifest', () => {
|
||||
const pod = generatePodSpec(spec, 'mcpctl-servers');
|
||||
const json = JSON.stringify(pod);
|
||||
expect(json).not.toContain('de4c69ed');
|
||||
expect(pod.spec.containers[0]?.env ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs as its own ServiceAccount with the SA token projected', () => {
|
||||
const pod = generatePodSpec(spec, 'mcpctl-servers');
|
||||
expect(pod.spec.serviceAccountName).toBe('mcpctl-server-gitea');
|
||||
// The agent cannot log in without it; false here is a silent crashloop.
|
||||
expect(pod.spec.automountServiceAccountToken).toBe(true);
|
||||
});
|
||||
|
||||
it('pins the auth path to the mount that validates THIS cluster', () => {
|
||||
const a = buildInjectorAnnotations([{ name: 'T', secretName: 's', key: 'k' }], CFG);
|
||||
expect(a['vault.hashicorp.com/auth-path']).toBe('auth/kubernetes-worker0');
|
||||
expect(a['vault.hashicorp.com/role']).toBe('mcpctl-server-gitea');
|
||||
// init container only — no sidecar in a 512Mi pod
|
||||
expect(a['vault.hashicorp.com/agent-pre-populate-only']).toBe('true');
|
||||
});
|
||||
|
||||
it('groups env vars by the secret they come from', () => {
|
||||
const a = buildInjectorAnnotations([
|
||||
{ name: 'HOST', secretName: 'gitea-creds', key: 'GITEA_HOST' },
|
||||
{ name: 'TOKEN', secretName: 'gitea-creds', key: 'GITEA_ACCESS_TOKEN' },
|
||||
{ name: 'OTHER', secretName: 'other-creds', key: 'K' },
|
||||
], CFG);
|
||||
expect(a['vault.hashicorp.com/agent-inject-secret-gitea-creds']).toBe('secret/data/mcpctl/gitea-creds');
|
||||
expect(a['vault.hashicorp.com/agent-inject-secret-other-creds']).toBe('secret/data/mcpctl/other-creds');
|
||||
const tpl = a['vault.hashicorp.com/agent-inject-template-gitea-creds'] ?? '';
|
||||
expect(tpl).toContain('export HOST=');
|
||||
expect(tpl).toContain('export TOKEN=');
|
||||
expect(tpl).not.toContain('export OTHER=');
|
||||
});
|
||||
|
||||
it('is stable under input reordering', () => {
|
||||
const refs = [
|
||||
{ name: 'B', secretName: 'z', key: 'k' },
|
||||
{ name: 'A', secretName: 'a', key: 'k' },
|
||||
];
|
||||
expect(buildInjectorAnnotations(refs, CFG)).toEqual(buildInjectorAnnotations([...refs].reverse(), CFG));
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapCommandForInjector', () => {
|
||||
it('preserves argv exactly via $0/$@ and execs so PID 1 is the real process', () => {
|
||||
// exec matters: mcpd attaches to PID 1 stdin/stdout for STDIO servers.
|
||||
const cmd = wrapCommandForInjector(['node', 'server.js', '--port', '3000'], ['a-creds']);
|
||||
expect(cmd[0]).toBe('/bin/sh');
|
||||
expect(cmd[1]).toBe('-c');
|
||||
expect(cmd[2]).toContain('exec "$0" "$@"');
|
||||
expect(cmd.slice(3)).toEqual(['node', 'server.js', '--port', '3000']);
|
||||
});
|
||||
|
||||
it('sources every referenced secret file, in a stable order', () => {
|
||||
const cmd = wrapCommandForInjector(['x'], ['b-creds', 'a-creds']);
|
||||
expect(cmd[2]).toBe('. /vault/secrets/a-creds; . /vault/secrets/b-creds; exec "$0" "$@"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell quoting survives adversarial values', () => {
|
||||
// Executed by a real /bin/sh: asserting on the string alone would prove
|
||||
// nothing about what the shell actually does with it.
|
||||
const nasty = [
|
||||
'plain',
|
||||
'with space',
|
||||
"single'quote",
|
||||
'double"quote',
|
||||
'$USER and `whoami`',
|
||||
'semi;colon && rm -rf /',
|
||||
'new\nline',
|
||||
'back\\slash',
|
||||
"'; export PWNED=1; '",
|
||||
];
|
||||
|
||||
it.each(nasty)('round-trips %j through a sourced file', (value) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
|
||||
const file = join(dir, 'env');
|
||||
writeFileSync(file, `export SECRET=${shellSingleQuote(value)}\n`);
|
||||
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$SECRET"`], { encoding: 'utf-8' });
|
||||
expect(out).toBe(value);
|
||||
});
|
||||
|
||||
it('does not let a crafted value execute anything', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mcpctl-quote-'));
|
||||
const file = join(dir, 'env');
|
||||
writeFileSync(file, `export SECRET=${shellSingleQuote("'; touch /tmp/mcpctl-pwned; '")}\n`);
|
||||
const out = execFileSync('/bin/sh', ['-c', `. ${file}; printf '%s' "$PWNED_MARKER"`], { encoding: 'utf-8' });
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the wrapper must REPLACE the image entrypoint, not extend it', () => {
|
||||
// Regression: mcpd maps ContainerSpec.command -> k8s `args` so the runner
|
||||
// image's `npx -y` / `uvx` entrypoint still runs. Emitting the wrapper there
|
||||
// meant the pod actually ran `npx -y /bin/sh -c '...'`, which crashlooped.
|
||||
it('emits container.command (entrypoint) and no args', () => {
|
||||
const pod = generatePodSpec({
|
||||
name: 'g', image: 'runner',
|
||||
entrypoint: ['/bin/sh', '-c', '. /vault/secrets/s; exec "$0" "$@"', 'npx', '-y', '@leval/mcp-grafana'],
|
||||
} as ContainerSpec, 'mcpctl-servers');
|
||||
expect(pod.spec.containers[0]?.command?.[0]).toBe('/bin/sh');
|
||||
expect(pod.spec.containers[0]?.args).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the normal entrypoint+args path alone when not wrapping', () => {
|
||||
const pod = generatePodSpec({ name: 'g', image: 'runner', command: ['@leval/mcp-grafana'] } as ContainerSpec, 'mcpctl-servers');
|
||||
expect(pod.spec.containers[0]?.command).toBeUndefined();
|
||||
expect(pod.spec.containers[0]?.args).toEqual(['@leval/mcp-grafana']);
|
||||
});
|
||||
});
|
||||
@@ -70,10 +70,11 @@ function mockOrchestrator(): McpOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
|
||||
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null; volumes: unknown }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'srv-1',
|
||||
name: overrides.name ?? 'slack',
|
||||
volumes: overrides.volumes ?? [],
|
||||
dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
|
||||
packageName: null,
|
||||
transport: overrides.transport ?? 'STDIO',
|
||||
@@ -143,6 +144,53 @@ describe('InstanceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('volumes', () => {
|
||||
it('names the claim after the server, not the instance', async () => {
|
||||
// The whole point of the feature: instances are recreated on every server
|
||||
// edit, so an instance-scoped claim would be destroyed exactly when the
|
||||
// data needs to survive.
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({
|
||||
name: 'docs',
|
||||
volumes: [{ name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }],
|
||||
}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes).toEqual([
|
||||
{ claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' },
|
||||
]);
|
||||
// Container name carries the instance id; the claim must not.
|
||||
expect(spec.name).toContain('inst-1');
|
||||
expect(spec.volumes![0]!.claimName).not.toContain('inst-1');
|
||||
});
|
||||
|
||||
it('defaults size and leaves storageClass unset when unspecified', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({
|
||||
name: 'docs',
|
||||
volumes: [{ name: 'data', mountPath: '/data' }],
|
||||
}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes![0]!.sizeGb).toBe(10);
|
||||
expect(spec.volumes![0]).not.toHaveProperty('storageClass');
|
||||
});
|
||||
|
||||
it('leaves the spec free of volumes when the server declares none', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile', () => {
|
||||
it('starts instances when below desired replicas', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
|
||||
@@ -463,3 +511,130 @@ describe('InstanceService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncStatus (mcpctl#114) — first-ever coverage of this method ──
|
||||
|
||||
describe('syncStatus', () => {
|
||||
let instanceRepo: IMcpInstanceRepository;
|
||||
let serverRepo: IMcpServerRepository;
|
||||
let orchestrator: McpOrchestrator;
|
||||
let service: InstanceService;
|
||||
let invalidator: ReturnType<typeof vi.fn>;
|
||||
|
||||
function inspect(info: Partial<{ state: string; restartCount: number }>) {
|
||||
vi.mocked(orchestrator.inspectContainer).mockResolvedValue({
|
||||
containerId: 'ctr-abc',
|
||||
name: 'test',
|
||||
state: (info.state ?? 'running') as 'running',
|
||||
createdAt: new Date(),
|
||||
...(info.restartCount !== undefined ? { restartCount: info.restartCount } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
instanceRepo = mockInstanceRepo();
|
||||
serverRepo = mockServerRepo();
|
||||
orchestrator = mockOrchestrator();
|
||||
service = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||
invalidator = vi.fn();
|
||||
service.setStdioInvalidator(invalidator);
|
||||
});
|
||||
|
||||
it('marks a stopped container ERROR and MERGES retry metadata instead of clobbering it', async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ status: 'RUNNING', metadata: { attemptCount: 3, nextRetryAt: future } }),
|
||||
]);
|
||||
inspect({ state: 'stopped' });
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!;
|
||||
expect(status).toBe('ERROR');
|
||||
const meta = fields!.metadata as Record<string, unknown>;
|
||||
expect(meta['error']).toBe('log output'); // last log line
|
||||
expect(meta['attemptCount']).toBe(3); // preserved — was clobbered before
|
||||
expect(meta['nextRetryAt']).toBe(future); // preserved — hot-loop killer
|
||||
expect(invalidator).toHaveBeenCalledWith('ctr-abc');
|
||||
});
|
||||
|
||||
it('recovers an ERROR instance whose pod is running again (the stuck state)', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({
|
||||
status: 'ERROR',
|
||||
metadata: { error: 'Container stopped', attemptCount: 4, nextRetryAt: 'x', lastAttemptAt: 'y' },
|
||||
}),
|
||||
]);
|
||||
inspect({ state: 'running', restartCount: 2 });
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!;
|
||||
expect(status).toBe('RUNNING');
|
||||
const meta = fields!.metadata as Record<string, unknown>;
|
||||
expect(meta['error']).toBeUndefined();
|
||||
expect(meta['attemptCount']).toBeUndefined();
|
||||
expect(meta['nextRetryAt']).toBeUndefined();
|
||||
expect(meta['lastRestartCount']).toBe(2);
|
||||
expect(invalidator).toHaveBeenCalledWith('ctr-abc');
|
||||
});
|
||||
|
||||
it('leaves an ERROR instance alone when its pod is genuinely gone', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ status: 'ERROR', metadata: { error: 'x' } }),
|
||||
]);
|
||||
vi.mocked(orchestrator.inspectContainer).mockRejectedValue(new Error('not found'));
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
expect(instanceRepo.updateStatus).not.toHaveBeenCalled();
|
||||
expect(invalidator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects an in-place restart via restartCount and invalidates the stdio pipe', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ status: 'RUNNING', metadata: { lastRestartCount: 1 } }),
|
||||
]);
|
||||
inspect({ state: 'running', restartCount: 2 });
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
const [, status, fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!;
|
||||
expect(status).toBe('RUNNING');
|
||||
expect((fields!.metadata as Record<string, unknown>)['lastRestartCount']).toBe(2);
|
||||
expect(invalidator).toHaveBeenCalledWith('ctr-abc');
|
||||
});
|
||||
|
||||
it('baselines a first-seen restartCount without invalidating', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ status: 'RUNNING', metadata: {} }),
|
||||
]);
|
||||
inspect({ state: 'running', restartCount: 0 });
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
const [, , fields] = vi.mocked(instanceRepo.updateStatus).mock.calls[0]!;
|
||||
expect((fields!.metadata as Record<string, unknown>)['lastRestartCount']).toBe(0);
|
||||
expect(invalidator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for a steady RUNNING instance with an unchanged restartCount', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ status: 'RUNNING', metadata: { lastRestartCount: 2 } }),
|
||||
]);
|
||||
inspect({ state: 'running', restartCount: 2 });
|
||||
|
||||
await service.syncStatus();
|
||||
|
||||
expect(instanceRepo.updateStatus).not.toHaveBeenCalled();
|
||||
expect(invalidator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remove() evicts the cached stdio client', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ status: 'RUNNING' }));
|
||||
|
||||
await service.remove('inst-1');
|
||||
|
||||
expect(invalidator).toHaveBeenCalledWith('ctr-abc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
generatePodSpec,
|
||||
generateDeploymentSpec,
|
||||
generateNamespaceSpec,
|
||||
generatePvcSpec,
|
||||
formatMemory,
|
||||
formatCpu,
|
||||
sanitizeName,
|
||||
@@ -156,6 +157,88 @@ describe('generateDeploymentSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('volumes', () => {
|
||||
const volumeSpec: ContainerSpec = {
|
||||
...baseSpec,
|
||||
volumes: [{ claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }],
|
||||
};
|
||||
|
||||
it('mounts the claim in the pod and declares the volume', () => {
|
||||
const pod = generatePodSpec(volumeSpec, 'mcpctl-servers');
|
||||
expect(pod.spec.volumes).toEqual([
|
||||
{ name: 'mcpctl-docs-data', persistentVolumeClaim: { claimName: 'mcpctl-docs-data' } },
|
||||
]);
|
||||
expect(pod.spec.containers[0]!.volumeMounts).toEqual([
|
||||
{ name: 'mcpctl-docs-data', mountPath: '/data' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits volume fields entirely when none are declared', () => {
|
||||
const pod = generatePodSpec(baseSpec, 'mcpctl-servers');
|
||||
expect(pod.spec.volumes).toBeUndefined();
|
||||
expect(pod.spec.containers[0]!.volumeMounts).toBeUndefined();
|
||||
expect(pod.spec.securityContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets fsGroup so a non-root image can write to a fresh claim', () => {
|
||||
// A PVC mounts root:root. Without fsGroup, any image that drops privileges
|
||||
// (docs-mcp-server runs as uid 1000) fails on first write.
|
||||
const pod = generatePodSpec(volumeSpec, 'mcpctl-servers');
|
||||
expect(pod.spec.securityContext?.fsGroup).toBe(1000);
|
||||
// "Always" re-chowns the entire volume on every start.
|
||||
expect(pod.spec.securityContext?.fsGroupChangePolicy).toBe('OnRootMismatch');
|
||||
});
|
||||
|
||||
it('honours an explicit fsGroup', () => {
|
||||
const pod = generatePodSpec(
|
||||
{ ...baseSpec, volumes: [{ claimName: 'c', mountPath: '/d', sizeGb: 1, fsGroup: 65534 }] },
|
||||
'ns',
|
||||
);
|
||||
expect(pod.spec.securityContext?.fsGroup).toBe(65534);
|
||||
});
|
||||
|
||||
it('carries volumes into a deployment pod template', () => {
|
||||
const dep = generateDeploymentSpec(volumeSpec, 'mcpctl-servers', 1);
|
||||
expect(dep.spec.template.spec.volumes).toHaveLength(1);
|
||||
expect(dep.spec.template.spec.containers[0]!.volumeMounts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds a PVC with the requested size and class', () => {
|
||||
const pvc = generatePvcSpec(volumeSpec.volumes![0]!, 'mcpctl-servers');
|
||||
expect(pvc.kind).toBe('PersistentVolumeClaim');
|
||||
expect(pvc.metadata.name).toBe('mcpctl-docs-data');
|
||||
expect(pvc.metadata.namespace).toBe('mcpctl-servers');
|
||||
expect(pvc.spec.resources.requests.storage).toBe('20Gi');
|
||||
expect(pvc.spec.accessModes).toEqual(['ReadWriteOnce']);
|
||||
expect(pvc.spec.storageClassName).toBe('longhorn');
|
||||
});
|
||||
|
||||
it('omits storageClassName rather than sending an empty string', () => {
|
||||
// An empty string means "no storage class" to Kubernetes, which is NOT the
|
||||
// same as omitting the field (use the cluster default).
|
||||
const prev = process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
delete process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
try {
|
||||
const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 1 }, 'ns');
|
||||
expect('storageClassName' in pvc.spec).toBe(false);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to MCPD_VOLUME_STORAGE_CLASS when the volume omits a class', () => {
|
||||
const prev = process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
process.env['MCPD_VOLUME_STORAGE_CLASS'] = 'longhorn';
|
||||
try {
|
||||
const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 5 }, 'ns');
|
||||
expect(pvc.spec.storageClassName).toBe('longhorn');
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
else process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNamespaceSpec', () => {
|
||||
it('generates namespace manifest', () => {
|
||||
const ns = generateNamespaceSpec('mcpctl-prod');
|
||||
@@ -187,7 +270,7 @@ describe('generatePodSpec — ephemeral session pods', () => {
|
||||
|
||||
it('declares emptyDir volumes and mounts them into the main container', () => {
|
||||
const pod = generatePodSpec(
|
||||
{ ...baseSpec, volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] },
|
||||
{ ...baseSpec, scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] },
|
||||
'ns',
|
||||
);
|
||||
expect(pod.spec.volumes).toEqual([{ name: 'workspace', emptyDir: {} }]);
|
||||
@@ -205,7 +288,7 @@ describe('generatePodSpec — ephemeral session pods', () => {
|
||||
const pod = generatePodSpec(
|
||||
{
|
||||
...baseSpec,
|
||||
volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }],
|
||||
scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }],
|
||||
initContainers: [
|
||||
{
|
||||
name: 'clone-api',
|
||||
@@ -263,9 +346,67 @@ describe('generateDeploymentSpec — restartPolicy', () => {
|
||||
|
||||
it('still carries volumes through to the pod template', () => {
|
||||
const dep = generateDeploymentSpec(
|
||||
{ ...baseSpec, volumes: [{ name: 'cache', mountPath: '/cache', kind: 'emptyDir' }] },
|
||||
{ ...baseSpec, scratchVolumes: [{ name: 'cache', mountPath: '/cache', kind: 'emptyDir' }] },
|
||||
'ns',
|
||||
);
|
||||
expect(dep.spec.template.spec.volumes).toEqual([{ name: 'cache', emptyDir: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistent and scratch volumes coexist', () => {
|
||||
/**
|
||||
* Two features developed in parallel both wanted the name `volumes` on
|
||||
* ContainerSpec, and both emitted the pod-level `volumes` list from their own
|
||||
* helper. Spreading two objects that each set `volumes` silently keeps only
|
||||
* the last one, so a server with persistent storage would have lost its PVC
|
||||
* the moment a session scratch volume was added (or vice versa).
|
||||
*
|
||||
* These pin the reconciliation: persistent volumes stay keyed by claimName,
|
||||
* scratch volumes are `scratchVolumes`, and both reach the pod together.
|
||||
*/
|
||||
const bothSpec = {
|
||||
...baseSpec,
|
||||
volumes: [{ claimName: 'docs-data', mountPath: '/data', sizeGb: 20 }],
|
||||
scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' as const }],
|
||||
};
|
||||
|
||||
it('declares both kinds in the pod volume list', () => {
|
||||
const pod = generatePodSpec(bothSpec, 'ns');
|
||||
expect(pod.spec.volumes).toEqual([
|
||||
{ name: 'docs-data', persistentVolumeClaim: { claimName: 'docs-data' } },
|
||||
{ name: 'workspace', emptyDir: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it('mounts both into the main container', () => {
|
||||
const pod = generatePodSpec(bothSpec, 'ns');
|
||||
expect(pod.spec.containers[0]?.volumeMounts).toEqual([
|
||||
{ name: 'docs-data', mountPath: '/data' },
|
||||
{ name: 'workspace', mountPath: '/workspace' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still sets fsGroup, which only the PVC needs', () => {
|
||||
const pod = generatePodSpec(bothSpec, 'ns');
|
||||
expect(pod.spec.securityContext?.fsGroup).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits securityContext when the pod has scratch space only', () => {
|
||||
// An emptyDir is created writable by the container's own user, so forcing
|
||||
// an fsGroup chown on a scratch-only pod is work with no purpose.
|
||||
const pod = generatePodSpec(
|
||||
{ ...baseSpec, scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] },
|
||||
'ns',
|
||||
);
|
||||
expect(pod.spec.securityContext).toBeUndefined();
|
||||
expect(pod.spec.volumes).toEqual([{ name: 'workspace', emptyDir: {} }]);
|
||||
});
|
||||
|
||||
it('carries both through the Deployment pod template too', () => {
|
||||
const dep = generateDeploymentSpec(bothSpec, 'ns');
|
||||
expect(dep.spec.template.spec.volumes).toEqual([
|
||||
{ name: 'docs-data', persistentVolumeClaim: { claimName: 'docs-data' } },
|
||||
{ name: 'workspace', emptyDir: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,22 @@ vi.mock('@kubernetes/client-node', () => {
|
||||
makeApiClient = vi.fn(() => mockCore);
|
||||
}
|
||||
|
||||
// Track the live Exec/Attach instances so tests can program their vi.fn()s
|
||||
// (each K8sOfficialClient constructs fresh ones).
|
||||
const instances: { exec?: { exec: ReturnType<typeof vi.fn> }; attach?: { attach: ReturnType<typeof vi.fn> } } = {};
|
||||
|
||||
class MockExec {
|
||||
exec = vi.fn();
|
||||
constructor() {
|
||||
instances.exec = this;
|
||||
}
|
||||
}
|
||||
|
||||
class MockAttach {
|
||||
attach = vi.fn();
|
||||
constructor() {
|
||||
instances.attach = this;
|
||||
}
|
||||
}
|
||||
|
||||
class MockLog {
|
||||
@@ -82,12 +92,12 @@ vi.mock('@kubernetes/client-node', () => {
|
||||
Attach: MockAttach,
|
||||
Log: MockLog,
|
||||
// Export test helpers
|
||||
__testHelpers: { setHandler, getHandler, clearHandlers, mockCore },
|
||||
__testHelpers: { setHandler, getHandler, clearHandlers, mockCore, instances },
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock
|
||||
import { KubernetesOrchestrator } from '../src/services/k8s/kubernetes-orchestrator.js';
|
||||
import { KubernetesOrchestrator, httpStatusOf } from '../src/services/k8s/kubernetes-orchestrator.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const k8sMock = await import('@kubernetes/client-node') as any;
|
||||
const { setHandler, clearHandlers, mockCore } = k8sMock.__testHelpers;
|
||||
@@ -356,3 +366,157 @@ describe('KubernetesOrchestrator', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpStatusOf', () => {
|
||||
it('reads .code, which is what client-node v1 ApiException actually sets', () => {
|
||||
// The bug this exists for: reading only .statusCode returned undefined, so
|
||||
// an expected 404 was rethrown and a first-start PVC create never happened.
|
||||
expect(httpStatusOf({ code: 404 })).toBe(404);
|
||||
expect(httpStatusOf({ code: 409 })).toBe(409);
|
||||
});
|
||||
|
||||
it('still reads the pre-1.0 HttpError shapes', () => {
|
||||
expect(httpStatusOf({ statusCode: 404 })).toBe(404);
|
||||
expect(httpStatusOf({ response: { statusCode: 409 } })).toBe(409);
|
||||
});
|
||||
|
||||
it('falls back to parsing the rendered message', () => {
|
||||
expect(httpStatusOf(new Error('HTTP-Code: 403\nMessage: Unknown API Status Code!'))).toBe(403);
|
||||
});
|
||||
|
||||
it('returns undefined when there is no status to find', () => {
|
||||
expect(httpStatusOf(new Error('socket hang up'))).toBeUndefined();
|
||||
expect(httpStatusOf({ code: 'ECONNREFUSED' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── mcpctl#114: interactive-session death signals, restartCount, 409-adopt ──
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
function makeFakeWs(): EventEmitter & { close: ReturnType<typeof vi.fn> } {
|
||||
const ws = new EventEmitter() as EventEmitter & { close: ReturnType<typeof vi.fn> };
|
||||
ws.close = vi.fn();
|
||||
return ws;
|
||||
}
|
||||
|
||||
const podRestarted = {
|
||||
...podRunning,
|
||||
status: {
|
||||
...podRunning.status,
|
||||
containerStatuses: [{
|
||||
state: { running: { startedAt: '2026-01-02T00:00:00Z' } },
|
||||
restartCount: 3,
|
||||
lastState: { terminated: { reason: 'OOMKilled', exitCode: 137 } },
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const podTerminated = {
|
||||
...podRunning,
|
||||
status: {
|
||||
phase: 'Failed',
|
||||
containerStatuses: [{
|
||||
state: { terminated: { reason: 'Error', exitCode: 1 } },
|
||||
restartCount: 1,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
describe('KubernetesOrchestrator interactive sessions (mcpctl#114)', () => {
|
||||
let orch: KubernetesOrchestrator;
|
||||
|
||||
beforeEach(() => {
|
||||
clearHandlers();
|
||||
vi.clearAllMocks();
|
||||
orch = new KubernetesOrchestrator({ serversNamespace: 'mcpctl-servers' });
|
||||
});
|
||||
|
||||
it("execInteractive: ws 'close' ends stdout so the consumer sees the death", async () => {
|
||||
setHandler('readNamespacedPod:my-server', podRunning);
|
||||
const ws = makeFakeWs();
|
||||
k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws);
|
||||
|
||||
const iexec = await orch.execInteractive!('my-server', ['node', 'index.js']);
|
||||
iexec.stdout.resume(); // PassThrough only emits 'end' once it is being read
|
||||
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
||||
ws.emit('close');
|
||||
await expect(ended).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("execInteractive: ws 'error' also ends stdout, and close() closes the ws", async () => {
|
||||
setHandler('readNamespacedPod:my-server', podRunning);
|
||||
const ws = makeFakeWs();
|
||||
k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws);
|
||||
|
||||
const iexec = await orch.execInteractive!('my-server', ['node']);
|
||||
iexec.stdout.resume();
|
||||
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
||||
ws.emit('error', new Error('abnormal closure'));
|
||||
await expect(ended).resolves.toBeUndefined();
|
||||
|
||||
iexec.close();
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attachInteractive: ws 'close' ends stdout (first-ever attach coverage)", async () => {
|
||||
setHandler('readNamespacedPod:my-server', podRunning);
|
||||
const ws = makeFakeWs();
|
||||
k8sMock.__testHelpers.instances.attach!.attach.mockResolvedValue(ws);
|
||||
|
||||
const iexec = await orch.attachInteractive!('my-server');
|
||||
iexec.stdout.resume();
|
||||
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
||||
ws.emit('close');
|
||||
await expect(ended).resolves.toBeUndefined();
|
||||
expect(k8sMock.__testHelpers.instances.attach!.attach).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('inspectContainer surfaces restartCount and startedAt', async () => {
|
||||
setHandler('readNamespacedPod:my-server', podRestarted);
|
||||
const info = await orch.inspectContainer('my-server');
|
||||
expect(info.state).toBe('running');
|
||||
expect(info.restartCount).toBe(3);
|
||||
expect(info.startedAt).toEqual(new Date('2026-01-02T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('createContainer ADOPTS an alive pod on 409 instead of throwing', async () => {
|
||||
setHandler('readNamespace:mcpctl-servers', {});
|
||||
setHandler('createNamespacedPod', undefined, { code: 409 });
|
||||
setHandler('readNamespacedPod:my-server', podRestarted);
|
||||
|
||||
const info = await orch.createContainer(testSpec);
|
||||
expect(info.containerId).toBe('my-server');
|
||||
expect(info.state).toBe('running');
|
||||
expect(mockCore.deleteNamespacedPod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createContainer replaces a genuinely dead pod on 409', async () => {
|
||||
setHandler('readNamespace:mcpctl-servers', {});
|
||||
setHandler('createNamespacedPod', undefined, { code: 409 });
|
||||
setHandler('readNamespacedPod:my-server', podTerminated);
|
||||
setHandler('deleteNamespacedPod:my-server', {});
|
||||
|
||||
const promise = orch.createContainer(testSpec);
|
||||
// Give it a tick to inspect + delete, then flip the world: pod is gone,
|
||||
// create succeeds.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// The gone-wait loop polls once per second: serve 404 so it breaks, then
|
||||
// restore the pod before the post-create inspect (~500ms later) runs.
|
||||
setHandler('readNamespacedPod:my-server', undefined, { code: 404 });
|
||||
setHandler('createNamespacedPod', podRunning);
|
||||
setTimeout(() => setHandler('readNamespacedPod:my-server', podRunning), 1200);
|
||||
|
||||
const info = await promise;
|
||||
expect(info.state).toBe('running');
|
||||
expect(mockCore.deleteNamespacedPod).toHaveBeenCalled();
|
||||
expect(mockCore.createNamespacedPod).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('createContainer still throws on non-409 errors', async () => {
|
||||
setHandler('readNamespace:mcpctl-servers', {});
|
||||
setHandler('createNamespacedPod', undefined, { code: 500 });
|
||||
await expect(orch.createContainer(testSpec)).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -725,3 +725,103 @@ describe('MCP server full flow', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── mcpctl#114: attach server survives an in-place container restart ──
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { InteractiveExec } from '../src/services/orchestrator.js';
|
||||
|
||||
/**
|
||||
* A scripted MCP server on the other end of an attach session: answers
|
||||
* initialize and every request with a result carrying its session number,
|
||||
* so the test can prove which session served which call.
|
||||
*/
|
||||
function makeScriptedAttach(session: number): InteractiveExec {
|
||||
const stdout = new PassThrough();
|
||||
return {
|
||||
stdout,
|
||||
write(data: string) {
|
||||
for (const line of data.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string };
|
||||
if (msg.id === undefined) continue; // notifications
|
||||
const result = msg.method === 'initialize'
|
||||
? { capabilities: {} }
|
||||
: { ok: true, session };
|
||||
stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n');
|
||||
}
|
||||
},
|
||||
close() {
|
||||
stdout.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('attach-mode STDIO restart recovery (mcpctl#114)', () => {
|
||||
it('survives an in-place container restart: syncStatus invalidates, next call rides a new session', async () => {
|
||||
const serverRepo = createInMemoryServerRepo();
|
||||
const instanceRepo = createInMemoryInstanceRepo();
|
||||
|
||||
const server = await serverRepo.create({
|
||||
name: 'gitea',
|
||||
transport: 'STDIO',
|
||||
dockerImage: 'ghcr.io/gitea-mcp:latest',
|
||||
replicas: 1,
|
||||
} as never);
|
||||
const instance = await instanceRepo.create({
|
||||
serverId: server.id,
|
||||
containerId: 'pod-gitea',
|
||||
status: 'RUNNING',
|
||||
metadata: { lastRestartCount: 0 },
|
||||
} as never);
|
||||
|
||||
let restartCount = 0;
|
||||
const sessions: InteractiveExec[] = [];
|
||||
const orchestrator = {
|
||||
ping: vi.fn(async () => true),
|
||||
pullImage: vi.fn(async () => {}),
|
||||
createContainer: vi.fn(),
|
||||
stopContainer: vi.fn(async () => {}),
|
||||
removeContainer: vi.fn(async () => {}),
|
||||
inspectContainer: vi.fn(async () => ({
|
||||
containerId: 'pod-gitea',
|
||||
name: 'pod-gitea',
|
||||
state: 'running' as const,
|
||||
createdAt: new Date(),
|
||||
restartCount,
|
||||
})),
|
||||
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||
execInContainer: vi.fn(),
|
||||
attachInteractive: vi.fn(async () => {
|
||||
const s = makeScriptedAttach(sessions.length + 1);
|
||||
sessions.push(s);
|
||||
return s;
|
||||
}),
|
||||
} as unknown as McpOrchestrator;
|
||||
|
||||
const proxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||
instanceService.setStdioInvalidator((cid) => proxyService.removeClient(cid));
|
||||
|
||||
// 1. A call round-trips on session 1.
|
||||
const first = await proxyService.execute({ serverId: server.id, method: 'tools/list' });
|
||||
expect((first.result as { session: number }).session).toBe(1);
|
||||
|
||||
// 2. The container crashes and kubelet restarts it in place: same pod
|
||||
// name, restartCount bumps, the old session's pipe dies.
|
||||
sessions[0]!.stdout.end();
|
||||
restartCount = 1;
|
||||
|
||||
// 3. One reconcile tick detects the bump, keeps the row RUNNING, and
|
||||
// evicts the stale client.
|
||||
await instanceService.syncStatus();
|
||||
const after = await instanceRepo.findById(instance.id);
|
||||
expect(after!.status).toBe('RUNNING');
|
||||
expect((after!.metadata as { lastRestartCount: number }).lastRestartCount).toBe(1);
|
||||
|
||||
// 4. The next call succeeds first-try on a fresh attach session.
|
||||
const second = await proxyService.execute({ serverId: server.id, method: 'tools/list' });
|
||||
expect((second.result as { session: number }).session).toBe(2);
|
||||
expect(orchestrator.attachInteractive).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
48
src/mcpd/tests/mcp-server-repository-fields.test.ts
Normal file
48
src/mcpd/tests/mcp-server-repository-fields.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The server repository maps update/create fields explicitly, field by field.
|
||||
* That means a new column silently does nothing until it is added here — and
|
||||
* the failure is invisible: `mcpctl patch server x secretDelivery=injector`
|
||||
* returns "patched" while the value never changes.
|
||||
*
|
||||
* Caught exactly that way in production. These assert the mapping instead.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { McpServerRepository } from '../src/repositories/mcp-server.repository.js';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
function prismaSpy() {
|
||||
const update = vi.fn(async ({ data }: { data: Record<string, unknown> }) => data);
|
||||
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => data);
|
||||
return { spy: { mcpServer: { update, create } } as unknown as PrismaClient, update, create };
|
||||
}
|
||||
|
||||
describe('McpServerRepository field mapping', () => {
|
||||
it('persists secretDelivery on update', async () => {
|
||||
const { spy, update } = prismaSpy();
|
||||
await new McpServerRepository(spy).update('id1', { secretDelivery: 'injector' });
|
||||
expect(update.mock.calls[0]?.[0].data).toMatchObject({ secretDelivery: 'injector' });
|
||||
});
|
||||
|
||||
it('persists entrypoint on update', async () => {
|
||||
const { spy, update } = prismaSpy();
|
||||
await new McpServerRepository(spy).update('id1', { entrypoint: ['/bin/x', '--flag'] });
|
||||
expect(update.mock.calls[0]?.[0].data).toMatchObject({ entrypoint: ['/bin/x', '--flag'] });
|
||||
});
|
||||
|
||||
it('leaves both untouched when not supplied', async () => {
|
||||
const { spy, update } = prismaSpy();
|
||||
await new McpServerRepository(spy).update('id1', { description: 'x' });
|
||||
const data = update.mock.calls[0]?.[0].data ?? {};
|
||||
expect(data).not.toHaveProperty('secretDelivery');
|
||||
expect(data).not.toHaveProperty('entrypoint');
|
||||
});
|
||||
|
||||
it('persists secretDelivery on create', async () => {
|
||||
const { spy, create } = prismaSpy();
|
||||
await new McpServerRepository(spy).create({
|
||||
name: 'x', description: '', transport: 'STDIO', replicas: 1, env: [], volumes: [],
|
||||
secretDelivery: 'injector',
|
||||
} as never);
|
||||
expect(create.mock.calls[0]?.[0].data).toMatchObject({ secretDelivery: 'injector' });
|
||||
});
|
||||
});
|
||||
@@ -109,3 +109,110 @@ describe('PersistentStdioClient', () => {
|
||||
await expect(client.send('tools/list')).rejects.toThrow(/interactive exec/i);
|
||||
});
|
||||
});
|
||||
|
||||
// Drive a fake session through the init handshake so send() can round-trip.
|
||||
async function completeHandshake(fake: ReturnType<typeof makeFakeExec>): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const init = JSON.parse(fake.written[0]!);
|
||||
fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } });
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
describe('PersistentStdioClient teardown & reconnect (mcpctl#114)', () => {
|
||||
it("stdout 'end' rejects in-flight requests and the next send() redials", async () => {
|
||||
const fakes = [makeFakeExec(), makeFakeExec()];
|
||||
let call = 0;
|
||||
const execInteractive = vi.fn(async () => fakes[call++]!.iexec);
|
||||
const client = new PersistentStdioClient(
|
||||
makeOrchestrator({ execInteractive }),
|
||||
'c1',
|
||||
{ kind: 'exec', command: ['node'] },
|
||||
);
|
||||
|
||||
const first = client.send('tools/list');
|
||||
await completeHandshake(fakes[0]!);
|
||||
// Kill the session while tools/list is in flight.
|
||||
fakes[0]!.iexec.stdout.end();
|
||||
await expect(first).rejects.toThrow('STDIO process exited');
|
||||
expect(client.isConnected).toBe(false);
|
||||
|
||||
// Next send must redial through a fresh interactive session.
|
||||
const second = client.send('tools/list');
|
||||
await completeHandshake(fakes[1]!);
|
||||
const req = JSON.parse(fakes[1]!.written[2]!);
|
||||
fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [] } });
|
||||
const res = await second;
|
||||
expect(res.result).toEqual({ tools: [] });
|
||||
expect(execInteractive).toHaveBeenCalledTimes(2);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("stdout 'error' tears down with the error message", async () => {
|
||||
const fake = makeFakeExec();
|
||||
const client = new PersistentStdioClient(
|
||||
makeOrchestrator({ execInteractive: vi.fn(async () => fake.iexec) }),
|
||||
'c1',
|
||||
{ kind: 'exec', command: ['node'] },
|
||||
);
|
||||
const pending = client.send('tools/list');
|
||||
await completeHandshake(fake);
|
||||
fake.iexec.stdout.emit('error', new Error('socket hang up'));
|
||||
await expect(pending).rejects.toThrow('STDIO stream error: socket hang up');
|
||||
expect(client.isConnected).toBe(false);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("a LATE 'end' from the previous session does not clobber the reconnected one", async () => {
|
||||
const fakes = [makeFakeExec(), makeFakeExec()];
|
||||
let call = 0;
|
||||
const client = new PersistentStdioClient(
|
||||
makeOrchestrator({ attachInteractive: vi.fn(async () => fakes[call++]!.iexec) }),
|
||||
'c1',
|
||||
{ kind: 'attach' },
|
||||
);
|
||||
|
||||
const first = client.send('tools/list');
|
||||
await completeHandshake(fakes[0]!);
|
||||
fakes[0]!.iexec.stdout.end();
|
||||
await expect(first).rejects.toThrow('STDIO process exited');
|
||||
|
||||
// Reconnect on a fresh session.
|
||||
const second = client.send('tools/list');
|
||||
await completeHandshake(fakes[1]!);
|
||||
|
||||
// The old session's stream fires a stale 'close' AFTER the reconnect —
|
||||
// the identity guard must ignore it.
|
||||
fakes[0]!.iexec.stdout.emit('close');
|
||||
expect(client.isConnected).toBe(true);
|
||||
|
||||
const req = JSON.parse(fakes[1]!.written[2]!);
|
||||
fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 't' }] } });
|
||||
const res = await second;
|
||||
expect((res.result as { tools: unknown[] }).tools).toHaveLength(1);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('attach mode also redials after teardown', async () => {
|
||||
const fakes = [makeFakeExec(), makeFakeExec()];
|
||||
let call = 0;
|
||||
const attachInteractive = vi.fn(async () => fakes[call++]!.iexec);
|
||||
const client = new PersistentStdioClient(
|
||||
makeOrchestrator({ attachInteractive }),
|
||||
'c-gitea',
|
||||
{ kind: 'attach' },
|
||||
);
|
||||
|
||||
const first = client.send('tools/list');
|
||||
await completeHandshake(fakes[0]!);
|
||||
fakes[0]!.iexec.stdout.emit('close');
|
||||
await expect(first).rejects.toThrow('STDIO stream closed');
|
||||
|
||||
const second = client.send('tools/list');
|
||||
await completeHandshake(fakes[1]!);
|
||||
const req = JSON.parse(fakes[1]!.written[2]!);
|
||||
fakes[1]!.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [] } });
|
||||
await expect(second).resolves.toMatchObject({ result: { tools: [] } });
|
||||
expect(attachInteractive).toHaveBeenCalledTimes(2);
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
|
||||
98
src/mcpd/tests/secret-backend-health-route.test.ts
Normal file
98
src/mcpd/tests/secret-backend-health-route.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { SecretBackend } from '@prisma/client';
|
||||
import { registerSecretBackendHealthRoutes } from '../src/routes/secret-backend-health.js';
|
||||
import { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
import type { ISecretBackendRepository } from '../src/repositories/secret-backend.repository.js';
|
||||
import type { SecretBackendDriver } from '../src/services/secret-backends/types.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
afterEach(async () => { await app?.close(); });
|
||||
|
||||
function backendRow(overrides: Partial<SecretBackend> = {}): SecretBackend {
|
||||
return {
|
||||
id: 'b1', name: 'bao-k8s', type: 'openbao',
|
||||
config: { url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
|
||||
isDefault: true, description: '', version: 1,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as SecretBackend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the route over a service whose driver is stubbed. We override
|
||||
* `driverFor` rather than the factory so the test drives the two probes
|
||||
* directly — the point here is the route's reporting, not driver internals.
|
||||
*/
|
||||
async function buildApp(
|
||||
probes: Pick<SecretBackendDriver, 'healthCheck' | 'authCheck'>,
|
||||
row: SecretBackend = backendRow(),
|
||||
): Promise<FastifyInstance> {
|
||||
const repo = {
|
||||
findById: vi.fn(async (id: string) => (id === row.id ? row : null)),
|
||||
} as unknown as ISecretBackendRepository;
|
||||
const svc = new SecretBackendService(repo, {
|
||||
plaintext: { listAllPlaintext: async () => [] },
|
||||
secretRefResolver: { resolve: async () => 'tok' },
|
||||
});
|
||||
vi.spyOn(svc, 'driverFor').mockReturnValue({ kind: 'openbao', ...probes } as SecretBackendDriver);
|
||||
vi.spyOn(svc, 'cacheStatsFor').mockReturnValue({ entries: 3, servingStale: 0, oldestStaleSince: undefined });
|
||||
|
||||
const a = Fastify();
|
||||
registerSecretBackendHealthRoutes(a, svc);
|
||||
await a.ready();
|
||||
return a;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/secretbackends/:id/health', () => {
|
||||
it('reports live+ready when the backend is fully working', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true, detail: 'active' }),
|
||||
authCheck: async () => ({ ok: true, detail: 'readable at secret/mcpctl' }),
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ backend: 'bao-k8s', live: true, ready: true });
|
||||
});
|
||||
|
||||
it('reports NOT live when OpenBao is sealed — regardless of rotation state', async () => {
|
||||
// The bug this endpoint exists for: a kubernetes-auth backend never writes
|
||||
// tokenMeta.lastRotationError, so the old status line stayed green here.
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: false, detail: 'sealed' }),
|
||||
authCheck: async () => ({ ok: true, detail: 'should not be consulted' }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body.live).toBe(false);
|
||||
expect(body.liveDetail).toBe('sealed');
|
||||
expect(body.ready).toBe(false);
|
||||
expect(body.readyDetail).toMatch(/not probed/);
|
||||
expect(body.rotation.lastRotationError).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes reachable-but-unusable (revoked grants) from unreachable', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true, detail: 'active' }),
|
||||
authCheck: async () => ({ ok: false, detail: 'OpenBao list: HTTP 403 permission denied' }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body).toMatchObject({ live: true, ready: false });
|
||||
expect(body.readyDetail).toMatch(/403/);
|
||||
});
|
||||
|
||||
it('surfaces cache state so degraded serving is visible', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true }),
|
||||
authCheck: async () => ({ ok: true }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body.cache).toMatchObject({ entries: 3, servingStale: 0 });
|
||||
});
|
||||
|
||||
it('404s for an unknown backend', async () => {
|
||||
app = await buildApp({ healthCheck: async () => ({ ok: true }), authCheck: async () => ({ ok: true }) });
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/nope/health' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
155
src/mcpd/tests/secret-backend-rotator-loop.test.ts
Normal file
155
src/mcpd/tests/secret-backend-rotator-loop.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* SecretBackendRotatorLoop had no coverage at all, despite being the boot-time
|
||||
* detector added after an upstream OpenBao re-init silently broke every secret
|
||||
* write for four days (e51b924). These pin the behaviours that matter when that
|
||||
* recurs: the boot health check fires, it reports through the injected logger
|
||||
* (so `mcpctl errors` sees it), and stop() genuinely stops.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { SecretBackend } from '@prisma/client';
|
||||
import { SecretBackendRotatorLoop } from '../src/services/secret-backend-rotator-loop.js';
|
||||
import type { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
import type { SecretBackendRotator } from '../src/services/secret-backend-rotator.service.js';
|
||||
|
||||
function backend(overrides: Partial<SecretBackend> = {}): SecretBackend {
|
||||
return {
|
||||
id: 'b1', name: 'bao', type: 'openbao',
|
||||
config: { url: 'http://bao.example:8200', rotation: { enabled: true, tokenRole: 'r', intervalHours: 24 } },
|
||||
isDefault: true, description: '', version: 1,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as SecretBackend;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
loop: SecretBackendRotatorLoop;
|
||||
rotator: { isRotatable: ReturnType<typeof vi.fn>; isOverdue: ReturnType<typeof vi.fn>; healthCheck: ReturnType<typeof vi.fn>; rotateOne: ReturnType<typeof vi.fn> };
|
||||
logs: { info: string[]; warn: string[]; error: Array<{ obj: Record<string, unknown>; msg: string }> };
|
||||
timers: Array<{ cb: () => void; ms: number }>;
|
||||
cleared: number;
|
||||
}
|
||||
|
||||
function harness(opts: {
|
||||
rows?: SecretBackend[];
|
||||
rotatable?: boolean;
|
||||
overdue?: boolean;
|
||||
health?: { ok: boolean; message?: string } | Error;
|
||||
} = {}): Harness {
|
||||
const rows = opts.rows ?? [backend()];
|
||||
const logs: Harness['logs'] = { info: [], warn: [], error: [] };
|
||||
const timers: Harness['timers'] = [];
|
||||
const state = { cleared: 0 };
|
||||
|
||||
const rotator = {
|
||||
isRotatable: vi.fn(() => opts.rotatable ?? true),
|
||||
isOverdue: vi.fn(() => opts.overdue ?? false),
|
||||
healthCheck: vi.fn(async () => {
|
||||
if (opts.health instanceof Error) throw opts.health;
|
||||
return opts.health ?? { ok: true };
|
||||
}),
|
||||
rotateOne: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
const loop = new SecretBackendRotatorLoop({
|
||||
backends: {
|
||||
list: async () => rows,
|
||||
getById: async (id: string) => rows.find((r) => r.id === id) ?? rows[0]!,
|
||||
} as unknown as SecretBackendService,
|
||||
rotator: rotator as unknown as SecretBackendRotator,
|
||||
setTimeout: ((cb: () => void, ms: number) => { timers.push({ cb, ms }); return { id: timers.length } as unknown as NodeJS.Timeout; }),
|
||||
clearTimeout: (() => { state.cleared++; }),
|
||||
log: {
|
||||
info: (m) => { logs.info.push(m); },
|
||||
warn: (m) => { logs.warn.push(m); },
|
||||
error: (obj, msg) => { logs.error.push({ obj, msg }); },
|
||||
},
|
||||
});
|
||||
|
||||
return { loop, rotator, logs, timers, get cleared() { return state.cleared; } } as Harness;
|
||||
}
|
||||
|
||||
/** The boot health check is fire-and-forget; let its microtasks settle. */
|
||||
const settle = async (): Promise<void> => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
|
||||
|
||||
describe('SecretBackendRotatorLoop', () => {
|
||||
it('stays idle when nothing is rotatable', async () => {
|
||||
const h = harness({ rotatable: false });
|
||||
await h.loop.start();
|
||||
expect(h.logs.info.join(' ')).toMatch(/no rotatable backends/);
|
||||
expect(h.timers).toHaveLength(0);
|
||||
expect(h.rotator.healthCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs a boot-time health check for every rotatable backend', async () => {
|
||||
const h = harness({ rows: [backend(), backend({ id: 'b2', name: 'bao2' })] });
|
||||
await h.loop.start();
|
||||
await settle();
|
||||
expect(h.rotator.healthCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('emits BACKEND_TOKEN_DEAD through the logger, not console', async () => {
|
||||
// The regression that made `mcpctl errors` blind to it: this used to be a
|
||||
// bare console.error, which bypasses the pino stream feeding ErrorLogBuffer.
|
||||
const h = harness({ health: { ok: false, message: 'token rejected' } });
|
||||
await h.loop.start();
|
||||
await settle();
|
||||
expect(h.logs.error).toHaveLength(1);
|
||||
expect(h.logs.error[0]?.obj).toMatchObject({ kind: 'BACKEND_TOKEN_DEAD', backend: 'bao' });
|
||||
expect(h.logs.error[0]?.msg).toBe('token rejected');
|
||||
});
|
||||
|
||||
it('does not log a fatal when the backend is healthy', async () => {
|
||||
const h = harness({ health: { ok: true } });
|
||||
await h.loop.start();
|
||||
await settle();
|
||||
expect(h.logs.error).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('survives a health check that throws', async () => {
|
||||
const h = harness({ health: new Error('network down') });
|
||||
await expect(h.loop.start()).resolves.toBeUndefined();
|
||||
await settle();
|
||||
expect(h.logs.warn.join(' ')).toMatch(/health check threw: network down/);
|
||||
});
|
||||
|
||||
it('rotates immediately when a backend is overdue, and still schedules', async () => {
|
||||
const h = harness({ overdue: true });
|
||||
await h.loop.start();
|
||||
await settle();
|
||||
expect(h.rotator.rotateOne).toHaveBeenCalledWith('b1');
|
||||
expect(h.timers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not rotate on boot when not overdue', async () => {
|
||||
const h = harness({ overdue: false });
|
||||
await h.loop.start();
|
||||
await settle();
|
||||
expect(h.rotator.rotateOne).not.toHaveBeenCalled();
|
||||
expect(h.timers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never schedules sooner than the 60s floor, even with adversarial jitter', async () => {
|
||||
// intervalHours tiny + default jitter would otherwise produce a negative delay.
|
||||
const rows = [backend({ config: { url: 'u', rotation: { enabled: true, tokenRole: 'r', intervalHours: 0.0001 } } } as Partial<SecretBackend>)];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const h = harness({ rows });
|
||||
await h.loop.start();
|
||||
expect(h.timers[0]?.ms).toBeGreaterThanOrEqual(60_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('stop() clears timers and suppresses further scheduling', async () => {
|
||||
const h = harness();
|
||||
await h.loop.start();
|
||||
expect(h.timers).toHaveLength(1);
|
||||
|
||||
h.loop.stop();
|
||||
expect(h.cleared).toBeGreaterThan(0);
|
||||
|
||||
// The `stopped` guard has never been exercised: a firing timer must not
|
||||
// reschedule after stop().
|
||||
const before = h.timers.length;
|
||||
await h.loop.rotateNow('b1').catch(() => undefined);
|
||||
expect(h.timers).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PlaintextDriver } from '../src/services/secret-backends/plaintext.js';
|
||||
import { OpenBaoDriver } from '../src/services/secret-backends/openbao.js';
|
||||
import { SecretNotFoundError, SecretBackendUnavailableError } from '../src/services/secret-backends/types.js';
|
||||
|
||||
describe('PlaintextDriver', () => {
|
||||
const driver = new PlaintextDriver({ listAllPlaintext: async () => [{ name: 'a', data: { k: 'v' } }] });
|
||||
@@ -86,9 +87,9 @@ describe('OpenBaoDriver', () => {
|
||||
await expect(driver.delete({ name: 'gone', externalRef: '' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('list returns names from the metadata LIST call', async () => {
|
||||
it('list returns names from the metadata listing', async () => {
|
||||
const fetchFn = makeFetch([{
|
||||
url: /\/v1\/secret\/metadata\/mcpctl\/$/,
|
||||
url: /\/v1\/secret\/metadata\/mcpctl\/\?list=true$/,
|
||||
status: 200,
|
||||
body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } },
|
||||
}]);
|
||||
@@ -97,6 +98,10 @@ describe('OpenBaoDriver', () => {
|
||||
{ fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver },
|
||||
);
|
||||
const result = await driver.list();
|
||||
// GET ?list=true rather than the LIST verb: proxies (Cilium's ingress
|
||||
// Envoy among them) answer a bare 400 to the non-standard method.
|
||||
const [, listInit] = fetchFn.mock.calls[0] as [unknown, RequestInit];
|
||||
expect(listInit.method).toBe('GET');
|
||||
// Sub-folders (trailing slash) are excluded; only leaf keys are returned.
|
||||
expect(result).toEqual([
|
||||
{ name: 'token1', externalRef: 'secret/mcpctl/token1' },
|
||||
@@ -242,3 +247,91 @@ describe('OpenBaoDriver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenBaoDriver: resilience', () => {
|
||||
const resolver = { resolve: vi.fn(async () => 'test-vault-token') };
|
||||
/** No real sleeping — otherwise the backoff tests take seconds. */
|
||||
const noSleep = async (): Promise<void> => undefined;
|
||||
|
||||
function driverWith(fetchFn: ReturnType<typeof vi.fn>, opts: Record<string, unknown> = {}): OpenBaoDriver {
|
||||
return new OpenBaoDriver(
|
||||
{ url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } },
|
||||
{ fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver, sleep: noSleep, ...opts },
|
||||
);
|
||||
}
|
||||
|
||||
it('maps a 404 read to SecretNotFoundError', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('', { status: 404 }));
|
||||
await expect(driverWith(fetchFn).read({ name: 'gone', externalRef: '', data: {} }))
|
||||
.rejects.toThrow(SecretNotFoundError);
|
||||
});
|
||||
|
||||
it('purges the token cache and retries once on 403 — outside the retry budget', async () => {
|
||||
// This path existed but was never covered; it is the revocation/re-init case.
|
||||
let n = 0;
|
||||
const fetchFn = vi.fn(async () => {
|
||||
n++;
|
||||
if (n === 1) return new Response('', { status: 403 });
|
||||
return new Response(JSON.stringify({ data: { data: { token: 'ok' } } }), { status: 200 });
|
||||
});
|
||||
const d = driverWith(fetchFn);
|
||||
await expect(d.read({ name: 's', externalRef: '', data: {} })).resolves.toEqual({ token: 'ok' });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries a 503 (sealed) and succeeds', async () => {
|
||||
let n = 0;
|
||||
const fetchFn = vi.fn(async () => {
|
||||
n++;
|
||||
if (n < 3) return new Response('', { status: 503 });
|
||||
return new Response(JSON.stringify({ data: { data: { token: 'ok' } } }), { status: 200 });
|
||||
});
|
||||
await expect(driverWith(fetchFn).read({ name: 's', externalRef: '', data: {} }))
|
||||
.resolves.toEqual({ token: 'ok' });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws SecretBackendUnavailableError once the retry budget is exhausted', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('', { status: 503 }));
|
||||
await expect(driverWith(fetchFn, { maxAttempts: 3 }).read({ name: 's', externalRef: '', data: {} }))
|
||||
.rejects.toThrow(SecretBackendUnavailableError);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('classifies a network/abort failure as SecretBackendUnavailableError', async () => {
|
||||
const fetchFn = vi.fn(async () => { throw new DOMException('timed out', 'TimeoutError'); });
|
||||
await expect(driverWith(fetchFn, { maxAttempts: 2 }).read({ name: 's', externalRef: '', data: {} }))
|
||||
.rejects.toThrow(SecretBackendUnavailableError);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes an AbortSignal on every request', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response(JSON.stringify({ data: { data: {} } }), { status: 200 }));
|
||||
await driverWith(fetchFn, { timeoutMs: 1234 }).read({ name: 's', externalRef: '', data: {} });
|
||||
const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit];
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('healthCheck is unauthenticated and maps OpenBao status codes', async () => {
|
||||
const cases: Array<[number, boolean, string]> = [
|
||||
[200, true, 'active'],
|
||||
[429, true, 'standby'],
|
||||
[501, false, 'not initialized'],
|
||||
[503, false, 'sealed'],
|
||||
];
|
||||
for (const [status, ok, detail] of cases) {
|
||||
const fetchFn = vi.fn(async () => new Response('', { status }));
|
||||
const result = await driverWith(fetchFn).healthCheck();
|
||||
expect(result).toEqual({ ok, detail });
|
||||
// The whole point of the split: no token is minted for a liveness probe.
|
||||
const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit];
|
||||
expect((init.headers as Record<string, string>)['X-Vault-Token']).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('authCheck reports false when the token can no longer list', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('', { status: 403 }));
|
||||
const result = await driverWith(fetchFn).authCheck();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
198
src/mcpd/tests/secret-cache.test.ts
Normal file
198
src/mcpd/tests/secret-cache.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
CachingSecretBackendDriver,
|
||||
type CachingDriverLog,
|
||||
} from '../src/services/secret-backends/caching.js';
|
||||
import {
|
||||
SecretNotFoundError,
|
||||
SecretBackendUnavailableError,
|
||||
type SecretBackendDriver,
|
||||
type SecretData,
|
||||
} from '../src/services/secret-backends/types.js';
|
||||
|
||||
/** Minimal fake backing driver whose read() behaviour the tests drive. */
|
||||
function makeInner(overrides: Partial<SecretBackendDriver> = {}): SecretBackendDriver & {
|
||||
read: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
kind: 'fake',
|
||||
read: vi.fn(async () => ({ token: 'live' } as SecretData)),
|
||||
write: vi.fn(async () => ({ externalRef: 'ref', storedData: {} as SecretData })),
|
||||
delete: vi.fn(async () => undefined),
|
||||
list: vi.fn(async () => []),
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeLog(): CachingDriverLog & { warns: Array<Record<string, unknown>>; infos: Array<Record<string, unknown>> } {
|
||||
const warns: Array<Record<string, unknown>> = [];
|
||||
const infos: Array<Record<string, unknown>> = [];
|
||||
return { warns, infos, warn: (o) => { warns.push(o); }, info: (o) => { infos.push(o); } };
|
||||
}
|
||||
|
||||
const REQ = { name: 'gitea-creds', externalRef: 'secret/mcpctl/gitea-creds', data: {} };
|
||||
|
||||
describe('CachingSecretBackendDriver', () => {
|
||||
it('serves from cache within the TTL without touching the backend', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 5_000, now: () => now });
|
||||
|
||||
expect(await d.read(REQ)).toEqual({ token: 'live' });
|
||||
now += 4_999;
|
||||
expect(await d.read(REQ)).toEqual({ token: 'live' });
|
||||
|
||||
expect(inner.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches once the TTL has elapsed', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 5_000, now: () => now });
|
||||
|
||||
await d.read(REQ);
|
||||
now += 5_001;
|
||||
await d.read(REQ);
|
||||
|
||||
expect(inner.read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('serves the stale value when the backend is unavailable', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const log = makeLog();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log, backendName: 'bao' });
|
||||
|
||||
await d.read(REQ);
|
||||
inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down'));
|
||||
now += 10_000;
|
||||
|
||||
// This is the whole point: no throw, so instance.service never marks ERROR.
|
||||
expect(await d.read(REQ)).toEqual({ token: 'live' });
|
||||
expect(log.warns[0]?.kind).toBe('BACKEND_UNREACHABLE');
|
||||
});
|
||||
|
||||
it('logs BACKEND_UNREACHABLE only on the transition, not on every stale read', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const log = makeLog();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log });
|
||||
|
||||
await d.read(REQ);
|
||||
inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down'));
|
||||
for (let i = 0; i < 5; i++) { now += 2_000; await d.read(REQ); }
|
||||
|
||||
expect(log.warns.filter((w) => w.kind === 'BACKEND_UNREACHABLE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('logs BACKEND_RECOVERED once the backend answers again', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const log = makeLog();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now, log });
|
||||
|
||||
await d.read(REQ);
|
||||
inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down'));
|
||||
now += 2_000;
|
||||
await d.read(REQ);
|
||||
|
||||
inner.read.mockResolvedValue({ token: 'rotated' });
|
||||
now += 2_000;
|
||||
expect(await d.read(REQ)).toEqual({ token: 'rotated' });
|
||||
expect(log.infos.filter((i) => i.kind === 'BACKEND_RECOVERED')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('NEVER serves stale for a deleted secret — evicts and rethrows', async () => {
|
||||
// Regression guard. Serving stale here would resurrect a revoked
|
||||
// credential, which is strictly worse than an outage.
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now });
|
||||
|
||||
await d.read(REQ);
|
||||
inner.read.mockRejectedValue(new SecretNotFoundError('gone'));
|
||||
now += 2_000;
|
||||
|
||||
await expect(d.read(REQ)).rejects.toThrow(SecretNotFoundError);
|
||||
expect(d.stats().entries).toBe(0);
|
||||
|
||||
// And the entry really is gone — a later unavailable error has nothing to serve.
|
||||
inner.read.mockRejectedValue(new SecretBackendUnavailableError('bao down'));
|
||||
await expect(d.read(REQ)).rejects.toThrow(SecretBackendUnavailableError);
|
||||
});
|
||||
|
||||
it('does not serve stale for a non-transport error (e.g. revoked grants)', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now });
|
||||
|
||||
await d.read(REQ);
|
||||
inner.read.mockRejectedValue(new Error('OpenBao read: HTTP 403 permission denied'));
|
||||
now += 2_000;
|
||||
|
||||
await expect(d.read(REQ)).rejects.toThrow(/403/);
|
||||
});
|
||||
|
||||
it('rethrows on a cold cache even when the backend is unavailable', async () => {
|
||||
const inner = makeInner({ read: vi.fn(async () => { throw new SecretBackendUnavailableError('bao down'); }) as never });
|
||||
const d = new CachingSecretBackendDriver(inner);
|
||||
|
||||
await expect(d.read(REQ)).rejects.toThrow(SecretBackendUnavailableError);
|
||||
});
|
||||
|
||||
it('write() refreshes the cache so a read-after-write does not lag', async () => {
|
||||
const inner = makeInner();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000 });
|
||||
|
||||
await d.read(REQ);
|
||||
await d.write({ name: REQ.name, data: { token: 'brand-new' } });
|
||||
|
||||
expect(await d.read(REQ)).toEqual({ token: 'brand-new' });
|
||||
expect(inner.read).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('delete() evicts', async () => {
|
||||
const inner = makeInner();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000 });
|
||||
|
||||
await d.read(REQ);
|
||||
await d.delete({ name: REQ.name, externalRef: REQ.externalRef });
|
||||
|
||||
expect(d.stats().entries).toBe(0);
|
||||
});
|
||||
|
||||
it('bounds the map with an LRU eviction', async () => {
|
||||
const inner = makeInner();
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 60_000, maxEntries: 2 });
|
||||
|
||||
await d.read({ ...REQ, name: 'a' });
|
||||
await d.read({ ...REQ, name: 'b' });
|
||||
await d.read({ ...REQ, name: 'a' }); // 'a' becomes most-recently-used
|
||||
await d.read({ ...REQ, name: 'c' }); // evicts 'b'
|
||||
|
||||
expect(d.stats().entries).toBe(2);
|
||||
inner.read.mockClear();
|
||||
await d.read({ ...REQ, name: 'a' });
|
||||
expect(inner.read).not.toHaveBeenCalled(); // 'a' survived
|
||||
await d.read({ ...REQ, name: 'b' });
|
||||
expect(inner.read).toHaveBeenCalledTimes(1); // 'b' was evicted
|
||||
});
|
||||
|
||||
it('reports stale count and age via stats()', async () => {
|
||||
const inner = makeInner();
|
||||
let now = 1_000;
|
||||
const d = new CachingSecretBackendDriver(inner, { ttlMs: 1_000, now: () => now });
|
||||
|
||||
await d.read({ ...REQ, name: 'a' });
|
||||
await d.read({ ...REQ, name: 'b' });
|
||||
expect(d.stats()).toMatchObject({ entries: 2, servingStale: 0 });
|
||||
|
||||
inner.read.mockRejectedValue(new SecretBackendUnavailableError('down'));
|
||||
now += 2_000;
|
||||
await d.read({ ...REQ, name: 'a' });
|
||||
|
||||
expect(d.stats()).toMatchObject({ entries: 2, servingStale: 1, oldestStaleSince: 3_000 });
|
||||
});
|
||||
});
|
||||
117
src/mcpd/tests/server-identity-service.test.ts
Normal file
117
src/mcpd/tests/server-identity-service.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Per-server identity provisioning. The property under test throughout is
|
||||
* CONTAINMENT: a server's identity must grant exactly its own secrets, because
|
||||
* the alternative (one shared role) would let third-party MCP images read every
|
||||
* secret under the prefix — worse than the pod-spec exposure it replaces.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { SecretBackend } from '@prisma/client';
|
||||
import { ServerIdentityService, type ServiceAccountPort } from '../src/services/server-identity.service.js';
|
||||
import type { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
import type { SecretBackendDriver } from '../src/services/secret-backends/types.js';
|
||||
|
||||
const NS = 'mcpctl-servers';
|
||||
|
||||
function envRef(name: string, secret: string, key: string): unknown {
|
||||
return { name, valueFrom: { secretRef: { name: secret, key } } };
|
||||
}
|
||||
|
||||
function harness(driverOverrides: Partial<SecretBackendDriver> = {}, backendType = 'openbao') {
|
||||
const calls = { sa: [] as string[], saRemoved: [] as string[] };
|
||||
const ensureServerIdentity = vi.fn(async () => undefined);
|
||||
const removeServerIdentity = vi.fn(async () => undefined);
|
||||
const driver = { kind: backendType, ensureServerIdentity, removeServerIdentity, ...driverOverrides } as unknown as SecretBackendDriver;
|
||||
|
||||
const backends = {
|
||||
getDefault: async (): Promise<SecretBackend> => ({ id: 'b1', name: 'bao-k8s', type: backendType } as SecretBackend),
|
||||
driverFor: () => driver,
|
||||
} as unknown as SecretBackendService;
|
||||
|
||||
const serviceAccounts: ServiceAccountPort = {
|
||||
namespace: NS,
|
||||
ensure: async (n) => { calls.sa.push(n); },
|
||||
remove: async (n) => { calls.saRemoved.push(n); },
|
||||
};
|
||||
|
||||
return { svc: new ServerIdentityService(backends, serviceAccounts), calls, ensureServerIdentity, removeServerIdentity };
|
||||
}
|
||||
|
||||
describe('ServerIdentityService', () => {
|
||||
it('grants a server exactly the secrets it declares', async () => {
|
||||
const h = harness();
|
||||
await h.svc.ensureFor({
|
||||
name: 'gitea',
|
||||
env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN'), envRef('GITEA_HOST', 'gitea-creds', 'GITEA_HOST')],
|
||||
} as never);
|
||||
|
||||
expect(h.ensureServerIdentity).toHaveBeenCalledWith({
|
||||
name: 'mcpctl-server-gitea',
|
||||
namespace: NS,
|
||||
// One secret, not two: the same secret referenced twice is one grant.
|
||||
secretNames: ['gitea-creds'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another server\'s secrets into the grant', async () => {
|
||||
const h = harness();
|
||||
await h.svc.ensureFor({ name: 'gitea', env: [envRef('T', 'gitea-creds', 'K')] } as never);
|
||||
const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] };
|
||||
expect(granted.secretNames).not.toContain('unifi-creds');
|
||||
expect(granted.secretNames).not.toContain('anthropic-key');
|
||||
});
|
||||
|
||||
it('ignores inline env values — they are not secrets', async () => {
|
||||
const h = harness();
|
||||
await h.svc.ensureFor({
|
||||
name: 's',
|
||||
env: [{ name: 'PLAIN', value: 'not-a-secret' }, envRef('T', 'real', 'K')],
|
||||
} as never);
|
||||
const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] };
|
||||
expect(granted.secretNames).toEqual(['real']);
|
||||
});
|
||||
|
||||
it('creates the ServiceAccount BEFORE binding the role to it', async () => {
|
||||
// Reverse order lets the pod start, fail to log in, and crashloop while the
|
||||
// role is still being written.
|
||||
const order: string[] = [];
|
||||
const h = harness();
|
||||
const svc = new ServerIdentityService(
|
||||
{ getDefault: async () => ({ id: 'b1', name: 'bao', type: 'openbao' } as SecretBackend),
|
||||
driverFor: () => ({ kind: 'openbao', ensureServerIdentity: async () => { order.push('role'); } }) } as unknown as SecretBackendService,
|
||||
{ namespace: NS, ensure: async () => { order.push('sa'); }, remove: async () => undefined },
|
||||
);
|
||||
await svc.ensureFor({ name: 'x', env: [envRef('T', 's', 'K')] } as never);
|
||||
expect(order).toEqual(['sa', 'role']);
|
||||
expect(h).toBeDefined();
|
||||
});
|
||||
|
||||
it('produces an empty grant for a server with no secret refs', async () => {
|
||||
const h = harness();
|
||||
await h.svc.ensureFor({ name: 'plain', env: [] } as never);
|
||||
const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] };
|
||||
expect(granted.secretNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses on a backend that cannot scope identities, rather than silently succeeding', async () => {
|
||||
// A plaintext backend has no auth mount to bind a role on. Succeeding here
|
||||
// would leave a pod believing it had been granted access it never got.
|
||||
const h = harness({ ensureServerIdentity: undefined }, 'plaintext');
|
||||
await expect(h.svc.ensureFor({ name: 's', env: [] } as never))
|
||||
.rejects.toThrow(/cannot provision per-server identities/);
|
||||
});
|
||||
|
||||
it('revokes the OpenBao identity and the ServiceAccount on removal', async () => {
|
||||
const h = harness();
|
||||
await h.svc.removeFor('gitea');
|
||||
expect(h.removeServerIdentity).toHaveBeenCalledWith({ name: 'mcpctl-server-gitea' });
|
||||
expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']);
|
||||
});
|
||||
|
||||
it('still removes the ServiceAccount when OpenBao teardown fails', async () => {
|
||||
// A half-removed identity grants nothing useful; abandoning the rest would
|
||||
// strand the k8s side.
|
||||
const h = harness({ removeServerIdentity: vi.fn(async () => { throw new Error('bao down'); }) as never });
|
||||
await expect(h.svc.removeFor('gitea')).resolves.toBeUndefined();
|
||||
expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
@@ -481,3 +544,32 @@ describe('HealthProbeRunner', () => {
|
||||
expect(result.message).toBe('ECONNREFUSED 10.0.0.1:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthProbeRunner stale-pipe eviction (mcpctl#114)', () => {
|
||||
it('evicts the cached stdio client exactly once, when failures cross the threshold', async () => {
|
||||
const instanceRepo = mockInstanceRepo();
|
||||
const serverRepo = mockServerRepo();
|
||||
const orchestrator = mockOrchestrator();
|
||||
const mcpProxyService = mockMcpProxyService();
|
||||
const runner = new HealthProbeRunner(instanceRepo, serverRepo, orchestrator, undefined, mcpProxyService);
|
||||
|
||||
const instance = makeInstance();
|
||||
// intervalSeconds: 0 so every tick actually probes; failureThreshold: 3.
|
||||
const server = makeServer({
|
||||
healthCheck: { tool: 'list_datasources', arguments: {}, intervalSeconds: 0, timeoutSeconds: 10, failureThreshold: 3 },
|
||||
} as Partial<McpServer>);
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||
vi.mocked(mcpProxyService.execute).mockRejectedValue(new Error('pipe is dead'));
|
||||
|
||||
await runner.tick(); // failure 1 — degraded
|
||||
await runner.tick(); // failure 2 — degraded
|
||||
expect(mcpProxyService.removeClient).not.toHaveBeenCalled();
|
||||
|
||||
await runner.tick(); // failure 3 — crosses threshold → evict once
|
||||
expect(mcpProxyService.removeClient).toHaveBeenCalledExactlyOnceWith('container-abc');
|
||||
|
||||
await runner.tick(); // failure 4 — already past threshold, no re-evict
|
||||
expect(mcpProxyService.removeClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user