diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index f9acba9..5f2221a 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -74,9 +74,10 @@ function formatServerDetail(server: Record): string { function formatInstanceDetail(instance: Record, inspect?: Record): string { const lines: string[] = []; - lines.push(`=== Instance: ${instance.id} ===`); + const server = instance.server as { name: string } | undefined; + lines.push(`=== Instance: ${server?.name ?? instance.id} ===`); lines.push(`${pad('Status:')}${instance.status}`); - lines.push(`${pad('Server ID:')}${instance.serverId}`); + lines.push(`${pad('Server:')}${server?.name ?? String(instance.serverId)}`); lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`); lines.push(`${pad('Port:')}${instance.port ?? '-'}`); @@ -277,10 +278,32 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { // Resolve name → ID let id: string; - try { - id = await resolveNameOrId(deps.client, resource, idOrName); - } catch { - id = idOrName; + if (resource === 'instances') { + // Instances: accept instance ID or server name (resolve to first running instance) + try { + id = await resolveNameOrId(deps.client, resource, idOrName); + } catch { + // Not an instance ID — try as server name + const servers = await deps.client.get>('/api/v1/servers'); + const server = servers.find((s) => s.name === idOrName || s.id === idOrName); + if (server) { + const instances = await deps.client.get>(`/api/v1/instances?serverId=${server.id}`); + const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0]; + if (running) { + id = running.id; + } else { + throw new Error(`No instances found for server '${idOrName}'`); + } + } else { + id = idOrName; + } + } + } else { + try { + id = await resolveNameOrId(deps.client, resource, idOrName); + } catch { + id = idOrName; + } } const item = await deps.fetchResource(resource, id) as Record; diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts index 013887f..18225cc 100644 --- a/src/cli/tests/commands/describe.test.ts +++ b/src/cli/tests/commands/describe.test.ts @@ -139,4 +139,152 @@ describe('describe command', () => { expect(text).toContain('RUNNING'); expect(text).toContain('abc123'); }); + + it('resolves server name to instance for describe instance', async () => { + const deps = makeDeps({ + id: 'inst-1', + serverId: 'srv-1', + server: { name: 'my-grafana' }, + status: 'RUNNING', + containerId: 'abc123', + port: 3000, + }); + // resolveNameOrId will throw (not a CUID, name won't match instances) + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // instances list (no name match) + .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list + .mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING' }] as never); // instances for server + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'instance', 'my-grafana']); + + expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-1'); + }); + + it('resolves server name and picks running instance over stopped', async () => { + const deps = makeDeps({ + id: 'inst-2', + serverId: 'srv-1', + server: { name: 'my-ha' }, + status: 'RUNNING', + containerId: 'def456', + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // instances list + .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-ha' }] as never) + .mockResolvedValueOnce([ + { id: 'inst-1', status: 'ERROR' }, + { id: 'inst-2', status: 'RUNNING' }, + ] as never); + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'instance', 'my-ha']); + + expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-2'); + }); + + it('throws when no instances found for server name', async () => { + const deps = makeDeps(); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // instances list + .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never) + .mockResolvedValueOnce([] as never); // no instances + + const cmd = createDescribeCommand(deps); + await expect(cmd.parseAsync(['node', 'test', 'instance', 'my-server'])).rejects.toThrow( + /No instances found/, + ); + }); + + it('shows instance with server name in header', async () => { + const deps = makeDeps({ + id: 'inst-1', + serverId: 'srv-1', + server: { name: 'my-grafana' }, + status: 'RUNNING', + containerId: 'abc123', + port: 3000, + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Instance: my-grafana ==='); + }); + + it('shows instance health and events', async () => { + const deps = makeDeps({ + id: 'inst-1', + serverId: 'srv-1', + server: { name: 'my-grafana' }, + status: 'RUNNING', + containerId: 'abc123', + healthStatus: 'healthy', + lastHealthCheck: '2025-01-15T10:30:00Z', + events: [ + { timestamp: '2025-01-15T10:30:00Z', type: 'Normal', message: 'Health check passed (45ms)' }, + ], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Health:'); + expect(text).toContain('healthy'); + expect(text).toContain('Events:'); + expect(text).toContain('Health check passed'); + }); + + it('shows server healthCheck section', async () => { + const deps = makeDeps({ + id: 'srv-1', + name: 'my-grafana', + transport: 'STDIO', + healthCheck: { + tool: 'list_datasources', + arguments: {}, + intervalSeconds: 60, + timeoutSeconds: 10, + failureThreshold: 3, + }, + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Health Check:'); + expect(text).toContain('list_datasources'); + expect(text).toContain('60s'); + expect(text).toContain('Failure Threshold:'); + }); + + it('shows template detail with healthCheck and usage', async () => { + const deps = makeDeps({ + id: 'tpl-1', + name: 'grafana', + transport: 'STDIO', + version: '1.0.0', + packageName: '@leval/mcp-grafana', + env: [ + { name: 'GRAFANA_URL', required: true, description: 'Grafana instance URL' }, + ], + healthCheck: { + tool: 'list_datasources', + arguments: {}, + intervalSeconds: 60, + timeoutSeconds: 10, + failureThreshold: 3, + }, + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'template', 'tpl-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Template: grafana ==='); + expect(text).toContain('@leval/mcp-grafana'); + expect(text).toContain('GRAFANA_URL'); + expect(text).toContain('Health Check:'); + expect(text).toContain('list_datasources'); + expect(text).toContain('mcpctl create server my-grafana --from-template=grafana'); + }); }); diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 1e47702..6c69c51 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -80,6 +80,9 @@ export class DockerContainerManager implements McpOrchestrator { Env: envArr, ExposedPorts: exposedPorts, Labels: labels, + // Keep stdin open for STDIO MCP servers (they read from stdin) + OpenStdin: true, + StdinOnce: false, HostConfig: { PortBindings: portBindings, Memory: memoryLimit,