Compare commits
6 Commits
feat/healt
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45c6079c1 | ||
| e4aef3acf1 | |||
|
|
a2cda38850 | ||
| 081e90de0f | |||
|
|
4e3d896ef6 | ||
| 0823e965bf |
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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } = {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user