Compare commits

...

14 Commits

Author SHA1 Message Date
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
Michal
dd1dfc629d fix: logs command resolves server names, proper replica handling
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
- `mcpctl logs <server-name>` resolves to first RUNNING instance
- `mcpctl logs <server-name> -i <N>` selects specific replica
- Shows "instance N/M" hint when server has multiple replicas
- Added 5 proper tests: server name resolution, RUNNING preference,
  replica selection, out-of-range error, no instances error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:12:39 +00:00
7b3dab142e Merge pull request 'fix: show server name in instances, logs by server name' (#13) from fix/instance-ux 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:07:57 +00:00
Michal
4c127a7dc3 fix: show server name in instances table, allow logs by server name
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
- Instance list now shows server NAME instead of cryptic server ID
- Include server relation in findAll query (Prisma include)
- Logs command accepts server name, server ID, or instance ID
  (resolves server name → first RUNNING instance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:07:42 +00:00
c1e3e4aed6 Merge pull request 'feat: auto-pull images + registry path for node-runner' (#12) from feat/node-runner-registry-pull 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:03:19 +00:00
Michal
e45c6079c1 feat: pull images before container creation, use registry path for node-runner
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
- Default node-runner image now uses mysources.co.uk registry path
- Add pullImage() call before createContainer() to auto-pull missing images
- Update stack/docker-compose.yml with MCPD_NODE_RUNNER_IMAGE and
  MCPD_MCP_NETWORK env vars, fix mcp-servers network naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:03:01 +00:00
e4aef3acf1 Merge pull request 'feat: add node-runner base image for npm-based MCP servers' (#11) from feat/node-runner-base-image 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-22 23:41:36 +00:00
Michal
a2cda38850 feat: add node-runner base image for npm-based MCP servers
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 servers with packageName (e.g. @leval/mcp-grafana) need a Node.js
container that runs `npx -y <package>`. Previously, packageName was used
as a Docker image reference causing "invalid reference format" errors.

- Add Dockerfile.node-runner: minimal node:20-alpine with npx entrypoint
- Update instance.service.ts: detect npm-based servers and use node-runner
  image with npx command instead of treating packageName as image name
- Fix NanoCPUs: only set when explicitly provided (kernel CFS not available
  on all hosts)
- Add mcp-servers network with explicit name for container isolation
- Configure MCPD_NODE_RUNNER_IMAGE and MCPD_MCP_NETWORK env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:41:16 +00:00
081e90de0f Merge pull request 'fix: error handling and --force flag for create commands' (#10) from fix/create-error-handling 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-22 23:06:52 +00:00
Michal
4e3d896ef6 fix: proper error handling and --force flag for create commands
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 global error handler: clean messages instead of stack traces
- Add --force flag to create server/secret/project: updates on 409 conflict
- Strip null values and template-only fields from --from-template payload
- Add tests: 409 handling, --force update, null-stripping from templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:06:33 +00:00
0823e965bf Merge pull request 'feat: MCP healthcheck probes + new templates' (#9) from feat/healthcheck-probes 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-22 22:50:10 +00:00
Michal
c97219f85e feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red)
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 healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold)
- Add healthStatus, lastHealthCheck, events fields to instances
- Create grafana, home-assistant, node-red templates with healthcheck probes
- Add healthcheck probes to existing templates (github, slack, postgres, jira)
- Show HEALTH column in `get instances` and Events section in `describe instance`
- Display healthCheck details in `describe server` and `describe template`
- Schema + storage + display only; actual probe runner is future work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:59 +00:00
93adcd4be7 Merge pull request 'feat: add MCP server templates and deployment infrastructure' (#8) from feat/mcp-templates 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-22 22:25:02 +00:00
33 changed files with 601 additions and 45 deletions

View File

@@ -288,7 +288,7 @@ update_stack() {
"env": $env, "env": $env,
"stackFileContent": $stackFileContent, "stackFileContent": $stackFileContent,
"prune": true, "prune": true,
"pullImage": false "pullImage": true
}') }')
local response local response

View File

@@ -0,0 +1,13 @@
# Base container for npm-based MCP servers (STDIO transport).
# mcpd uses this image to run `npx -y <packageName>` when a server
# has packageName but no dockerImage.
# Using slim (Debian) instead of alpine for better npm package compatibility.
FROM node:20-slim
WORKDIR /mcp
# Pre-warm npx cache directory
RUN mkdir -p /root/.npm
# Default entrypoint — overridden by mcpd via container command
ENTRYPOINT ["npx", "-y"]

View File

@@ -30,6 +30,8 @@ services:
MCPD_PORT: "3100" MCPD_PORT: "3100"
MCPD_HOST: "0.0.0.0" MCPD_HOST: "0.0.0.0"
MCPD_LOG_LEVEL: info MCPD_LOG_LEVEL: info
MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest
MCPD_MCP_NETWORK: mcp-servers
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -48,6 +50,16 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# Base image for npm-based MCP servers (built once, used by mcpd)
node-runner:
build:
context: ..
dockerfile: deploy/Dockerfile.node-runner
image: mcpctl-node-runner:latest
profiles:
- build
entrypoint: ["echo", "Image built successfully"]
postgres-test: postgres-test:
image: postgres:16-alpine image: postgres:16-alpine
container_name: mcpctl-postgres-test container_name: mcpctl-postgres-test
@@ -71,8 +83,11 @@ networks:
mcpctl: mcpctl:
driver: bridge driver: bridge
mcp-servers: mcp-servers:
name: mcp-servers
driver: bridge driver: bridge
internal: true # Not internal — MCP servers need outbound access to reach external APIs
# (e.g., Grafana, Home Assistant). Isolation is enforced by not binding
# host ports on MCP server containers; only mcpd can reach them.
volumes: volumes:
mcpctl-pgdata: mcpctl-pgdata:

View File

@@ -4,6 +4,14 @@ import yaml from 'js-yaml';
import { z } from 'zod'; import { z } from 'zod';
import type { ApiClient } from '../api-client.js'; import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
const ServerEnvEntrySchema = z.object({ const ServerEnvEntrySchema = z.object({
name: z.string().min(1), name: z.string().min(1),
value: z.string().optional(), value: z.string().optional(),
@@ -24,6 +32,7 @@ const ServerSpecSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]), env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
}); });
const SecretSpecSchema = z.object({ const SecretSpecSchema = z.object({
@@ -51,6 +60,7 @@ const TemplateSpecSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]), env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
}); });
const ProjectSpecSchema = z.object({ const ProjectSpecSchema = z.object({

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander'; import { Command } from 'commander';
import type { ApiClient } from '../api-client.js'; import { type ApiClient, ApiError } from '../api-client.js';
export interface CreateCommandDeps { export interface CreateCommandDeps {
client: ApiClient; client: ApiClient;
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
@@ -72,6 +72,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--replicas <count>', 'Number of replicas') .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('--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('--from-template <name>', 'Create from template (name or name:version)')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
let base: Record<string, unknown> = {}; let base: Record<string, unknown> = {};
@@ -92,9 +93,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (!template) throw new Error(`Template '${tplName}' not found`); if (!template) throw new Error(`Template '${tplName}' not found`);
} }
// Copy template fields as base (strip template-only fields) // Copy template fields as base (strip template-only, internal, and null fields)
const { id: _id, createdAt: _c, updatedAt: _u, ...tplFields } = template; const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
base = { ...tplFields }; base = {};
for (const [k, v] of Object.entries(tplFields)) {
if (v !== null && v !== undefined) base[k] = v;
}
// Convert template env (description/required) to server env (name/value/valueFrom) // 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; const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
@@ -144,8 +148,20 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (!body.replicas) body.replicas = 1; if (!body.replicas) body.replicas = 1;
} }
try {
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body); const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`); log(`server '${server.name}' created (id: ${server.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/servers')).find((s) => s.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/servers/${existing.id}`, updateBody);
log(`server '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
}); });
// --- create secret --- // --- create secret ---
@@ -153,13 +169,25 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a secret') .description('Create a secret')
.argument('<name>', 'Secret name (lowercase, hyphens allowed)') .argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, []) .option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
const data = parseEnvEntries(opts.data); const data = parseEnvEntries(opts.data);
try {
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', { const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name, name,
data, data,
}); });
log(`secret '${secret.name}' created (id: ${secret.id})`); log(`secret '${secret.name}' created (id: ${secret.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
if (!existing) throw err;
await client.put(`/api/v1/secrets/${existing.id}`, { data });
log(`secret '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
}); });
// --- create project --- // --- create project ---
@@ -167,12 +195,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a project') .description('Create a project')
.argument('<name>', 'Project name') .argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '') .option('-d, --description <text>', 'Project description', '')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', { const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name, name,
description: opts.description, description: opts.description,
}); });
log(`project '${project.name}' created (id: ${project.id})`); log(`project '${project.name}' created (id: ${project.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
if (!existing) throw err;
await client.put(`/api/v1/projects/${existing.id}`, { description: opts.description });
log(`project '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
}); });
return cmd; return cmd;

View File

@@ -50,6 +50,19 @@ function formatServerDetail(server: Record<string, unknown>): string {
} }
} }
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push(''); lines.push('');
lines.push('Metadata:'); lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`); lines.push(` ${pad('ID:', 12)}${server.id}`);
@@ -67,6 +80,16 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
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 ?? '-'}`);
// Health section
const healthStatus = instance.healthStatus as string | null;
const lastHealthCheck = instance.lastHealthCheck as string | null;
if (healthStatus || lastHealthCheck) {
lines.push('');
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
}
const metadata = instance.metadata as Record<string, unknown> | undefined; const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) { if (metadata && Object.keys(metadata).length > 0) {
lines.push(''); lines.push('');
@@ -88,6 +111,19 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
} }
} }
// Events section (k8s-style)
const events = instance.events as Array<{ timestamp: string; type: string; message: string }> | undefined;
if (events && events.length > 0) {
lines.push('');
lines.push('Events:');
const tsW = 26;
const typeW = 10;
lines.push(` ${'TIMESTAMP'.padEnd(tsW)}${'TYPE'.padEnd(typeW)}MESSAGE`);
for (const ev of events) {
lines.push(` ${(ev.timestamp ?? '').padEnd(tsW)}${(ev.type ?? '').padEnd(typeW)}${ev.message ?? ''}`);
}
}
lines.push(''); lines.push('');
lines.push(` ${pad('ID:', 12)}${instance.id}`); lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`); if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
@@ -177,6 +213,19 @@ function formatTemplateDetail(template: Record<string, unknown>): string {
} }
} }
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push(''); lines.push('');
lines.push('Usage:'); lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`); lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);

View File

@@ -42,9 +42,11 @@ interface TemplateRow {
interface InstanceRow { interface InstanceRow {
id: string; id: string;
serverId: string; serverId: string;
server?: { name: string };
status: string; status: string;
containerId: string | null; containerId: string | null;
port: number | null; port: number | null;
healthStatus: string | null;
} }
const serverColumns: Column<ServerRow>[] = [ const serverColumns: Column<ServerRow>[] = [
@@ -77,8 +79,9 @@ const templateColumns: Column<TemplateRow>[] = [
]; ];
const instanceColumns: Column<InstanceRow>[] = [ const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 }, { header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' }, { header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 }, { header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 }, { header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },

View File

@@ -6,15 +6,84 @@ export interface LogsCommandDeps {
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
} }
interface InstanceInfo {
id: string;
status: string;
containerId: string | null;
}
/**
* Resolve a name/ID to an instance ID.
* Accepts: instance ID, server name, or server ID.
* For servers with multiple replicas, picks by --instance index or first RUNNING.
*/
async function resolveInstance(
client: ApiClient,
nameOrId: string,
instanceIndex?: number,
): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> {
// Try as instance ID first
try {
await client.get(`/api/v1/instances/${nameOrId}`);
return { instanceId: nameOrId };
} catch {
// Not a valid instance ID
}
// Try as server name/ID → find its instances
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
if (!server) {
throw new Error(`Instance or server '${nameOrId}' not found`);
}
const instances = await client.get<InstanceInfo[]>(`/api/v1/instances?serverId=${server.id}`);
if (instances.length === 0) {
throw new Error(`No instances found for server '${server.name}'`);
}
// Select by index or pick first running
let selected: InstanceInfo | undefined;
if (instanceIndex !== undefined) {
if (instanceIndex < 0 || instanceIndex >= instances.length) {
throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`);
}
selected = instances[instanceIndex];
} else {
selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
}
if (!selected) {
throw new Error(`No instances found for server '${server.name}'`);
}
const result: { instanceId: string; serverName?: string; replicaInfo?: string } = {
instanceId: selected.id,
serverName: server.name,
};
if (instances.length > 1) {
result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`;
}
return result;
}
export function createLogsCommand(deps: LogsCommandDeps): Command { export function createLogsCommand(deps: LogsCommandDeps): Command {
const { client, log } = deps; const { client, log } = deps;
return new Command('logs') return new Command('logs')
.description('Get logs from an MCP server instance') .description('Get logs from an MCP server instance')
.argument('<instance-id>', 'Instance ID') .argument('<name>', 'Server name, server ID, or instance ID')
.option('-t, --tail <lines>', 'Number of lines to show') .option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => { .option('-i, --instance <index>', 'Instance/replica index (0-based, for servers with multiple replicas)')
let url = `/api/v1/instances/${id}/logs`; .action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => {
const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined;
const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex);
if (replicaInfo) {
process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`);
}
let url = `/api/v1/instances/${instanceId}/logs`;
if (opts.tail) { if (opts.tail) {
url += `?tail=${opts.tail}`; url += `?tail=${opts.tail}`;
} }

View File

@@ -14,7 +14,7 @@ import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js'; import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js'; import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js'; import { resolveNameOrId } from './commands/shared.js';
@@ -143,5 +143,21 @@ const isDirectRun =
import.meta.url === `file://${process.argv[1]}`; import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) { if (isDirectRun) {
createProgram().parseAsync(process.argv); createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string };
msg = parsed.error ?? parsed.message ?? err.body;
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error(`Error: ${String(err)}`);
}
process.exit(1);
});
} }

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js'; import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js'; import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
return { return {
@@ -73,6 +73,59 @@ describe('create command', () => {
transport: 'STDIO', transport: 'STDIO',
})); }));
}); });
it('strips null values from template when using --from-template', async () => {
vi.mocked(client.get).mockResolvedValueOnce([{
id: 'tpl-1',
name: 'grafana',
version: '1.0.0',
description: 'Grafana MCP',
packageName: '@leval/mcp-grafana',
dockerImage: null,
transport: 'STDIO',
repositoryUrl: 'https://github.com/test',
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
healthCheck: { tool: 'test', arguments: {} },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
}] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'my-grafana', '--from-template=grafana',
'--env', 'TOKEN=secretRef:creds:TOKEN',
], { from: 'user' });
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
// null fields from template should NOT be in the body
expect(call).not.toHaveProperty('dockerImage');
expect(call).not.toHaveProperty('externalUrl');
expect(call).not.toHaveProperty('command');
expect(call).not.toHaveProperty('containerPort');
// non-null fields should be present
expect(call.packageName).toBe('@leval/mcp-grafana');
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
expect(call.templateName).toBe('grafana');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing server on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("server 'my-server' updated");
});
}); });
describe('create secret', () => { describe('create secret', () => {
@@ -98,6 +151,21 @@ describe('create command', () => {
data: {}, data: {},
}); });
}); });
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing secret on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
expect(output.join('\n')).toContain("secret 'my-creds' updated");
});
}); });
describe('create project', () => { describe('create project', () => {
@@ -119,5 +187,14 @@ describe('create command', () => {
description: '', description: '',
}); });
}); });
it('updates existing project on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
}); });
}); });

