Compare commits

...

4 Commits

Author SHA1 Message Date
Michal
ecbf48dd49 fix: keep stdin open for STDIO servers + describe instance resolves server names
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
STDIO MCP servers read from stdin and exit on EOF. Docker containers close
stdin by default, causing all STDIO servers to crash immediately. Added
OpenStdin: true to container creation.

Describe instance now resolves server names (like logs command), preferring
RUNNING instances. Added 7 new describe tests covering server name resolution,
healthcheck display, events section, and template detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:26:28 +00:00
d38b5aac60 Merge pull request 'feat: container liveness sync + node-runner slim base' (#15) from feat/container-liveness-sync into main
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
2026-02-23 00:18:41 +00:00
Michal
d07d4d11dd feat: container liveness sync + node-runner slim base
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add syncStatus() to InstanceService: detects crashed/stopped containers,
  marks them ERROR with last log line as context
- Reconcile now syncs container status first (detect dead before counting)
- Add 30s periodic sync loop in main.ts
- Switch node-runner from alpine to slim (Debian) for npm compatibility
  (fixes home-assistant-mcp-server binary not found on Alpine)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:18:28 +00:00
fa58c1b5ed Merge pull request 'fix: logs resolves server names + replica handling + tests' (#14) from fix/logs-resolve-and-tests into main
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
2026-02-23 00:12:50 +00:00
6 changed files with 232 additions and 8 deletions

View File

@@ -1,7 +1,8 @@
# Base container for npm-based MCP servers (STDIO transport). # Base container for npm-based MCP servers (STDIO transport).
# mcpd uses this image to run `npx -y <packageName>` when a server # mcpd uses this image to run `npx -y <packageName>` when a server
# has packageName but no dockerImage. # has packageName but no dockerImage.
FROM node:20-alpine # Using slim (Debian) instead of alpine for better npm package compatibility.
FROM node:20-slim
WORKDIR /mcp WORKDIR /mcp

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,11 +278,33 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
// Resolve name → ID // Resolve name → ID
let id: string; 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 { try {
id = await resolveNameOrId(deps.client, resource, idOrName); id = await resolveNameOrId(deps.client, resource, idOrName);
} catch { } catch {
id = idOrName; 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

@@ -134,9 +134,22 @@ async function main(): Promise<void> {
await app.listen({ port: config.port, host: config.host }); await app.listen({ port: config.port, host: config.host });
app.log.info(`mcpd listening on ${config.host}:${config.port}`); app.log.info(`mcpd listening on ${config.host}:${config.port}`);
// Periodic container liveness sync — detect crashed containers
const SYNC_INTERVAL_MS = 30_000; // 30s
const syncTimer = setInterval(async () => {
try {
await instanceService.syncStatus();
} catch (err) {
app.log.error({ err }, 'Container status sync failed');
}
}, SYNC_INTERVAL_MS);
// Graceful shutdown // Graceful shutdown
setupGracefulShutdown(app, { setupGracefulShutdown(app, {
disconnectDb: () => prisma.$disconnect(), disconnectDb: async () => {
clearInterval(syncTimer);
await prisma.$disconnect();
},
}); });
} }

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,

View File

@@ -36,8 +36,41 @@ export class InstanceService {
return instance; return instance;
} }
/**
* Sync instance statuses with actual container state.
* Detects crashed/stopped containers and marks them ERROR.
*/
async syncStatus(): Promise<void> {
const instances = await this.instanceRepo.findAll();
for (const inst of instances) {
if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) {
try {
const info = await this.orchestrator.inspectContainer(inst.containerId);
if (info.state === 'stopped' || info.state === 'error') {
// Container died — get last logs for error context
let errorMsg = `Container ${info.state}`;
try {
const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 });
const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop();
if (lastLog) errorMsg = lastLog;
} catch { /* best-effort */ }
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
metadata: { error: errorMsg },
});
}
} catch {
// Container gone entirely
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
metadata: { error: 'Container not found' },
});
}
}
}
}
/** /**
* Reconcile instances for a server to match desired replica count. * Reconcile instances for a server to match desired replica count.
* - Syncs container statuses first (detect crashed containers)
* - If fewer running instances than replicas: start new ones * - If fewer running instances than replicas: start new ones
* - If more running instances than replicas: remove excess (oldest first) * - If more running instances than replicas: remove excess (oldest first)
*/ */
@@ -45,6 +78,9 @@ export class InstanceService {
const server = await this.serverRepo.findById(serverId); const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
// Sync container statuses before counting active instances
await this.syncStatus();
const instances = await this.instanceRepo.findAll(serverId); const instances = await this.instanceRepo.findAll(serverId);
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING'); const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
const desired = server.replicas; const desired = server.replicas;