feat: mcpctl v0.0.1 — first public release
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

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:
Michal
2026-02-27 17:05:05 +00:00
parent 414a8d3774
commit 69867bd47a
65 changed files with 5710 additions and 695 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/cli",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"bin": {

View File

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

View File

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

View File

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

View 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();
}

View 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
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

@@ -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', () => {