View File

@@ -69,11 +69,13 @@ describe('get command', () => {
it('lists instances with correct columns', async () => { it('lists instances with correct columns', async () => {
const deps = makeDeps([ const deps = makeDeps([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 }, { id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
]); ]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']); await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('STATUS'); expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('my-grafana');
expect(deps.output.join('\n')).toContain('RUNNING'); expect(deps.output.join('\n')).toContain('RUNNING');
}); });

View File

@@ -68,16 +68,79 @@ describe('logs command', () => {
output = []; output = [];
}); });
it('shows logs', async () => { it('shows logs by instance ID', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup
.mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1'], { from: 'user' }); await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world'); expect(output.join('\n')).toContain('hello world');
}); });
it('resolves server name to instance ID', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found')) // instance lookup fails
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server
.mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['my-grafana'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('grafana logs');
});
it('picks RUNNING instance over others', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-err', status: 'ERROR', containerId: null },
{ id: 'inst-ok', status: 'RUNNING', containerId: 'abc' },
] as never)
.mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs');
});
it('selects specific replica with --instance', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-0', status: 'RUNNING', containerId: 'a' },
{ id: 'inst-1', status: 'RUNNING', containerId: 'b' },
] as never)
.mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
});
it('throws on out-of-range --instance index', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range');
});
it('throws when server has no instances', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never)
.mockResolvedValueOnce([] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found');
});
it('passes tail option', async () => { it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' }); vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1' } as never)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as never);
const cmd = createLogsCommand({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' }); await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');

View File

@@ -62,6 +62,7 @@ model McpServer {
containerPort Int? containerPort Int?
replicas Int @default(1) replicas Int @default(1)
env Json @default("[]") env Json @default("[]")
healthCheck Json?
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -96,6 +97,7 @@ model McpTemplate {
containerPort Int? containerPort Int?
replicas Int @default(1) replicas Int @default(1)
env Json @default("[]") env Json @default("[]")
healthCheck Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -141,6 +143,9 @@ model McpInstance {
status InstanceStatus @default(STOPPED) status InstanceStatus @default(STOPPED)
port Int? port Int?
metadata Json @default("{}") metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -15,4 +15,4 @@ export type {
} from '@prisma/client'; } from '@prisma/client';
export { seedTemplates } from './seed/index.js'; export { seedTemplates } from './seed/index.js';
export type { SeedTemplate, TemplateEnvEntry } from './seed/index.js'; export type { SeedTemplate, TemplateEnvEntry, HealthCheckSpec } from './seed/index.js';

View File

@@ -7,6 +7,14 @@ export interface TemplateEnvEntry {
defaultValue?: string; defaultValue?: string;
} }
export interface HealthCheckSpec {
tool: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
export interface SeedTemplate { export interface SeedTemplate {
name: string; name: string;
version: string; version: string;
@@ -20,6 +28,7 @@ export interface SeedTemplate {
containerPort?: number; containerPort?: number;
replicas?: number; replicas?: number;
env?: TemplateEnvEntry[]; env?: TemplateEnvEntry[];
healthCheck?: HealthCheckSpec;
} }
export async function seedTemplates( export async function seedTemplates(
@@ -43,6 +52,7 @@ export async function seedTemplates(
containerPort: tpl.containerPort ?? null, containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1, replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
}, },
create: { create: {
name: tpl.name, name: tpl.name,
@@ -57,6 +67,7 @@ export async function seedTemplates(
containerPort: tpl.containerPort ?? null, containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1, replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
}, },
}); });
upserted++; upserted++;

View File

@@ -69,6 +69,7 @@ async function main(): Promise<void> {
transport: parsed.transport ?? 'STDIO', transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0', version: parsed.version ?? '1.0.0',
description: parsed.description ?? '', description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
}; };
}); });
await seedTemplates(prisma, templates); await seedTemplates(prisma, templates);
@@ -133,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

