Compare commits
10 Commits
feat/mcp-t
...
fix/instan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c127a7dc3 | ||
| c1e3e4aed6 | |||
|
|
e45c6079c1 | ||
| e4aef3acf1 | |||
|
|
a2cda38850 | ||
| 081e90de0f | |||
|
|
4e3d896ef6 | ||
| 0823e965bf | |||
|
|
c97219f85e | ||
| 93adcd4be7 |
@@ -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
|
||||||
|
|||||||
12
deploy/Dockerfile.node-runner
Normal file
12
deploy/Dockerfile.node-runner
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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.
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /mcp
|
||||||
|
|
||||||
|
# Pre-warm npx cache directory
|
||||||
|
RUN mkdir -p /root/.npm
|
||||||
|
|
||||||
|
# Default entrypoint — overridden by mcpd via container command
|
||||||
|
ENTRYPOINT ["npx", "-y"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
|
try {
|
||||||
log(`server '${server.name}' created (id: ${server.id})`);
|
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
|
||||||
|
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);
|
||||||
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
try {
|
||||||
name,
|
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
||||||
data,
|
name,
|
||||||
});
|
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) => {
|
||||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
|
try {
|
||||||
name,
|
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
|
||||||
description: opts.description,
|
name,
|
||||||
});
|
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;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -6,15 +6,43 @@ export interface LogsCommandDeps {
|
|||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a name/ID to an instance ID.
|
||||||
|
* Accepts: instance ID, server name, or server ID.
|
||||||
|
* For servers, picks the first RUNNING instance.
|
||||||
|
*/
|
||||||
|
async function resolveInstanceId(client: ApiClient, nameOrId: string): Promise<string> {
|
||||||
|
// Try as instance ID first
|
||||||
|
try {
|
||||||
|
await client.get(`/api/v1/instances/${nameOrId}`);
|
||||||
|
return nameOrId;
|
||||||
|
} catch {
|
||||||
|
// Not a valid instance ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as server name → 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) {
|
||||||
|
const instances = await 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) return running.id;
|
||||||
|
throw new Error(`No instances found for server '${nameOrId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Instance or server '${nameOrId}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
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 }) => {
|
.action(async (nameOrId: string, opts: { tail?: string }) => {
|
||||||
let url = `/api/v1/instances/${id}/logs`;
|
const instanceId = await resolveInstanceId(client, nameOrId);
|
||||||
|
let url = `/api/v1/instances/${instanceId}/logs`;
|
||||||
if (opts.tail) {
|
if (opts.tail) {
|
||||||
url += `?tail=${opts.tail}`;
|
url += `?tail=${opts.tail}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -140,10 +142,13 @@ model McpInstance {
|
|||||||
containerId String?
|
containerId String?
|
||||||
status InstanceStatus @default(STOPPED)
|
status InstanceStatus @default(STOPPED)
|
||||||
port Int?
|
port Int?
|
||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
version Int @default(1)
|
healthStatus String?
|
||||||
createdAt DateTime @default(now())
|
lastHealthCheck DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
events Json @default("[]")
|
||||||
|
version Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -139,7 +145,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 +173,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,9 +182,15 @@ 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;
|
||||||
}
|
}
|
||||||
const command = server.command as string[] | null;
|
// npm-based servers: command = [packageName, ...args] (entrypoint handles npx -y)
|
||||||
if (command) {
|
// Docker-image servers: use explicit command if provided
|
||||||
spec.command = command;
|
if (npmCommand) {
|
||||||
|
spec.command = npmCommand;
|
||||||
|
} else {
|
||||||
|
const command = server.command as string[] | null;
|
||||||
|
if (command) {
|
||||||
|
spec.command = command;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve env vars from inline values and secret refs
|
// Resolve env vars from inline values and secret refs
|
||||||
@@ -177,6 +206,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 } = {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
16
templates/grafana.yaml
Normal 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
|
||||||
16
templates/home-assistant.yaml
Normal file
16
templates/home-assistant.yaml
Normal 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
|
||||||
@@ -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
16
templates/node-red.yaml
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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-...)
|
||||||
|
|||||||
Reference in New Issue
Block a user