feat: mcpctl v0.0.1 — first public release
Comprehensive MCP server management with kubectl-style CLI. Key features in this release: - Declarative YAML apply/get round-trip with project cloning support - Gated sessions with prompt intelligence for Claude - Interactive MCP console with traffic inspector - Persistent STDIO connections for containerized servers - RBAC with name-scoped bindings - Shell completions (fish + bash) auto-generated - Rate-limit retry with exponential backoff in apply - Project-scoped prompt management - Credential scrubbing from git history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mcpctl/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -106,12 +106,19 @@ const RbacBindingSpecSchema = z.object({
|
||||
|
||||
const PromptSpecSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
content: z.string().min(1).max(50000),
|
||||
content: z.string().min(1).max(50000).optional(),
|
||||
projectId: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
priority: z.number().int().min(1).max(10).optional(),
|
||||
link: z.string().optional(),
|
||||
linkTarget: z.string().optional(),
|
||||
});
|
||||
|
||||
const ServerAttachmentSpecSchema = z.object({
|
||||
server: z.string().min(1),
|
||||
project: z.string().min(1),
|
||||
});
|
||||
|
||||
const ProjectSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
@@ -130,6 +137,7 @@ const ApplyConfigSchema = z.object({
|
||||
groups: z.array(GroupSpecSchema).default([]),
|
||||
projects: z.array(ProjectSpecSchema).default([]),
|
||||
templates: z.array(TemplateSpecSchema).default([]),
|
||||
serverattachments: z.array(ServerAttachmentSpecSchema).default([]),
|
||||
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
||||
rbac: z.array(RbacBindingSpecSchema).default([]),
|
||||
prompts: z.array(PromptSpecSchema).default([]),
|
||||
@@ -169,6 +177,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
|
||||
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
|
||||
if (config.serverattachments.length > 0) log(` ${config.serverattachments.length} serverattachment(s)`);
|
||||
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
|
||||
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
|
||||
return;
|
||||
@@ -194,14 +203,62 @@ function readStdin(): string {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
/** Map singular kind → plural resource key used by ApplyConfigSchema */
|
||||
const KIND_TO_RESOURCE: Record<string, string> = {
|
||||
server: 'servers',
|
||||
project: 'projects',
|
||||
secret: 'secrets',
|
||||
template: 'templates',
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
rbac: 'rbac',
|
||||
prompt: 'prompts',
|
||||
promptrequest: 'promptrequests',
|
||||
serverattachment: 'serverattachments',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert multi-doc format (array of {kind, ...} items) into the grouped
|
||||
* format that ApplyConfigSchema expects.
|
||||
*/
|
||||
function multiDocToGrouped(docs: Array<Record<string, unknown>>): Record<string, unknown[]> {
|
||||
const grouped: Record<string, unknown[]> = {};
|
||||
for (const doc of docs) {
|
||||
const kind = doc.kind as string;
|
||||
const resource = KIND_TO_RESOURCE[kind] ?? kind;
|
||||
const { kind: _k, ...rest } = doc;
|
||||
if (!grouped[resource]) grouped[resource] = [];
|
||||
grouped[resource].push(rest);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function loadConfigFile(path: string): ApplyConfig {
|
||||
const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8');
|
||||
let parsed: unknown;
|
||||
|
||||
if (path === '-' ? raw.trimStart().startsWith('{') : path.endsWith('.json')) {
|
||||
const isJson = path === '-' ? raw.trimStart().startsWith('{') || raw.trimStart().startsWith('[') : path.endsWith('.json');
|
||||
if (isJson) {
|
||||
parsed = JSON.parse(raw);
|
||||
} else {
|
||||
parsed = yaml.load(raw);
|
||||
// Try multi-document YAML first
|
||||
const docs: unknown[] = [];
|
||||
yaml.loadAll(raw, (doc) => docs.push(doc));
|
||||
const allDocs = docs.flatMap((d) => Array.isArray(d) ? d : [d]) as Array<Record<string, unknown>>;
|
||||
if (allDocs.length > 0 && allDocs[0] != null && 'kind' in allDocs[0]) {
|
||||
// Multi-doc or single doc with kind field
|
||||
parsed = multiDocToGrouped(allDocs);
|
||||
} else {
|
||||
parsed = docs[0]; // Fall back to single-doc grouped format
|
||||
}
|
||||
}
|
||||
|
||||
// JSON: handle array of {kind, ...} docs
|
||||
if (Array.isArray(parsed)) {
|
||||
const arr = parsed as Array<Record<string, unknown>>;
|
||||
if (arr.length > 0 && arr[0] != null && 'kind' in arr[0]) {
|
||||
parsed = multiDocToGrouped(arr);
|
||||
}
|
||||
}
|
||||
|
||||
return ApplyConfigSchema.parse(parsed);
|
||||
@@ -210,15 +267,59 @@ function loadConfigFile(path: string): ApplyConfig {
|
||||
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
|
||||
// Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
|
||||
|
||||
// Cache for name→record lookups to avoid repeated API calls (rate limit protection)
|
||||
const nameCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();
|
||||
|
||||
async function cachedFindByName(resource: string, name: string): Promise<{ id: string; [key: string]: unknown } | null> {
|
||||
if (!nameCache.has(resource)) {
|
||||
try {
|
||||
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
|
||||
const map = new Map<string, { id: string; [key: string]: unknown }>();
|
||||
for (const item of items) {
|
||||
if (item.name) map.set(item.name, item);
|
||||
}
|
||||
nameCache.set(resource, map);
|
||||
} catch {
|
||||
nameCache.set(resource, new Map());
|
||||
}
|
||||
}
|
||||
return nameCache.get(resource)!.get(name) ?? null;
|
||||
}
|
||||
|
||||
/** Invalidate a resource cache after a create/update so subsequent lookups see it */
|
||||
function invalidateCache(resource: string): void {
|
||||
nameCache.delete(resource);
|
||||
}
|
||||
|
||||
/** Retry a function on 429 rate-limit errors with exponential backoff */
|
||||
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < maxRetries && msg.includes('429')) {
|
||||
const delay = 2000 * Math.pow(2, attempt); // 2s, 4s, 8s, 16s, 32s
|
||||
process.stderr.write(`\r\x1b[33mRate limited, retrying in ${delay / 1000}s...\x1b[0m`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
process.stderr.write('\r\x1b[K'); // clear the line
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply secrets
|
||||
for (const secret of config.secrets) {
|
||||
try {
|
||||
const existing = await findByName(client, 'secrets', secret.name);
|
||||
const existing = await cachedFindByName('secrets', secret.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
|
||||
await withRetry(() => client.put(`/api/v1/secrets/${existing.id}`, { data: secret.data }));
|
||||
log(`Updated secret: ${secret.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/secrets', secret);
|
||||
await withRetry(() => client.post('/api/v1/secrets', secret));
|
||||
invalidateCache('secrets');
|
||||
log(`Created secret: ${secret.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -229,12 +330,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
// Apply servers
|
||||
for (const server of config.servers) {
|
||||
try {
|
||||
const existing = await findByName(client, 'servers', server.name);
|
||||
const existing = await cachedFindByName('servers', server.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server);
|
||||
await withRetry(() => client.put(`/api/v1/servers/${existing.id}`, server));
|
||||
log(`Updated server: ${server.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/servers', server);
|
||||
await withRetry(() => client.post('/api/v1/servers', server));
|
||||
invalidateCache('servers');
|
||||
log(`Created server: ${server.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -245,12 +347,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
// Apply users (matched by email)
|
||||
for (const user of config.users) {
|
||||
try {
|
||||
// Users use email, not name — use uncached findByField
|
||||
const existing = await findByField(client, 'users', 'email', user.email);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
|
||||
await withRetry(() => client.put(`/api/v1/users/${(existing as { id: string }).id}`, user));
|
||||
log(`Updated user: ${user.email}`);
|
||||
} else {
|
||||
await client.post('/api/v1/users', user);
|
||||
await withRetry(() => client.post('/api/v1/users', user));
|
||||
log(`Created user: ${user.email}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -261,12 +364,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
// Apply groups
|
||||
for (const group of config.groups) {
|
||||
try {
|
||||
const existing = await findByName(client, 'groups', group.name);
|
||||
const existing = await cachedFindByName('groups', group.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
|
||||
await withRetry(() => client.put(`/api/v1/groups/${existing.id}`, group));
|
||||
log(`Updated group: ${group.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/groups', group);
|
||||
await withRetry(() => client.post('/api/v1/groups', group));
|
||||
invalidateCache('groups');
|
||||
log(`Created group: ${group.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -277,12 +381,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
// Apply projects (send full spec including servers)
|
||||
for (const project of config.projects) {
|
||||
try {
|
||||
const existing = await findByName(client, 'projects', project.name);
|
||||
const existing = await cachedFindByName('projects', project.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
|
||||
await withRetry(() => client.put(`/api/v1/projects/${existing.id}`, project));
|
||||
log(`Updated project: ${project.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/projects', project);
|
||||
await withRetry(() => client.post('/api/v1/projects', project));
|
||||
invalidateCache('projects');
|
||||
log(`Created project: ${project.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -293,12 +398,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
// Apply templates
|
||||
for (const template of config.templates) {
|
||||
try {
|
||||
const existing = await findByName(client, 'templates', template.name);
|
||||
const existing = await cachedFindByName('templates', template.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
|
||||
await withRetry(() => client.put(`/api/v1/templates/${existing.id}`, template));
|
||||
log(`Updated template: ${template.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/templates', template);
|
||||
await withRetry(() => client.post('/api/v1/templates', template));
|
||||
invalidateCache('templates');
|
||||
log(`Created template: ${template.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -306,15 +412,37 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply server attachments (after projects and servers exist)
|
||||
for (const sa of config.serverattachments) {
|
||||
try {
|
||||
const project = await cachedFindByName('projects', sa.project);
|
||||
if (!project) {
|
||||
log(`Error applying serverattachment: project '${sa.project}' not found`);
|
||||
continue;
|
||||
}
|
||||
await withRetry(() => client.post(`/api/v1/projects/${project.id}/servers`, { server: sa.server }));
|
||||
log(`Attached server '${sa.server}' to project '${sa.project}'`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Ignore "already attached" conflicts silently
|
||||
if (msg.includes('409') || msg.includes('already')) {
|
||||
log(`Server '${sa.server}' already attached to project '${sa.project}'`);
|
||||
} else {
|
||||
log(`Error applying serverattachment '${sa.project}/${sa.server}': ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply RBAC bindings
|
||||
for (const rbacBinding of config.rbacBindings) {
|
||||
try {
|
||||
const existing = await findByName(client, 'rbac', rbacBinding.name);
|
||||
const existing = await cachedFindByName('rbac', rbacBinding.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
|
||||
await withRetry(() => client.put(`/api/v1/rbac/${existing.id}`, rbacBinding));
|
||||
log(`Updated rbacBinding: ${rbacBinding.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/rbac', rbacBinding);
|
||||
await withRetry(() => client.post('/api/v1/rbac', rbacBinding));
|
||||
invalidateCache('rbac');
|
||||
log(`Created rbacBinding: ${rbacBinding.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -322,17 +450,77 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply prompts
|
||||
// Apply prompts — project-scoped: same name in different projects are distinct resources.
|
||||
// Cache project-scoped prompt lookups separately from global cache.
|
||||
const promptProjectIds = new Map<string, string>();
|
||||
const projectPromptCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();
|
||||
|
||||
async function findPromptInProject(name: string, projectId: string | undefined): Promise<{ id: string; [key: string]: unknown } | null> {
|
||||
// Global prompts (no project) — use standard cache
|
||||
if (!projectId) {
|
||||
return cachedFindByName('prompts', name);
|
||||
}
|
||||
// Project-scoped: query prompts filtered by projectId
|
||||
if (!projectPromptCache.has(projectId)) {
|
||||
try {
|
||||
const items = await client.get<Array<{ id: string; name: string; projectId?: string }>>(`/api/v1/prompts?projectId=${projectId}`);
|
||||
const map = new Map<string, { id: string; [key: string]: unknown }>();
|
||||
for (const item of items) {
|
||||
if (item.name) map.set(item.name, item);
|
||||
}
|
||||
projectPromptCache.set(projectId, map);
|
||||
} catch {
|
||||
projectPromptCache.set(projectId, new Map());
|
||||
}
|
||||
}
|
||||
return projectPromptCache.get(projectId)!.get(name) ?? null;
|
||||
}
|
||||
|
||||
for (const prompt of config.prompts) {
|
||||
try {
|
||||
const existing = await findByName(client, 'prompts', prompt.name);
|
||||
// Resolve project name → projectId if needed
|
||||
let projectId = prompt.projectId;
|
||||
if (!projectId && prompt.project) {
|
||||
if (promptProjectIds.has(prompt.project)) {
|
||||
projectId = promptProjectIds.get(prompt.project)!;
|
||||
} else {
|
||||
const proj = await cachedFindByName('projects', prompt.project);
|
||||
if (!proj) {
|
||||
log(`Error applying prompt '${prompt.name}': project '${prompt.project}' not found`);
|
||||
continue;
|
||||
}
|
||||
projectId = proj.id;
|
||||
promptProjectIds.set(prompt.project, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize: accept both `link` and `linkTarget`, prefer `link`
|
||||
const linkTarget = prompt.link ?? prompt.linkTarget;
|
||||
|
||||
// Linked prompts use placeholder content if none provided
|
||||
const content = prompt.content ?? (linkTarget ? `Linked prompt — content fetched from ${linkTarget}` : '');
|
||||
if (!content) {
|
||||
log(`Error applying prompt '${prompt.name}': content is required (or provide link)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build API body (strip the `project` name field, use projectId)
|
||||
const body: Record<string, unknown> = { name: prompt.name, content };
|
||||
if (projectId) body.projectId = projectId;
|
||||
if (prompt.priority !== undefined) body.priority = prompt.priority;
|
||||
if (linkTarget) body.linkTarget = linkTarget;
|
||||
|
||||
const existing = await findPromptInProject(prompt.name, projectId);
|
||||
if (existing) {
|
||||
const updateData: Record<string, unknown> = { content: prompt.content };
|
||||
const updateData: Record<string, unknown> = { content };
|
||||
if (projectId) updateData.projectId = projectId;
|
||||
if (prompt.priority !== undefined) updateData.priority = prompt.priority;
|
||||
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, updateData);
|
||||
if (linkTarget) updateData.linkTarget = linkTarget;
|
||||
await withRetry(() => client.put(`/api/v1/prompts/${existing.id}`, updateData));
|
||||
log(`Updated prompt: ${prompt.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/prompts', prompt);
|
||||
await withRetry(() => client.post('/api/v1/prompts', body));
|
||||
projectPromptCache.delete(projectId ?? '');
|
||||
log(`Created prompt: ${prompt.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -341,15 +529,6 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
||||
try {
|
||||
const items = await client.get<Array<{ name: string }>>(`/api/v1/${resource}`);
|
||||
return items.find((item) => item.name === name) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
|
||||
try {
|
||||
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
|
||||
|
||||
@@ -90,39 +90,51 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
const cmd = config
|
||||
.command(name)
|
||||
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
|
||||
.requiredOption('--project <name>', 'Project name')
|
||||
.option('--project <name>', 'Project name')
|
||||
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
||||
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
||||
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
|
||||
.option('--stdout', 'Print to stdout instead of writing a file')
|
||||
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
||||
const mcpConfig: McpConfig = {
|
||||
mcpServers: {
|
||||
[opts.project]: {
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', opts.project],
|
||||
},
|
||||
},
|
||||
};
|
||||
.action((opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean }) => {
|
||||
if (!opts.project && !opts.inspect) {
|
||||
log('Error: at least one of --project or --inspect is required');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const servers: McpConfig['mcpServers'] = {};
|
||||
if (opts.project) {
|
||||
servers[opts.project] = {
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', opts.project],
|
||||
};
|
||||
}
|
||||
if (opts.inspect) {
|
||||
servers['mcpctl-inspect'] = {
|
||||
command: 'mcpctl',
|
||||
args: ['console', '--inspect', '--stdin-mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.stdout) {
|
||||
log(JSON.stringify(mcpConfig, null, 2));
|
||||
log(JSON.stringify({ mcpServers: servers }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = resolve(opts.output);
|
||||
let finalConfig = mcpConfig;
|
||||
let finalConfig: McpConfig = { mcpServers: servers };
|
||||
|
||||
if (opts.merge && existsSync(outputPath)) {
|
||||
// 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,
|
||||
...mcpConfig.mcpServers,
|
||||
...servers,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// If existing file is invalid, just overwrite
|
||||
// If existing file is invalid, start fresh
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ export interface ConsoleCommandDeps {
|
||||
export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
|
||||
const cmd = new Command('console')
|
||||
.description('Interactive MCP console — see what an LLM sees when attached to a project')
|
||||
.argument('<project>', 'Project name to connect to')
|
||||
.action(async (projectName: string) => {
|
||||
.argument('[project]', 'Project name to connect to')
|
||||
.option('--inspect', 'Passive traffic inspector — observe other clients\' MCP traffic')
|
||||
.option('--stdin-mcp', 'Run inspector as MCP server over stdin/stdout (for Claude)')
|
||||
.action(async (projectName: string | undefined, opts: { inspect?: boolean; stdinMcp?: boolean }) => {
|
||||
let mcplocalUrl = 'http://localhost:3200';
|
||||
if (deps.configLoader) {
|
||||
mcplocalUrl = deps.configLoader().mcplocalUrl;
|
||||
@@ -23,6 +25,28 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
|
||||
}
|
||||
}
|
||||
|
||||
// --inspect --stdin-mcp: MCP server for Claude
|
||||
if (opts.inspect && opts.stdinMcp) {
|
||||
const { runInspectMcp } = await import('./inspect-mcp.js');
|
||||
await runInspectMcp(mcplocalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// --inspect: TUI traffic inspector
|
||||
if (opts.inspect) {
|
||||
const { renderInspect } = await import('./inspect-app.js');
|
||||
await renderInspect({ mcplocalUrl, projectFilter: projectName });
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular interactive console — requires project name
|
||||
if (!projectName) {
|
||||
console.error('Error: project name is required for interactive console mode.');
|
||||
console.error('Usage: mcpctl console <project>');
|
||||
console.error(' mcpctl console --inspect [project]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let token: string | undefined;
|
||||
if (deps.credentialsLoader) {
|
||||
token = deps.credentialsLoader()?.token;
|
||||
|
||||
825
src/cli/src/commands/console/inspect-app.tsx
Normal file
825
src/cli/src/commands/console/inspect-app.tsx
Normal file
@@ -0,0 +1,825 @@
|
||||
/**
|
||||
* Inspector TUI — passive MCP traffic sniffer.
|
||||
*
|
||||
* Connects to mcplocal's /inspect SSE endpoint and displays
|
||||
* live traffic per project/session with color coding.
|
||||
*
|
||||
* Keys:
|
||||
* s toggle sidebar
|
||||
* j/k navigate events
|
||||
* Enter expand/collapse event detail
|
||||
* Esc close detail / deselect
|
||||
* ↑/↓ select session (when sidebar visible)
|
||||
* a all sessions
|
||||
* c clear traffic
|
||||
* q quit
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
|
||||
// ── Types matching mcplocal's TrafficEvent ──
|
||||
|
||||
interface TrafficEvent {
|
||||
timestamp: string;
|
||||
projectName: string;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
method?: string;
|
||||
upstreamName?: string;
|
||||
body: unknown;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ActiveSession {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
// ── SSE Client ──
|
||||
|
||||
function connectSSE(
|
||||
url: string,
|
||||
opts: {
|
||||
onSessions: (sessions: ActiveSession[]) => void;
|
||||
onEvent: (event: TrafficEvent) => void;
|
||||
onLive: () => void;
|
||||
onError: (err: string) => void;
|
||||
},
|
||||
): () => void {
|
||||
let aborted = false;
|
||||
const parsed = new URL(url);
|
||||
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
},
|
||||
(res: IncomingMessage) => {
|
||||
let buffer = '';
|
||||
let currentEventType = 'message';
|
||||
|
||||
res.setEncoding('utf-8');
|
||||
res.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop()!; // Keep incomplete line
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEventType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (currentEventType === 'sessions') {
|
||||
opts.onSessions(parsed as ActiveSession[]);
|
||||
} else if (currentEventType === 'live') {
|
||||
opts.onLive();
|
||||
} else {
|
||||
opts.onEvent(parsed as TrafficEvent);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unparseable data
|
||||
}
|
||||
currentEventType = 'message';
|
||||
}
|
||||
// Ignore comments (: keepalive) and blank lines
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (!aborted) opts.onError('SSE connection closed');
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
if (!aborted) opts.onError(err.message);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (!aborted) opts.onError(err.message);
|
||||
});
|
||||
|
||||
req.end();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
req.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Formatting helpers ──
|
||||
|
||||
/** Safely dig into unknown objects */
|
||||
function dig(obj: unknown, ...keys: string[]): unknown {
|
||||
let cur = obj;
|
||||
for (const k of keys) {
|
||||
if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function trunc(s: string, maxLen: number): string {
|
||||
return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
|
||||
}
|
||||
|
||||
function nameList(items: unknown[], key: string, max: number): string {
|
||||
if (items.length === 0) return '(none)';
|
||||
const names = items.map((it) => dig(it, key) as string).filter(Boolean);
|
||||
const shown = names.slice(0, max);
|
||||
const rest = names.length - shown.length;
|
||||
return shown.join(', ') + (rest > 0 ? ` +${rest} more` : '');
|
||||
}
|
||||
|
||||
/** Extract meaningful summary from request params (strips jsonrpc/id boilerplate) */
|
||||
function summarizeRequest(method: string, body: unknown): string {
|
||||
const params = dig(body, 'params') as Record<string, unknown> | undefined;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
const name = dig(params, 'clientInfo', 'name') ?? '?';
|
||||
const ver = dig(params, 'clientInfo', 'version') ?? '';
|
||||
const proto = dig(params, 'protocolVersion') ?? '';
|
||||
return `client=${name}${ver ? ` v${ver}` : ''} proto=${proto}`;
|
||||
}
|
||||
case 'tools/call': {
|
||||
const toolName = dig(params, 'name') as string ?? '?';
|
||||
const args = dig(params, 'arguments') as Record<string, unknown> | undefined;
|
||||
if (!args || Object.keys(args).length === 0) return `${toolName}()`;
|
||||
const pairs = Object.entries(args).map(([k, v]) => {
|
||||
const vs = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
return `${k}: ${trunc(vs, 40)}`;
|
||||
});
|
||||
return `${toolName}(${trunc(pairs.join(', '), 80)})`;
|
||||
}
|
||||
case 'resources/read': {
|
||||
const uri = dig(params, 'uri') as string ?? '';
|
||||
return uri;
|
||||
}
|
||||
case 'prompts/get': {
|
||||
const name = dig(params, 'name') as string ?? '';
|
||||
return name;
|
||||
}
|
||||
case 'tools/list':
|
||||
case 'resources/list':
|
||||
case 'prompts/list':
|
||||
case 'notifications/initialized':
|
||||
return '';
|
||||
default: {
|
||||
if (!params || Object.keys(params).length === 0) return '';
|
||||
const s = JSON.stringify(params);
|
||||
return trunc(s, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract meaningful summary from response result */
|
||||
function summarizeResponse(method: string, body: unknown): string {
|
||||
const error = dig(body, 'error') as { message?: string; code?: number } | undefined;
|
||||
if (error) {
|
||||
return `ERROR ${error.code ?? ''}: ${error.message ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
const result = dig(body, 'result') as Record<string, unknown> | undefined;
|
||||
if (!result) return '';
|
||||
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
const name = dig(result, 'serverInfo', 'name') ?? '?';
|
||||
const ver = dig(result, 'serverInfo', 'version') ?? '';
|
||||
const caps = dig(result, 'capabilities') as Record<string, unknown> | undefined;
|
||||
const capList = caps ? Object.keys(caps).filter((k) => caps[k] && Object.keys(caps[k] as object).length > 0) : [];
|
||||
return `server=${name}${ver ? ` v${ver}` : ''}${capList.length ? ` caps=[${capList.join(',')}]` : ''}`;
|
||||
}
|
||||
case 'tools/list': {
|
||||
const tools = (result.tools ?? []) as unknown[];
|
||||
return `${tools.length} tools: ${nameList(tools, 'name', 6)}`;
|
||||
}
|
||||
case 'resources/list': {
|
||||
const resources = (result.resources ?? []) as unknown[];
|
||||
return `${resources.length} resources: ${nameList(resources, 'name', 6)}`;
|
||||
}
|
||||
case 'prompts/list': {
|
||||
const prompts = (result.prompts ?? []) as unknown[];
|
||||
if (prompts.length === 0) return '0 prompts';
|
||||
return `${prompts.length} prompts: ${nameList(prompts, 'name', 6)}`;
|
||||
}
|
||||
case 'tools/call': {
|
||||
const content = (result.content ?? []) as unknown[];
|
||||
const isError = result.isError;
|
||||
const first = content[0];
|
||||
const text = (dig(first, 'text') as string) ?? '';
|
||||
const prefix = isError ? 'ERROR: ' : '';
|
||||
if (text) return prefix + trunc(text.replace(/\n/g, ' '), 100);
|
||||
return prefix + `${content.length} content block(s)`;
|
||||
}
|
||||
case 'resources/read': {
|
||||
const contents = (result.contents ?? []) as unknown[];
|
||||
const first = contents[0];
|
||||
const text = (dig(first, 'text') as string) ?? '';
|
||||
if (text) return trunc(text.replace(/\n/g, ' '), 80);
|
||||
return `${contents.length} content block(s)`;
|
||||
}
|
||||
case 'notifications/initialized':
|
||||
return 'ok';
|
||||
default: {
|
||||
if (Object.keys(result).length === 0) return 'ok';
|
||||
const s = JSON.stringify(result);
|
||||
return trunc(s, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Format full event body for expanded detail view (multi-line, readable) */
|
||||
function formatBodyDetail(event: TrafficEvent): string[] {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
if (!body) return ['(no body)'];
|
||||
|
||||
const lines: string[] = [];
|
||||
const method = event.method ?? '';
|
||||
|
||||
// Strip jsonrpc envelope — show meaningful content only
|
||||
if (event.eventType.includes('request') || event.eventType === 'client_notification') {
|
||||
const params = body['params'] as Record<string, unknown> | undefined;
|
||||
if (method === 'tools/call' && params) {
|
||||
lines.push(`Tool: ${params['name'] as string}`);
|
||||
const args = params['arguments'] as Record<string, unknown> | undefined;
|
||||
if (args && Object.keys(args).length > 0) {
|
||||
lines.push('Arguments:');
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
const vs = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
|
||||
for (const vl of vs.split('\n')) {
|
||||
lines.push(` ${k}: ${vl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (method === 'initialize' && params) {
|
||||
const ci = params['clientInfo'] as Record<string, unknown> | undefined;
|
||||
lines.push(`Client: ${ci?.['name'] ?? '?'} v${ci?.['version'] ?? '?'}`);
|
||||
lines.push(`Protocol: ${params['protocolVersion'] ?? '?'}`);
|
||||
const caps = params['capabilities'] as Record<string, unknown> | undefined;
|
||||
if (caps) lines.push(`Capabilities: ${JSON.stringify(caps)}`);
|
||||
} else if (params && Object.keys(params).length > 0) {
|
||||
for (const l of JSON.stringify(params, null, 2).split('\n')) {
|
||||
lines.push(l);
|
||||
}
|
||||
} else {
|
||||
lines.push('(empty params)');
|
||||
}
|
||||
} else if (event.eventType.includes('response')) {
|
||||
const error = body['error'] as Record<string, unknown> | undefined;
|
||||
if (error) {
|
||||
lines.push(`Error ${error['code']}: ${error['message']}`);
|
||||
if (error['data']) {
|
||||
for (const l of JSON.stringify(error['data'], null, 2).split('\n')) {
|
||||
lines.push(` ${l}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = body['result'] as Record<string, unknown> | undefined;
|
||||
if (!result) {
|
||||
lines.push('(empty result)');
|
||||
} else if (method === 'tools/list') {
|
||||
const tools = (result['tools'] ?? []) as Array<{ name: string; description?: string }>;
|
||||
lines.push(`${tools.length} tools:`);
|
||||
for (const t of tools) {
|
||||
lines.push(` ${t.name}${t.description ? ` — ${trunc(t.description, 60)}` : ''}`);
|
||||
}
|
||||
} else if (method === 'resources/list') {
|
||||
const resources = (result['resources'] ?? []) as Array<{ name: string; uri?: string; description?: string }>;
|
||||
lines.push(`${resources.length} resources:`);
|
||||
for (const r of resources) {
|
||||
lines.push(` ${r.name}${r.uri ? ` (${r.uri})` : ''}${r.description ? ` — ${trunc(r.description, 50)}` : ''}`);
|
||||
}
|
||||
} else if (method === 'prompts/list') {
|
||||
const prompts = (result['prompts'] ?? []) as Array<{ name: string; description?: string }>;
|
||||
lines.push(`${prompts.length} prompts:`);
|
||||
for (const p of prompts) {
|
||||
lines.push(` ${p.name}${p.description ? ` — ${trunc(p.description, 60)}` : ''}`);
|
||||
}
|
||||
} else if (method === 'tools/call') {
|
||||
const isErr = result['isError'];
|
||||
const content = (result['content'] ?? []) as Array<{ type?: string; text?: string }>;
|
||||
if (isErr) lines.push('(error response)');
|
||||
for (const c of content) {
|
||||
if (c.text) {
|
||||
for (const l of c.text.split('\n')) {
|
||||
lines.push(l);
|
||||
}
|
||||
} else {
|
||||
lines.push(`[${c.type ?? 'unknown'} content]`);
|
||||
}
|
||||
}
|
||||
} else if (method === 'initialize') {
|
||||
const si = result['serverInfo'] as Record<string, unknown> | undefined;
|
||||
lines.push(`Server: ${si?.['name'] ?? '?'} v${si?.['version'] ?? '?'}`);
|
||||
lines.push(`Protocol: ${result['protocolVersion'] ?? '?'}`);
|
||||
const caps = result['capabilities'] as Record<string, unknown> | undefined;
|
||||
if (caps) {
|
||||
lines.push('Capabilities:');
|
||||
for (const [k, v] of Object.entries(caps)) {
|
||||
if (v && typeof v === 'object' && Object.keys(v).length > 0) {
|
||||
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const instructions = result['instructions'] as string | undefined;
|
||||
if (instructions) {
|
||||
lines.push('');
|
||||
lines.push('Instructions:');
|
||||
for (const l of instructions.split('\n')) {
|
||||
lines.push(` ${l}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const l of JSON.stringify(result, null, 2).split('\n')) {
|
||||
lines.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lifecycle events
|
||||
for (const l of JSON.stringify(body, null, 2).split('\n')) {
|
||||
lines.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
interface FormattedEvent {
|
||||
arrow: string;
|
||||
color: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
detailColor?: string | undefined;
|
||||
}
|
||||
|
||||
function formatEvent(event: TrafficEvent): FormattedEvent {
|
||||
const method = event.method ?? '';
|
||||
|
||||
switch (event.eventType) {
|
||||
case 'client_request':
|
||||
return { arrow: '→', color: 'green', label: method, detail: summarizeRequest(method, event.body) };
|
||||
case 'client_response': {
|
||||
const detail = summarizeResponse(method, event.body);
|
||||
const hasError = detail.startsWith('ERROR');
|
||||
return { arrow: '←', color: 'blue', label: method, detail, detailColor: hasError ? 'red' : undefined };
|
||||
}
|
||||
case 'client_notification':
|
||||
return { arrow: '◂', color: 'magenta', label: method, detail: summarizeRequest(method, event.body) };
|
||||
case 'upstream_request':
|
||||
return { arrow: ' ⇢', color: 'yellowBright', label: `${event.upstreamName ?? '?'}/${method}`, detail: summarizeRequest(method, event.body) };
|
||||
case 'upstream_response': {
|
||||
const ms = event.durationMs !== undefined ? `${event.durationMs}ms` : '';
|
||||
const detail = summarizeResponse(method, event.body);
|
||||
const hasError = detail.startsWith('ERROR');
|
||||
return { arrow: ' ⇠', color: 'yellowBright', label: `${event.upstreamName ?? '?'}/${method}`, detail: ms ? `[${ms}] ${detail}` : detail, detailColor: hasError ? 'red' : undefined };
|
||||
}
|
||||
case 'session_created':
|
||||
return { arrow: '●', color: 'cyan', label: `session ${event.sessionId.slice(0, 8)}`, detail: `project=${event.projectName}` };
|
||||
case 'session_closed':
|
||||
return { arrow: '○', color: 'red', label: `session ${event.sessionId.slice(0, 8)}`, detail: 'closed' };
|
||||
default:
|
||||
return { arrow: '?', color: 'white', label: event.eventType, detail: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '??:??:??';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session Sidebar ──
|
||||
|
||||
function SessionList({ sessions, selected, eventCounts }: {
|
||||
sessions: ActiveSession[];
|
||||
selected: number;
|
||||
eventCounts: Map<string, number>;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" width={32} borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
{' '}Sessions{' '}
|
||||
<Text dimColor>({sessions.length})</Text>
|
||||
</Text>
|
||||
<Box marginTop={0}>
|
||||
<Text color={selected === -1 ? 'cyan' : undefined} bold={selected === -1}>
|
||||
{selected === -1 ? ' ▸ ' : ' '}
|
||||
<Text>all sessions</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{sessions.length === 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> waiting for connections…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{sessions.map((s, i) => {
|
||||
const count = eventCounts.get(s.sessionId) ?? 0;
|
||||
return (
|
||||
<Box key={s.sessionId} flexDirection="column">
|
||||
<Text wrap="truncate">
|
||||
<Text color={i === selected ? 'cyan' : undefined} bold={i === selected}>
|
||||
{i === selected ? ' ▸ ' : ' '}
|
||||
{s.projectName}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text wrap="truncate" dimColor>
|
||||
{' '}
|
||||
{s.sessionId.slice(0, 8)}
|
||||
{count > 0 ? ` · ${count} events` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box flexGrow={1} />
|
||||
<Box borderStyle="single" borderTop borderColor="gray" paddingTop={0}>
|
||||
<Text dimColor>
|
||||
{'[↑↓] session [a] all\n[s] sidebar [c] clear\n[j/k] event [⏎] expand\n[q] quit'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Traffic Log ──
|
||||
|
||||
function TrafficLog({ events, height, showProject, focusedIdx }: {
|
||||
events: TrafficEvent[];
|
||||
height: number;
|
||||
showProject: boolean;
|
||||
focusedIdx: number; // -1 = no focus (auto-scroll to bottom)
|
||||
}) {
|
||||
// When focusedIdx >= 0, center the focused event in the view
|
||||
// When focusedIdx === -1, show the latest events (auto-scroll)
|
||||
const maxVisible = height - 2;
|
||||
let startIdx: number;
|
||||
if (focusedIdx >= 0) {
|
||||
// Center focused event, but clamp to valid range
|
||||
startIdx = Math.max(0, Math.min(focusedIdx - Math.floor(maxVisible / 2), events.length - maxVisible));
|
||||
} else {
|
||||
startIdx = Math.max(0, events.length - maxVisible);
|
||||
}
|
||||
const visible = events.slice(startIdx, startIdx + maxVisible);
|
||||
const visibleBaseIdx = startIdx;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
|
||||
<Text bold>
|
||||
Traffic <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` · #${focusedIdx + 1} selected` : ''})</Text>
|
||||
</Text>
|
||||
{visible.length === 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> waiting for traffic…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visible.map((event, vi) => {
|
||||
const absIdx = visibleBaseIdx + vi;
|
||||
const isFocused = absIdx === focusedIdx;
|
||||
const { arrow, color, label, detail, detailColor } = formatEvent(event);
|
||||
const isUpstream = event.eventType.startsWith('upstream_');
|
||||
const isLifecycle = event.eventType === 'session_created' || event.eventType === 'session_closed';
|
||||
const marker = isFocused ? '▸' : ' ';
|
||||
|
||||
if (isLifecycle) {
|
||||
return (
|
||||
<Text key={vi} wrap="truncate">
|
||||
<Text color={isFocused ? 'cyan' : undefined}>{marker}</Text>
|
||||
<Text dimColor>{formatTime(event.timestamp)} </Text>
|
||||
<Text color={color} bold>{arrow} {label}</Text>
|
||||
<Text dimColor> {detail}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={vi} wrap="truncate">
|
||||
<Text color={isFocused ? 'cyan' : undefined}>{marker}</Text>
|
||||
<Text dimColor>{formatTime(event.timestamp)} </Text>
|
||||
{showProject && <Text color="gray">[{trunc(event.projectName, 12)}] </Text>}
|
||||
<Text color={color}>{arrow} </Text>
|
||||
<Text bold={!isUpstream} color={color}>{label}</Text>
|
||||
{detail ? (
|
||||
<Text color={detailColor} dimColor={!detailColor}> {detail}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail Pane ──
|
||||
|
||||
function DetailPane({ event, maxLines, scrollOffset }: {
|
||||
event: TrafficEvent;
|
||||
maxLines: number;
|
||||
scrollOffset: number;
|
||||
}) {
|
||||
const { arrow, color, label } = formatEvent(event);
|
||||
const allLines = formatBodyDetail(event);
|
||||
const bodyHeight = maxLines - 3; // header + border
|
||||
const visibleLines = allLines.slice(scrollOffset, scrollOffset + bodyHeight);
|
||||
const totalLines = allLines.length;
|
||||
const canScroll = totalLines > bodyHeight;
|
||||
const atEnd = scrollOffset + bodyHeight >= totalLines;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} height={maxLines}>
|
||||
<Text bold>
|
||||
<Text color={color}>{arrow} {label}</Text>
|
||||
<Text dimColor> {formatTime(event.timestamp)} {event.projectName}/{event.sessionId.slice(0, 8)}</Text>
|
||||
{canScroll ? (
|
||||
<Text dimColor> [{scrollOffset + 1}-{Math.min(scrollOffset + bodyHeight, totalLines)}/{totalLines}] ↑↓ scroll Esc close</Text>
|
||||
) : (
|
||||
<Text dimColor> Esc to close</Text>
|
||||
)}
|
||||
</Text>
|
||||
{visibleLines.map((line, i) => (
|
||||
<Text key={i} wrap="truncate" dimColor={line.startsWith(' ')}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{canScroll && !atEnd && (
|
||||
<Text dimColor>… +{totalLines - scrollOffset - bodyHeight} more lines ↓</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Root App ──
|
||||
|
||||
interface InspectAppProps {
|
||||
inspectUrl: string;
|
||||
projectFilter?: string;
|
||||
}
|
||||
|
||||
function InspectApp({ inspectUrl, projectFilter }: InspectAppProps) {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
const termHeight = stdout?.rows ?? 24;
|
||||
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [events, setEvents] = useState<TrafficEvent[]>([]);
|
||||
const [selectedSession, setSelectedSession] = useState(-1); // -1 = all
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [focusedEvent, setFocusedEvent] = useState(-1); // -1 = auto-scroll
|
||||
const [expandedEvent, setExpandedEvent] = useState(false);
|
||||
const [detailScroll, setDetailScroll] = useState(0);
|
||||
|
||||
// Track latest event count for auto-follow
|
||||
const prevCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(inspectUrl);
|
||||
if (projectFilter) url.searchParams.set('project', projectFilter);
|
||||
|
||||
const disconnect = connectSSE(url.toString(), {
|
||||
onSessions: (s) => setSessions(s),
|
||||
onEvent: (e) => {
|
||||
setEvents((prev) => [...prev, e]);
|
||||
// Auto-add new sessions we haven't seen
|
||||
if (e.eventType === 'session_created') {
|
||||
setSessions((prev) => {
|
||||
if (prev.some((s) => s.sessionId === e.sessionId)) return prev;
|
||||
return [...prev, { sessionId: e.sessionId, projectName: e.projectName, startedAt: e.timestamp }];
|
||||
});
|
||||
}
|
||||
if (e.eventType === 'session_closed') {
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== e.sessionId));
|
||||
}
|
||||
},
|
||||
onLive: () => setConnected(true),
|
||||
onError: (msg) => setError(msg),
|
||||
});
|
||||
|
||||
return disconnect;
|
||||
}, [inspectUrl, projectFilter]);
|
||||
|
||||
// Filter events by selected session
|
||||
const filteredEvents = selectedSession === -1
|
||||
? events
|
||||
: events.filter((e) => e.sessionId === sessions[selectedSession]?.sessionId);
|
||||
|
||||
// Auto-follow: when new events arrive and we're not browsing, stay at bottom
|
||||
useEffect(() => {
|
||||
if (focusedEvent === -1 && filteredEvents.length > prevCountRef.current) {
|
||||
// Auto-scrolling (focusedEvent === -1 means "follow tail")
|
||||
}
|
||||
prevCountRef.current = filteredEvents.length;
|
||||
}, [filteredEvents.length, focusedEvent]);
|
||||
|
||||
// Event counts per session
|
||||
const eventCounts = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
eventCounts.set(e.sessionId, (eventCounts.get(e.sessionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const showProject = selectedSession === -1 && sessions.length > 1;
|
||||
|
||||
// Keyboard
|
||||
useInput((input, key) => {
|
||||
if (input === 'q') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// When detail pane is expanded, arrows scroll the detail content
|
||||
if (expandedEvent && focusedEvent >= 0) {
|
||||
if (key.escape) {
|
||||
setExpandedEvent(false);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow || input === 'j') {
|
||||
setDetailScroll((s) => s + 1);
|
||||
return;
|
||||
}
|
||||
if (key.upArrow || input === 'k') {
|
||||
setDetailScroll((s) => Math.max(0, s - 1));
|
||||
return;
|
||||
}
|
||||
// Enter: close detail
|
||||
if (key.return) {
|
||||
setExpandedEvent(false);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
// q still quits even in detail mode
|
||||
return;
|
||||
}
|
||||
|
||||
// Esc: deselect event
|
||||
if (key.escape) {
|
||||
if (focusedEvent >= 0) {
|
||||
setFocusedEvent(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: open detail pane for focused event
|
||||
if (key.return && focusedEvent >= 0 && focusedEvent < filteredEvents.length) {
|
||||
setExpandedEvent(true);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// s: toggle sidebar
|
||||
if (input === 's') {
|
||||
setShowSidebar((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// a: all sessions
|
||||
if (input === 'a') {
|
||||
setSelectedSession(-1);
|
||||
setFocusedEvent(-1);
|
||||
setExpandedEvent(false);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// c: clear
|
||||
if (input === 'c') {
|
||||
setEvents([]);
|
||||
setFocusedEvent(-1);
|
||||
setExpandedEvent(false);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// j/k or arrow keys: navigate events
|
||||
if (input === 'j' || key.downArrow) {
|
||||
if (key.downArrow && showSidebar && focusedEvent < 0) {
|
||||
// Arrow keys control session selection when sidebar visible and no event focused
|
||||
setSelectedSession((s) => Math.min(sessions.length - 1, s + 1));
|
||||
} else {
|
||||
// j always controls event navigation, down-arrow too when event is focused
|
||||
setFocusedEvent((prev) => {
|
||||
const next = prev + 1;
|
||||
return next >= filteredEvents.length ? filteredEvents.length - 1 : next;
|
||||
});
|
||||
setExpandedEvent(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (input === 'k' || key.upArrow) {
|
||||
if (key.upArrow && showSidebar && focusedEvent < 0) {
|
||||
setSelectedSession((s) => Math.max(-1, s - 1));
|
||||
} else {
|
||||
setFocusedEvent((prev) => {
|
||||
if (prev <= 0) return -1; // Back to auto-scroll
|
||||
return prev - 1;
|
||||
});
|
||||
setExpandedEvent(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// G: jump to latest (end)
|
||||
if (input === 'G') {
|
||||
setFocusedEvent(-1);
|
||||
setExpandedEvent(false);
|
||||
setDetailScroll(0);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Layout calculations
|
||||
const headerHeight = 1;
|
||||
const footerHeight = 1;
|
||||
// Detail pane takes up to half the screen
|
||||
const detailHeight = expandedEvent && focusedEvent >= 0 ? Math.max(6, Math.floor(termHeight * 0.45)) : 0;
|
||||
const contentHeight = termHeight - headerHeight - footerHeight - detailHeight;
|
||||
|
||||
const focusedEventObj = focusedEvent >= 0 ? filteredEvents[focusedEvent] : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={termHeight}>
|
||||
{/* ── Header ── */}
|
||||
<Box paddingX={1}>
|
||||
<Text bold color="cyan">MCP Inspector</Text>
|
||||
<Text dimColor> </Text>
|
||||
<Text color={connected ? 'green' : 'yellow'}>{connected ? '● live' : '○ connecting…'}</Text>
|
||||
{projectFilter && <Text dimColor> project: {projectFilter}</Text>}
|
||||
{selectedSession >= 0 && sessions[selectedSession] && (
|
||||
<Text dimColor> session: {sessions[selectedSession]!.sessionId.slice(0, 8)}</Text>
|
||||
)}
|
||||
{!showSidebar && <Text dimColor> [s] show sidebar</Text>}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box paddingX={1}>
|
||||
<Text color="red"> {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<Box flexDirection="row" height={contentHeight}>
|
||||
{showSidebar && (
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selected={selectedSession}
|
||||
eventCounts={eventCounts}
|
||||
/>
|
||||
)}
|
||||
<TrafficLog
|
||||
events={filteredEvents}
|
||||
height={contentHeight}
|
||||
showProject={showProject}
|
||||
focusedIdx={focusedEvent}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Detail pane ── */}
|
||||
{expandedEvent && focusedEventObj && (
|
||||
<DetailPane event={focusedEventObj} maxLines={detailHeight} scrollOffset={detailScroll} />
|
||||
)}
|
||||
|
||||
{/* ── Footer legend ── */}
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
<Text color="green">→ req</Text>
|
||||
{' '}
|
||||
<Text color="blue">← resp</Text>
|
||||
{' '}
|
||||
<Text color="yellowBright">⇢⇠ upstream</Text>
|
||||
{' '}
|
||||
<Text color="magenta">◂ notify</Text>
|
||||
{' │ '}
|
||||
{!showSidebar && <Text>[s] sidebar </Text>}
|
||||
<Text>[j/k] navigate [⏎] expand [G] latest [q] quit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render entrypoint ──
|
||||
|
||||
export interface InspectRenderOptions {
|
||||
mcplocalUrl: string;
|
||||
projectFilter?: string;
|
||||
}
|
||||
|
||||
export async function renderInspect(opts: InspectRenderOptions): Promise<void> {
|
||||
const inspectUrl = `${opts.mcplocalUrl.replace(/\/$/, '')}/inspect`;
|
||||
const instance = render(
|
||||
<InspectApp inspectUrl={inspectUrl} projectFilter={opts.projectFilter} />,
|
||||
);
|
||||
await instance.waitUntilExit();
|
||||
}
|
||||
404
src/cli/src/commands/console/inspect-mcp.ts
Normal file
404
src/cli/src/commands/console/inspect-mcp.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* MCP server over stdin/stdout for the traffic inspector.
|
||||
*
|
||||
* Claude adds this to .mcp.json as:
|
||||
* { "mcpctl-inspect": { "command": "mcpctl", "args": ["console", "--inspect", "--stdin-mcp"] } }
|
||||
*
|
||||
* Subscribes to mcplocal's /inspect SSE endpoint and exposes traffic
|
||||
* data via MCP tools: list_sessions, get_traffic, get_session_info.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface TrafficEvent {
|
||||
timestamp: string;
|
||||
projectName: string;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
method?: string;
|
||||
upstreamName?: string;
|
||||
body: unknown;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ActiveSession {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
startedAt: string;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
const sessions = new Map<string, ActiveSession>();
|
||||
const events: TrafficEvent[] = [];
|
||||
const MAX_EVENTS = 10000;
|
||||
|
||||
// ── SSE Client ──
|
||||
|
||||
function connectSSE(url: string): void {
|
||||
const parsed = new URL(url);
|
||||
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
},
|
||||
(res: IncomingMessage) => {
|
||||
let buffer = '';
|
||||
let currentEventType = 'message';
|
||||
|
||||
res.setEncoding('utf-8');
|
||||
res.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop()!;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEventType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (currentEventType === 'sessions') {
|
||||
for (const s of data as Array<{ sessionId: string; projectName: string; startedAt: string }>) {
|
||||
sessions.set(s.sessionId, { ...s, eventCount: 0 });
|
||||
}
|
||||
} else if (currentEventType !== 'live') {
|
||||
handleEvent(data as TrafficEvent);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
currentEventType = 'message';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
// Reconnect after 2s
|
||||
setTimeout(() => connectSSE(url), 2000);
|
||||
});
|
||||
|
||||
res.on('error', () => {
|
||||
setTimeout(() => connectSSE(url), 2000);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', () => {
|
||||
setTimeout(() => connectSSE(url), 2000);
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
|
||||
function handleEvent(event: TrafficEvent): void {
|
||||
events.push(event);
|
||||
if (events.length > MAX_EVENTS) {
|
||||
events.splice(0, events.length - MAX_EVENTS);
|
||||
}
|
||||
|
||||
// Track sessions
|
||||
if (event.eventType === 'session_created') {
|
||||
sessions.set(event.sessionId, {
|
||||
sessionId: event.sessionId,
|
||||
projectName: event.projectName,
|
||||
startedAt: event.timestamp,
|
||||
eventCount: 0,
|
||||
});
|
||||
} else if (event.eventType === 'session_closed') {
|
||||
sessions.delete(event.sessionId);
|
||||
}
|
||||
|
||||
// Increment event count
|
||||
const session = sessions.get(event.sessionId);
|
||||
if (session) {
|
||||
session.eventCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP Protocol Handlers ──
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'list_sessions',
|
||||
description: 'List all active MCP sessions with their project name, start time, and event count.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
project: { type: 'string' as const, description: 'Filter by project name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_traffic',
|
||||
description: 'Get captured MCP traffic events. Returns recent events, optionally filtered by session, method, or event type.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
sessionId: { type: 'string' as const, description: 'Filter by session ID (first 8 chars is enough)' },
|
||||
method: { type: 'string' as const, description: 'Filter by JSON-RPC method (e.g. "tools/call", "initialize")' },
|
||||
eventType: { type: 'string' as const, description: 'Filter by event type: client_request, client_response, client_notification, upstream_request, upstream_response' },
|
||||
limit: { type: 'number' as const, description: 'Max events to return (default: 50)' },
|
||||
offset: { type: 'number' as const, description: 'Skip first N matching events' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_session_info',
|
||||
description: 'Get detailed information about a specific session including its recent traffic summary.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
sessionId: { type: 'string' as const, description: 'Session ID (first 8 chars is enough)' },
|
||||
},
|
||||
required: ['sessionId'] as const,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function handleInitialize(id: string | number): void {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: { name: 'mcpctl-inspector', version: '1.0.0' },
|
||||
capabilities: { tools: {} },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleToolsList(id: string | number): void {
|
||||
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
||||
}
|
||||
|
||||
function handleToolsCall(id: string | number, params: { name: string; arguments?: Record<string, unknown> }): void {
|
||||
const args = params.arguments ?? {};
|
||||
|
||||
switch (params.name) {
|
||||
case 'list_sessions': {
|
||||
let result = [...sessions.values()];
|
||||
const project = args['project'] as string | undefined;
|
||||
if (project) {
|
||||
result = result.filter((s) => s.projectName === project);
|
||||
}
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get_traffic': {
|
||||
const sessionFilter = args['sessionId'] as string | undefined;
|
||||
const methodFilter = args['method'] as string | undefined;
|
||||
const typeFilter = args['eventType'] as string | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 50;
|
||||
const offset = (args['offset'] as number | undefined) ?? 0;
|
||||
|
||||
let filtered = events;
|
||||
if (sessionFilter) {
|
||||
filtered = filtered.filter((e) => e.sessionId.startsWith(sessionFilter));
|
||||
}
|
||||
if (methodFilter) {
|
||||
filtered = filtered.filter((e) => e.method === methodFilter);
|
||||
}
|
||||
if (typeFilter) {
|
||||
filtered = filtered.filter((e) => e.eventType === typeFilter);
|
||||
}
|
||||
|
||||
const sliced = filtered.slice(offset, offset + limit);
|
||||
|
||||
// Format as readable lines (strip jsonrpc/id boilerplate)
|
||||
const lines = sliced.map((e) => {
|
||||
const arrow = e.eventType === 'client_request' ? '→'
|
||||
: e.eventType === 'client_response' ? '←'
|
||||
: e.eventType === 'client_notification' ? '◂'
|
||||
: e.eventType === 'upstream_request' ? '⇢'
|
||||
: e.eventType === 'upstream_response' ? '⇠'
|
||||
: e.eventType === 'session_created' ? '●'
|
||||
: e.eventType === 'session_closed' ? '○'
|
||||
: '?';
|
||||
const layer = e.eventType.startsWith('upstream') ? 'internal' : 'client';
|
||||
const ms = e.durationMs !== undefined ? ` (${e.durationMs}ms)` : '';
|
||||
const upstream = e.upstreamName ? `${e.upstreamName}/` : '';
|
||||
const time = e.timestamp.split('T')[1]?.replace('Z', '') ?? e.timestamp;
|
||||
|
||||
// Extract meaningful content from body (strip jsonrpc/id envelope)
|
||||
const body = e.body as Record<string, unknown> | null;
|
||||
let content = '';
|
||||
if (body) {
|
||||
if (e.eventType.includes('request') || e.eventType === 'client_notification') {
|
||||
const params = body['params'] as Record<string, unknown> | undefined;
|
||||
if (e.method === 'tools/call' && params) {
|
||||
const toolArgs = params['arguments'] as Record<string, unknown> | undefined;
|
||||
content = `tool=${params['name']}${toolArgs ? ` args=${JSON.stringify(toolArgs)}` : ''}`;
|
||||
} else if (e.method === 'resources/read' && params) {
|
||||
content = `uri=${params['uri']}`;
|
||||
} else if (e.method === 'initialize' && params) {
|
||||
const ci = params['clientInfo'] as Record<string, unknown> | undefined;
|
||||
content = ci ? `client=${ci['name']} v${ci['version']}` : '';
|
||||
} else if (params && Object.keys(params).length > 0) {
|
||||
content = JSON.stringify(params);
|
||||
}
|
||||
} else if (e.eventType.includes('response')) {
|
||||
const result = body['result'] as Record<string, unknown> | undefined;
|
||||
const error = body['error'] as Record<string, unknown> | undefined;
|
||||
if (error) {
|
||||
content = `ERROR ${error['code']}: ${error['message']}`;
|
||||
} else if (result) {
|
||||
if (e.method === 'tools/list') {
|
||||
const tools = (result['tools'] ?? []) as Array<{ name: string }>;
|
||||
content = `${tools.length} tools: ${tools.map((t) => t.name).join(', ')}`;
|
||||
} else if (e.method === 'resources/list') {
|
||||
const res = (result['resources'] ?? []) as Array<{ name: string }>;
|
||||
content = `${res.length} resources: ${res.map((r) => r.name).join(', ')}`;
|
||||
} else if (e.method === 'tools/call') {
|
||||
const c = (result['content'] ?? []) as Array<{ text?: string }>;
|
||||
const text = c[0]?.text ?? '';
|
||||
content = text.length > 200 ? text.slice(0, 200) + '…' : text;
|
||||
} else if (e.method === 'initialize') {
|
||||
const si = result['serverInfo'] as Record<string, unknown> | undefined;
|
||||
content = si ? `server=${si['name']} v${si['version']}` : '';
|
||||
} else if (Object.keys(result).length > 0) {
|
||||
const s = JSON.stringify(result);
|
||||
content = s.length > 200 ? s.slice(0, 200) + '…' : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${time} ${arrow} [${layer}] ${upstream}${e.method ?? e.eventType}${ms}${content ? ' ' + content : ''}`;
|
||||
});
|
||||
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${filtered.length} total events (showing ${offset + 1}-${offset + sliced.length})\n\n${lines.join('\n')}`,
|
||||
}],
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get_session_info': {
|
||||
const sid = args['sessionId'] as string;
|
||||
const session = [...sessions.values()].find((s) => s.sessionId.startsWith(sid));
|
||||
if (!session) {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: `Session not found: ${sid}` }],
|
||||
isError: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionEvents = events.filter((e) => e.sessionId === session.sessionId);
|
||||
const methods = new Map<string, number>();
|
||||
for (const e of sessionEvents) {
|
||||
if (e.method) {
|
||||
methods.set(e.method, (methods.get(e.method) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const info = {
|
||||
...session,
|
||||
totalEvents: sessionEvents.length,
|
||||
methodCounts: Object.fromEntries(methods),
|
||||
lastEvent: sessionEvents.length > 0
|
||||
? sessionEvents[sessionEvents.length - 1]!.timestamp
|
||||
: null,
|
||||
};
|
||||
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32601, message: `Unknown tool: ${params.name}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequest(request: JsonRpcRequest): void {
|
||||
switch (request.method) {
|
||||
case 'initialize':
|
||||
handleInitialize(request.id);
|
||||
break;
|
||||
case 'notifications/initialized':
|
||||
// Notification — no response
|
||||
break;
|
||||
case 'tools/list':
|
||||
handleToolsList(request.id);
|
||||
break;
|
||||
case 'tools/call':
|
||||
handleToolsCall(request.id, request.params as { name: string; arguments?: Record<string, unknown> });
|
||||
break;
|
||||
default:
|
||||
if (request.id !== undefined) {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32601, message: `Method not supported: ${request.method}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(message: unknown): void {
|
||||
process.stdout.write(JSON.stringify(message) + '\n');
|
||||
}
|
||||
|
||||
// ── Entrypoint ──
|
||||
|
||||
export async function runInspectMcp(mcplocalUrl: string): Promise<void> {
|
||||
const inspectUrl = `${mcplocalUrl.replace(/\/$/, '')}/inspect`;
|
||||
connectSSE(inspectUrl);
|
||||
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const request = JSON.parse(trimmed) as JsonRpcRequest;
|
||||
handleRequest(request);
|
||||
} catch {
|
||||
// Ignore unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from 'commander';
|
||||
import { type ApiClient, ApiError } from '../api-client.js';
|
||||
import { resolveNameOrId } from './shared.js';
|
||||
export interface CreateCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
@@ -55,7 +56,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('create')
|
||||
.description('Create a resource (server, secret, project, user, group, rbac)');
|
||||
.description('Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)');
|
||||
|
||||
// --- create server ---
|
||||
cmd.command('server')
|
||||
@@ -72,6 +73,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--replicas <count>', 'Number of replicas')
|
||||
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
|
||||
.option('--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')
|
||||
.action(async (name: string, opts) => {
|
||||
let base: Record<string, unknown> = {};
|
||||
@@ -103,7 +105,33 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
// Convert template env (description/required) to server env (name/value/valueFrom)
|
||||
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
|
||||
if (tplEnv && tplEnv.length > 0) {
|
||||
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
|
||||
if (opts.envFromSecret) {
|
||||
// --env-from-secret: map all template env vars from the specified secret
|
||||
const secretName = opts.envFromSecret as string;
|
||||
const secrets = await client.get<Array<{ name: string; data: Record<string, string> }>>('/api/v1/secrets');
|
||||
const secret = secrets.find((s) => s.name === secretName);
|
||||
if (!secret) throw new Error(`Secret '${secretName}' not found`);
|
||||
|
||||
const missing = tplEnv
|
||||
.filter((e) => e.required !== false && !(e.name in secret.data))
|
||||
.map((e) => e.name);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Secret '${secretName}' is missing required keys: ${missing.join(', ')}\n` +
|
||||
`Secret has: ${Object.keys(secret.data).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
base.env = tplEnv.map((e) => {
|
||||
if (e.name in secret.data) {
|
||||
return { name: e.name, valueFrom: { secretRef: { name: secretName, key: e.name } } };
|
||||
}
|
||||
return { name: e.name, value: e.defaultValue ?? '' };
|
||||
});
|
||||
log(`Mapped ${tplEnv.filter((e) => e.name in secret.data).length} env var(s) from secret '${secretName}'`);
|
||||
} else {
|
||||
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Track template origin
|
||||
@@ -363,6 +391,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const fs = await import('node:fs/promises');
|
||||
content = await fs.readFile(opts.contentFile as string, 'utf-8');
|
||||
}
|
||||
// For linked prompts, auto-generate placeholder content if none provided
|
||||
if (!content && opts.link) {
|
||||
content = `Linked prompt — content fetched from ${opts.link as string}`;
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error('--content or --content-file is required');
|
||||
}
|
||||
@@ -390,6 +422,22 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
});
|
||||
|
||||
// --- create serverattachment ---
|
||||
cmd.command('serverattachment')
|
||||
.alias('sa')
|
||||
.description('Attach a server to a project')
|
||||
.argument('<server>', 'Server name')
|
||||
.option('--project <name>', 'Project name')
|
||||
.action(async (serverName: string, opts) => {
|
||||
const projectName = opts.project as string | undefined;
|
||||
if (!projectName) {
|
||||
throw new Error('--project is required. Usage: mcpctl create serverattachment <server> --project <name>');
|
||||
}
|
||||
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
|
||||
log(`server '${serverName}' attached to project '${projectName}'`);
|
||||
});
|
||||
|
||||
// --- create promptrequest ---
|
||||
cmd.command('promptrequest')
|
||||
.description('Create a prompt request (pending proposal that needs approval)')
|
||||
|
||||
@@ -14,9 +14,21 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
||||
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
|
||||
.argument('<resource>', 'resource type')
|
||||
.argument('<id>', 'resource ID or name')
|
||||
.action(async (resourceArg: string, idOrName: string) => {
|
||||
.option('--project <name>', 'Project name (for serverattachment)')
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Serverattachments: delete serverattachment <server> --project <project>
|
||||
if (resource === 'serverattachments') {
|
||||
if (!opts.project) {
|
||||
throw new Error('--project is required. Usage: mcpctl delete serverattachment <server> --project <name>');
|
||||
}
|
||||
const projectId = await resolveNameOrId(client, 'projects', opts.project);
|
||||
await client.delete(`/api/v1/projects/${projectId}/servers/${idOrName}`);
|
||||
log(`server '${idOrName}' detached from project '${opts.project}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve name → ID for any resource type
|
||||
let id: string;
|
||||
try {
|
||||
|
||||
@@ -504,6 +504,95 @@ function formatRbacDetail(rbac: Record<string, unknown>): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function formatPromptDetail(prompt: Record<string, unknown>, client?: ApiClient): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Prompt: ${prompt.name} ===`);
|
||||
lines.push(`${pad('Name:')}${prompt.name}`);
|
||||
|
||||
const proj = prompt.project as { name: string } | null | undefined;
|
||||
lines.push(`${pad('Project:')}${proj?.name ?? (prompt.projectId ? String(prompt.projectId) : '(global)')}`);
|
||||
lines.push(`${pad('Priority:')}${prompt.priority ?? 5}`);
|
||||
|
||||
// Link info
|
||||
const link = prompt.linkTarget as string | null | undefined;
|
||||
if (link) {
|
||||
lines.push('');
|
||||
lines.push('Link:');
|
||||
lines.push(` ${pad('Target:', 12)}${link}`);
|
||||
const status = prompt.linkStatus as string | null | undefined;
|
||||
if (status) lines.push(` ${pad('Status:', 12)}${status}`);
|
||||
}
|
||||
|
||||
// Content — resolve linked content if possible
|
||||
let content = prompt.content as string | undefined;
|
||||
if (link && client) {
|
||||
const resolved = await resolveLink(link, client);
|
||||
if (resolved) content = resolved;
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Content:');
|
||||
if (content) {
|
||||
// Indent content with 2 spaces for readability
|
||||
for (const line of content.split('\n')) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(' (no content)');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${prompt.id}`);
|
||||
if (prompt.version) lines.push(` ${pad('Version:', 12)}${prompt.version}`);
|
||||
if (prompt.createdAt) lines.push(` ${pad('Created:', 12)}${prompt.createdAt}`);
|
||||
if (prompt.updatedAt) lines.push(` ${pad('Updated:', 12)}${prompt.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a prompt link target via mcpd proxy's resources/read.
|
||||
* Returns resolved content string or null on failure.
|
||||
*/
|
||||
async function resolveLink(linkTarget: string, client: ApiClient): Promise<string | null> {
|
||||
try {
|
||||
// Parse link: project/server:uri
|
||||
const slashIdx = linkTarget.indexOf('/');
|
||||
if (slashIdx < 1) return null;
|
||||
const project = linkTarget.slice(0, slashIdx);
|
||||
const rest = linkTarget.slice(slashIdx + 1);
|
||||
const colonIdx = rest.indexOf(':');
|
||||
if (colonIdx < 1) return null;
|
||||
const serverName = rest.slice(0, colonIdx);
|
||||
const uri = rest.slice(colonIdx + 1);
|
||||
|
||||
// Resolve server name → ID
|
||||
const servers = await client.get<Array<{ id: string; name: string }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(project)}/servers`,
|
||||
);
|
||||
const target = servers.find((s) => s.name === serverName);
|
||||
if (!target) return null;
|
||||
|
||||
// Call resources/read via proxy
|
||||
const proxyResponse = await client.post<{
|
||||
result?: { contents?: Array<{ text?: string }> };
|
||||
error?: { code: number; message: string };
|
||||
}>('/api/v1/mcp/proxy', {
|
||||
serverId: target.id,
|
||||
method: 'resources/read',
|
||||
params: { uri },
|
||||
});
|
||||
|
||||
if (proxyResponse.error) return null;
|
||||
const contents = proxyResponse.result?.contents;
|
||||
if (!contents || contents.length === 0) return null;
|
||||
return contents.map((c) => c.text ?? '').join('\n');
|
||||
} catch {
|
||||
return null; // Silently fall back to stored content
|
||||
}
|
||||
}
|
||||
|
||||
function formatGenericDetail(obj: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
@@ -563,10 +652,15 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
id = await resolveNameOrId(deps.client, resource, idOrName);
|
||||
} catch {
|
||||
// Prompts/promptrequests: let fetchResource handle scoping (it respects --project)
|
||||
if (resource === 'prompts' || resource === 'promptrequests') {
|
||||
id = idOrName;
|
||||
} else {
|
||||
try {
|
||||
id = await resolveNameOrId(deps.client, resource, idOrName);
|
||||
} catch {
|
||||
id = idOrName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +724,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'rbac':
|
||||
deps.log(formatRbacDetail(item));
|
||||
break;
|
||||
case 'prompts':
|
||||
deps.log(await formatPromptDetail(item, deps.client));
|
||||
break;
|
||||
default:
|
||||
deps.log(formatGenericDetail(item));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Command } from 'commander';
|
||||
import { formatTable } from '../formatters/table.js';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import { formatJson, formatYamlMultiDoc } from '../formatters/output.js';
|
||||
import type { Column } from '../formatters/table.js';
|
||||
import { resolveResource, stripInternalFields } from './shared.js';
|
||||
|
||||
export interface GetCommandDeps {
|
||||
fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
|
||||
log: (...args: string[]) => void;
|
||||
getProject?: () => string | undefined;
|
||||
}
|
||||
|
||||
interface ServerRow {
|
||||
@@ -179,6 +180,16 @@ const instanceColumns: Column<InstanceRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
interface ServerAttachmentRow {
|
||||
project: string;
|
||||
server: string;
|
||||
}
|
||||
|
||||
const serverAttachmentColumns: Column<ServerAttachmentRow>[] = [
|
||||
{ header: 'SERVER', key: 'server', width: 25 },
|
||||
{ header: 'PROJECT', key: 'project', width: 25 },
|
||||
];
|
||||
|
||||
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
@@ -201,6 +212,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
return promptColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'promptrequests':
|
||||
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'serverattachments':
|
||||
return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
return [
|
||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||
@@ -209,38 +222,61 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/** Map plural resource name → singular kind for YAML documents */
|
||||
const RESOURCE_KIND: Record<string, string> = {
|
||||
servers: 'server',
|
||||
projects: 'project',
|
||||
secrets: 'secret',
|
||||
templates: 'template',
|
||||
instances: 'instance',
|
||||
users: 'user',
|
||||
groups: 'group',
|
||||
rbac: 'rbac',
|
||||
prompts: 'prompt',
|
||||
promptrequests: 'promptrequest',
|
||||
serverattachments: 'serverattachment',
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform API response items into apply-compatible format.
|
||||
* Strips internal fields and wraps in the resource key.
|
||||
* Transform API response items into apply-compatible multi-doc format.
|
||||
* Each item gets a `kind` field and internal fields stripped.
|
||||
*/
|
||||
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
|
||||
const cleaned = items.map((item) => {
|
||||
return stripInternalFields(item as Record<string, unknown>);
|
||||
function toApplyDocs(resource: string, items: unknown[]): Array<{ kind: string } & Record<string, unknown>> {
|
||||
const kind = RESOURCE_KIND[resource] ?? resource;
|
||||
return items.map((item) => {
|
||||
const cleaned = stripInternalFields(item as Record<string, unknown>);
|
||||
return { kind, ...cleaned };
|
||||
});
|
||||
return { [resource]: cleaned };
|
||||
}
|
||||
|
||||
export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
return new Command('get')
|
||||
.description('List resources (servers, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, projects, instances)')
|
||||
.description('List resources (servers, projects, instances, all)')
|
||||
.argument('<resource>', 'resource type (servers, projects, instances, all)')
|
||||
.argument('[id]', 'specific resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.option('--project <name>', 'Filter by project')
|
||||
.option('-A, --all', 'Show all (including project-scoped) resources')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
// Merge parent --project with local --project
|
||||
const project = opts.project ?? deps.getProject?.();
|
||||
|
||||
// Handle `get all --project X` composite export
|
||||
if (resource === 'all') {
|
||||
await handleGetAll(deps, { ...opts, project });
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchOpts: { project?: string; all?: boolean } = {};
|
||||
if (opts.project) fetchOpts.project = opts.project;
|
||||
if (project) fetchOpts.project = project;
|
||||
if (opts.all) fetchOpts.all = true;
|
||||
const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined);
|
||||
|
||||
if (opts.output === 'json') {
|
||||
// Apply-compatible JSON wrapped in resource key
|
||||
deps.log(formatJson(toApplyFormat(resource, items)));
|
||||
deps.log(formatJson(toApplyDocs(resource, items)));
|
||||
} else if (opts.output === 'yaml') {
|
||||
// Apply-compatible YAML wrapped in resource key
|
||||
deps.log(formatYaml(toApplyFormat(resource, items)));
|
||||
deps.log(formatYamlMultiDoc(toApplyDocs(resource, items)));
|
||||
} else {
|
||||
if (items.length === 0) {
|
||||
deps.log(`No ${resource} found.`);
|
||||
@@ -251,3 +287,59 @@ export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetAll(
|
||||
deps: GetCommandDeps,
|
||||
opts: { output: string; project?: string },
|
||||
): Promise<void> {
|
||||
if (!opts.project) {
|
||||
throw new Error('--project is required with "get all". Usage: mcpctl get all --project <name>');
|
||||
}
|
||||
|
||||
const docs: Array<{ kind: string } & Record<string, unknown>> = [];
|
||||
|
||||
// 1. Fetch the project
|
||||
const projects = await deps.fetchResource('projects', opts.project);
|
||||
if (projects.length === 0) {
|
||||
deps.log(`Project '${opts.project}' not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Add the project itself
|
||||
for (const p of projects) {
|
||||
docs.push({ kind: 'project', ...stripInternalFields(p as Record<string, unknown>) });
|
||||
}
|
||||
|
||||
// 3. Extract serverattachments from project's server list
|
||||
const project = projects[0] as ProjectRow;
|
||||
let attachmentCount = 0;
|
||||
if (project.servers && project.servers.length > 0) {
|
||||
for (const ps of project.servers) {
|
||||
docs.push({
|
||||
kind: 'serverattachment',
|
||||
server: typeof ps === 'string' ? ps : ps.server.name,
|
||||
project: project.name,
|
||||
});
|
||||
attachmentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch prompts owned by this project (exclude global prompts)
|
||||
const prompts = await deps.fetchResource('prompts', undefined, { project: opts.project });
|
||||
const projectPrompts = prompts.filter((p) => (p as { projectId?: string }).projectId != null);
|
||||
for (const p of projectPrompts) {
|
||||
docs.push({ kind: 'prompt', ...stripInternalFields(p as Record<string, unknown>) });
|
||||
}
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(docs));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYamlMultiDoc(docs));
|
||||
} else {
|
||||
// Table output: show summary
|
||||
deps.log(`Project: ${opts.project}`);
|
||||
deps.log(` Server Attachments: ${attachmentCount}`);
|
||||
deps.log(` Prompts: ${projectPrompts.length}`);
|
||||
deps.log(`\nUse -o yaml or -o json for apply-compatible output.`);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/cli/src/commands/patch.ts
Normal file
58
src/cli/src/commands/patch.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveResource, resolveNameOrId } from './shared.js';
|
||||
|
||||
export interface PatchCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "key=value" pairs into a partial update object.
|
||||
* Supports: key=value, key=null (sets null), key=123 (number if parseable).
|
||||
*/
|
||||
function parsePatches(pairs: string[]): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const pair of pairs) {
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
throw new Error(`Invalid patch format '${pair}'. Expected key=value`);
|
||||
}
|
||||
const key = pair.slice(0, eqIdx);
|
||||
const raw = pair.slice(eqIdx + 1);
|
||||
|
||||
if (raw === 'null') {
|
||||
result[key] = null;
|
||||
} else if (raw === 'true') {
|
||||
result[key] = true;
|
||||
} else if (raw === 'false') {
|
||||
result[key] = false;
|
||||
} else if (/^\d+$/.test(raw)) {
|
||||
result[key] = parseInt(raw, 10);
|
||||
} else {
|
||||
result[key] = raw;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPatchCommand(deps: PatchCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('patch')
|
||||
.description('Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)')
|
||||
.argument('<resource>', 'resource type (server, project, secret, ...)')
|
||||
.argument('<name>', 'resource name or ID')
|
||||
.argument('<patches...>', 'key=value pairs to patch')
|
||||
.action(async (resourceArg: string, nameOrId: string, patches: string[]) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const id = await resolveNameOrId(client, resource, nameOrId);
|
||||
const body = parsePatches(patches);
|
||||
|
||||
await client.put(`/api/v1/${resource}/${id}`, body);
|
||||
const fields = Object.entries(body)
|
||||
.map(([k, v]) => `${k}=${v === null ? 'null' : String(v)}`)
|
||||
.join(', ');
|
||||
log(`patched ${resource.replace(/s$/, '')} '${nameOrId}': ${fields}`);
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
promptrequest: 'promptrequests',
|
||||
promptrequests: 'promptrequests',
|
||||
pr: 'promptrequests',
|
||||
serverattachment: 'serverattachments',
|
||||
serverattachments: 'serverattachments',
|
||||
sa: 'serverattachments',
|
||||
all: 'all',
|
||||
};
|
||||
|
||||
export function resolveResource(name: string): string {
|
||||
@@ -61,21 +65,53 @@ export async function resolveNameOrId(
|
||||
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
|
||||
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...obj };
|
||||
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters']) {
|
||||
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters', 'linkStatus', 'serverId']) {
|
||||
delete result[key];
|
||||
}
|
||||
// Strip relationship joins that aren't part of the resource spec (like k8s namespaces don't list deployments)
|
||||
if ('servers' in result && Array.isArray(result.servers)) {
|
||||
delete result.servers;
|
||||
|
||||
// Rename linkTarget → link for cleaner YAML
|
||||
if ('linkTarget' in result) {
|
||||
result.link = result.linkTarget;
|
||||
delete result.linkTarget;
|
||||
// Linked prompts: strip content (it's fetched from the link source, not static)
|
||||
if (result.link) {
|
||||
delete result.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert project servers join array → string[] of server names
|
||||
if ('servers' in result && Array.isArray(result.servers)) {
|
||||
const entries = result.servers as Array<{ server?: { name: string } }>;
|
||||
if (entries.length > 0 && entries[0]?.server) {
|
||||
result.servers = entries.map((e) => e.server!.name);
|
||||
} else if (entries.length === 0) {
|
||||
result.servers = [];
|
||||
} else {
|
||||
delete result.servers;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert prompt projectId CUID → project name string
|
||||
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
|
||||
const proj = result.project as { name: string };
|
||||
result.project = proj.name;
|
||||
delete result.projectId;
|
||||
}
|
||||
|
||||
// Strip remaining relationship objects
|
||||
if ('owner' in result && typeof result.owner === 'object') {
|
||||
delete result.owner;
|
||||
}
|
||||
if ('members' in result && Array.isArray(result.members)) {
|
||||
delete result.members;
|
||||
}
|
||||
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
|
||||
delete result.project;
|
||||
|
||||
// Strip null values last (null = unset, omitting from YAML is cleaner and equivalent)
|
||||
for (const key of Object.keys(result)) {
|
||||
if (result[key] === null) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function reorderKeys(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(reorderKeys);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const rec = obj as Record<string, unknown>;
|
||||
const lastKeys = ['content', 'prompt'];
|
||||
const firstKeys = ['kind'];
|
||||
const lastKeys = ['link', 'content', 'prompt'];
|
||||
const ordered: Record<string, unknown> = {};
|
||||
for (const key of firstKeys) {
|
||||
if (key in rec) ordered[key] = rec[key];
|
||||
}
|
||||
for (const key of Object.keys(rec)) {
|
||||
if (!lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
|
||||
if (!firstKeys.includes(key) && !lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
|
||||
}
|
||||
for (const key of lastKeys) {
|
||||
if (key in rec) ordered[key] = rec[key];
|
||||
@@ -32,3 +36,16 @@ export function formatYaml(data: unknown): string {
|
||||
const reordered = reorderKeys(data);
|
||||
return yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multiple resources as Kubernetes-style multi-document YAML.
|
||||
* Each item gets its own `---` separated document with a `kind` field.
|
||||
*/
|
||||
export function formatYamlMultiDoc(items: Array<{ kind: string } & Record<string, unknown>>): string {
|
||||
return items
|
||||
.map((item) => {
|
||||
const reordered = reorderKeys(item);
|
||||
return '---\n' + yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createProgram(): Command {
|
||||
.enablePositionalOptions()
|
||||
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
|
||||
.option('--project <name>', 'Target project for project commands');
|
||||
.option('-p, --project <name>', 'Target project for project commands');
|
||||
|
||||
program.addCommand(createStatusCommand());
|
||||
program.addCommand(createLoginCommand());
|
||||
@@ -59,17 +59,26 @@ export function createProgram(): Command {
|
||||
const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise<unknown[]> => {
|
||||
const projectName = opts?.project ?? program.opts().project as string | undefined;
|
||||
|
||||
// --project scoping for servers and instances
|
||||
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
|
||||
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||
if (resource === 'servers') {
|
||||
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
|
||||
// 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');
|
||||
}
|
||||
// instances: fetch project servers, then filter instances by serverId
|
||||
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
|
||||
const serverIds = new Set(projectServers.map((s) => s.id));
|
||||
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
|
||||
return allInstances.filter((inst) => serverIds.has(inst.serverId));
|
||||
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 prompts and promptrequests
|
||||
@@ -101,6 +110,21 @@ export function createProgram(): Command {
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
id = await resolveNameOrId(client, resource, nameOrId);
|
||||
@@ -113,6 +137,7 @@ export function createProgram(): Command {
|
||||
program.addCommand(createGetCommand({
|
||||
fetchResource,
|
||||
log: (...args) => console.log(...args),
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
}));
|
||||
|
||||
program.addCommand(createDescribeCommand({
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('createProgram', () => {
|
||||
|
||||
it('has version flag', () => {
|
||||
const program = createProgram();
|
||||
expect(program.version()).toBe('0.1.0');
|
||||
expect(program.version()).toBe('0.0.1');
|
||||
});
|
||||
|
||||
it('has config subcommand', () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('config claude', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('merges with existing .mcp.json', async () => {
|
||||
it('always merges with existing .mcp.json', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(outPath, JSON.stringify({
|
||||
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
|
||||
@@ -74,7 +74,7 @@ describe('config claude', () => {
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||
@@ -85,6 +85,36 @@ describe('config claude', () => {
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
});
|
||||
|
||||
it('adds inspect MCP server with --inspect', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--inspect', '-o', outPath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['mcpctl-inspect']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['console', '--inspect', '--stdin-mcp'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('1 server(s)');
|
||||
});
|
||||
|
||||
it('adds both project and inspect with --project --inspect', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
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-inspect']).toBeDefined();
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
});
|
||||
|
||||
it('backward compat: claude-generate still works', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
|
||||
@@ -41,27 +41,28 @@ describe('get command', () => {
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined);
|
||||
});
|
||||
|
||||
it('outputs apply-compatible JSON format', async () => {
|
||||
it('outputs apply-compatible JSON format (multi-doc)', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
// Wrapped in resource key, internal fields stripped
|
||||
expect(parsed).toHaveProperty('servers');
|
||||
expect(parsed.servers[0].name).toBe('slack');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('id');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('version');
|
||||
// Array of documents with kind field, internal fields stripped
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed[0].kind).toBe('server');
|
||||
expect(parsed[0].name).toBe('slack');
|
||||
expect(parsed[0]).not.toHaveProperty('id');
|
||||
expect(parsed[0]).not.toHaveProperty('createdAt');
|
||||
expect(parsed[0]).not.toHaveProperty('updatedAt');
|
||||
expect(parsed[0]).not.toHaveProperty('version');
|
||||
});
|
||||
|
||||
it('outputs apply-compatible YAML format', async () => {
|
||||
it('outputs apply-compatible YAML format (multi-doc)', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
|
||||
const text = deps.output[0];
|
||||
expect(text).toContain('servers:');
|
||||
expect(text).toContain('kind: server');
|
||||
expect(text).toContain('name: slack');
|
||||
expect(text).not.toContain('id:');
|
||||
expect(text).not.toContain('createdAt:');
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('status command', () => {
|
||||
const cmd = createStatusCommand(baseDeps());
|
||||
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||
expect(parsed['version']).toBe('0.1.0');
|
||||
expect(parsed['version']).toBe('0.0.1');
|
||||
expect(parsed['mcplocalReachable']).toBe(true);
|
||||
expect(parsed['mcpdReachable']).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||
const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8');
|
||||
const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8');
|
||||
|
||||
describe('freshness', () => {
|
||||
it('committed completions match generator output', () => {
|
||||
const generatorPath = join(root, 'scripts', 'generate-completions.ts');
|
||||
expect(existsSync(generatorPath), 'generator script must exist').toBe(true);
|
||||
// Run the generator in --check mode; exit 0 means files are up to date
|
||||
execSync(`npx tsx ${generatorPath} --check`, { cwd: root, stdio: 'pipe' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fish completions', () => {
|
||||
it('erases stale completions at the top', () => {
|
||||
const lines = fishFile.split('\n');
|
||||
@@ -52,8 +62,8 @@ describe('fish completions', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('defines --project option', () => {
|
||||
expect(fishFile).toContain("complete -c mcpctl -l project");
|
||||
it('defines --project option with -p shorthand', () => {
|
||||
expect(fishFile).toContain("-s p -l project");
|
||||
});
|
||||
|
||||
it('attach-server command only shows with --project', () => {
|
||||
@@ -139,8 +149,11 @@ describe('bash completions', () => {
|
||||
|
||||
it('fetches resource names dynamically after resource type', () => {
|
||||
expect(bashFile).toContain('_mcpctl_resource_names');
|
||||
// get/describe/delete should use resource_names when resource_type is set
|
||||
expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/);
|
||||
// get, describe, and delete should each use resource_names when resource_type is set
|
||||
for (const cmd of ['get', 'describe', 'delete']) {
|
||||
const block = bashFile.match(new RegExp(`${cmd}\\)[\\s\\S]*?return ;;`))?.[0] ?? '';
|
||||
expect(block, `${cmd} case must use _mcpctl_resource_names`).toContain('_mcpctl_resource_names');
|
||||
}
|
||||
});
|
||||
|
||||
it('attach-server filters out already-attached servers and guards against repeat', () => {
|
||||
|
||||
Reference in New Issue
Block a user