@@ -16,7 +16,7 @@ export interface IMcpInstanceRepository {
findById(id: string): Promise<McpInstance | null>; findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: string): Promise<McpInstance | null>; findByContainerId(containerId: string): Promise<McpInstance | null>;
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>; create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>; updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
} }

View File

@@ -11,6 +11,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
} }
return this.prisma.mcpInstance.findMany({ return this.prisma.mcpInstance.findMany({
where, where,
include: { server: { select: { name: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
@@ -44,7 +45,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
async updateStatus( async updateStatus(
id: string, id: string,
status: InstanceStatus, status: InstanceStatus,
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] },
): Promise<McpInstance> { ): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = { const updateData: Prisma.McpInstanceUpdateInput = {
status, status,
@@ -59,6 +60,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
if (fields?.metadata !== undefined) { if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue; updateData.metadata = fields.metadata as Prisma.InputJsonValue;
} }
if (fields?.healthStatus !== undefined) {
updateData.healthStatus = fields.healthStatus;
}
if (fields?.lastHealthCheck !== undefined) {
updateData.lastHealthCheck = fields.lastHealthCheck;
}
if (fields?.events !== undefined) {
updateData.events = fields.events as unknown as Prisma.InputJsonValue;
}
return this.prisma.mcpInstance.update({ return this.prisma.mcpInstance.update({
where: { id }, where: { id },
data: updateData, data: updateData,

View File

@@ -31,6 +31,7 @@ export class McpServerRepository implements IMcpServerRepository {
containerPort: data.containerPort ?? null, containerPort: data.containerPort ?? null,
replicas: data.replicas, replicas: data.replicas,
env: data.env, env: data.env,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
}, },
}); });
} }
@@ -47,6 +48,7 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort; if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
if (data.replicas !== undefined) updateData['replicas'] = data.replicas; if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
if (data.env !== undefined) updateData['env'] = data.env; if (data.env !== undefined) updateData['env'] = data.env;
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpServer.update({ where: { id }, data: updateData }); return this.prisma.mcpServer.update({ where: { id }, data: updateData });
} }

View File

@@ -50,6 +50,7 @@ export class TemplateRepository implements ITemplateRepository {
containerPort: data.containerPort ?? null, containerPort: data.containerPort ?? null,
replicas: data.replicas, replicas: data.replicas,
env: (data.env ?? []) as unknown as Prisma.InputJsonValue, env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
}, },
}); });
} }
@@ -67,6 +68,7 @@ export class TemplateRepository implements ITemplateRepository {
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort; if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
if (data.replicas !== undefined) updateData.replicas = data.replicas; if (data.replicas !== undefined) updateData.replicas = data.replicas;
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue; if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpTemplate.update({ return this.prisma.mcpTemplate.update({
where: { id }, where: { id },

View File

@@ -24,6 +24,7 @@ function loadTemplatesFromDir(dir: string): SeedTemplate[] {
transport: parsed.transport ?? 'STDIO', transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0', version: parsed.version ?? '1.0.0',
description: parsed.description ?? '', description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
}); });
} }
} }

