fix: stdin open for STDIO servers + describe instance resolution #16
@@ -74,9 +74,10 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
|
||||
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): 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,11 +278,33 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
|
||||
// Resolve name → ID
|
||||
let id: string;
|
||||
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<Array<{ id: string; name: string }>>('/api/v1/servers');
|
||||
const server = servers.find((s) => s.name === idOrName || s.id === idOrName);
|
||||
if (server) {
|
||||
const instances = await deps.client.get<Array<{ id: string; status: string }>>(`/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<string, unknown>;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user