Compare commits

..

6 Commits

Author SHA1 Message Date
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
9 changed files with 113 additions and 14 deletions

View 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"]

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

@@ -42,6 +42,7 @@ 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;
@@ -78,9 +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: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 }, { header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
{ 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,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}`;
} }

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

@@ -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' },
}); });
} }

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) {
@@ -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,10 +182,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 +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 } = {

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: