From a2cda38850867137d260bfd7869f45052c04219f Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 23:41:16 +0000 Subject: [PATCH] feat: add node-runner base image for npm-based MCP servers STDIO servers with packageName (e.g. @leval/mcp-grafana) need a Node.js container that runs `npx -y `. 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 --- deploy/Dockerfile.node-runner | 12 ++++++ deploy/docker-compose.yml | 17 ++++++++- .../src/services/docker/container-manager.ts | 6 +-- src/mcpd/src/services/instance.service.ts | 37 +++++++++++++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 deploy/Dockerfile.node-runner diff --git a/deploy/Dockerfile.node-runner b/deploy/Dockerfile.node-runner new file mode 100644 index 0000000..91be2fb --- /dev/null +++ b/deploy/Dockerfile.node-runner @@ -0,0 +1,12 @@ +# Base container for npm-based MCP servers (STDIO transport). +# mcpd uses this image to run `npx -y ` 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"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cb1199b..1321492 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -30,6 +30,8 @@ services: MCPD_PORT: "3100" MCPD_HOST: "0.0.0.0" MCPD_LOG_LEVEL: info + MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest + MCPD_MCP_NETWORK: mcp-servers depends_on: postgres: condition: service_healthy @@ -48,6 +50,16 @@ services: retries: 3 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: image: postgres:16-alpine container_name: mcpctl-postgres-test @@ -71,8 +83,11 @@ networks: mcpctl: driver: bridge mcp-servers: + name: mcp-servers 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: mcpctl-pgdata: diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 76797e8..1e47702 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -5,7 +5,7 @@ import type { ContainerInfo, ContainerLogs, } 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'; @@ -54,7 +54,7 @@ export class DockerContainerManager implements McpOrchestrator { async createContainer(spec: ContainerSpec): Promise { const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT; - const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS; + const nanoCpus = spec.nanoCpus; const portBindings: Record> = {}; const exposedPorts: Record> = {}; @@ -83,7 +83,7 @@ export class DockerContainerManager implements McpOrchestrator { HostConfig: { PortBindings: portBindings, Memory: memoryLimit, - NanoCpus: nanoCpus, + ...(nanoCpus ? { NanoCpus: nanoCpus } : {}), NetworkMode: spec.network ?? 'bridge', }, }; diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index ac4689f..893ef17 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -4,6 +4,12 @@ import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrat import { NotFoundError } from './mcp-server.service.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'] ?? '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 { readonly statusCode = 409; 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({ serverId, @@ -151,6 +173,7 @@ export class InstanceService { image, name: `mcpctl-${server.name}-${instance.id}`, hostPort: null, + network: MCP_SERVERS_NETWORK, labels: { 'mcpctl.server-id': serverId, 'mcpctl.instance-id': instance.id, @@ -159,9 +182,15 @@ export class InstanceService { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { spec.containerPort = server.containerPort ?? 3000; } - const command = server.command as string[] | null; - if (command) { - spec.command = command; + // 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; + if (command) { + spec.command = command; + } } // Resolve env vars from inline values and secret refs