Merge pull request 'fix: stdin open for STDIO servers + describe instance resolution' (#16) from fix/stdin-describe-instance into main

This commit is contained in:
2026-02-23 00:26:49 +00:00
3 changed files with 180 additions and 6 deletions

View File

@@ -74,9 +74,10 @@ function formatServerDetail(server: Record<string, unknown>): string {
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string { function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: 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('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('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`); lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
@@ -277,10 +278,32 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
// Resolve name → ID // Resolve name → ID
let id: string; let id: string;
try { if (resource === 'instances') {
id = await resolveNameOrId(deps.client, resource, idOrName); // Instances: accept instance ID or server name (resolve to first running instance)
} catch { try {
id = idOrName; 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>; const item = await deps.fetchResource(resource, id) as Record<string, unknown>;

View File

@@ -139,4 +139,152 @@ describe('describe command', () => {
expect(text).toContain('RUNNING'); expect(text).toContain('RUNNING');
expect(text).toContain('abc123'); 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');
});
}); });

View File

@@ -80,6 +80,9 @@ export class DockerContainerManager implements McpOrchestrator {
Env: envArr, Env: envArr,
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
Labels: labels, Labels: labels,
// Keep stdin open for STDIO MCP servers (they read from stdin)
OpenStdin: true,
StdinOnce: false,
HostConfig: { HostConfig: {
PortBindings: portBindings, PortBindings: portBindings,
Memory: memoryLimit, Memory: memoryLimit,