Some checks failed
CI/CD / lint (pull_request) Successful in 1m1s
CI/CD / typecheck (pull_request) Successful in 1m2s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / smoke (pull_request) Failing after 1m50s
CI/CD / build (pull_request) Successful in 5m12s
CI/CD / publish (pull_request) Has been skipped
Operators can now see recent mcpd error/fatal logs without kubectl: - mcpd tees level>=error pino records into an in-memory ring buffer (src/mcpd/src/services/error-log-buffer.ts; wired via pino.multistream in server.ts so stdout logging is unchanged). Captures structured errors incl. fatal kinds like BACKEND_TOKEN_DEAD. - GET /api/v1/logs/errors?limit=N (RBAC: 'logs' operation). - CLI: 'mcpctl errors [-n N]' renders TIME/LEVEL/DETAIL, most-recent-first. Buffer unit tests (6); full suite 2223 passing. Needs a deploy to go live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
369 lines
14 KiB
JavaScript
369 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
import { Command } from 'commander';
|
|
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
|
|
import { createConfigCommand } from './commands/config.js';
|
|
import { createStatusCommand } from './commands/status.js';
|
|
import { createGetCommand } from './commands/get.js';
|
|
import { createDescribeCommand } from './commands/describe.js';
|
|
import { createDeleteCommand } from './commands/delete.js';
|
|
import { createLogsCommand } from './commands/logs.js';
|
|
import { createApplyCommand } from './commands/apply.js';
|
|
import { createTestCommand } from './commands/test-mcp.js';
|
|
import { createCreateCommand } from './commands/create.js';
|
|
import { createEditCommand } from './commands/edit.js';
|
|
import { createBackupCommand } from './commands/backup.js';
|
|
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
|
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
|
|
import { createMcpCommand } from './commands/mcp.js';
|
|
import { createPatchCommand } from './commands/patch.js';
|
|
import { createConsoleCommand } from './commands/console/index.js';
|
|
import { createCacheCommand } from './commands/cache.js';
|
|
import { createProviderCommand } from './commands/provider.js';
|
|
import { createChatCommand } from './commands/chat.js';
|
|
import { createChatLlmCommand } from './commands/chat-llm.js';
|
|
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 { createPasswdCommand } from './commands/passwd.js';
|
|
import { createErrorsCommand } from './commands/errors.js';
|
|
import { ApiClient, ApiError } from './api-client.js';
|
|
import { loadConfig } from './config/index.js';
|
|
import { loadCredentials } from './auth/index.js';
|
|
import { resolveNameOrId } from './commands/shared.js';
|
|
|
|
export function createProgram(): Command {
|
|
const program = new Command()
|
|
.name(APP_NAME)
|
|
.description('Manage MCP servers like kubectl manages containers')
|
|
.version(APP_VERSION, '-v, --version')
|
|
.enablePositionalOptions()
|
|
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
|
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
|
|
.option('-p, --project <name>', 'Target project for project commands');
|
|
|
|
program.addCommand(createStatusCommand());
|
|
program.addCommand(createLoginCommand());
|
|
program.addCommand(createLogoutCommand());
|
|
|
|
// Resolve target URL: --direct goes to mcpd, default goes to mcplocal.
|
|
//
|
|
// Commander's `program.opts()` returns the default values until
|
|
// `program.parse(argv)` runs — but commands (and ApiClient) need the
|
|
// resolved baseUrl at construction time. The chicken-and-egg meant
|
|
// `--direct` was previously a no-op for ApiClient: every command went to
|
|
// mcplocal regardless. Some commands accidentally still worked because
|
|
// mcplocal forwards plain `/api/v1/*` to mcpd, but flows that need direct
|
|
// SSE streaming (e.g. `mcpctl chat`) went to mcplocal:3200, which doesn't
|
|
// route them.
|
|
//
|
|
// Fix: peek at process.argv directly for the two global flags we need
|
|
// before Commander's full parse runs.
|
|
const config = loadConfig();
|
|
const creds = loadCredentials();
|
|
const argv = process.argv;
|
|
const directFlag = argv.includes('--direct');
|
|
const daemonUrlIdx = argv.indexOf('--daemon-url');
|
|
const daemonUrlVal = daemonUrlIdx > -1 && daemonUrlIdx + 1 < argv.length
|
|
? argv[daemonUrlIdx + 1]
|
|
: undefined;
|
|
let baseUrl: string;
|
|
if (daemonUrlVal !== undefined) {
|
|
baseUrl = daemonUrlVal;
|
|
} else if (directFlag) {
|
|
baseUrl = config.mcpdUrl;
|
|
} else {
|
|
baseUrl = config.mcplocalUrl;
|
|
}
|
|
|
|
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
|
|
|
|
program.addCommand(createConfigCommand(undefined, {
|
|
client,
|
|
credentialsDeps: {},
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise<unknown[]> => {
|
|
const projectName = opts?.project ?? program.opts().project as string | undefined;
|
|
|
|
// Virtual resource: serverattachments (composed from project data)
|
|
if (resource === 'serverattachments') {
|
|
type ProjectWithServers = { name: string; id: string; servers?: Array<{ server: { name: string } }> };
|
|
let projects: ProjectWithServers[];
|
|
if (projectName) {
|
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
|
const project = await client.get<ProjectWithServers>(`/api/v1/projects/${projectId}`);
|
|
projects = [project];
|
|
} else {
|
|
projects = await client.get<ProjectWithServers[]>('/api/v1/projects');
|
|
}
|
|
const attachments: Array<{ project: string; server: string }> = [];
|
|
for (const p of projects) {
|
|
if (p.servers) {
|
|
for (const ps of p.servers) {
|
|
attachments.push({ server: ps.server.name, project: p.name });
|
|
}
|
|
}
|
|
}
|
|
return attachments;
|
|
}
|
|
|
|
// --project scoping for servers: show only attached servers
|
|
if (!nameOrId && resource === 'servers' && projectName) {
|
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
|
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
|
|
}
|
|
|
|
// --project scoping for prompts and promptrequests
|
|
if (!nameOrId && (resource === 'prompts' || resource === 'promptrequests')) {
|
|
if (projectName) {
|
|
return client.get<unknown[]>(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`);
|
|
}
|
|
// Default: global-only. --all (-A) shows everything.
|
|
if (!opts?.all) {
|
|
return client.get<unknown[]>(`/api/v1/${resource}?scope=global`);
|
|
}
|
|
}
|
|
|
|
// --project scoping for mcptokens
|
|
if (!nameOrId && resource === 'mcptokens' && projectName) {
|
|
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
|
|
}
|
|
|
|
// Name-based lookup for mcptokens: names are unique only within a project
|
|
if (nameOrId && resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
|
|
if (!projectName) {
|
|
throw new Error('mcptoken names are scoped to a project — pass --project <name> or use the token id (cuid)');
|
|
}
|
|
const items = await client.get<Array<{ id: string; name: string }>>(
|
|
`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`,
|
|
);
|
|
const match = items.find((i) => i.name === nameOrId);
|
|
if (!match) throw new Error(`mcptoken '${nameOrId}' not found in project '${projectName}'`);
|
|
const item = await client.get(`/api/v1/mcptokens/${match.id}`);
|
|
return [item];
|
|
}
|
|
|
|
if (nameOrId) {
|
|
// Glob pattern — use query param filtering
|
|
if (nameOrId.includes('*')) {
|
|
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
|
|
}
|
|
let id: string;
|
|
try {
|
|
id = await resolveNameOrId(client, resource, nameOrId);
|
|
} catch {
|
|
id = nameOrId;
|
|
}
|
|
const item = await client.get(`/api/v1/${resource}/${id}`);
|
|
return [item];
|
|
}
|
|
return client.get<unknown[]>(`/api/v1/${resource}`);
|
|
};
|
|
|
|
const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
|
|
const projectName = program.opts().project as string | undefined;
|
|
|
|
// Prompts: resolve within project scope (or global-only without --project)
|
|
if (resource === 'prompts' || resource === 'promptrequests') {
|
|
const scope = projectName
|
|
? `?project=${encodeURIComponent(projectName)}`
|
|
: '?scope=global';
|
|
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}${scope}`);
|
|
const match = items.find((item) => item.name === nameOrId);
|
|
if (!match) {
|
|
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found${projectName ? ` in project '${projectName}'` : ' (global scope). Use --project to specify a project'}`);
|
|
}
|
|
return client.get(`/api/v1/${resource}/${match.id as string}`);
|
|
}
|
|
|
|
// Mcptokens: names are project-scoped. CUIDs pass straight through.
|
|
if (resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
|
|
if (!projectName) {
|
|
throw new Error('mcptoken names are scoped to a project — pass --project <name> or use the token id (cuid)');
|
|
}
|
|
const items = await client.get<Array<Record<string, unknown>>>(
|
|
`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`,
|
|
);
|
|
const match = items.find((item) => item.name === nameOrId);
|
|
if (!match) throw new Error(`mcptoken '${nameOrId}' not found in project '${projectName}'`);
|
|
return client.get(`/api/v1/mcptokens/${match.id as string}`);
|
|
}
|
|
|
|
let id: string;
|
|
try {
|
|
id = await resolveNameOrId(client, resource, nameOrId);
|
|
} catch {
|
|
id = nameOrId;
|
|
}
|
|
return client.get(`/api/v1/${resource}/${id}`);
|
|
};
|
|
|
|
program.addCommand(createGetCommand({
|
|
fetchResource,
|
|
log: (...args) => console.log(...args),
|
|
getProject: () => program.opts().project as string | undefined,
|
|
mcplocalUrl: config.mcplocalUrl,
|
|
}));
|
|
|
|
program.addCommand(createDescribeCommand({
|
|
client,
|
|
fetchResource: fetchSingleResource,
|
|
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
|
|
log: (...args) => console.log(...args),
|
|
mcplocalUrl: config.mcplocalUrl,
|
|
}));
|
|
|
|
program.addCommand(createDeleteCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createLogsCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createCreateCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createEditCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createApplyCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createChatCommand({
|
|
client,
|
|
baseUrl,
|
|
...(creds?.token !== undefined ? { token: creds.token } : {}),
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createChatLlmCommand({
|
|
client,
|
|
baseUrl,
|
|
...(creds?.token !== undefined ? { token: creds.token } : {}),
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createPatchCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createPasswdCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createErrorsCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createBackupCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
const projectOpsDeps = {
|
|
client,
|
|
log: (...args: string[]) => console.log(...args),
|
|
getProject: () => program.opts().project as string | undefined,
|
|
};
|
|
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
|
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
|
program.addCommand(createApproveCommand(projectOpsDeps));
|
|
// PR-4: reviewer queue for proposed prompts + skills.
|
|
program.addCommand(createReviewCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
// PR-5: skills sync to ~/.claude/skills/ on demand or via SessionStart hook.
|
|
program.addCommand(createSkillsCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
program.addCommand(createMcpCommand({
|
|
getProject: () => program.opts().project as string | undefined,
|
|
}), { hidden: true });
|
|
|
|
program.addCommand(createConsoleCommand({
|
|
getProject: () => program.opts().project as string | undefined,
|
|
}));
|
|
|
|
program.addCommand(createCacheCommand({
|
|
log: (...args) => console.log(...args),
|
|
mcplocalUrl: config.mcplocalUrl,
|
|
}));
|
|
|
|
program.addCommand(createProviderCommand({
|
|
log: (...args) => console.log(...args),
|
|
mcplocalUrl: config.mcplocalUrl,
|
|
}));
|
|
|
|
program.addCommand(createTestCommand({
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createMigrateCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
program.addCommand(createRotateCommand({
|
|
client,
|
|
log: (...args) => console.log(...args),
|
|
}));
|
|
|
|
return program;
|
|
}
|
|
|
|
// Run when invoked directly
|
|
const isDirectRun =
|
|
typeof process !== 'undefined' &&
|
|
process.argv[1] !== undefined &&
|
|
import.meta.url === `file://${process.argv[1]}`;
|
|
|
|
if (isDirectRun) {
|
|
createProgram().parseAsync(process.argv).catch((err: unknown) => {
|
|
if (err instanceof ApiError) {
|
|
if (err.status === 401) {
|
|
console.error("Error: you need to log in. Run 'mcpctl login' to authenticate.");
|
|
} else if (err.status === 403) {
|
|
console.error('Error: permission denied. You do not have access to this resource.');
|
|
} else {
|
|
let msg: string;
|
|
try {
|
|
const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown };
|
|
msg = parsed.error ?? parsed.message ?? err.body;
|
|
if (parsed.details && Array.isArray(parsed.details)) {
|
|
const issues = parsed.details as Array<{ message?: string; path?: string[] }>;
|
|
const detail = issues.map((i) => {
|
|
const path = i.path?.join('.') ?? '';
|
|
return path ? `${path}: ${i.message}` : (i.message ?? '');
|
|
}).filter(Boolean).join('; ');
|
|
if (detail) msg += `: ${detail}`;
|
|
}
|
|
} catch {
|
|
msg = err.body;
|
|
}
|
|
console.error(`Error: ${msg}`);
|
|
}
|
|
} else if (err instanceof Error) {
|
|
console.error(`Error: ${err.message}`);
|
|
} else {
|
|
console.error(`Error: ${String(err)}`);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|