View File

@@ -5,7 +5,7 @@ import type {
ContainerInfo, ContainerInfo,
ContainerLogs, ContainerLogs,
} from '../orchestrator.js'; } from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js'; import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
const MCPCTL_LABEL = 'mcpctl.managed'; const MCPCTL_LABEL = 'mcpctl.managed';
@@ -54,7 +54,7 @@ export class DockerContainerManager implements McpOrchestrator {
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> { async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT; const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS; const nanoCpus = spec.nanoCpus;
const portBindings: Record<string, Array<{ HostPort: string }>> = {}; const portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, Record<string, never>> = {}; const exposedPorts: Record<string, Record<string, never>> = {};
@@ -83,7 +83,7 @@ export class DockerContainerManager implements McpOrchestrator {
HostConfig: { HostConfig: {
PortBindings: portBindings, PortBindings: portBindings,
Memory: memoryLimit, Memory: memoryLimit,
NanoCpus: nanoCpus, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
NetworkMode: spec.network ?? 'bridge', NetworkMode: spec.network ?? 'bridge',
}, },
}; };

View File

@@ -4,6 +4,12 @@ import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrat
import { NotFoundError } from './mcp-server.service.js'; import { NotFoundError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js'; import { resolveServerEnv } from './env-resolver.js';
/** Default image for npm-based MCP servers (STDIO with packageName, no dockerImage). */
const DEFAULT_NODE_RUNNER_IMAGE = process.env['MCPD_NODE_RUNNER_IMAGE'] ?? 'mysources.co.uk/michal/mcpctl-node-runner:latest';
/** Network for MCP server containers (matches docker-compose mcp-servers network). */
const MCP_SERVERS_NETWORK = process.env['MCPD_MCP_NETWORK'] ?? 'mcp-servers';
export class InvalidStateError extends Error { export class InvalidStateError extends Error {
readonly statusCode = 409; readonly statusCode = 409;
constructor(message: string) { constructor(message: string) {
@@ -30,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)
*/ */
@@ -39,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;
@@ -139,7 +181,23 @@ export class InstanceService {
}); });
} }
const image = server.dockerImage ?? server.packageName ?? server.name; // Determine image + command based on server config:
// 1. Explicit dockerImage → use as-is
// 2. packageName (npm) → use node-runner image + npx command
// 3. Fallback → server name (legacy)
let image: string;
let npmCommand: string[] | undefined;
if (server.dockerImage) {
image = server.dockerImage;
} else if (server.packageName) {
image = DEFAULT_NODE_RUNNER_IMAGE;
// Build npx command: entrypoint is ["npx", "-y"], so CMD = [packageName, ...args]
const serverCommand = server.command as string[] | null;
npmCommand = [server.packageName, ...(serverCommand ?? [])];
} else {
image = server.name;
}
let instance = await this.instanceRepo.create({ let instance = await this.instanceRepo.create({
serverId, serverId,
@@ -151,6 +209,7 @@ export class InstanceService {
image, image,
name: `mcpctl-${server.name}-${instance.id}`, name: `mcpctl-${server.name}-${instance.id}`,
hostPort: null, hostPort: null,
network: MCP_SERVERS_NETWORK,
labels: { labels: {
'mcpctl.server-id': serverId, 'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id, 'mcpctl.instance-id': instance.id,
@@ -159,10 +218,16 @@ export class InstanceService {
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = server.containerPort ?? 3000; spec.containerPort = server.containerPort ?? 3000;
} }
// npm-based servers: command = [packageName, ...args] (entrypoint handles npx -y)
// Docker-image servers: use explicit command if provided
if (npmCommand) {
spec.command = npmCommand;
} else {
const command = server.command as string[] | null; const command = server.command as string[] | null;
if (command) { if (command) {
spec.command = command; spec.command = command;
} }
}
// Resolve env vars from inline values and secret refs // Resolve env vars from inline values and secret refs
if (this.secretRepo) { if (this.secretRepo) {
@@ -177,6 +242,13 @@ export class InstanceService {
} }
} }
// Pull image if not available locally
try {
await this.orchestrator.pullImage(image);
} catch {
// Image may already be available locally
}
const containerInfo = await this.orchestrator.createContainer(spec); const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = { const updateFields: { containerId: string; port?: number } = {

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { HealthCheckSchema } from './template.schema.js';
const SecretRefSchema = z.object({ const SecretRefSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -30,6 +31,7 @@ export const CreateMcpServerSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]), env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
}); });
export const UpdateMcpServerSchema = z.object({ export const UpdateMcpServerSchema = z.object({
@@ -43,6 +45,7 @@ export const UpdateMcpServerSchema = z.object({
containerPort: z.number().int().min(1).max(65535).nullable().optional(), containerPort: z.number().int().min(1).max(65535).nullable().optional(),
replicas: z.number().int().min(0).max(10).optional(), replicas: z.number().int().min(0).max(10).optional(),
env: z.array(ServerEnvEntrySchema).optional(), env: z.array(ServerEnvEntrySchema).optional(),
healthCheck: HealthCheckSchema.nullable().optional(),
}); });
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>; export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

@@ -7,6 +7,16 @@ const TemplateEnvEntrySchema = z.object({
defaultValue: z.string().optional(), defaultValue: z.string().optional(),
}); });
export const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
export type HealthCheckInput = z.infer<typeof HealthCheckSchema>;
export const CreateTemplateSchema = z.object({ export const CreateTemplateSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
version: z.string().default('1.0.0'), version: z.string().default('1.0.0'),
@@ -20,6 +30,7 @@ export const CreateTemplateSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]), env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
}); });
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true }); export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });

View File

@@ -28,6 +28,8 @@ services:
MCPD_PORT: "3100" MCPD_PORT: "3100"
MCPD_HOST: "0.0.0.0" MCPD_HOST: "0.0.0.0"
MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-info} MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-info}
MCPD_NODE_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-node-runner:latest
MCPD_MCP_NETWORK: mcp-servers
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -47,8 +49,10 @@ networks:
mcpctl: mcpctl:
driver: bridge driver: bridge
mcp-servers: mcp-servers:
name: mcp-servers
driver: bridge driver: bridge
internal: true # Not internal — MCP servers need outbound access for external APIs.
# Isolation enforced by not binding host ports on MCP containers.
volumes: volumes:
mcpctl-pgdata: mcpctl-pgdata:

View File

@@ -4,6 +4,10 @@ description: GitHub MCP server for repos, issues, PRs, and code search
packageName: "@anthropic/github-mcp" packageName: "@anthropic/github-mcp"
transport: STDIO transport: STDIO
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/github repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/github
healthCheck:
tool: search_repositories
arguments:
query: "test"
env: env:
- name: GITHUB_TOKEN - name: GITHUB_TOKEN
description: Personal access token with repo scope description: Personal access token with repo scope

16
templates/grafana.yaml Normal file
View File

@@ -0,0 +1,16 @@
name: grafana
version: "1.0.0"
description: Grafana MCP server for dashboards, datasources, and alerts
packageName: "@leval/mcp-grafana"
transport: STDIO
repositoryUrl: https://github.com/levalhq/mcp-grafana
healthCheck:
tool: list_datasources
arguments: {}
env:
- name: GRAFANA_URL
description: Grafana instance URL (e.g. https://grafana.example.com)
required: true
- name: GRAFANA_SERVICE_ACCOUNT_TOKEN
description: Grafana service account token (glsa_...)
required: true

View File

@@ -0,0 +1,16 @@
name: home-assistant
version: "1.0.0"
description: Home Assistant MCP server for smart home control and entity management
packageName: "home-assistant-mcp-server"
transport: STDIO
repositoryUrl: https://github.com/tevonsb/homeassistant-mcp
healthCheck:
tool: get_entities
arguments: {}
env:
- name: HASS_URL
description: Home Assistant instance URL (e.g. http://homeassistant.local:8123)
required: true
- name: HASS_TOKEN
description: Home Assistant long-lived access token
required: true

View File

@@ -4,6 +4,11 @@ description: Jira MCP server for issues, projects, and boards
packageName: "@anthropic/jira-mcp" packageName: "@anthropic/jira-mcp"
transport: STDIO transport: STDIO
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/jira repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/jira
healthCheck:
tool: search_issues
arguments:
jql: "created >= -1d"
maxResults: 1
env: env:
- name: JIRA_URL - name: JIRA_URL
description: Jira instance URL (e.g. https://company.atlassian.net) description: Jira instance URL (e.g. https://company.atlassian.net)

16
templates/node-red.yaml Normal file
View File

@@ -0,0 +1,16 @@
name: node-red
version: "1.0.0"
description: Node-RED MCP server for flow management and automation
packageName: "mcp-node-red"
transport: STDIO
repositoryUrl: https://github.com/fx/mcp-node-red
healthCheck:
tool: get_settings
arguments: {}
env:
- name: NODE_RED_URL
description: Node-RED instance URL (e.g. http://nodered.local:1880)
required: true
- name: NODE_RED_TOKEN
description: Node-RED access token (optional if no auth)
required: false

View File

@@ -4,6 +4,10 @@ description: PostgreSQL MCP server for database queries and schema inspection
packageName: "@anthropic/postgres-mcp" packageName: "@anthropic/postgres-mcp"
transport: STDIO transport: STDIO
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/postgres repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/postgres
healthCheck:
tool: query
arguments:
sql: "SELECT 1"
env: env:
- name: POSTGRES_CONNECTION_STRING - name: POSTGRES_CONNECTION_STRING
description: PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db) description: PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db)

View File

@@ -4,6 +4,9 @@ description: Slack MCP server for reading channels, messages, and user info
packageName: "@anthropic/slack-mcp" packageName: "@anthropic/slack-mcp"
transport: STDIO transport: STDIO
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/slack repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/slack
healthCheck:
tool: list_channels
arguments: {}
env: env:
- name: SLACK_BOT_TOKEN - name: SLACK_BOT_TOKEN
description: Slack bot token (xoxb-...) description: Slack bot token (xoxb-...)